Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4874bedbef | |||
| d6a1864961 | |||
| 1c963345a4 | |||
| 268970671a | |||
| e879100f07 | |||
| 7a71df31ff | |||
| 76a5e4f87f | |||
| 10ade0306b | |||
| 73e319324f | |||
| f0b49261a5 | |||
| 0e3d2fb880 | |||
| bb8565ee9d | |||
| bfbdae8e3a | |||
| 40f9db30ce | |||
| 22fb83320e | |||
| 63e4bb985b | |||
| fe890429a4 | |||
| 655165cde4 | |||
| 17557b1aeb | |||
| 9600da2c8f | |||
| 2b6dab6136 | |||
| ac9025e9fc | |||
| fafe3e35d2 | |||
| f16f1cced3 | |||
| 963631a3d4 |
@@ -102,6 +102,12 @@ jobs:
|
||||
- name: 'Run yamllint'
|
||||
run: 'node scripts/lint.js --yamllint'
|
||||
|
||||
- name: 'Build project for typecheck'
|
||||
run: 'npm run build'
|
||||
|
||||
- name: 'Run typecheck'
|
||||
run: 'npm run typecheck'
|
||||
|
||||
- name: 'Run Prettier'
|
||||
run: 'node scripts/lint.js --prettier'
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# Running User Simulation in Docker with External Knowledge Source
|
||||
|
||||
This guide explains how to run the User Simulator in a Docker environment while
|
||||
mounting an external knowledge base. This setup allows the simulator to "learn"
|
||||
from its interactions and persist that knowledge back to your host machine.
|
||||
|
||||
We have provided an automated script that handles the entire setup, execution,
|
||||
and cleanup process.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker** installed and running.
|
||||
- **Gemini API Key** (standard `AIza...` key).
|
||||
- Local checkout of the `gemini-cli` repository.
|
||||
|
||||
## Execution via Automation Script (Recommended)
|
||||
|
||||
The easiest and most reliable way to run the simulation is using the provided
|
||||
bash script. This script automatically:
|
||||
|
||||
1. Creates a uniquely timestamped workspace folder on your host.
|
||||
2. Generates a global `settings.json` file to natively bypass the CLI's
|
||||
interactive Folder Trust and Authentication dialogs.
|
||||
3. Builds the sandbox image from your current branch.
|
||||
4. Mounts the workspace and runs the container with `--init` to gracefully
|
||||
handle termination (e.g., `Ctrl+C`).
|
||||
|
||||
### Running the Script
|
||||
|
||||
Ensure your API key is exported:
|
||||
|
||||
```bash
|
||||
export GEMINI_API_KEY="AIzaSy..."
|
||||
```
|
||||
|
||||
Run the script from the root of the repository:
|
||||
|
||||
```bash
|
||||
# Uses the default prompt ("make a snake game in python")
|
||||
./scripts/run_simulator_docker.sh
|
||||
|
||||
# Or, provide a custom prompt:
|
||||
./scripts/run_simulator_docker.sh "create a simple react counter component"
|
||||
```
|
||||
|
||||
## Manual Execution Breakdown
|
||||
|
||||
If you need to run the simulation manually, here is exactly what the automated
|
||||
script does under the hood:
|
||||
|
||||
### 1. Prepare Workspace & Knowledge Source
|
||||
|
||||
```bash
|
||||
WORKSPACE_DIR="/tmp/gemini_docker_workspace"
|
||||
mkdir -p "$WORKSPACE_DIR"
|
||||
touch "$WORKSPACE_DIR/knowledge.md"
|
||||
chmod -R 777 "$WORKSPACE_DIR"
|
||||
```
|
||||
|
||||
### 2. Bypass Interactive Startup Dialogs
|
||||
|
||||
To prevent the simulator from getting stuck on the initial Auth or Folder Trust
|
||||
screens, generate a global `settings.json` file.
|
||||
|
||||
```bash
|
||||
mkdir -p "$WORKSPACE_DIR/.gemini"
|
||||
echo '{
|
||||
"security": {
|
||||
"auth": { "selectedType": "gemini-api-key" },
|
||||
"folderTrust": { "enabled": false }
|
||||
}
|
||||
}' > "$WORKSPACE_DIR/.gemini/settings.json"
|
||||
chmod 777 "$WORKSPACE_DIR/.gemini/settings.json"
|
||||
```
|
||||
|
||||
### 3. Build the Image
|
||||
|
||||
```bash
|
||||
GEMINI_SANDBOX=docker npm run build:sandbox -- -i gemini-cli-simulator:latest
|
||||
```
|
||||
|
||||
### 4. Run the Container
|
||||
|
||||
Notice the `--init` flag (for `Ctrl+C` support) and the explicit mount mapping
|
||||
the `settings.json` file into `/home/node/.gemini/` inside the container.
|
||||
|
||||
```bash
|
||||
docker run -it --rm --init \
|
||||
-v "$WORKSPACE_DIR:/workspace" \
|
||||
-v "$WORKSPACE_DIR/.gemini/settings.json:/home/node/.gemini/settings.json" \
|
||||
-w /workspace \
|
||||
-e GEMINI_API_KEY="$GEMINI_API_KEY" \
|
||||
-e GEMINI_DEBUG_LOG_FILE="/workspace/debug.log" \
|
||||
gemini-cli-simulator:latest \
|
||||
gemini --prompt-interactive "make a snake game in python" \
|
||||
--approval-mode plan \
|
||||
--simulate-user \
|
||||
--knowledge-source "/workspace/knowledge.md"
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Once the simulation completes, verify the results in your workspace folder:
|
||||
|
||||
1. **Generated Code:** Check for project files (e.g., `snake.py`).
|
||||
2. **Persistent Knowledge:** Check `knowledge.md`. You should see new rules
|
||||
dynamically appended by the simulator.
|
||||
3. **Logs:**
|
||||
- `debug.log`: Detailed internal LLM decision logic.
|
||||
- `interactions_<timestamp>.txt`: Raw screen scrape frames seen by the
|
||||
simulator's "eyes".
|
||||
@@ -130,7 +130,9 @@ These are the only allowed tools:
|
||||
[`cli_help`](../core/subagents.md#cli-help-agent)
|
||||
- **Interaction:** [`ask_user`](../tools/ask-user.md)
|
||||
- **MCP tools (Read):** Read-only [MCP tools](../tools/mcp-server.md) (for
|
||||
example, `github_read_issue`, `postgres_read_schema`) are allowed.
|
||||
example, `github_read_issue`, `postgres_read_schema`) and core
|
||||
[MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`,
|
||||
`read_mcp_resource`) are allowed.
|
||||
- **Planning (Write):**
|
||||
[`write_file`](../tools/file-system.md#3-write_file-writefile) and
|
||||
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
|
||||
|
||||
@@ -24,20 +24,21 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
|
||||
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ----------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Terminal Notifications | `general.enableNotifications` | Enable terminal run-event notifications for action-required prompts and session completion. | `false` |
|
||||
| Terminal Notification Method | `general.notificationMethod` | How to send terminal notifications. | `"auto"` |
|
||||
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
|
||||
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
|
||||
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
|
||||
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
|
||||
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
|
||||
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
|
||||
|
||||
### Output
|
||||
|
||||
|
||||
@@ -134,10 +134,15 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.enableNotifications`** (boolean):
|
||||
- **Description:** Enable run-event notifications for action-required prompts
|
||||
and session completion.
|
||||
- **Description:** Enable terminal run-event notifications for action-required
|
||||
prompts and session completion.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.notificationMethod`** (enum):
|
||||
- **Description:** How to send terminal notifications.
|
||||
- **Default:** `"auto"`
|
||||
- **Values:** `"auto"`, `"osc9"`, `"osc777"`, `"bell"`
|
||||
|
||||
- **`general.checkpointing.enabled`** (boolean):
|
||||
- **Description:** Enable session checkpointing for recovery
|
||||
- **Default:** `false`
|
||||
|
||||
@@ -120,6 +120,12 @@ There are three possible decisions a rule can enforce:
|
||||
|
||||
### Priority system and tiers
|
||||
|
||||
> [!WARNING] The **Workspace** tier (project-level policies) is currently
|
||||
> non-functional. Defining policies in a workspace's `.gemini/policies`
|
||||
> directory will not have any effect. See
|
||||
> [issue #18186](https://github.com/google-gemini/gemini-cli/issues/18186). Use
|
||||
> User or Admin policies instead.
|
||||
|
||||
The policy engine uses a sophisticated priority system to resolve conflicts when
|
||||
multiple rules match a single tool call. The core principle is simple: **the
|
||||
rule with the highest priority wins**.
|
||||
@@ -127,13 +133,13 @@ rule with the highest priority wins**.
|
||||
To provide a clear hierarchy, policies are organized into three tiers. Each tier
|
||||
has a designated number that forms the base of the final priority calculation.
|
||||
|
||||
| Tier | Base | Description |
|
||||
| :-------- | :--- | :-------------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with Gemini CLI. |
|
||||
| Extension | 2 | Policies defined in extensions. |
|
||||
| Workspace | 3 | Policies defined in the current workspace's configuration directory. |
|
||||
| User | 4 | Custom policies defined by the user. |
|
||||
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
|
||||
| Tier | Base | Description |
|
||||
| :-------- | :--- | :-------------------------------------------------------------------------------------------- |
|
||||
| Default | 1 | Built-in policies that ship with Gemini CLI. |
|
||||
| Extension | 2 | Policies defined in extensions. |
|
||||
| Workspace | 3 | **(Currently disabled)** Policies defined in the current workspace's configuration directory. |
|
||||
| User | 4 | Custom policies defined by the user. |
|
||||
| Admin | 5 | Policies managed by an administrator (for example, in an enterprise environment). |
|
||||
|
||||
Within a TOML policy file, you assign a priority value from **0 to 999**. The
|
||||
engine transforms this into a final priority using the following formula:
|
||||
@@ -214,11 +220,11 @@ User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :---------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :------------------------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
|
||||
@@ -92,6 +92,13 @@ each tool.
|
||||
| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. |
|
||||
| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. |
|
||||
|
||||
### MCP
|
||||
|
||||
| Tool | Kind | Description |
|
||||
| :------------------------------------------------ | :------- | :--------------------------------------------------------------------- |
|
||||
| [`list_mcp_resources`](../tools/mcp-resources.md) | `Search` | Lists all available resources exposed by connected MCP servers. |
|
||||
| [`read_mcp_resource`](../tools/mcp-resources.md) | `Read` | Reads the content of a specific Model Context Protocol (MCP) resource. |
|
||||
|
||||
### Memory
|
||||
|
||||
| Tool | Kind | Description |
|
||||
|
||||
@@ -122,7 +122,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{
|
||||
"label": "MCP servers",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Resource tools", "slug": "docs/tools/mcp-resources" }
|
||||
]
|
||||
},
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# MCP resource tools
|
||||
|
||||
MCP resource tools let Gemini CLI discover and retrieve data from contextual
|
||||
resources exposed by Model Context Protocol (MCP) servers.
|
||||
|
||||
## 1. `list_mcp_resources` (ListMcpResources)
|
||||
|
||||
`list_mcp_resources` retrieves a list of all available resources from connected
|
||||
MCP servers. This is primarily a discovery tool that helps the model understand
|
||||
what external data sources are available for reference.
|
||||
|
||||
- **Tool name:** `list_mcp_resources`
|
||||
- **Display name:** List MCP Resources
|
||||
- **Kind:** `Search`
|
||||
- **File:** `list-mcp-resources.ts`
|
||||
- **Parameters:**
|
||||
- `serverName` (string, optional): An optional filter to list resources from a
|
||||
specific server.
|
||||
- **Behavior:**
|
||||
- Iterates through all connected MCP servers.
|
||||
- Fetches the list of resources each server exposes.
|
||||
- Formats the results into a plain-text list of URIs and descriptions.
|
||||
- **Output (`llmContent`):** A formatted list of available resources, including
|
||||
their URI, server name, and optional description.
|
||||
- **Confirmation:** No. This is a read-only discovery tool.
|
||||
|
||||
## 2. `read_mcp_resource` (ReadMcpResource)
|
||||
|
||||
`read_mcp_resource` retrieves the content of a specific resource identified by
|
||||
its URI.
|
||||
|
||||
- **Tool name:** `read_mcp_resource`
|
||||
- **Display name:** Read MCP Resource
|
||||
- **Kind:** `Read`
|
||||
- **File:** `read-mcp-resource.ts`
|
||||
- **Parameters:**
|
||||
- `uri` (string, required): The URI of the MCP resource to read.
|
||||
- **Behavior:**
|
||||
- Locates the resource and its associated server by URI.
|
||||
- Calls the server's `resources/read` method.
|
||||
- Processes the response, extracting text or binary data.
|
||||
- **Output (`llmContent`):** The content of the resource. For binary data, it
|
||||
returns a placeholder indicating the data type.
|
||||
- **Confirmation:** No. This is a read-only retrieval tool.
|
||||
@@ -64,7 +64,8 @@ Gemini CLI supports three MCP transport types:
|
||||
|
||||
Some MCP servers expose contextual “resources” in addition to the tools and
|
||||
prompts. Gemini CLI discovers these automatically and gives you the possibility
|
||||
to reference them in the chat.
|
||||
to reference them in the chat. For more information on the tools used to
|
||||
interact with these resources, see [MCP resource tools](mcp-resources.md).
|
||||
|
||||
### Discovery and listing
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import path from 'node:path';
|
||||
|
||||
describe('Background Process Monitoring', () => {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should naturally use read output tool to find token',
|
||||
prompt:
|
||||
"Run the script using 'bash generate_token.sh'. It will emit a token after a short delay and continue running. Find the token and tell me what it is.",
|
||||
@@ -50,6 +52,8 @@ sleep 100
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should naturally use list tool to verify multiple processes',
|
||||
prompt:
|
||||
"Start three background processes that run 'sleep 100', 'sleep 200', and 'sleep 300' respectively. Verify that all three are currently running.",
|
||||
|
||||
@@ -298,6 +298,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
|
||||
params: {
|
||||
settings,
|
||||
@@ -333,7 +335,7 @@ describe('plan_mode', () => {
|
||||
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
|
||||
`Expected write_file to succeed, but got error: ${(planWrite?.toolRequest as any).error}`,
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
@@ -341,6 +343,8 @@ describe('plan_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should not exit plan mode or draft before informal agreement',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
describe('subtask delegation eval test cases', () => {
|
||||
@@ -22,6 +19,8 @@ describe('subtask delegation eval test cases', () => {
|
||||
* 3. Documenting (doc expert)
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate sequential subtasks to relevant experts using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
@@ -90,6 +89,8 @@ You are the doc expert. Document the provided implementation clearly.`,
|
||||
* to multiple subagents in parallel using the task tracker to manage state.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should delegate independent subtasks to specialists using the task tracker',
|
||||
params: {
|
||||
settings: {
|
||||
|
||||
@@ -119,6 +119,8 @@ describe('tracker_mode', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should correctly identify the task tracker storage location from the system prompt',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@google/gemini-cli-core": ["../packages/core/index.ts"],
|
||||
"@google/gemini-cli": ["../packages/cli/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["logs"],
|
||||
"references": [{ "path": "../packages/core" }, { "path": "../packages/cli" }]
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
import { evalTest, TestRig } from './test-helper.js';
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'Reproduction: Agent uses Object.create() for cloning/delegation',
|
||||
prompt:
|
||||
'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.',
|
||||
|
||||
@@ -21,6 +21,8 @@ describe('update_topic_behavior', () => {
|
||||
* more than 1/4 turns.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should be used at start, end and middle for complex tasks',
|
||||
prompt: `Create a simple users REST API using Express.
|
||||
1. Initialize a new npm project and install express.
|
||||
@@ -117,6 +119,8 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should NOT be used for informational coding tasks (Obvious)',
|
||||
approvalMode: 'default',
|
||||
prompt:
|
||||
@@ -142,6 +146,8 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should NOT be used for surgical symbol searches (Grey Area)',
|
||||
approvalMode: 'default',
|
||||
prompt:
|
||||
@@ -169,6 +175,8 @@ describe('update_topic_behavior', () => {
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should be used for medium complexity multi-step tasks',
|
||||
prompt:
|
||||
'Refactor the `users-api` project. Move the routing logic from src/app.ts into a new file src/routes.ts, and update app.ts to use the new routes file.',
|
||||
@@ -212,7 +220,9 @@ export default app;
|
||||
expect(topicCalls.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Verify it actually did the refactoring to ensure it didn't just fail immediately
|
||||
expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(rig.testDir!, 'src/routes.ts'))).toBe(
|
||||
true,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,6 +234,8 @@ export default app;
|
||||
* the prompt change that improves the behavior.
|
||||
*/
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'update_topic should not be called twice in a row',
|
||||
prompt: `
|
||||
We need to build a C compiler.
|
||||
|
||||
@@ -39,7 +39,11 @@ describe('web-fetch rate limiting', () => {
|
||||
const rateLimitedCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'web_fetch' &&
|
||||
log.toolRequest.error?.includes('Rate limit exceeded'),
|
||||
(
|
||||
('error' in log.toolRequest
|
||||
? (log.toolRequest as unknown as Record<string, string>)['error']
|
||||
: '') as string
|
||||
)?.includes('Rate limit exceeded'),
|
||||
);
|
||||
|
||||
expect(rateLimitedCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -164,7 +164,8 @@ describe.skipIf(skipFlaky)(
|
||||
);
|
||||
expect(blockHook).toBeDefined();
|
||||
expect(
|
||||
blockHook?.hookCall.stdout + blockHook?.hookCall.stderr,
|
||||
(blockHook?.hookCall.stdout || '') +
|
||||
(blockHook?.hookCall.stderr || ''),
|
||||
).toContain(blockMsg);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -0,0 +1,2 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_mcp_resources","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are the resources: test://resource1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_mcp_resource","args":{"uri":"test://resource1"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The content is: content of resource 1"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('mcp-resources-integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should list mcp resources', async () => {
|
||||
await rig.setup('mcp-list-resources-test', {
|
||||
settings: {
|
||||
model: {
|
||||
name: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
fakeResponsesPath: join(__dirname, 'mcp-list-resources.responses'),
|
||||
});
|
||||
|
||||
// Workaround for ProjectRegistry save issue
|
||||
const userGeminiDir = join(rig.homeDir!, '.gemini');
|
||||
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
|
||||
|
||||
// Add a dummy server to get setup done
|
||||
rig.addTestMcpServer('resource-server', {
|
||||
name: 'resource-server',
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// Overwrite the script with resource support
|
||||
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
|
||||
const scriptContent = `
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1',
|
||||
mimeType: 'text/plain',
|
||||
description: 'A test resource',
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const output = await rig.run({
|
||||
args: 'List all available MCP resources.',
|
||||
env: { GEMINI_API_KEY: 'dummy' },
|
||||
});
|
||||
|
||||
const foundCall = await rig.waitForToolCall('list_mcp_resources');
|
||||
expect(foundCall).toBeTruthy();
|
||||
expect(output).toContain('test://resource1');
|
||||
}, 60000);
|
||||
|
||||
it('should read mcp resource', async () => {
|
||||
await rig.setup('mcp-read-resource-test', {
|
||||
settings: {
|
||||
model: {
|
||||
name: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
fakeResponsesPath: join(__dirname, 'mcp-read-resource.responses'),
|
||||
});
|
||||
|
||||
// Workaround for ProjectRegistry save issue
|
||||
const userGeminiDir = join(rig.homeDir!, '.gemini');
|
||||
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
|
||||
|
||||
// Add a dummy server to get setup done
|
||||
rig.addTestMcpServer('resource-server', {
|
||||
name: 'resource-server',
|
||||
tools: [],
|
||||
});
|
||||
|
||||
// Overwrite the script with resource support
|
||||
const scriptPath = join(rig.testDir!, 'test-mcp-resource-server.mjs');
|
||||
const scriptContent = `
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'resource-server',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
resources: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Need to provide list resources so the tool is active!
|
||||
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
||||
return {
|
||||
resources: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1',
|
||||
mimeType: 'text/plain',
|
||||
description: 'A test resource',
|
||||
}
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
||||
if (request.params.uri === 'test://resource1') {
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri: 'test://resource1',
|
||||
mimeType: 'text/plain',
|
||||
text: 'This is the content of resource 1',
|
||||
}
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error('Resource not found');
|
||||
});
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
`;
|
||||
fs.writeFileSync(scriptPath, scriptContent);
|
||||
|
||||
const output = await rig.run({
|
||||
args: 'Read the MCP resource test://resource1.',
|
||||
env: { GEMINI_API_KEY: 'dummy' },
|
||||
});
|
||||
|
||||
const foundCall = await rig.waitForToolCall('read_mcp_resource');
|
||||
expect(foundCall).toBeTruthy();
|
||||
expect(output).toContain('content of resource 1');
|
||||
}, 60000);
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe('Plan Mode', () => {
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('Plan Mode', () => {
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
).toBe(true);
|
||||
});
|
||||
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
|
||||
@@ -270,13 +270,24 @@ describe('Plan Mode', () => {
|
||||
);
|
||||
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
|
||||
const modelNames = apiRequests.map(
|
||||
(r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown') || 'unknown',
|
||||
);
|
||||
|
||||
const proRequests = apiRequests.filter((r) =>
|
||||
r.attributes?.model?.includes('pro'),
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('pro'),
|
||||
);
|
||||
const flashRequests = apiRequests.filter((r) =>
|
||||
r.attributes?.model?.includes('flash'),
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('flash'),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -5,5 +5,9 @@
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [{ "path": "../packages/core" }]
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" },
|
||||
{ "path": "../packages/cli" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -489,8 +489,12 @@ async function generateSharedLargeChatData(tempDir: string) {
|
||||
|
||||
// Wait for streams to finish
|
||||
await Promise.all([
|
||||
new Promise((res) => activeResponsesStream.on('finish', res)),
|
||||
new Promise((res) => resumeResponsesStream.on('finish', res)),
|
||||
new Promise((res) =>
|
||||
activeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
new Promise((res) =>
|
||||
resumeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"lint:ci": "npm run lint:all",
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
@@ -94,6 +94,7 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"read-package-up": "^11.0.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
@@ -137,6 +138,7 @@
|
||||
"strip-ansi": "^7.1.2",
|
||||
"ts-prune": "^0.10.3",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vitest": "^3.2.4",
|
||||
"yargs": "^17.7.2"
|
||||
|
||||
@@ -278,6 +278,24 @@ describe('parseArguments', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('knowledgeSource', () => {
|
||||
it('should parse --knowledge-source flag with a path', async () => {
|
||||
process.argv = ['node', 'script.js', '--knowledge-source', 'mykb.md'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.knowledgeSource).toBe('mykb.md');
|
||||
});
|
||||
|
||||
it('should default to ~/.agents/kb.md when --knowledge-source is provided without a path', async () => {
|
||||
process.argv = ['node', 'script.js', '--knowledge-source'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
expect(argv.knowledgeSource).toBe(
|
||||
path.join(os.homedir(), '.agents', 'kb.md'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'long flags',
|
||||
@@ -909,6 +927,25 @@ describe('loadCliConfig', () => {
|
||||
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
|
||||
});
|
||||
|
||||
it('should enable simulateUser when knowledgeSource is provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--knowledge-source', 'k.txt'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getSimulateUser()).toBe(true);
|
||||
expect(config.getKnowledgeSource()).toBe(
|
||||
path.resolve(process.cwd(), 'k.txt'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable simulateUser when simulateUser flag is provided', async () => {
|
||||
process.argv = ['node', 'script.js', '--simulate-user'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getSimulateUser()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be non-interactive when isCommand is set', async () => {
|
||||
process.argv = ['node', 'script.js', 'mcp', 'list'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
|
||||
@@ -8,6 +8,7 @@ import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { execa } from 'execa';
|
||||
import { mcpCommand } from '../commands/mcp.js';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
@@ -79,6 +80,7 @@ export interface CliArgs {
|
||||
model: string | undefined;
|
||||
sandbox: boolean | string | undefined;
|
||||
debug: boolean | undefined;
|
||||
disableStreaming?: boolean;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
worktree?: string;
|
||||
@@ -106,6 +108,8 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
simulateUser: boolean | undefined;
|
||||
knowledgeSource: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,6 +422,10 @@ export async function parseArguments(
|
||||
type: 'boolean',
|
||||
description: 'Enable screen reader mode for accessibility.',
|
||||
})
|
||||
.option('disable-streaming', {
|
||||
type: 'boolean',
|
||||
description: 'Disable streaming responses from the model',
|
||||
})
|
||||
.option('output-format', {
|
||||
alias: 'o',
|
||||
type: 'string',
|
||||
@@ -443,6 +451,24 @@ export async function parseArguments(
|
||||
.option('accept-raw-output-risk', {
|
||||
type: 'boolean',
|
||||
description: 'Suppress the security warning when using --raw-output.',
|
||||
})
|
||||
.option('simulate-user', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Run the user simulation agent in the background for evaluation purposes.',
|
||||
})
|
||||
.option('knowledge-source', {
|
||||
type: 'string',
|
||||
skipValidation: true,
|
||||
description:
|
||||
'A file path to load into the user simulator context and update with new knowledge. Defaults to ~/.agents/kb.md if passed without a value.',
|
||||
coerce: (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') {
|
||||
return path.join(os.homedir(), '.agents', 'kb.md');
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
}),
|
||||
)
|
||||
.version(await getVersion()) // This will enable the --version flag based on package.json
|
||||
@@ -897,6 +923,7 @@ export async function loadCliConfig(
|
||||
return new Config({
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
disableStreaming: argv.disableStreaming,
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
@@ -948,6 +975,10 @@ export async function loadCliConfig(
|
||||
approvalMode,
|
||||
disableYoloMode:
|
||||
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
|
||||
simulateUser: !!argv.simulateUser || !!argv.knowledgeSource,
|
||||
knowledgeSource: argv.knowledgeSource
|
||||
? path.resolve(cwd, resolvePath(argv.knowledgeSource))
|
||||
: undefined,
|
||||
disableAlwaysAllow:
|
||||
settings.security?.disableAlwaysAllow ||
|
||||
settings.admin?.secureModeEnabled,
|
||||
|
||||
@@ -256,14 +256,29 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
enableNotifications: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Notifications',
|
||||
label: 'Enable Terminal Notifications',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable run-event notifications for action-required prompts and session completion.',
|
||||
'Enable terminal run-event notifications for action-required prompts and session completion.',
|
||||
showInDialog: true,
|
||||
},
|
||||
notificationMethod: {
|
||||
type: 'enum',
|
||||
label: 'Terminal Notification Method',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: 'auto',
|
||||
description: 'How to send terminal notifications.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'auto', label: 'Auto' },
|
||||
{ value: 'osc9', label: 'OSC 9' },
|
||||
{ value: 'osc777', label: 'OSC 777' },
|
||||
{ value: 'bell', label: 'Bell' },
|
||||
],
|
||||
},
|
||||
checkpointing: {
|
||||
type: 'object',
|
||||
label: 'Checkpointing',
|
||||
|
||||
@@ -555,6 +555,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
knowledgeSource: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -613,6 +615,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
knowledgeSource: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,11 +9,19 @@ import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { UserSimulator } from './services/UserSimulator.js';
|
||||
import {
|
||||
registerCleanup,
|
||||
removeCleanup,
|
||||
setupTtyCheck,
|
||||
} from './utils/cleanup.js';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
interface RenderMetrics {
|
||||
renderTime: number;
|
||||
output: string;
|
||||
staticOutput?: string;
|
||||
}
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
@@ -135,6 +143,12 @@ export async function startInteractiveUI(
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const simulateUser = config.getSimulateUser();
|
||||
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
|
||||
|
||||
let lastFrame: string | undefined;
|
||||
const staticHistory: string[] = [];
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -146,12 +160,20 @@ export async function startInteractiveUI(
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
|
||||
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
onRender: (metrics: RenderMetrics) => {
|
||||
lastFrame = metrics.output;
|
||||
if (metrics.staticOutput) {
|
||||
staticHistory.push(metrics.staticOutput);
|
||||
if (staticHistory.length > 50) {
|
||||
staticHistory.shift();
|
||||
}
|
||||
}
|
||||
if (metrics.renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, metrics.renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
@@ -188,6 +210,21 @@ export async function startInteractiveUI(
|
||||
}
|
||||
});
|
||||
|
||||
if (simulateUser) {
|
||||
const simulator = new UserSimulator(
|
||||
config,
|
||||
() => {
|
||||
if (lastFrame === undefined) return undefined;
|
||||
// Combine history with latest frame for the simulator
|
||||
const historyText = staticHistory.join('\n');
|
||||
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
|
||||
},
|
||||
simulatedStdin,
|
||||
);
|
||||
simulator.start();
|
||||
registerCleanup(() => simulator.stop());
|
||||
}
|
||||
|
||||
const cleanupUnmount = () => instance.unmount();
|
||||
registerCleanup(cleanupUnmount);
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('SkillCommandLoader', () => {
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
toolArgs: { name: 'test-skill' },
|
||||
postSubmitPrompt: undefined,
|
||||
postSubmitPrompt: 'Use the skill test-skill',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ export class SkillCommandLoader implements ICommandLoader {
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
toolArgs: { name: skill.name },
|
||||
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
|
||||
postSubmitPrompt:
|
||||
args.trim().length > 0
|
||||
? args.trim()
|
||||
: `Use the skill ${skill.name}`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
LlmRole,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
resolveModel,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Writable } from 'node:stream';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
interface SimulatorResponse {
|
||||
action?: string;
|
||||
thought?: string;
|
||||
used_knowledge?: boolean;
|
||||
new_rule?: string;
|
||||
}
|
||||
|
||||
export class UserSimulator {
|
||||
private isRunning = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private lastScreenContent = '';
|
||||
private isProcessing = false;
|
||||
private interactionsFile: string | null = null;
|
||||
|
||||
private knowledgeBase = '';
|
||||
private editableKnowledgeFile: string | null = null;
|
||||
private actionHistory: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly getScreen: () => string | undefined,
|
||||
private readonly stdinBuffer: Writable,
|
||||
) {}
|
||||
|
||||
start() {
|
||||
if (!this.config.getSimulateUser()) {
|
||||
return;
|
||||
}
|
||||
const source = this.config.getKnowledgeSource?.();
|
||||
if (source) {
|
||||
if (!fs.existsSync(source)) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(source), { recursive: true });
|
||||
fs.writeFileSync(source, '', 'utf8');
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to create knowledge file at ${source}`, e);
|
||||
}
|
||||
}
|
||||
this.editableKnowledgeFile = source;
|
||||
this.loadKnowledge(source);
|
||||
}
|
||||
this.interactionsFile = `interactions_${Date.now()}.txt`;
|
||||
this.isRunning = true;
|
||||
this.timer = setInterval(() => this.tick(), 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false;
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
debugLogger.log('User simulator stopped');
|
||||
}
|
||||
|
||||
private loadKnowledge(p: string) {
|
||||
try {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const stats = fs.statSync(p);
|
||||
if (stats.isFile()) {
|
||||
const content = fs.readFileSync(p, 'utf-8');
|
||||
if (content.trim()) {
|
||||
this.knowledgeBase = content + '\n';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to load knowledge from ${p}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
private async tick() {
|
||||
if (!this.isRunning || this.isProcessing) return;
|
||||
|
||||
try {
|
||||
this.isProcessing = true;
|
||||
const screen = this.getScreen();
|
||||
if (!screen) return;
|
||||
|
||||
const strippedScreen = screen
|
||||
.replace(
|
||||
// eslint-disable-next-line no-control-regex
|
||||
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
||||
'',
|
||||
)
|
||||
.replace(/\n([ \t]*\n)+/g, '\n\n');
|
||||
|
||||
const normalizedScreen = strippedScreen
|
||||
.replace(/[\u2800-\u28FF]/g, '')
|
||||
.replace(/[|/-\\]/g, '')
|
||||
.replace(/\b\d+(\.\d+)?s\b/g, '')
|
||||
.replace(/\b\d+m(\s+\d+s)?\b/g, '')
|
||||
.replace(/\(\s*\)/g, '')
|
||||
.trim();
|
||||
|
||||
if (normalizedScreen === this.lastScreenContent) return;
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentGenerator = this.config.getContentGenerator();
|
||||
if (!contentGenerator) return;
|
||||
|
||||
const originalGoal = this.config.getQuestion();
|
||||
const goalInstruction = originalGoal
|
||||
? `\nThe original goal was: "${originalGoal}"\n`
|
||||
: '';
|
||||
|
||||
const knowledgeInstruction = this.knowledgeBase
|
||||
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
|
||||
: '';
|
||||
|
||||
const historyInstruction =
|
||||
this.actionHistory.length > 0
|
||||
? `\nRecent Simulator Actions (last 10):\n${this.actionHistory
|
||||
.slice(-10)
|
||||
.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`)
|
||||
.join('\n')}\n`
|
||||
: '';
|
||||
|
||||
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
|
||||
Look carefully at the screen and determine the CLI's current state:
|
||||
|
||||
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
|
||||
- In this case, your action MUST be exactly: <WAIT>
|
||||
|
||||
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
|
||||
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
|
||||
|
||||
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
|
||||
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
|
||||
- If the task is indeed fully complete, your action should be "Thank you\\r" to graciously finish the simulation.
|
||||
- If you have already said thank you, your action MUST be exactly: <DONE>
|
||||
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
|
||||
|
||||
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
|
||||
- Your action should be the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
|
||||
|
||||
CRITICAL RULES:
|
||||
- RULE 1: If there is ANY active spinner (e.g., ⠋, ⠙, ⠹, ⠸, ⠼, ⠴, ⠧) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. Your action MUST be <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
|
||||
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
|
||||
- RULE 3: If prompted to allow execution of a command with options like 'Allow once' and 'Allow for this session', you MUST choose the option for 'Allow for this session' (typically by sending '2\\r').
|
||||
- RULE 4: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
|
||||
|
||||
JSON FORMAT:
|
||||
{
|
||||
"action": "<The exact raw characters to send, <WAIT>, or <DONE>>",
|
||||
"used_knowledge": <true if you used the User Knowledge Base below to answer this prompt, false otherwise>,
|
||||
"new_rule": "<If used_knowledge is false and action is not <WAIT> or <DONE>, formulate a single, clear, reusable one-line rule combining the question and your answer without using option numbers (e.g. 1, 2) that might change. For example: 'If asked to allow pip execution, always allow it.' or 'Automatically accept edits for snake game implementation.'>"
|
||||
}
|
||||
${goalInstruction}${knowledgeInstruction}${historyInstruction}
|
||||
|
||||
Here is the current terminal screen output:
|
||||
|
||||
<screen>
|
||||
${strippedScreen}
|
||||
</screen>`;
|
||||
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const model = resolveModel(
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
false, // useGemini3_1
|
||||
false, // useGemini3_1FlashLite
|
||||
false, // useCustomToolModel
|
||||
this.config.getHasAccessToPreviewModel?.() ?? true,
|
||||
this.config,
|
||||
);
|
||||
|
||||
const response = await contentGenerator.generateContent(
|
||||
{
|
||||
model,
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
},
|
||||
'simulator-prompt',
|
||||
LlmRole.UTILITY_SIMULATOR,
|
||||
);
|
||||
|
||||
let responseText = '';
|
||||
let parsedJson: SimulatorResponse = {};
|
||||
try {
|
||||
let cleanJson = response.text || '';
|
||||
const startIdx = cleanJson.indexOf('{');
|
||||
const endIdx = cleanJson.lastIndexOf('}');
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
cleanJson = cleanJson.substring(startIdx, endIdx + 1);
|
||||
} else {
|
||||
cleanJson = cleanJson.replace(/^```json\s*|\s*```$/gm, '').trim();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
parsedJson = JSON.parse(cleanJson) as SimulatorResponse;
|
||||
responseText = parsedJson.action || '';
|
||||
} catch (err) {
|
||||
debugLogger.error('Failed to parse simulator response as JSON', err);
|
||||
const text = (response.text || '').trim();
|
||||
if (
|
||||
text === '<WAIT>' ||
|
||||
text === '<DONE>' ||
|
||||
/^\d+\\r$/.test(text) ||
|
||||
text === '\\r'
|
||||
) {
|
||||
responseText = text.replace(/^[`"']+|[`"']+$/g, '');
|
||||
} else {
|
||||
responseText = ''; // Prevent typing broken JSON string
|
||||
}
|
||||
}
|
||||
|
||||
const trimmedResponse = responseText.trim();
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
|
||||
);
|
||||
}
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
|
||||
);
|
||||
|
||||
if (trimmedResponse === '<DONE>') {
|
||||
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
|
||||
debugLogger.log(msg);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`\n${msg}`);
|
||||
this.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (trimmedResponse === '<WAIT>') {
|
||||
debugLogger.log(
|
||||
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
|
||||
);
|
||||
this.actionHistory.push('<WAIT>');
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Action History updated with: "<WAIT>"\n\n`,
|
||||
);
|
||||
}
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseText) {
|
||||
const keys = responseText
|
||||
.replace(/\\n|\n/g, '\r')
|
||||
.replace(/\\r/g, '\r');
|
||||
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
|
||||
);
|
||||
|
||||
this.actionHistory.push(keys);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Action History updated with: ${JSON.stringify(keys)}\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!parsedJson.used_knowledge &&
|
||||
parsedJson.new_rule &&
|
||||
this.editableKnowledgeFile
|
||||
) {
|
||||
const newKnowledge = `- ${parsedJson.new_rule}\n`;
|
||||
this.knowledgeBase += newKnowledge;
|
||||
try {
|
||||
fs.appendFileSync(this.editableKnowledgeFile, newKnowledge);
|
||||
debugLogger.log(
|
||||
`[SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}`,
|
||||
);
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Saved new knowledge to ${this.editableKnowledgeFile}\n\n`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugLogger.error(`Failed to append knowledge`, e);
|
||||
}
|
||||
}
|
||||
|
||||
for (const char of keys) {
|
||||
if (char === '\r') {
|
||||
// Wait a bit to ensure the previous character is rendered before submitting
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
this.stdinBuffer.write(char);
|
||||
// Small delay to ensure Ink processes each keypress event individually
|
||||
// while preventing UI state collisions during long simulated inputs.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
} else {
|
||||
debugLogger.log('[SIMULATOR] Skipping (empty response)');
|
||||
|
||||
this.actionHistory.push('<EMPTY>');
|
||||
if (this.interactionsFile) {
|
||||
fs.appendFileSync(
|
||||
this.interactionsFile,
|
||||
`[LOG] [SIMULATOR] Action History updated with: "<EMPTY>"\n\n`,
|
||||
);
|
||||
}
|
||||
|
||||
this.lastScreenContent = normalizedScreen;
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
debugLogger.error('UserSimulator tick failed', e);
|
||||
} finally {
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
setSessionId: vi.fn(),
|
||||
resetNewSessionState: vi.fn(),
|
||||
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
|
||||
getWorktreeSettings: vi.fn(() => undefined),
|
||||
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
|
||||
@@ -64,6 +65,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getGeminiMdFileCount: vi.fn(() => 0),
|
||||
getDeferredCommand: vi.fn(() => undefined),
|
||||
getFileSystemService: vi.fn(() => ({})),
|
||||
getSimulateUser: vi.fn(() => false),
|
||||
clientVersion: '1.0.0',
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
|
||||
|
||||
@@ -53,6 +53,7 @@ const mocks = vi.hoisted(() => ({
|
||||
const terminalNotificationsMocks = vi.hoisted(() => ({
|
||||
notifyViaTerminal: vi.fn().mockResolvedValue(true),
|
||||
isNotificationsEnabled: vi.fn(() => true),
|
||||
getNotificationMethod: vi.fn(() => 'auto'),
|
||||
buildRunEventNotificationContent: vi.fn((event) => ({
|
||||
title: 'Mock Notification',
|
||||
subtitle: 'Mock Subtitle',
|
||||
@@ -194,6 +195,7 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
|
||||
vi.mock('../utils/terminalNotifications.js', () => ({
|
||||
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
|
||||
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
|
||||
getNotificationMethod: terminalNotificationsMocks.getNotificationMethod,
|
||||
buildRunEventNotificationContent:
|
||||
terminalNotificationsMocks.buildRunEventNotificationContent,
|
||||
}));
|
||||
|
||||
@@ -181,7 +181,10 @@ import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
|
||||
import { useSuspend } from './hooks/useSuspend.js';
|
||||
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
|
||||
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
|
||||
import {
|
||||
isNotificationsEnabled,
|
||||
getNotificationMethod,
|
||||
} from '../utils/terminalNotifications.js';
|
||||
import {
|
||||
getLastTurnToolCallIds,
|
||||
isToolExecuting,
|
||||
@@ -225,6 +228,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
const settings = useSettings();
|
||||
const { reset } = useOverflowActions()!;
|
||||
const notificationsEnabled = isNotificationsEnabled(settings);
|
||||
const notificationMethod = getNotificationMethod(settings);
|
||||
|
||||
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
|
||||
useContext(InkAppContext);
|
||||
@@ -1403,7 +1407,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
|
||||
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
|
||||
debugLogger.log(
|
||||
`[AppContainer] handleFinalSubmit: streamingState=${streamingState}, isIdle=${isIdle}, isSlash=${isSlash}`,
|
||||
);
|
||||
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
|
||||
debugLogger.log(
|
||||
`[AppContainer] handleFinalSubmit: condition met, calling submitQuery`,
|
||||
);
|
||||
if (!isSlash) {
|
||||
const permissions = await checkPermissions(submittedValue, config);
|
||||
if (permissions.length > 0) {
|
||||
@@ -2284,6 +2294,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
useRunEventNotifications({
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
streamingState,
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('clearCommand', () => {
|
||||
agentContext: {
|
||||
config: {
|
||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||
setSessionId: vi.fn(),
|
||||
resetNewSessionState: vi.fn(),
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -74,6 +74,9 @@ describe('clearCommand', () => {
|
||||
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
expect(mockHintClear).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mockContext.services.agentContext?.config.resetNewSessionState,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(uiTelemetryService.clear).toHaveBeenCalled();
|
||||
expect(uiTelemetryService.clear).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -39,7 +39,7 @@ export const clearCommand: SlashCommand = {
|
||||
let newSessionId: string | undefined;
|
||||
if (config) {
|
||||
newSessionId = randomUUID();
|
||||
config.setSessionId(newSessionId);
|
||||
config.resetNewSessionState(newSessionId);
|
||||
}
|
||||
|
||||
if (geminiClient) {
|
||||
|
||||
@@ -868,24 +868,28 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
: undefined;
|
||||
|
||||
// Reserve space for at least 3 items if more selectionItems available.
|
||||
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
|
||||
|
||||
const questionHeightLimit =
|
||||
listHeight && !isAlternateBuffer
|
||||
? question.unconstrainedHeight
|
||||
? Math.max(1, listHeight - selectionItems.length * 2)
|
||||
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
|
||||
: Math.min(
|
||||
30,
|
||||
Math.max(1, listHeight - Math.min(selectionItems.length, 5) * 2),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const maxItemsToShow =
|
||||
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
|
||||
? Math.min(
|
||||
selectionItems.length,
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
|
||||
),
|
||||
)
|
||||
: selectionItems.length;
|
||||
let maxItemsToShow = selectionItems.length;
|
||||
if (listHeight && (!isAlternateBuffer || availableHeight !== undefined)) {
|
||||
if (selectionItems.length <= 5) {
|
||||
maxItemsToShow = selectionItems.length;
|
||||
} else {
|
||||
maxItemsToShow = Math.min(
|
||||
selectionItems.length,
|
||||
Math.max(1, Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -79,6 +79,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
setState({ status: PlanStatus.Loading });
|
||||
debugLogger.debug('usePlanContent loading plan:', planPath);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -125,6 +126,10 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
|
||||
setState({ status: PlanStatus.Error, error: 'Plan file is empty.' });
|
||||
return;
|
||||
}
|
||||
debugLogger.debug(
|
||||
'usePlanContent loaded successfully, length:',
|
||||
content.length,
|
||||
);
|
||||
setState({ status: PlanStatus.Loaded, content });
|
||||
} catch (err: unknown) {
|
||||
if (ignore) return;
|
||||
|
||||
@@ -375,6 +375,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleSubmitAndClear = useCallback(
|
||||
(submittedValue: string) => {
|
||||
debugLogger.log(`[InputPrompt] handleSubmitAndClear: \${submittedValue}`);
|
||||
let processedValue = submittedValue;
|
||||
if (buffer.pastedContent) {
|
||||
processedValue = expandPastePlaceholders(
|
||||
@@ -425,6 +426,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
debugLogger.log(`[InputPrompt] handleSubmit: \${submittedValue}`);
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
@@ -649,6 +651,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
debugLogger.log(
|
||||
`[UI INPUT] handleInput received key: ${JSON.stringify(key)}`,
|
||||
);
|
||||
// Determine if this keypress is a history navigation command
|
||||
const isHistoryUp =
|
||||
!shellModeActive &&
|
||||
@@ -1214,9 +1219,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SUBMIT](key)) {
|
||||
debugLogger.log(
|
||||
`[InputPrompt] Command.SUBMIT matched, buffer.text="${buffer.text}"`,
|
||||
);
|
||||
if (buffer.text.trim()) {
|
||||
// Check if a paste operation occurred recently to prevent accidental auto-submission
|
||||
if (recentUnsafePasteTime !== null) {
|
||||
debugLogger.log(
|
||||
`[InputPrompt] Command.SUBMIT ignored due to recentUnsafePasteTime`,
|
||||
);
|
||||
// Paste occurred recently in a terminal where we don't trust pastes
|
||||
// to be reported correctly so assume this paste was really a
|
||||
// newline that was part of the paste.
|
||||
@@ -1234,8 +1245,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
buffer.backspace();
|
||||
buffer.newline();
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[InputPrompt] Calling handleSubmit from handleInput`,
|
||||
);
|
||||
handleSubmit(buffer.text);
|
||||
}
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[InputPrompt] Command.SUBMIT ignored because buffer is empty`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,12 +14,24 @@ Spinner Working...
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
|
||||
"
|
||||
Spinner Working...
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
Spinner Working...
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
|
||||
"
|
||||
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -56,47 +56,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -67,47 +67,47 @@
|
||||
<text x="0" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="291" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
|
||||
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
|
||||
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</text>
|
||||
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="359" fill="#ffffff" textLength="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</text>
|
||||
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="45" y="410" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="45" y="427" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
|
||||
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="45" y="461" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
|
||||
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="478" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
|
||||
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
|
||||
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
|
||||
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -19,8 +19,11 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -65,8 +65,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -111,8 +111,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable Auto Update true* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -157,8 +157,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -203,8 +203,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -249,8 +249,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -295,8 +295,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -341,8 +341,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -387,8 +387,11 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ Enable Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ Enable Terminal Notifications false │
|
||||
│ Enable terminal run-event notifications for action-required prompts and session com… │
|
||||
│ │
|
||||
│ Terminal Notification Method Auto │
|
||||
│ How to send terminal notifications. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Plan Model Routing true │
|
||||
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
|
||||
│ │
|
||||
│ Retry Fetch Errors true │
|
||||
│ Retry on "exception TypeError: fetch failed sending request" errors. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -246,11 +246,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
(showClosingBorder ? 1 : 0);
|
||||
} else if (isTopicToolCall) {
|
||||
// Topic Message Spacing Breakdown:
|
||||
// 1. Top Margin (1): Always present for spacing.
|
||||
// 2. Topic Content (1).
|
||||
// 3. Bottom Margin (1): Always present around TopicMessage for breathing room.
|
||||
// 4. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
|
||||
height += 1 + 1 + 1 + (showClosingBorder ? 1 : 0);
|
||||
// 1. Topic Content (1).
|
||||
// 2. Bottom Margin (1): Always present around TopicMessage for breathing room.
|
||||
// 3. Closing Border (1): Added if transition logic (showClosingBorder) requires it.
|
||||
height += 1 + 1 + (showClosingBorder ? 1 : 0);
|
||||
} else if (isCompact) {
|
||||
// Compact Tool: Always renders as a single dense line.
|
||||
height += 1;
|
||||
@@ -439,7 +438,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Box marginBottom={1}>
|
||||
<TopicMessage {...commonProps} />
|
||||
</Box>
|
||||
) : isShellToolCall ? (
|
||||
|
||||
@@ -77,8 +77,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
|
||||
"
|
||||
Testing Topic: This is the description
|
||||
" Testing Topic: This is the description
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ read_file Read a file │
|
||||
@@ -143,8 +142,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
|
||||
`;
|
||||
|
||||
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
|
||||
"
|
||||
Testing Topic: This is the description
|
||||
" Testing Topic: This is the description
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type React from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { Text, Box, type DOMElement } from 'ink';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
|
||||
@@ -56,6 +57,9 @@ export function TextInput({
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key) => {
|
||||
debugLogger.log(
|
||||
`[TEXT INPUT] handleKeyPress received key: ${JSON.stringify(key)}`,
|
||||
);
|
||||
if (key.name === 'escape' && onCancel) {
|
||||
onCancel();
|
||||
return true;
|
||||
|
||||
@@ -873,8 +873,15 @@ export function KeypressProvider({
|
||||
|
||||
process.stdin.setEncoding('utf8'); // Make data events emit strings
|
||||
|
||||
debugLogger.log(
|
||||
`[DEBUG] KeypressProvider simulateUser: ${config?.getSimulateUser()}`,
|
||||
);
|
||||
|
||||
let processor = nonKeyboardEventFilter(broadcast);
|
||||
if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
if (
|
||||
!terminalCapabilityManager.isKittyProtocolEnabled() &&
|
||||
!config?.getSimulateUser()
|
||||
) {
|
||||
processor = bufferFastReturn(processor);
|
||||
}
|
||||
processor = bufferBackslashEnter(processor);
|
||||
|
||||
@@ -15,12 +15,14 @@ import { getPendingAttentionNotification } from '../utils/pendingAttentionNotifi
|
||||
import {
|
||||
buildRunEventNotificationContent,
|
||||
notifyViaTerminal,
|
||||
type TerminalNotificationMethod,
|
||||
} from '../../utils/terminalNotifications.js';
|
||||
|
||||
const ATTENTION_NOTIFICATION_COOLDOWN_MS = 20_000;
|
||||
|
||||
interface RunEventNotificationParams {
|
||||
notificationsEnabled: boolean;
|
||||
notificationMethod: TerminalNotificationMethod;
|
||||
isFocused: boolean;
|
||||
hasReceivedFocusEvent: boolean;
|
||||
streamingState: StreamingState;
|
||||
@@ -36,6 +38,7 @@ interface RunEventNotificationParams {
|
||||
|
||||
export function useRunEventNotifications({
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
streamingState,
|
||||
@@ -124,11 +127,13 @@ export function useRunEventNotifications({
|
||||
void notifyViaTerminal(
|
||||
notificationsEnabled,
|
||||
buildRunEventNotificationContent(pendingAttentionNotification.event),
|
||||
notificationMethod,
|
||||
);
|
||||
}, [
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
pendingAttentionNotification,
|
||||
]);
|
||||
|
||||
@@ -159,12 +164,14 @@ export function useRunEventNotifications({
|
||||
type: 'session_complete',
|
||||
detail: 'Gemini CLI finished responding.',
|
||||
}),
|
||||
notificationMethod,
|
||||
);
|
||||
}, [
|
||||
streamingState,
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
hasPendingActionRequired,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-assignment */
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Text,
|
||||
@@ -210,7 +212,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map(
|
||||
(w) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
|
||||
w + COLUMN_PADDING,
|
||||
);
|
||||
|
||||
@@ -263,7 +265,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
isHeader = false,
|
||||
): React.ReactNode => {
|
||||
const renderedCells = cells.map((cell, index) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
|
||||
const width = adjustedWidths[index] || 0;
|
||||
return renderCell(cell, width, isHeader);
|
||||
});
|
||||
|
||||
@@ -365,76 +365,123 @@ describe('TerminalCapabilityManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('supportsOsc9Notifications', () => {
|
||||
describe('isTmux', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'WezTerm (terminal name)',
|
||||
terminalName: 'WezTerm',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'iTerm.app (terminal name)',
|
||||
terminalName: 'iTerm.app',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'ghostty (terminal name)',
|
||||
terminalName: 'ghostty',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'kitty (terminal name)',
|
||||
terminalName: 'kitty',
|
||||
env: {},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'some-other-term (terminal name)',
|
||||
terminalName: 'some-other-term',
|
||||
env: {},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'iTerm.app (TERM_PROGRAM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM_PROGRAM: 'iTerm.app' },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'vscode (TERM_PROGRAM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM_PROGRAM: 'vscode' },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'xterm-kitty (TERM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM: 'xterm-kitty' },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: 'xterm-256color (TERM)',
|
||||
terminalName: undefined,
|
||||
env: { TERM: 'xterm-256color' },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: 'Windows Terminal (WT_SESSION)',
|
||||
terminalName: 'iTerm.app',
|
||||
env: { WT_SESSION: 'some-guid' },
|
||||
expected: false,
|
||||
},
|
||||
])(
|
||||
'should return $expected for $name',
|
||||
({ terminalName, env, expected }) => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName);
|
||||
expect(manager.supportsOsc9Notifications(env)).toBe(expected);
|
||||
},
|
||||
);
|
||||
it('returns true when TMUX is set', () => {
|
||||
expect(manager.isTmux({ TMUX: '1' })).toBe(true);
|
||||
expect(manager.isTmux({ TMUX: 'tmux-1234' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when TMUX is not set', () => {
|
||||
expect(manager.isTmux({})).toBe(false);
|
||||
expect(manager.isTmux({ STY: '1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isScreen', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when STY is set', () => {
|
||||
expect(manager.isScreen({ STY: '1' })).toBe(true);
|
||||
expect(manager.isScreen({ STY: 'screen.1234' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when STY is not set', () => {
|
||||
expect(manager.isScreen({})).toBe(false);
|
||||
expect(manager.isScreen({ TMUX: '1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isITerm2', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when iTerm is in terminal name', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue('iTerm.app');
|
||||
expect(manager.isITerm2({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when TERM_PROGRAM is iTerm.app', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
|
||||
expect(manager.isITerm2({ TERM_PROGRAM: 'iTerm.app' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm');
|
||||
expect(manager.isITerm2({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAlacritty', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when ALACRITTY_WINDOW_ID is set', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
|
||||
expect(manager.isAlacritty({ ALACRITTY_WINDOW_ID: '123' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when TERM is alacritty', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
|
||||
expect(manager.isAlacritty({ TERM: 'alacritty' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when terminal name contains alacritty', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue('alacritty');
|
||||
expect(manager.isAlacritty({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
|
||||
expect(manager.isAlacritty({ TERM: 'xterm' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppleTerminal', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when apple_terminal is in terminal name', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue('apple_terminal');
|
||||
expect(manager.isAppleTerminal({})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when TERM_PROGRAM is Apple_Terminal', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue(undefined);
|
||||
expect(manager.isAppleTerminal({ TERM_PROGRAM: 'Apple_Terminal' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
vi.spyOn(manager, 'getTerminalName').mockReturnValue('xterm');
|
||||
expect(manager.isAppleTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVSCodeTerminal', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when TERM_PROGRAM is vscode', () => {
|
||||
expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'vscode' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
expect(manager.isVSCodeTerminal({ TERM_PROGRAM: 'iTerm.app' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWindowsTerminal', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
it('returns true when WT_SESSION is set', () => {
|
||||
expect(manager.isWindowsTerminal({ WT_SESSION: 'some-guid' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
expect(manager.isWindowsTerminal({})).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,31 +284,43 @@ export class TerminalCapabilityManager {
|
||||
);
|
||||
}
|
||||
|
||||
supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (env['WT_SESSION']) {
|
||||
return false;
|
||||
}
|
||||
isTmux(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env['TMUX'];
|
||||
}
|
||||
|
||||
return (
|
||||
this.hasOsc9TerminalSignature(this.getTerminalName()) ||
|
||||
this.hasOsc9TerminalSignature(env['TERM_PROGRAM']) ||
|
||||
this.hasOsc9TerminalSignature(env['TERM'])
|
||||
isScreen(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env['STY'];
|
||||
}
|
||||
|
||||
isITerm2(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!(
|
||||
this.getTerminalName()?.toLowerCase().includes('iterm') ||
|
||||
env['TERM_PROGRAM']?.toLowerCase().includes('iterm')
|
||||
);
|
||||
}
|
||||
|
||||
private hasOsc9TerminalSignature(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
return (
|
||||
normalized.includes('wezterm') ||
|
||||
normalized.includes('ghostty') ||
|
||||
normalized.includes('iterm') ||
|
||||
normalized.includes('kitty')
|
||||
isAlacritty(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!(
|
||||
this.getTerminalName()?.toLowerCase().includes('alacritty') ||
|
||||
env['ALACRITTY_WINDOW_ID'] ||
|
||||
env['TERM']?.toLowerCase().includes('alacritty')
|
||||
);
|
||||
}
|
||||
|
||||
isAppleTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!(
|
||||
this.getTerminalName()?.toLowerCase().includes('apple_terminal') ||
|
||||
env['TERM_PROGRAM']?.toLowerCase().includes('apple_terminal')
|
||||
);
|
||||
}
|
||||
|
||||
isVSCodeTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env['TERM_PROGRAM']?.toLowerCase().includes('vscode');
|
||||
}
|
||||
|
||||
isWindowsTerminal(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env['WT_SESSION'];
|
||||
}
|
||||
}
|
||||
|
||||
export const terminalCapabilityManager =
|
||||
|
||||
@@ -8,8 +8,13 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { spawn, exec, execFile, execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { start_sandbox } from './sandbox.js';
|
||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
FatalSandboxError,
|
||||
homedir,
|
||||
type SandboxConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -133,6 +138,7 @@ describe('sandbox', () => {
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
process.argv = originalArgv;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('start_sandbox', () => {
|
||||
@@ -171,6 +177,105 @@ describe('sandbox', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve custom seatbelt profile from user home directory', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes(
|
||||
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
|
||||
),
|
||||
);
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
|
||||
expect(spawnArgs).toEqual(
|
||||
expect.arrayContaining(['-f', expect.any(String)]),
|
||||
);
|
||||
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
|
||||
expect(profileArg).toEqual(
|
||||
expect.stringContaining(
|
||||
path.join(homedir(), '.gemini', 'sandbox-macos-custom-test.sb'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to project .gemini directory when user profile is missing', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.stubEnv('SEATBELT_PROFILE', 'custom-test');
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const s = String(p);
|
||||
return (
|
||||
s.includes(path.join('.gemini', 'sandbox-macos-custom-test.sb')) &&
|
||||
!s.includes(path.join(homedir(), '.gemini'))
|
||||
);
|
||||
});
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.any(Array),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
const spawnArgs = vi.mocked(spawn).mock.calls[0]?.[1];
|
||||
expect(spawnArgs).toEqual(
|
||||
expect.arrayContaining(['-f', expect.any(String)]),
|
||||
);
|
||||
const profileArg = spawnArgs?.[spawnArgs.indexOf('-f') + 1];
|
||||
expect(profileArg).toEqual(
|
||||
expect.stringContaining(
|
||||
path.join('.gemini', 'sandbox-macos-custom-test.sb'),
|
||||
),
|
||||
);
|
||||
expect(profileArg).not.toContain(homedir());
|
||||
});
|
||||
|
||||
it('should throw FatalSandboxError if seatbelt profile is missing', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
@@ -68,9 +68,17 @@ export async function start_sandbox(
|
||||
let profileFile = fileURLToPath(
|
||||
new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
|
||||
);
|
||||
// if profile name is not recognized, then look for file under project settings directory
|
||||
// if profile name is not recognized, look in user-level ~/.gemini first,
|
||||
// then fall back to project-level .gemini. path.basename() strips any
|
||||
// directory separators to prevent path traversal via SEATBELT_PROFILE.
|
||||
if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
|
||||
profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`);
|
||||
const safeProfile = path.basename(profile);
|
||||
const fileName = `sandbox-macos-${safeProfile}.sb`;
|
||||
const userProfileFile = path.join(homedir(), GEMINI_DIR, fileName);
|
||||
const projectProfileFile = path.join(GEMINI_DIR, fileName);
|
||||
profileFile = fs.existsSync(userProfileFile)
|
||||
? userProfileFile
|
||||
: projectProfileFile;
|
||||
}
|
||||
if (!fs.existsSync(profileFile)) {
|
||||
throw new FatalSandboxError(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MAX_NOTIFICATION_SUBTITLE_CHARS,
|
||||
MAX_NOTIFICATION_TITLE_CHARS,
|
||||
notifyViaTerminal,
|
||||
TerminalNotificationMethod,
|
||||
} from './terminalNotifications.js';
|
||||
|
||||
const writeToStdout = vi.hoisted(() => vi.fn());
|
||||
@@ -24,38 +25,19 @@ vi.mock('@google/gemini-cli-core', () => ({
|
||||
}));
|
||||
|
||||
describe('terminal notifications', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
});
|
||||
vi.stubEnv('TMUX', '');
|
||||
vi.stubEnv('STY', '');
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
vi.stubEnv('ALACRITTY_WINDOW_ID', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('emits notification on non-macOS platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 't',
|
||||
body: 'b',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false without writing when disabled', async () => {
|
||||
@@ -68,8 +50,7 @@ describe('terminal notifications', () => {
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits OSC 9 notification when supported terminal is detected', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
it('emits OSC 9 notification when iTerm2 is detected', async () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
@@ -85,23 +66,57 @@ describe('terminal notifications', () => {
|
||||
expect(emitted.endsWith('\x07')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits BEL fallback when OSC 9 is not supported', async () => {
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
|
||||
it('emits OSC 777 for unknown terminals', async () => {
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
expect(emitted.startsWith('\x1b]777;notify;')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses BEL when Windows Terminal is detected', async () => {
|
||||
vi.stubEnv('WT_SESSION', '1');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledWith('\x07');
|
||||
});
|
||||
|
||||
it('uses BEL fallback when WT_SESSION is set', async () => {
|
||||
vi.stubEnv('WT_SESSION', '1');
|
||||
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
|
||||
it('uses BEL when Alacritty is detected', async () => {
|
||||
vi.stubEnv('ALACRITTY_WINDOW_ID', '1');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledWith('\x07');
|
||||
});
|
||||
|
||||
it('uses BEL when Apple Terminal is detected', async () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledWith('\x07');
|
||||
});
|
||||
|
||||
it('uses BEL when VSCode Terminal is detected', async () => {
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
@@ -127,7 +142,6 @@ describe('terminal notifications', () => {
|
||||
});
|
||||
|
||||
it('strips terminal control sequences and newlines from payload text', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
@@ -162,4 +176,124 @@ describe('terminal notifications', () => {
|
||||
MAX_NOTIFICATION_BODY_CHARS,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits OSC 9 notification when method is explicitly set to osc9', async () => {
|
||||
// Explicitly set terminal to something that would normally use BEL
|
||||
vi.stubEnv('WT_SESSION', '1');
|
||||
|
||||
const shown = await notifyViaTerminal(
|
||||
true,
|
||||
{
|
||||
title: 'Explicit OSC 9',
|
||||
body: 'Body',
|
||||
},
|
||||
TerminalNotificationMethod.Osc9,
|
||||
);
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
expect(emitted.startsWith('\x1b]9;')).toBe(true);
|
||||
expect(emitted.endsWith('\x07')).toBe(true);
|
||||
expect(emitted).toContain('Explicit OSC 9');
|
||||
});
|
||||
|
||||
it('emits OSC 777 notification when method is explicitly set to osc777', async () => {
|
||||
// Explicitly set terminal to something that would normally use BEL
|
||||
vi.stubEnv('WT_SESSION', '1');
|
||||
const shown = await notifyViaTerminal(
|
||||
true,
|
||||
{
|
||||
title: 'Explicit OSC 777',
|
||||
body: 'Body',
|
||||
},
|
||||
TerminalNotificationMethod.Osc777,
|
||||
);
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
expect(emitted.startsWith('\x1b]777;notify;')).toBe(true);
|
||||
expect(emitted.endsWith('\x07')).toBe(true);
|
||||
expect(emitted).toContain('Explicit OSC 777');
|
||||
});
|
||||
|
||||
it('emits BEL notification when method is explicitly set to bell', async () => {
|
||||
// Explicitly set terminal to something that supports OSC 9
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(
|
||||
true,
|
||||
{
|
||||
title: 'Explicit BEL',
|
||||
body: 'Body',
|
||||
},
|
||||
TerminalNotificationMethod.Bell,
|
||||
);
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
expect(writeToStdout).toHaveBeenCalledWith('\x07');
|
||||
});
|
||||
|
||||
it('replaces semicolons with colons in OSC 777 to avoid breaking the sequence', async () => {
|
||||
const shown = await notifyViaTerminal(
|
||||
true,
|
||||
{
|
||||
title: 'Title; with; semicolons',
|
||||
subtitle: 'Sub;title',
|
||||
body: 'Body; with; semicolons',
|
||||
},
|
||||
TerminalNotificationMethod.Osc777,
|
||||
);
|
||||
|
||||
expect(shown).toBe(true);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
|
||||
// Format: \x1b]777;notify;title;body\x07
|
||||
expect(emitted).toContain('Title: with: semicolons');
|
||||
expect(emitted).toContain('Sub:title');
|
||||
expect(emitted).toContain('Body: with: semicolons');
|
||||
expect(emitted).not.toContain('Title; with; semicolons');
|
||||
expect(emitted).not.toContain('Body; with; semicolons');
|
||||
|
||||
// Extract everything after '\x1b]777;notify;' and before '\x07'
|
||||
const payload = emitted.slice('\x1b]777;notify;'.length, -1);
|
||||
|
||||
// There should be exactly one semicolon separating title and body
|
||||
const semicolonsCount = (payload.match(/;/g) || []).length;
|
||||
expect(semicolonsCount).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps OSC sequence in tmux passthrough when TMUX env var is set', async () => {
|
||||
vi.stubEnv('TMUX', '1');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
expect(emitted.startsWith('\x1bPtmux;\x1b\x1b]9;')).toBe(true);
|
||||
expect(emitted.endsWith('\x1b\\')).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps OSC sequence in GNU screen passthrough when STY env var is set', async () => {
|
||||
vi.stubEnv('STY', '1');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
body: 'Body',
|
||||
});
|
||||
|
||||
expect(shown).toBe(true);
|
||||
expect(writeToStdout).toHaveBeenCalledTimes(1);
|
||||
const emitted = String(writeToStdout.mock.calls[0][0]);
|
||||
expect(emitted.startsWith('\x1bP\x1b]9;')).toBe(true);
|
||||
expect(emitted.endsWith('\x1b\\')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,12 +15,8 @@ export const MAX_NOTIFICATION_BODY_CHARS = 180;
|
||||
|
||||
const BEL = '\x07';
|
||||
const OSC9_PREFIX = '\x1b]9;';
|
||||
const OSC9_SEPARATOR = ' | ';
|
||||
const MAX_OSC9_MESSAGE_CHARS =
|
||||
MAX_NOTIFICATION_TITLE_CHARS +
|
||||
MAX_NOTIFICATION_SUBTITLE_CHARS +
|
||||
MAX_NOTIFICATION_BODY_CHARS +
|
||||
OSC9_SEPARATOR.length * 2;
|
||||
const OSC777_PREFIX = '\x1b]777;notify;';
|
||||
const OSC_TEXT_SEPARATOR = ' | ';
|
||||
|
||||
export interface RunEventNotificationContent {
|
||||
title: string;
|
||||
@@ -81,36 +77,100 @@ export function isNotificationsEnabled(settings: LoadedSettings): boolean {
|
||||
return general?.enableNotifications === true;
|
||||
}
|
||||
|
||||
function buildTerminalNotificationMessage(
|
||||
content: RunEventNotificationContent,
|
||||
): string {
|
||||
const pieces = [content.title, content.subtitle, content.body].filter(
|
||||
Boolean,
|
||||
);
|
||||
const combined = pieces.join(OSC9_SEPARATOR);
|
||||
return sanitizeForDisplay(combined, MAX_OSC9_MESSAGE_CHARS);
|
||||
export enum TerminalNotificationMethod {
|
||||
Auto = 'auto',
|
||||
Osc9 = 'osc9',
|
||||
Osc777 = 'osc777',
|
||||
Bell = 'bell',
|
||||
}
|
||||
|
||||
export function getNotificationMethod(
|
||||
settings: LoadedSettings,
|
||||
): TerminalNotificationMethod {
|
||||
switch (settings.merged.general?.notificationMethod) {
|
||||
case TerminalNotificationMethod.Osc9:
|
||||
return TerminalNotificationMethod.Osc9;
|
||||
case TerminalNotificationMethod.Osc777:
|
||||
return TerminalNotificationMethod.Osc777;
|
||||
case TerminalNotificationMethod.Bell:
|
||||
return TerminalNotificationMethod.Bell;
|
||||
default:
|
||||
return TerminalNotificationMethod.Auto;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapWithPassthrough(sequence: string): string {
|
||||
const capabilityManager = TerminalCapabilityManager.getInstance();
|
||||
if (capabilityManager.isTmux()) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
|
||||
} else if (capabilityManager.isScreen()) {
|
||||
return `\x1bP${sequence}\x1b\\`;
|
||||
}
|
||||
return sequence;
|
||||
}
|
||||
|
||||
function emitOsc9Notification(content: RunEventNotificationContent): void {
|
||||
const message = buildTerminalNotificationMessage(content);
|
||||
if (!TerminalCapabilityManager.getInstance().supportsOsc9Notifications()) {
|
||||
writeToStdout(BEL);
|
||||
return;
|
||||
}
|
||||
const sanitized = sanitizeNotificationContent(content);
|
||||
const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter(
|
||||
Boolean,
|
||||
);
|
||||
const combined = pieces.join(OSC_TEXT_SEPARATOR);
|
||||
|
||||
writeToStdout(`${OSC9_PREFIX}${message}${BEL}`);
|
||||
writeToStdout(wrapWithPassthrough(`${OSC9_PREFIX}${combined}${BEL}`));
|
||||
}
|
||||
|
||||
function emitOsc777Notification(content: RunEventNotificationContent): void {
|
||||
const sanitized = sanitizeNotificationContent(content);
|
||||
const bodyParts = [sanitized.subtitle, sanitized.body].filter(Boolean);
|
||||
const body = bodyParts.join(OSC_TEXT_SEPARATOR);
|
||||
|
||||
// Replace ';' with ':' to avoid breaking the OSC 777 sequence
|
||||
const safeTitle = sanitized.title.replace(/;/g, ':');
|
||||
const safeBody = body.replace(/;/g, ':');
|
||||
|
||||
writeToStdout(
|
||||
wrapWithPassthrough(`${OSC777_PREFIX}${safeTitle};${safeBody}${BEL}`),
|
||||
);
|
||||
}
|
||||
|
||||
function emitBellNotification(): void {
|
||||
writeToStdout(BEL);
|
||||
}
|
||||
|
||||
export async function notifyViaTerminal(
|
||||
notificationsEnabled: boolean,
|
||||
content: RunEventNotificationContent,
|
||||
method: TerminalNotificationMethod = TerminalNotificationMethod.Auto,
|
||||
): Promise<boolean> {
|
||||
if (!notificationsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
emitOsc9Notification(sanitizeNotificationContent(content));
|
||||
if (method === TerminalNotificationMethod.Osc9) {
|
||||
emitOsc9Notification(content);
|
||||
} else if (method === TerminalNotificationMethod.Osc777) {
|
||||
emitOsc777Notification(content);
|
||||
} else if (method === TerminalNotificationMethod.Bell) {
|
||||
emitBellNotification();
|
||||
} else {
|
||||
// auto
|
||||
const capabilityManager = TerminalCapabilityManager.getInstance();
|
||||
if (capabilityManager.isITerm2()) {
|
||||
emitOsc9Notification(content);
|
||||
} else if (
|
||||
capabilityManager.isAlacritty() ||
|
||||
capabilityManager.isAppleTerminal() ||
|
||||
capabilityManager.isVSCodeTerminal() ||
|
||||
capabilityManager.isWindowsTerminal()
|
||||
) {
|
||||
emitBellNotification();
|
||||
} else {
|
||||
emitOsc777Notification(content);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLogger.debug('Failed to emit terminal notification:', error);
|
||||
|
||||
@@ -1774,6 +1774,95 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config1.topicState.getTopic()).toBe('Topic 1');
|
||||
expect(config2.topicState.getTopic()).toBe('Topic 2');
|
||||
});
|
||||
|
||||
it('updates storage session-scoped directories when the sessionId changes', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sessionId: 'session-one',
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const tempDir = config.storage.getProjectTempDir();
|
||||
const oldPlansDir = path.join(tempDir, 'session-one', 'plans');
|
||||
const oldTrackerService = config.getTrackerService();
|
||||
|
||||
config.setSessionId('session-two');
|
||||
|
||||
expect(config.getSessionId()).toBe('session-two');
|
||||
expect(config.storage.getProjectTempPlansDir()).toBe(
|
||||
path.join(tempDir, 'session-two', 'plans'),
|
||||
);
|
||||
expect(config.storage.getProjectTempTrackerDir()).toBe(
|
||||
path.join(tempDir, 'session-two', 'tracker'),
|
||||
);
|
||||
expect(config.getTrackerService()).not.toBe(oldTrackerService);
|
||||
expect(config.getTrackerService().trackerDir).toBe(
|
||||
path.join(tempDir, 'session-two', 'tracker'),
|
||||
);
|
||||
expect(config.getWorkspaceContext().getDirectories()).not.toContain(
|
||||
oldPlansDir,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when changing sessions before the previous plans dir exists', async () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sessionId: 'session-one',
|
||||
plan: true,
|
||||
});
|
||||
|
||||
await config.initialize();
|
||||
const missingPlansDir = config.storage.getProjectTempPlansDir();
|
||||
const realpathMock = vi.mocked(fs.realpathSync);
|
||||
const originalImplementation = realpathMock.getMockImplementation();
|
||||
|
||||
try {
|
||||
realpathMock.mockImplementation((input) => {
|
||||
const normalizedInput =
|
||||
typeof input === 'string' || Buffer.isBuffer(input)
|
||||
? input
|
||||
: input.toString();
|
||||
|
||||
if (normalizedInput === missingPlansDir) {
|
||||
const error = new Error(
|
||||
`ENOENT: no such file or directory, ${normalizedInput}`,
|
||||
);
|
||||
Object.assign(error, { code: 'ENOENT' });
|
||||
throw error;
|
||||
}
|
||||
if (originalImplementation) {
|
||||
return originalImplementation(input);
|
||||
}
|
||||
return normalizedInput;
|
||||
});
|
||||
|
||||
expect(() => config.setSessionId('session-two')).not.toThrow();
|
||||
} finally {
|
||||
realpathMock.mockImplementation((input) => {
|
||||
if (originalImplementation) {
|
||||
return originalImplementation(input);
|
||||
}
|
||||
return typeof input === 'string' || Buffer.isBuffer(input)
|
||||
? input
|
||||
: input.toString();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the approved plan when starting a new session', () => {
|
||||
const config = new Config({
|
||||
...baseParams,
|
||||
sessionId: 'session-one',
|
||||
});
|
||||
|
||||
config.setApprovedPlanPath('/tmp/session-one/plans/approved.md');
|
||||
|
||||
expect(() => config.resetNewSessionState('session-two')).not.toThrow();
|
||||
|
||||
expect(config.getSessionId()).toBe('session-two');
|
||||
expect(config.getApprovedPlanPath()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GemmaModelRouterSettings', () => {
|
||||
|
||||
@@ -30,6 +30,8 @@ import { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { LSTool } from '../tools/ls.js';
|
||||
import { ReadFileTool } from '../tools/read-file.js';
|
||||
import { ReadMcpResourceTool } from '../tools/read-mcp-resource.js';
|
||||
import { ListMcpResourcesTool } from '../tools/list-mcp-resources.js';
|
||||
import { GrepTool } from '../tools/grep.js';
|
||||
import { canUseRipgrep, RipGrepTool } from '../tools/ripGrep.js';
|
||||
import { GlobTool } from '../tools/glob.js';
|
||||
@@ -624,6 +626,7 @@ export interface ConfigParameters {
|
||||
bugCommand?: BugCommandSettings;
|
||||
model: string;
|
||||
disableLoopDetection?: boolean;
|
||||
disableStreaming?: boolean;
|
||||
maxSessionTurns?: number;
|
||||
acpMode?: boolean;
|
||||
listSessions?: boolean;
|
||||
@@ -726,6 +729,8 @@ export interface ConfigParameters {
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
simulateUser?: boolean;
|
||||
knowledgeSource?: string;
|
||||
}
|
||||
|
||||
export class Config implements McpContext, AgentLoopContext {
|
||||
@@ -956,6 +961,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private lastModeSwitchTime: number = performance.now();
|
||||
readonly injectionService: InjectionService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
private readonly simulateUser: boolean;
|
||||
private readonly knowledgeSource?: string;
|
||||
private readonly disableStreaming: boolean;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this._sessionId = params.sessionId;
|
||||
@@ -1276,6 +1284,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
this.enableConseca = params.enableConseca ?? false;
|
||||
this.simulateUser = params.simulateUser ?? false;
|
||||
this.knowledgeSource = params.knowledgeSource;
|
||||
this.disableStreaming = params.disableStreaming ?? false;
|
||||
|
||||
// Initialize Safety Infrastructure
|
||||
const contextBuilder = new ContextBuilder(this);
|
||||
@@ -1760,7 +1771,22 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string): void {
|
||||
const previousPlansDir = this.storage.isInitialized()
|
||||
? this.storage.getPlansDir()
|
||||
: undefined;
|
||||
|
||||
this._sessionId = sessionId;
|
||||
this.storage.setSessionId(sessionId);
|
||||
this.trackerService = undefined;
|
||||
|
||||
if (previousPlansDir) {
|
||||
this.refreshSessionScopedPlansDirectory(previousPlansDir);
|
||||
}
|
||||
}
|
||||
|
||||
resetNewSessionState(sessionId: string): void {
|
||||
this.setSessionId(sessionId);
|
||||
this.approvedPlanPath = undefined;
|
||||
}
|
||||
|
||||
setTerminalBackground(terminalBackground: string | undefined): void {
|
||||
@@ -2049,6 +2075,37 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return getWorkspaceContextOverride() ?? this.workspaceContext;
|
||||
}
|
||||
|
||||
private refreshSessionScopedPlansDirectory(previousPlansDir: string): void {
|
||||
const nextPlansDir = this.storage.getPlansDir();
|
||||
if (previousPlansDir === nextPlansDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathsToRemove = new Set([previousPlansDir]);
|
||||
try {
|
||||
pathsToRemove.add(resolveToRealPath(previousPlansDir));
|
||||
} catch {
|
||||
// The previous session's plans directory may never have been created.
|
||||
// In that case there is nothing to resolve or remove beyond the raw path.
|
||||
}
|
||||
|
||||
const currentDirectories = this.workspaceContext
|
||||
.getDirectories()
|
||||
.filter((dir) => !pathsToRemove.has(dir));
|
||||
|
||||
this.workspaceContext.setDirectories(currentDirectories);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(nextPlansDir)) {
|
||||
this.workspaceContext.addDirectory(nextPlansDir);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid or unreadable plans directories here. This mirrors
|
||||
// initialization behavior, which only adds the plans directory when it
|
||||
// already exists and is readable.
|
||||
}
|
||||
}
|
||||
|
||||
getAgentRegistry(): AgentRegistry {
|
||||
return this.agentRegistry;
|
||||
}
|
||||
@@ -2822,6 +2879,18 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.usageStatisticsEnabled;
|
||||
}
|
||||
|
||||
getSimulateUser(): boolean {
|
||||
return this.simulateUser;
|
||||
}
|
||||
|
||||
getDisableStreaming(): boolean {
|
||||
return this.disableStreaming;
|
||||
}
|
||||
|
||||
getKnowledgeSource(): string | undefined {
|
||||
return this.knowledgeSource;
|
||||
}
|
||||
|
||||
getAcpMode(): boolean {
|
||||
return this.acpMode;
|
||||
}
|
||||
@@ -3579,6 +3648,12 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
maybeRegister(WebFetchTool, () =>
|
||||
registry.registerTool(new WebFetchTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ReadMcpResourceTool, () =>
|
||||
registry.registerTool(new ReadMcpResourceTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ListMcpResourcesTool, () =>
|
||||
registry.registerTool(new ListMcpResourcesTool(this, this.messageBus)),
|
||||
);
|
||||
maybeRegister(ShellTool, () =>
|
||||
registry.registerTool(new ShellTool(this, this.messageBus)),
|
||||
);
|
||||
|
||||
@@ -294,7 +294,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
isVisible: true,
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
|
||||
@@ -211,6 +211,27 @@ describe('Storage – additional helpers', () => {
|
||||
expect(storageWithSession.getProjectTempTrackerDir()).toBe(expected);
|
||||
});
|
||||
|
||||
it('updates session-scoped directories when the sessionId changes', async () => {
|
||||
const storageWithSession = new Storage(projectRoot, 'session-one');
|
||||
ProjectRegistry.prototype.getShortId = vi
|
||||
.fn()
|
||||
.mockReturnValue(PROJECT_SLUG);
|
||||
await storageWithSession.initialize();
|
||||
const tempDir = storageWithSession.getProjectTempDir();
|
||||
|
||||
storageWithSession.setSessionId('session-two');
|
||||
|
||||
expect(storageWithSession.getProjectTempPlansDir()).toBe(
|
||||
path.join(tempDir, 'session-two', 'plans'),
|
||||
);
|
||||
expect(storageWithSession.getProjectTempTrackerDir()).toBe(
|
||||
path.join(tempDir, 'session-two', 'tracker'),
|
||||
);
|
||||
expect(storageWithSession.getProjectTempTasksDir()).toBe(
|
||||
path.join(tempDir, 'session-two', 'tasks'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Session and JSON Loading', () => {
|
||||
beforeEach(async () => {
|
||||
await storage.initialize();
|
||||
|
||||
@@ -28,7 +28,7 @@ export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml';
|
||||
|
||||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
private readonly sessionId: string | undefined;
|
||||
private sessionId: string | undefined;
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
@@ -42,6 +42,14 @@ export class Storage {
|
||||
this.customPlansDir = dir;
|
||||
}
|
||||
|
||||
setSessionId(sessionId: string | undefined): void {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return !!this.projectIdentifier;
|
||||
}
|
||||
|
||||
static getGlobalGeminiDir(): string {
|
||||
const homeDir = homedir();
|
||||
if (!homeDir) {
|
||||
|
||||
@@ -656,6 +656,23 @@ export class GeminiChat {
|
||||
lastConfig = config;
|
||||
lastContentsToUse = contentsToUse;
|
||||
|
||||
if (this.context.config.getDisableStreaming()) {
|
||||
const response = await this.context.config
|
||||
.getContentGenerator()
|
||||
.generateContent(
|
||||
{
|
||||
model: modelToUse,
|
||||
contents: contentsToUse,
|
||||
config,
|
||||
},
|
||||
prompt_id,
|
||||
role,
|
||||
);
|
||||
return (async function* () {
|
||||
yield response;
|
||||
})();
|
||||
}
|
||||
|
||||
return this.context.config.getContentGenerator().generateContentStream(
|
||||
{
|
||||
model: modelToUse,
|
||||
|
||||
@@ -47,7 +47,10 @@ toolName = [
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
"update_topic",
|
||||
# Core agent lifecycle tool
|
||||
"complete_task"
|
||||
"complete_task",
|
||||
# MCP resource tools
|
||||
"read_mcp_resource",
|
||||
"list_mcp_resources"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
@@ -1057,6 +1057,25 @@ priority = 100
|
||||
cliHelpResult.decision,
|
||||
'cli_help should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
// 7. Verify MCP resource tools are ALLOWED
|
||||
const listMcpResult = await engine.check(
|
||||
{ name: 'list_mcp_resources' },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
listMcpResult.decision,
|
||||
'list_mcp_resources should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
const readMcpResult = await engine.check(
|
||||
{ name: 'read_mcp_resource', args: { uri: 'test://resource' } },
|
||||
undefined,
|
||||
);
|
||||
expect(
|
||||
readMcpResult.decision,
|
||||
'read_mcp_resource should be ALLOWED in Plan Mode',
|
||||
).toBe(PolicyDecision.ALLOW);
|
||||
} finally {
|
||||
await fs.rm(tempPolicyDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { assertValidPathString } from '../../utils/paths.js';
|
||||
import {
|
||||
@@ -40,6 +39,11 @@ import {
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { handleReadWriteCommands } from '../utils/sandboxReadWriteUtils.js';
|
||||
import { buildBwrapArgs } from './bwrapArgsBuilder.js';
|
||||
import {
|
||||
getCommandRoots,
|
||||
initializeShellParsers,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
|
||||
let cachedBpfPath: string | undefined;
|
||||
|
||||
@@ -218,7 +222,15 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
args = ['-c', 'cat > "$1"', '_', ...args];
|
||||
}
|
||||
|
||||
const commandName = await getCommandName({ ...req, command, args });
|
||||
await initializeShellParsers();
|
||||
const fullCmd = [command, ...args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
const roots = getCommandRoots(stripped).filter(
|
||||
(r) => r !== 'shopt' && r !== 'set',
|
||||
);
|
||||
const commandName = roots.length > 0 ? roots[0] : join(command);
|
||||
const isGitCommand = roots.includes('git');
|
||||
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
@@ -253,6 +265,15 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
false,
|
||||
};
|
||||
|
||||
// If the workspace is writable and we're running a git command,
|
||||
// automatically allow write access to the .git directory.
|
||||
if (workspaceWrite && isGitCommand) {
|
||||
const gitDir = join(this.options.workspace, '.git');
|
||||
if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) {
|
||||
mergedAdditional.fileSystem!.write!.push(gitDir);
|
||||
}
|
||||
}
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
req,
|
||||
mergedAdditional,
|
||||
|
||||
@@ -115,14 +115,14 @@ describe.skipIf(os.platform() === 'win32')('buildBwrapArgs', () => {
|
||||
workspace,
|
||||
workspace,
|
||||
'--ro-bind',
|
||||
`${workspace}/.git`,
|
||||
`${workspace}/.git`,
|
||||
'--ro-bind',
|
||||
`${workspace}/.gitignore`,
|
||||
`${workspace}/.gitignore`,
|
||||
'--ro-bind',
|
||||
`${workspace}/.geminiignore`,
|
||||
`${workspace}/.geminiignore`,
|
||||
'--ro-bind',
|
||||
`${workspace}/.git`,
|
||||
`${workspace}/.git`,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { isErrnoException } from '../utils/fsUtils.js';
|
||||
import { spawnAsync } from '../../utils/shell-utils.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { toPathKey } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Options for building bubblewrap (bwrap) arguments.
|
||||
@@ -63,58 +64,101 @@ export async function buildBwrapArgs(
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
||||
type MountType =
|
||||
| '--bind'
|
||||
| '--ro-bind'
|
||||
| '--bind-try'
|
||||
| '--ro-bind-try'
|
||||
| '--symlink';
|
||||
|
||||
bwrapArgs.push(bindFlag, workspace.original, workspace.original);
|
||||
type Mount =
|
||||
| {
|
||||
type: MountType;
|
||||
src: string;
|
||||
dest: string;
|
||||
}
|
||||
| { type: '--tmpfs-ro'; dest: string };
|
||||
|
||||
const mounts: Mount[] = [];
|
||||
|
||||
const bindFlag: MountType = workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
||||
mounts.push({
|
||||
type: bindFlag,
|
||||
src: workspace.original,
|
||||
dest: workspace.original,
|
||||
});
|
||||
if (workspace.resolved !== workspace.original) {
|
||||
bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved);
|
||||
mounts.push({
|
||||
type: bindFlag,
|
||||
src: workspace.resolved,
|
||||
dest: workspace.resolved,
|
||||
});
|
||||
}
|
||||
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
bwrapArgs.push('--ro-bind-try', includeDir, includeDir);
|
||||
mounts.push({ type: '--ro-bind-try', src: includeDir, dest: includeDir });
|
||||
}
|
||||
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
if (fs.existsSync(allowedPath)) {
|
||||
bwrapArgs.push('--bind-try', allowedPath, allowedPath);
|
||||
mounts.push({ type: '--bind-try', src: allowedPath, dest: allowedPath });
|
||||
} else {
|
||||
// If the path doesn't exist, we still want to allow access to its parent
|
||||
// to enable creating it.
|
||||
const parent = dirname(allowedPath);
|
||||
bwrapArgs.push(
|
||||
isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
|
||||
parent,
|
||||
parent,
|
||||
);
|
||||
mounts.push({
|
||||
type: isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
|
||||
src: parent,
|
||||
dest: parent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of resolvedPaths.policyRead) {
|
||||
bwrapArgs.push('--ro-bind-try', p, p);
|
||||
mounts.push({ type: '--ro-bind-try', src: p, dest: p });
|
||||
}
|
||||
|
||||
// Collect explicit additional write permissions.
|
||||
for (const p of resolvedPaths.policyWrite) {
|
||||
bwrapArgs.push('--bind-try', p, p);
|
||||
mounts.push({ type: '--bind-try', src: p, dest: p });
|
||||
}
|
||||
|
||||
const policyWriteKeys = new Set(resolvedPaths.policyWrite.map(toPathKey));
|
||||
|
||||
for (const file of GOVERNANCE_FILES) {
|
||||
const filePath = join(workspace.original, file.path);
|
||||
const realPath = join(workspace.resolved, file.path);
|
||||
bwrapArgs.push('--ro-bind', filePath, filePath);
|
||||
if (realPath !== filePath) {
|
||||
bwrapArgs.push('--ro-bind', realPath, realPath);
|
||||
|
||||
const isExplicitlyWritable =
|
||||
policyWriteKeys.has(toPathKey(filePath)) ||
|
||||
policyWriteKeys.has(toPathKey(realPath));
|
||||
|
||||
// If the workspace is writable, we allow editing .gitignore and .geminiignore by default.
|
||||
// .git remains protected unless explicitly requested (e.g. for git commands).
|
||||
const isImplicitlyWritable = workspaceWrite && file.path !== '.git';
|
||||
|
||||
if (!isExplicitlyWritable && !isImplicitlyWritable) {
|
||||
mounts.push({ type: '--ro-bind', src: filePath, dest: filePath });
|
||||
if (realPath !== filePath) {
|
||||
mounts.push({ type: '--ro-bind', src: realPath, dest: realPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Grant read-only access to git worktrees/submodules. We do this last in order to
|
||||
// ensure that these rules aren't overwritten by broader write policies.
|
||||
// Grant read-only access to git worktrees/submodules.
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
|
||||
if (worktreeGitDir) {
|
||||
bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir);
|
||||
if (worktreeGitDir && !policyWriteKeys.has(toPathKey(worktreeGitDir))) {
|
||||
mounts.push({
|
||||
type: '--ro-bind-try',
|
||||
src: worktreeGitDir,
|
||||
dest: worktreeGitDir,
|
||||
});
|
||||
}
|
||||
if (mainGitDir) {
|
||||
bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir);
|
||||
if (mainGitDir && !policyWriteKeys.has(toPathKey(mainGitDir))) {
|
||||
mounts.push({
|
||||
type: '--ro-bind-try',
|
||||
src: mainGitDir,
|
||||
dest: mainGitDir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,37 +167,23 @@ export async function buildBwrapArgs(
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
bwrapArgs.push('--tmpfs', p, '--remount-ro', p);
|
||||
mounts.push({ type: '--tmpfs-ro', dest: p });
|
||||
} else {
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code === 'ENOENT') {
|
||||
bwrapArgs.push('--symlink', '/dev/null', p);
|
||||
mounts.push({ type: '--symlink', src: '/dev/null', dest: p });
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mask secret files (.env, .env.*)
|
||||
const secretArgs = await getSecretFilesArgs(resolvedPaths, maskFilePath);
|
||||
bwrapArgs.push(...secretArgs);
|
||||
|
||||
return bwrapArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates bubblewrap arguments to mask secret files.
|
||||
*/
|
||||
async function getSecretFilesArgs(
|
||||
resolvedPaths: ResolvedSandboxPaths,
|
||||
maskPath: string,
|
||||
): Promise<string[]> {
|
||||
const args: string[] = [];
|
||||
const searchDirs = new Set([
|
||||
resolvedPaths.workspace.original,
|
||||
resolvedPaths.workspace.resolved,
|
||||
@@ -164,9 +194,6 @@ async function getSecretFilesArgs(
|
||||
|
||||
for (const dir of searchDirs) {
|
||||
try {
|
||||
// Use the native 'find' command for performance and to catch nested secrets.
|
||||
// We limit depth to 3 to keep it fast while covering common nested structures.
|
||||
// We use -prune to skip heavy directories efficiently while matching dotfiles.
|
||||
const findResult = await spawnAsync('find', [
|
||||
dir,
|
||||
'-maxdepth',
|
||||
@@ -203,7 +230,7 @@ async function getSecretFilesArgs(
|
||||
const files = findResult.stdout.toString().split('\0');
|
||||
for (const file of files) {
|
||||
if (file.trim()) {
|
||||
args.push('--bind', maskPath, file.trim());
|
||||
mounts.push({ type: '--bind', src: maskFilePath, dest: file.trim() });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -213,5 +240,19 @@ async function getSecretFilesArgs(
|
||||
);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
|
||||
// Sort mounts by destination path length to ensure parents are bound before children.
|
||||
// This prevents hierarchical masking where a parent mount would hide a child mount.
|
||||
mounts.sort((a, b) => a.dest.length - b.dest.length);
|
||||
|
||||
// Emit final bwrap arguments
|
||||
for (const m of mounts) {
|
||||
if (m.type === '--tmpfs-ro') {
|
||||
bwrapArgs.push('--tmpfs', m.dest, '--remount-ro', m.dest);
|
||||
} else {
|
||||
bwrapArgs.push(m.type, m.src, m.dest);
|
||||
}
|
||||
}
|
||||
|
||||
return bwrapArgs;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
|
||||
import { initializeShellParsers } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
initializeShellParsers,
|
||||
getCommandRoots,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
@@ -133,6 +137,22 @@ export class MacOsSandboxManager implements SandboxManager {
|
||||
false,
|
||||
};
|
||||
|
||||
// If the workspace is writable and we're running a git command,
|
||||
// automatically allow write access to the .git directory.
|
||||
const fullCmd = [command, ...args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
const roots = getCommandRoots(stripped).filter(
|
||||
(r) => r !== 'shopt' && r !== 'set',
|
||||
);
|
||||
const isGitCommand = roots.includes('git');
|
||||
|
||||
if (workspaceWrite && isGitCommand) {
|
||||
const gitDir = path.join(this.options.workspace, '.git');
|
||||
if (!mergedAdditional.fileSystem!.write!.includes(gitDir)) {
|
||||
mergedAdditional.fileSystem!.write!.push(gitDir);
|
||||
}
|
||||
}
|
||||
|
||||
const { command: finalCommand, args: finalArgs } = handleReadWriteCommands(
|
||||
req,
|
||||
mergedAdditional,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
SECRET_FILES,
|
||||
type ResolvedSandboxPaths,
|
||||
} from '../../services/sandboxManager.js';
|
||||
import { resolveToRealPath } from '../../utils/paths.js';
|
||||
import { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt profile.
|
||||
@@ -37,6 +37,47 @@ export function escapeSchemeString(str: string): string {
|
||||
return str.replace(/[\\"]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is explicitly allowed by additional write permissions.
|
||||
*/
|
||||
function isPathExplicitlyAllowed(
|
||||
filePath: string,
|
||||
realFilePath: string,
|
||||
policyWrite: string[],
|
||||
): boolean {
|
||||
return policyWrite.some(
|
||||
(p) =>
|
||||
p === filePath ||
|
||||
p === realFilePath ||
|
||||
isSubpath(p, filePath) ||
|
||||
isSubpath(p, realFilePath),
|
||||
);
|
||||
}
|
||||
|
||||
function denyUnlessExplicitlyAllowed(
|
||||
targetPath: string,
|
||||
ruleType: 'literal' | 'subpath',
|
||||
policyWrite: string[],
|
||||
implicitlyAllowed: boolean = false,
|
||||
): string {
|
||||
if (implicitlyAllowed) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const realPath = resolveToRealPath(targetPath);
|
||||
|
||||
if (isPathExplicitlyAllowed(targetPath, realPath, policyWrite)) {
|
||||
return ''; // Skip if explicitly allowed
|
||||
}
|
||||
|
||||
let rules = `(deny file-write* (${ruleType} "${escapeSchemeString(targetPath)}"))\n`;
|
||||
if (realPath !== targetPath) {
|
||||
rules += `(deny file-write* (${ruleType} "${escapeSchemeString(realPath)}"))\n`;
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete macOS Seatbelt profile string using a strict allowlist.
|
||||
* It embeds paths directly into the profile, properly escaped for Scheme.
|
||||
@@ -108,38 +149,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\n`;
|
||||
}
|
||||
|
||||
// Handle granular additional read permissions
|
||||
for (let i = 0; i < resolvedPaths.policyRead.length; i++) {
|
||||
const resolved = resolvedPaths.policyRead[i];
|
||||
let isFile = false;
|
||||
try {
|
||||
isFile = fs.statSync(resolved).isFile();
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
if (isFile) {
|
||||
profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`;
|
||||
} else {
|
||||
profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle granular additional write permissions
|
||||
for (let i = 0; i < resolvedPaths.policyWrite.length; i++) {
|
||||
const resolved = resolvedPaths.policyWrite[i];
|
||||
let isFile = false;
|
||||
try {
|
||||
isFile = fs.statSync(resolved).isFile();
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
if (isFile) {
|
||||
profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`;
|
||||
} else {
|
||||
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add explicit deny rules for governance files in the workspace.
|
||||
// These are added after the workspace allow rule to ensure they take precedence
|
||||
// (Seatbelt evaluates rules in order, later rules win for same path).
|
||||
@@ -161,13 +170,12 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
// Ignore errors, use default guess
|
||||
}
|
||||
|
||||
const ruleType = isDirectory ? 'subpath' : 'literal';
|
||||
|
||||
profile += `(deny file-write* (${ruleType} "${escapeSchemeString(governanceFile)}"))\n`;
|
||||
|
||||
if (realGovernanceFile !== governanceFile) {
|
||||
profile += `(deny file-write* (${ruleType} "${escapeSchemeString(realGovernanceFile)}"))\n`;
|
||||
}
|
||||
profile += denyUnlessExplicitlyAllowed(
|
||||
governanceFile,
|
||||
isDirectory ? 'subpath' : 'literal',
|
||||
resolvedPaths.policyWrite,
|
||||
workspaceWrite && GOVERNANCE_FILES[i].path !== '.git',
|
||||
);
|
||||
}
|
||||
|
||||
// Grant read-only access to git worktrees/submodules. We do this last in order to
|
||||
@@ -175,10 +183,18 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
|
||||
if (worktreeGitDir) {
|
||||
profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`;
|
||||
profile += denyUnlessExplicitlyAllowed(
|
||||
worktreeGitDir,
|
||||
'subpath',
|
||||
resolvedPaths.policyWrite,
|
||||
);
|
||||
}
|
||||
if (mainGitDir) {
|
||||
profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`;
|
||||
profile += denyUnlessExplicitlyAllowed(
|
||||
mainGitDir,
|
||||
'subpath',
|
||||
resolvedPaths.policyWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +227,38 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle granular additional read permissions
|
||||
for (let i = 0; i < resolvedPaths.policyRead.length; i++) {
|
||||
const resolved = resolvedPaths.policyRead[i];
|
||||
let isFile = false;
|
||||
try {
|
||||
isFile = fs.statSync(resolved).isFile();
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
if (isFile) {
|
||||
profile += `(allow file-read* (literal "${escapeSchemeString(resolved)}"))\n`;
|
||||
} else {
|
||||
profile += `(allow file-read* (subpath "${escapeSchemeString(resolved)}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle granular additional write permissions
|
||||
for (let i = 0; i < resolvedPaths.policyWrite.length; i++) {
|
||||
const resolved = resolvedPaths.policyWrite[i];
|
||||
let isFile = false;
|
||||
try {
|
||||
isFile = fs.statSync(resolved).isFile();
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
if (isFile) {
|
||||
profile += `(allow file-read* file-write* (literal "${escapeSchemeString(resolved)}"))\n`;
|
||||
} else {
|
||||
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(resolved)}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle forbiddenPaths
|
||||
const forbiddenPaths = resolvedPaths.forbidden;
|
||||
for (let i = 0; i < forbiddenPaths.length; i++) {
|
||||
|
||||
@@ -25,7 +25,13 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
spawnAsync,
|
||||
getCommandName,
|
||||
initializeShellParsers,
|
||||
getCommandRoots,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
@@ -261,6 +267,14 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
|
||||
const networkAccess = defaultNetwork || mergedAdditional.network;
|
||||
|
||||
await initializeShellParsers();
|
||||
const fullCmd = [command, ...args].join(' ');
|
||||
const stripped = stripShellWrapper(fullCmd);
|
||||
const roots = getCommandRoots(stripped).filter(
|
||||
(r) => r !== 'shopt' && r !== 'set',
|
||||
);
|
||||
const isGitCommand = roots.includes('git');
|
||||
|
||||
const resolvedPaths = await resolveSandboxPaths(
|
||||
this.options,
|
||||
req,
|
||||
@@ -348,6 +362,13 @@ export class WindowsSandboxManager implements SandboxManager {
|
||||
|
||||
if (workspaceWrite) {
|
||||
addWritableRoot(resolvedPaths.workspace.resolved);
|
||||
|
||||
// If the workspace is writable and we're running a git command,
|
||||
// automatically allow write access to the .git directory.
|
||||
if (isGitCommand) {
|
||||
const gitDir = path.join(resolvedPaths.workspace.resolved, '.git');
|
||||
addWritableRoot(gitDir);
|
||||
}
|
||||
}
|
||||
|
||||
// B. Globally included directories
|
||||
|
||||
@@ -865,6 +865,336 @@ describe('SandboxManager Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Governance Files', () => {
|
||||
it('blocks write access to governance files in the workspace', async () => {
|
||||
const tempWorkspace = createTempDir('workspace-');
|
||||
const gitDir = path.join(tempWorkspace, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
const testFile = path.join(gitDir, 'config');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: tempWorkspace },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(testFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'failure');
|
||||
expect(fs.existsSync(testFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows write access to governance files when explicitly requested via additionalPermissions', async () => {
|
||||
const tempWorkspace = createTempDir('workspace-');
|
||||
const gitDir = path.join(tempWorkspace, '.git');
|
||||
fs.mkdirSync(gitDir);
|
||||
const testFile = path.join(gitDir, 'config');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: tempWorkspace },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(testFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: tempWorkspace,
|
||||
env: process.env,
|
||||
policy: {
|
||||
additionalPermissions: { fileSystem: { write: [gitDir] } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git Worktree Support', () => {
|
||||
it('allows access to git common directory in a worktree', async () => {
|
||||
const mainRepo = createTempDir('main-repo-');
|
||||
const worktreeDir = createTempDir('worktree-');
|
||||
|
||||
const mainGitDir = path.join(mainRepo, '.git');
|
||||
fs.mkdirSync(mainGitDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(mainGitDir, 'config'),
|
||||
'[core]\n\trepositoryformatversion = 0\n',
|
||||
);
|
||||
|
||||
const worktreeGitDir = path.join(
|
||||
mainGitDir,
|
||||
'worktrees',
|
||||
'test-worktree',
|
||||
);
|
||||
fs.mkdirSync(worktreeGitDir, { recursive: true });
|
||||
|
||||
// Create the .git file in the worktree directory pointing to the worktree git dir
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeDir, '.git'),
|
||||
`gitdir: ${worktreeGitDir}\n`,
|
||||
);
|
||||
|
||||
// Create the backlink from worktree git dir to the worktree's .git file
|
||||
const backlinkPath = path.join(worktreeGitDir, 'gitdir');
|
||||
fs.writeFileSync(backlinkPath, path.join(worktreeDir, '.git'));
|
||||
|
||||
// Create a file in the worktree git dir that we want to access
|
||||
const secretFile = path.join(worktreeGitDir, 'secret.txt');
|
||||
fs.writeFileSync(secretFile, 'git-secret');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: worktreeDir },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.cat(secretFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: worktreeDir,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(result.stdout.trim()).toBe('git-secret');
|
||||
});
|
||||
|
||||
it('blocks write access to git common directory in a worktree', async () => {
|
||||
const mainRepo = createTempDir('main-repo-');
|
||||
const worktreeDir = createTempDir('worktree-');
|
||||
|
||||
const mainGitDir = path.join(mainRepo, '.git');
|
||||
fs.mkdirSync(mainGitDir, { recursive: true });
|
||||
|
||||
const worktreeGitDir = path.join(
|
||||
mainGitDir,
|
||||
'worktrees',
|
||||
'test-worktree',
|
||||
);
|
||||
fs.mkdirSync(worktreeGitDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeDir, '.git'),
|
||||
`gitdir: ${worktreeGitDir}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeGitDir, 'gitdir'),
|
||||
path.join(worktreeDir, '.git'),
|
||||
);
|
||||
|
||||
const targetFile = path.join(worktreeGitDir, 'secret.txt');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
// Use YOLO mode to ensure the workspace is fully writable, but git worktrees should still be read-only
|
||||
{ workspace: worktreeDir, modeConfig: { yolo: true } },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(targetFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: worktreeDir,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'failure');
|
||||
expect(fs.existsSync(targetFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('blocks write access to git common directory in a worktree when not explicitly requested via additionalPermissions', async () => {
|
||||
const mainRepo = createTempDir('main-repo-');
|
||||
const worktreeDir = createTempDir('worktree-');
|
||||
|
||||
const mainGitDir = path.join(mainRepo, '.git');
|
||||
fs.mkdirSync(mainGitDir, { recursive: true });
|
||||
|
||||
const worktreeGitDir = path.join(
|
||||
mainGitDir,
|
||||
'worktrees',
|
||||
'test-worktree',
|
||||
);
|
||||
fs.mkdirSync(worktreeGitDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeDir, '.git'),
|
||||
`gitdir: ${worktreeGitDir}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeGitDir, 'gitdir'),
|
||||
path.join(worktreeDir, '.git'),
|
||||
);
|
||||
|
||||
const targetFile = path.join(worktreeGitDir, 'secret.txt');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: worktreeDir },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(targetFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: worktreeDir,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'failure');
|
||||
expect(fs.existsSync(targetFile)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows write access to git common directory in a worktree when explicitly requested via additionalPermissions', async () => {
|
||||
const mainRepo = createTempDir('main-repo-');
|
||||
const worktreeDir = createTempDir('worktree-');
|
||||
|
||||
const mainGitDir = path.join(mainRepo, '.git');
|
||||
fs.mkdirSync(mainGitDir, { recursive: true });
|
||||
|
||||
const worktreeGitDir = path.join(
|
||||
mainGitDir,
|
||||
'worktrees',
|
||||
'test-worktree',
|
||||
);
|
||||
fs.mkdirSync(worktreeGitDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeDir, '.git'),
|
||||
`gitdir: ${worktreeGitDir}\n`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(worktreeGitDir, 'gitdir'),
|
||||
path.join(worktreeDir, '.git'),
|
||||
);
|
||||
|
||||
const targetFile = path.join(worktreeGitDir, 'secret.txt');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: worktreeDir },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(targetFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: worktreeDir,
|
||||
env: process.env,
|
||||
policy: {
|
||||
additionalPermissions: { fileSystem: { write: [worktreeGitDir] } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(fs.existsSync(targetFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows write access to external git directory in a non-worktree environment when explicitly requested via additionalPermissions', async () => {
|
||||
const externalGitDir = createTempDir('external-git-');
|
||||
const workspaceDir = createTempDir('workspace-');
|
||||
|
||||
fs.mkdirSync(externalGitDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(workspaceDir, '.git'),
|
||||
`gitdir: ${externalGitDir}\n`,
|
||||
);
|
||||
|
||||
const targetFile = path.join(externalGitDir, 'secret.txt');
|
||||
|
||||
const osManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace: workspaceDir },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(targetFile);
|
||||
const sandboxed = await osManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: workspaceDir,
|
||||
env: process.env,
|
||||
policy: {
|
||||
additionalPermissions: { fileSystem: { write: [externalGitDir] } },
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(fs.existsSync(targetFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git and Governance Write Access', () => {
|
||||
it('allows write access to .gitignore when workspace is writable', async () => {
|
||||
const testFile = path.join(workspace, '.gitignore');
|
||||
fs.writeFileSync(testFile, 'initial');
|
||||
|
||||
const editManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace, modeConfig: { readonly: false, allowOverrides: true } },
|
||||
);
|
||||
|
||||
const { command, args } = Platform.touch(testFile);
|
||||
const sandboxed = await editManager.prepareCommand({
|
||||
command,
|
||||
args,
|
||||
cwd: workspace,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
});
|
||||
|
||||
it('automatically allows write access to .git when running git command and workspace is writable', async () => {
|
||||
const gitDir = path.join(workspace, '.git');
|
||||
if (!fs.existsSync(gitDir)) fs.mkdirSync(gitDir);
|
||||
const lockFile = path.join(gitDir, 'index.lock');
|
||||
|
||||
const editManager = createSandboxManager(
|
||||
{ enabled: true },
|
||||
{ workspace, modeConfig: { readonly: false, allowOverrides: true } },
|
||||
);
|
||||
|
||||
// We use a command that looks like git to trigger the special handling.
|
||||
// LinuxSandboxManager identifies the command root from the shell wrapper.
|
||||
const { command: nodePath, args: nodeArgs } = Platform.touch(lockFile);
|
||||
|
||||
const commandString = Platform.isWindows
|
||||
? `git --version > NUL && "${nodePath.replace(/\\/g, '/')}" ${nodeArgs
|
||||
.map((a) => `'${a.replace(/\\/g, '/')}'`)
|
||||
.join(' ')}`
|
||||
: `git --version > /dev/null; "${nodePath}" ${nodeArgs
|
||||
.map((a) => (a.includes(' ') || a.includes('(') ? `'${a}'` : a))
|
||||
.join(' ')}`;
|
||||
|
||||
const sandboxed = await editManager.prepareCommand({
|
||||
command: 'sh',
|
||||
args: ['-c', commandString],
|
||||
cwd: workspace,
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
const result = await runCommand(sandboxed);
|
||||
assertResult(result, sandboxed, 'success');
|
||||
expect(fs.existsSync(lockFile)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network Security', () => {
|
||||
describe('Network Access', () => {
|
||||
let server: http.Server;
|
||||
|
||||
@@ -409,6 +409,23 @@ export async function resolveSandboxPaths(
|
||||
? { gitWorktree: { worktreeGitDir, mainGitDir } }
|
||||
: undefined;
|
||||
|
||||
if (worktreeGitDir) {
|
||||
const gitIdentities = new Set(
|
||||
[
|
||||
path.join(options.workspace, '.git'),
|
||||
path.join(resolvedWorkspace, '.git'),
|
||||
].map(toPathKey),
|
||||
);
|
||||
if (policyRead.some((p) => gitIdentities.has(toPathKey(p)))) {
|
||||
policyRead.push(worktreeGitDir);
|
||||
if (mainGitDir) policyRead.push(mainGitDir);
|
||||
}
|
||||
if (policyWrite.some((p) => gitIdentities.has(toPathKey(p)))) {
|
||||
policyWrite.push(worktreeGitDir);
|
||||
if (mainGitDir) policyWrite.push(mainGitDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
|
||||
*/
|
||||
|
||||
@@ -16,5 +16,6 @@ export enum LlmRole {
|
||||
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
|
||||
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
|
||||
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
|
||||
UTILITY_SIMULATOR = 'utility_simulator',
|
||||
UTILITY_STATE_SNAPSHOT_PROCESSOR = 'utility_state_snapshot_processor',
|
||||
}
|
||||
|
||||
@@ -137,3 +137,7 @@ export const TOPIC_PARAM_STRATEGIC_INTENT = 'strategic_intent';
|
||||
// -- complete_task --
|
||||
export const COMPLETE_TASK_TOOL_NAME = 'complete_task';
|
||||
export const COMPLETE_TASK_DISPLAY_NAME = 'Complete Task';
|
||||
|
||||
// -- MCP Resources --
|
||||
export const READ_MCP_RESOURCE_TOOL_NAME = 'read_mcp_resource';
|
||||
export const LIST_MCP_RESOURCES_TOOL_NAME = 'list_mcp_resources';
|
||||
|
||||
@@ -43,6 +43,8 @@ export {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -280,3 +282,17 @@ export function getActivateSkillDefinition(
|
||||
overrides: (modelId) => getToolSet(modelId).activate_skill(skillNames),
|
||||
};
|
||||
}
|
||||
|
||||
export const READ_MCP_RESOURCE_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.read_mcp_resource;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).read_mcp_resource,
|
||||
};
|
||||
|
||||
export const LIST_MCP_RESOURCES_DEFINITION: ToolDefinition = {
|
||||
get base() {
|
||||
return DEFAULT_LEGACY_SET.list_mcp_resources;
|
||||
},
|
||||
overrides: (modelId) => getToolSet(modelId).list_mcp_resources,
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -756,4 +758,37 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
|
||||
exit_plan_mode: () => getExitPlanModeDeclaration(),
|
||||
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
|
||||
|
||||
read_mcp_resource: {
|
||||
name: READ_MCP_RESOURCE_TOOL_NAME,
|
||||
description:
|
||||
'Reads the content of a specified Model Context Protocol (MCP) resource.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uri: {
|
||||
description: 'The URI of the MCP resource to read.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['uri'],
|
||||
},
|
||||
},
|
||||
|
||||
list_mcp_resources: {
|
||||
name: LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
description:
|
||||
'Lists all available resources exposed by connected MCP servers.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverName: {
|
||||
description:
|
||||
'Optional filter to list resources from a specific server.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
GET_INTERNAL_DOCS_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -733,4 +735,37 @@ The agent did not use the todo list because this task could be completed by a ti
|
||||
exit_plan_mode: () => getExitPlanModeDeclaration(),
|
||||
activate_skill: (skillNames) => getActivateSkillDeclaration(skillNames),
|
||||
update_topic: getUpdateTopicDeclaration(),
|
||||
|
||||
read_mcp_resource: {
|
||||
name: READ_MCP_RESOURCE_TOOL_NAME,
|
||||
description:
|
||||
'Reads the content of a specified Model Context Protocol (MCP) resource.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
uri: {
|
||||
description: 'The URI of the MCP resource to read.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['uri'],
|
||||
},
|
||||
},
|
||||
|
||||
list_mcp_resources: {
|
||||
name: LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
description:
|
||||
'Lists all available resources exposed by connected MCP servers.',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
serverName: {
|
||||
description:
|
||||
'Optional filter to list resources from a specific server.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,5 +50,7 @@ export interface CoreToolSet {
|
||||
enter_plan_mode: FunctionDeclaration;
|
||||
exit_plan_mode: () => FunctionDeclaration;
|
||||
activate_skill: (skillNames: string[]) => FunctionDeclaration;
|
||||
read_mcp_resource: FunctionDeclaration;
|
||||
list_mcp_resources: FunctionDeclaration;
|
||||
update_topic?: FunctionDeclaration;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,12 @@ class GetInternalDocsInvocation extends BaseToolInvocation<
|
||||
const docsRoot = await getDocsRoot();
|
||||
|
||||
if (!this.params.path) {
|
||||
// List all .md files recursively
|
||||
const files = await glob('**/*.md', { cwd: docsRoot, posix: true });
|
||||
// List all .md and .mdx files recursively
|
||||
const files = await glob('**/*.{md,mdx}', {
|
||||
cwd: docsRoot,
|
||||
posix: true,
|
||||
});
|
||||
|
||||
files.sort();
|
||||
|
||||
const fileList = files.map((f) => `- ${f}`).join('\n');
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { ListMcpResourcesTool } from './list-mcp-resources.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
describe('ListMcpResourcesTool', () => {
|
||||
let tool: ListMcpResourcesTool;
|
||||
let mockContext: {
|
||||
config: {
|
||||
getMcpClientManager: Mock;
|
||||
};
|
||||
};
|
||||
let mockMcpManager: {
|
||||
getAllResources: Mock;
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMcpManager = {
|
||||
getAllResources: vi.fn(),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
config: {
|
||||
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
|
||||
},
|
||||
};
|
||||
|
||||
tool = new ListMcpResourcesTool(
|
||||
mockContext as unknown as AgentLoopContext,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully list all resources', async () => {
|
||||
const resources = [
|
||||
{
|
||||
uri: 'protocol://r1',
|
||||
serverName: 'server1',
|
||||
name: 'R1',
|
||||
description: 'D1',
|
||||
},
|
||||
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
|
||||
];
|
||||
mockMcpManager.getAllResources.mockReturnValue(resources);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.getAllResources).toHaveBeenCalled();
|
||||
expect(result.llmContent).toContain('Available MCP Resources:');
|
||||
expect(result.llmContent).toContain('protocol://r1');
|
||||
expect(result.llmContent).toContain('protocol://r2');
|
||||
expect(result.returnDisplay).toBe('Listed 2 resources.');
|
||||
});
|
||||
|
||||
it('should filter by server name', async () => {
|
||||
const resources = [
|
||||
{ uri: 'protocol://r1', serverName: 'server1', name: 'R1' },
|
||||
{ uri: 'protocol://r2', serverName: 'server2', name: 'R2' },
|
||||
];
|
||||
mockMcpManager.getAllResources.mockReturnValue(resources);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ serverName: 'server1' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toContain('protocol://r1');
|
||||
expect(result.llmContent).not.toContain('protocol://r2');
|
||||
expect(result.returnDisplay).toBe('Listed 1 resources.');
|
||||
});
|
||||
|
||||
it('should return message if no resources found', async () => {
|
||||
mockMcpManager.getAllResources.mockReturnValue([]);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toBe('No MCP resources found.');
|
||||
expect(result.returnDisplay).toBe('No MCP resources found.');
|
||||
});
|
||||
|
||||
it('should return message if no resources found for server', async () => {
|
||||
mockMcpManager.getAllResources.mockReturnValue([]);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ serverName: 'nonexistent' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(result.llmContent).toBe(
|
||||
'No resources found for server: nonexistent',
|
||||
);
|
||||
expect(result.returnDisplay).toBe(
|
||||
'No resources found for server: nonexistent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error if MCP Client Manager not available', async () => {
|
||||
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({});
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
|
||||
expect(result.error?.message).toContain('MCP Client Manager not available');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { LIST_MCP_RESOURCES_TOOL_NAME } from './tool-names.js';
|
||||
import { LIST_MCP_RESOURCES_DEFINITION } from './definitions/coreTools.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
|
||||
export interface ListMcpResourcesParams {
|
||||
serverName?: string;
|
||||
}
|
||||
|
||||
export class ListMcpResourcesTool extends BaseDeclarativeTool<
|
||||
ListMcpResourcesParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = LIST_MCP_RESOURCES_TOOL_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ListMcpResourcesTool.Name,
|
||||
'List MCP Resources',
|
||||
LIST_MCP_RESOURCES_DEFINITION.base.description!,
|
||||
Kind.Search,
|
||||
LIST_MCP_RESOURCES_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ListMcpResourcesParams,
|
||||
): ListMcpResourcesToolInvocation {
|
||||
return new ListMcpResourcesToolInvocation(
|
||||
this.context,
|
||||
params,
|
||||
this.messageBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ListMcpResourcesToolInvocation extends BaseToolInvocation<
|
||||
ListMcpResourcesParams,
|
||||
ToolResult
|
||||
> {
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: ListMcpResourcesParams,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(params, messageBus, ListMcpResourcesTool.Name, 'List MCP Resources');
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'List MCP resources';
|
||||
}
|
||||
|
||||
async execute({
|
||||
abortSignal: _abortSignal,
|
||||
}: ExecuteOptions): Promise<ToolResult> {
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
if (!mcpManager) {
|
||||
return {
|
||||
llmContent: 'Error: MCP Client Manager not available.',
|
||||
returnDisplay: 'Error: MCP Client Manager not available.',
|
||||
error: {
|
||||
message: 'MCP Client Manager not available.',
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let resources = mcpManager.getAllResources();
|
||||
|
||||
const serverName = this.params.serverName;
|
||||
if (serverName) {
|
||||
resources = resources.filter((r) => r.serverName === serverName);
|
||||
}
|
||||
|
||||
if (resources.length === 0) {
|
||||
const msg = serverName
|
||||
? `No resources found for server: ${serverName}`
|
||||
: 'No MCP resources found.';
|
||||
return {
|
||||
llmContent: msg,
|
||||
returnDisplay: msg,
|
||||
};
|
||||
}
|
||||
|
||||
// Format the list
|
||||
let content = 'Available MCP Resources:\n';
|
||||
for (const resource of resources) {
|
||||
content += `- ${resource.serverName}:${resource.uri}`;
|
||||
if (resource.name) {
|
||||
content += ` | ${resource.name}`;
|
||||
}
|
||||
if (resource.description) {
|
||||
content += ` | ${resource.description}`;
|
||||
}
|
||||
content += '\n';
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: content,
|
||||
returnDisplay: `Listed ${resources.length} resources.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -821,4 +821,64 @@ describe('McpClientManager', () => {
|
||||
expect(coreEventsMock.emitFeedback).toHaveBeenCalledTimes(2); // Now the actual error
|
||||
});
|
||||
});
|
||||
|
||||
describe('findResourceByUri', () => {
|
||||
it('should find resource by exact URI match', () => {
|
||||
const mockResource = { uri: 'test://resource1', name: 'Resource 1' };
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([mockResource]),
|
||||
findResourceByUri: vi.fn(),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('test://resource1');
|
||||
expect(result).toBe(mockResource);
|
||||
});
|
||||
|
||||
it('should try ResourceRegistry.findResourceByUri first', () => {
|
||||
const mockResourceQualified = {
|
||||
uri: 'test://resource1',
|
||||
name: 'Resource 1 Qualified',
|
||||
};
|
||||
const mockResourceDirect = {
|
||||
uri: 'test-server:test://resource1',
|
||||
name: 'Resource 1 Direct',
|
||||
};
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([mockResourceDirect]),
|
||||
findResourceByUri: vi.fn().mockReturnValue(mockResourceQualified),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('test-server:test://resource1');
|
||||
expect(result).toBe(mockResourceQualified);
|
||||
expect(mockResourceRegistry.findResourceByUri).toHaveBeenCalledWith(
|
||||
'test-server:test://resource1',
|
||||
);
|
||||
expect(mockResourceRegistry.getAllResources).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined if both fail', () => {
|
||||
const mockResourceRegistry = {
|
||||
getAllResources: vi.fn().mockReturnValue([]),
|
||||
findResourceByUri: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
mockConfig.getResourceRegistry.mockReturnValue(
|
||||
mockResourceRegistry as unknown as ResourceRegistry,
|
||||
);
|
||||
|
||||
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
|
||||
|
||||
const result = manager.findResourceByUri('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,10 @@ import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { stableStringify } from '../policy/stable-stringify.js';
|
||||
import type { PromptRegistry } from '../prompts/prompt-registry.js';
|
||||
import type { ResourceRegistry } from '../resources/resource-registry.js';
|
||||
import type {
|
||||
ResourceRegistry,
|
||||
MCPResource,
|
||||
} from '../resources/resource-registry.js';
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of multiple MCP clients, including local child processes.
|
||||
@@ -161,7 +164,32 @@ export class McpClientManager {
|
||||
}
|
||||
|
||||
getClient(serverName: string): McpClient | undefined {
|
||||
return this.clients.get(serverName);
|
||||
for (const client of this.clients.values()) {
|
||||
if (client.getServerName() === serverName) {
|
||||
return client;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
findResourceByUri(uri: string): MCPResource | undefined {
|
||||
if (!this.mainResourceRegistry) return undefined;
|
||||
|
||||
// Try serverName:uri format first
|
||||
const qualifiedMatch = this.mainResourceRegistry.findResourceByUri(uri);
|
||||
if (qualifiedMatch) {
|
||||
return qualifiedMatch;
|
||||
}
|
||||
|
||||
// Try direct URI match
|
||||
return this.mainResourceRegistry
|
||||
.getAllResources()
|
||||
.find((r) => r.uri === uri);
|
||||
}
|
||||
|
||||
getAllResources(): MCPResource[] {
|
||||
if (!this.mainResourceRegistry) return [];
|
||||
return this.mainResourceRegistry.getAllResources();
|
||||
}
|
||||
|
||||
removeRegistries(registries: {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { ReadMcpResourceTool } from './read-mcp-resource.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
|
||||
|
||||
describe('ReadMcpResourceTool', () => {
|
||||
let tool: ReadMcpResourceTool;
|
||||
let mockContext: {
|
||||
config: {
|
||||
getMcpClientManager: Mock;
|
||||
};
|
||||
};
|
||||
let mockMcpManager: {
|
||||
findResourceByUri: Mock;
|
||||
getClient: Mock;
|
||||
};
|
||||
const abortSignal = new AbortController().signal;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMcpManager = {
|
||||
findResourceByUri: vi.fn(),
|
||||
getClient: vi.fn(),
|
||||
};
|
||||
|
||||
mockContext = {
|
||||
config: {
|
||||
getMcpClientManager: vi.fn().mockReturnValue(mockMcpManager),
|
||||
},
|
||||
};
|
||||
|
||||
tool = new ReadMcpResourceTool(
|
||||
mockContext as unknown as AgentLoopContext,
|
||||
createMockMessageBus(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully read a resource', async () => {
|
||||
const uri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
const resourceName = 'Test Resource';
|
||||
const resourceContent = 'Resource Content';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri,
|
||||
serverName,
|
||||
name: resourceName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
contents: [{ text: resourceContent }],
|
||||
}),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
getDescription: () => string;
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri });
|
||||
|
||||
// Verify description
|
||||
expect(invocation.getDescription()).toBe(
|
||||
`Read MCP resource "${resourceName}" from server "${serverName}"`,
|
||||
);
|
||||
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(uri);
|
||||
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
|
||||
expect(mockClient.readResource).toHaveBeenCalledWith(uri);
|
||||
expect(result).toEqual({
|
||||
llmContent: resourceContent + '\n',
|
||||
returnDisplay: `Successfully read resource "${resourceName}" from server "${serverName}"`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass raw URI to client when using qualified URI', async () => {
|
||||
const qualifiedUri = 'test-server:protocol://resource';
|
||||
const rawUri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
const resourceName = 'Test Resource';
|
||||
const resourceContent = 'Resource Content';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri: rawUri,
|
||||
serverName,
|
||||
name: resourceName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockResolvedValue({
|
||||
contents: [{ text: resourceContent }],
|
||||
}),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: qualifiedUri });
|
||||
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
llmContent: string;
|
||||
returnDisplay: string;
|
||||
};
|
||||
|
||||
expect(mockMcpManager.findResourceByUri).toHaveBeenCalledWith(qualifiedUri);
|
||||
expect(mockMcpManager.getClient).toHaveBeenCalledWith(serverName);
|
||||
expect(mockClient.readResource).toHaveBeenCalledWith(rawUri);
|
||||
expect(result.llmContent).toBe(resourceContent + '\n');
|
||||
});
|
||||
|
||||
it('should return error if MCP Client Manager not available', async () => {
|
||||
mockContext.config.getMcpClientManager.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: 'uri' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.EXECUTION_FAILED);
|
||||
expect(result.error?.message).toContain('MCP Client Manager not available');
|
||||
});
|
||||
|
||||
it('should return error if resource not found', async () => {
|
||||
mockMcpManager.findResourceByUri.mockReturnValue(undefined);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri: 'uri' });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.MCP_RESOURCE_NOT_FOUND);
|
||||
expect(result.error?.message).toContain('Resource not found');
|
||||
});
|
||||
|
||||
it('should return error if reading fails', async () => {
|
||||
const uri = 'protocol://resource';
|
||||
const serverName = 'test-server';
|
||||
|
||||
mockMcpManager.findResourceByUri.mockReturnValue({
|
||||
uri,
|
||||
serverName,
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
readResource: vi.fn().mockRejectedValue(new Error('Failed to read')),
|
||||
};
|
||||
mockMcpManager.getClient.mockReturnValue(mockClient);
|
||||
|
||||
const invocation = (
|
||||
tool as unknown as {
|
||||
createInvocation: (params: Record<string, unknown>) => {
|
||||
execute: (options: { abortSignal: AbortSignal }) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).createInvocation({ uri });
|
||||
const result = (await invocation.execute({ abortSignal })) as {
|
||||
error: { type: string; message: string };
|
||||
};
|
||||
|
||||
expect(result.error?.type).toBe(ToolErrorType.MCP_TOOL_ERROR);
|
||||
expect(result.error?.message).toContain('Failed to read resource');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseDeclarativeTool,
|
||||
BaseToolInvocation,
|
||||
Kind,
|
||||
type ToolResult,
|
||||
type ExecuteOptions,
|
||||
} from './tools.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
import { READ_MCP_RESOURCE_TOOL_NAME } from './tool-names.js';
|
||||
import { READ_MCP_RESOURCE_DEFINITION } from './definitions/coreTools.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { ToolErrorType } from './tool-error.js';
|
||||
import type { MCPResource } from '../resources/resource-registry.js';
|
||||
|
||||
export interface ReadMcpResourceParams {
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export class ReadMcpResourceTool extends BaseDeclarativeTool<
|
||||
ReadMcpResourceParams,
|
||||
ToolResult
|
||||
> {
|
||||
static readonly Name = READ_MCP_RESOURCE_TOOL_NAME;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(
|
||||
ReadMcpResourceTool.Name,
|
||||
'Read MCP Resource',
|
||||
READ_MCP_RESOURCE_DEFINITION.base.description!,
|
||||
Kind.Read,
|
||||
READ_MCP_RESOURCE_DEFINITION.base.parametersJsonSchema,
|
||||
messageBus,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
protected createInvocation(
|
||||
params: ReadMcpResourceParams,
|
||||
): ReadMcpResourceToolInvocation {
|
||||
return new ReadMcpResourceToolInvocation(
|
||||
this.context,
|
||||
params,
|
||||
this.messageBus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReadMcpResourceToolInvocation extends BaseToolInvocation<
|
||||
ReadMcpResourceParams,
|
||||
ToolResult
|
||||
> {
|
||||
private resource: MCPResource | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly context: AgentLoopContext,
|
||||
params: ReadMcpResourceParams,
|
||||
messageBus: MessageBus,
|
||||
) {
|
||||
super(params, messageBus, ReadMcpResourceTool.Name, 'Read MCP Resource');
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
this.resource = mcpManager?.findResourceByUri(params.uri);
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
if (this.resource) {
|
||||
return `Read MCP resource "${this.resource.name}" from server "${this.resource.serverName}"`;
|
||||
}
|
||||
return `Read MCP resource: ${this.params.uri}`;
|
||||
}
|
||||
|
||||
async execute({
|
||||
abortSignal: _abortSignal,
|
||||
}: ExecuteOptions): Promise<ToolResult> {
|
||||
const mcpManager = this.context.config.getMcpClientManager();
|
||||
if (!mcpManager) {
|
||||
return {
|
||||
llmContent: 'Error: MCP Client Manager not available.',
|
||||
returnDisplay: 'Error: MCP Client Manager not available.',
|
||||
error: {
|
||||
message: 'MCP Client Manager not available.',
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uri = this.params.uri;
|
||||
if (!uri) {
|
||||
return {
|
||||
llmContent: 'Error: No URI provided.',
|
||||
returnDisplay: 'Error: No URI provided.',
|
||||
error: {
|
||||
message: 'No URI provided.',
|
||||
type: ToolErrorType.INVALID_TOOL_PARAMS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const resource = mcpManager.findResourceByUri(uri);
|
||||
if (!resource) {
|
||||
const errorMessage = `Resource not found for URI: ${uri}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MCP_RESOURCE_NOT_FOUND,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const client = mcpManager.getClient(resource.serverName);
|
||||
if (!client) {
|
||||
const errorMessage = `MCP Client not found for server: ${resource.serverName}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.EXECUTION_FAILED,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.readResource(resource.uri);
|
||||
// The result should contain contents.
|
||||
// Let's assume it returns a string or an object with contents.
|
||||
// According to MCP spec, it returns { contents: [...] }.
|
||||
// We should format it nicely.
|
||||
let contentText = '';
|
||||
if (result && result.contents) {
|
||||
for (const content of result.contents) {
|
||||
if ('text' in content && content.text) {
|
||||
contentText += content.text + '\n';
|
||||
} else if ('blob' in content && content.blob) {
|
||||
contentText += `[Binary Data (${content.mimeType})]` + '\n';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
llmContent: contentText || 'No content returned from resource.',
|
||||
returnDisplay: this.resource
|
||||
? `Successfully read resource "${this.resource.name}" from server "${this.resource.serverName}"`
|
||||
: `Successfully read resource: ${uri}`,
|
||||
};
|
||||
} catch (e) {
|
||||
const errorMessage = `Failed to read resource: ${e instanceof Error ? e.message : String(e)}`;
|
||||
return {
|
||||
llmContent: `Error: ${errorMessage}`,
|
||||
returnDisplay: `Error: ${errorMessage}`,
|
||||
error: {
|
||||
message: errorMessage,
|
||||
type: ToolErrorType.MCP_TOOL_ERROR,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export enum ToolErrorType {
|
||||
|
||||
// MCP-specific Errors
|
||||
MCP_TOOL_ERROR = 'mcp_tool_error',
|
||||
MCP_RESOURCE_NOT_FOUND = 'mcp_resource_not_found',
|
||||
|
||||
// Memory-specific Errors
|
||||
MEMORY_TOOL_EXECUTION_ERROR = 'memory_tool_execution_error',
|
||||
|
||||
@@ -79,6 +79,8 @@ import {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
TOPIC_PARAM_TITLE,
|
||||
TOPIC_PARAM_SUMMARY,
|
||||
TOPIC_PARAM_STRATEGIC_INTENT,
|
||||
@@ -106,6 +108,8 @@ export {
|
||||
UPDATE_TOPIC_DISPLAY_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
COMPLETE_TASK_DISPLAY_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
// Shared parameter names
|
||||
PARAM_FILE_PATH,
|
||||
PARAM_DIR_PATH,
|
||||
@@ -272,6 +276,8 @@ export const ALL_BUILTIN_TOOL_NAMES = [
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
COMPLETE_TASK_TOOL_NAME,
|
||||
AGENT_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -291,6 +297,8 @@ export const PLAN_MODE_TOOLS = [
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
'codebase_investigator',
|
||||
'cli_help',
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
UPDATE_TOPIC_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
READ_MCP_RESOURCE_TOOL_NAME,
|
||||
LIST_MCP_RESOURCES_TOOL_NAME,
|
||||
} from './tool-names.js';
|
||||
|
||||
type ToolParams = Record<string, unknown>;
|
||||
@@ -602,6 +604,16 @@ export class ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
tool.name === READ_MCP_RESOURCE_TOOL_NAME ||
|
||||
tool.name === LIST_MCP_RESOURCES_TOOL_NAME
|
||||
) {
|
||||
const mcpManager = this.config.getMcpClientManager();
|
||||
if (!mcpManager || mcpManager.getAllResources().length === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN;
|
||||
if (
|
||||
(tool.name === ENTER_PLAN_MODE_TOOL_NAME && isPlanMode) ||
|
||||
|
||||
@@ -350,7 +350,7 @@ export async function isEmpty(filePath: string): Promise<boolean> {
|
||||
*/
|
||||
export async function isBinaryFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
return await isBinaryFileCheck(filePath);
|
||||
return Boolean(await isBinaryFileCheck(filePath));
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to check if file is binary: ${filePath}`,
|
||||
|
||||