Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0203ab0a1c | |||
| cdf5c265d8 | |||
| d0272a6436 | |||
| d14205a4ce | |||
| 20ca623cb8 | |||
| 7ea1654706 | |||
| 4cde103712 | |||
| ee635bb0e9 | |||
| 7181242f69 | |||
| 44abbdf56e | |||
| 152806379c | |||
| 03efe65dd5 | |||
| 9e3a48a3b6 | |||
| e064cfe043 | |||
| 63a6211fe0 | |||
| c2a17ae257 | |||
| c1297436b9 | |||
| 3d1c2e849c | |||
| cfac19e772 | |||
| ad28dc83c3 | |||
| bcd0acae4f | |||
| 1755678cf9 | |||
| 886025f6b9 | |||
| 7cdfaaa6bd | |||
| ea4dd5ae35 | |||
| 8df9a80cc4 | |||
| e3baad48c8 | |||
| 0ca2e4e2ae | |||
| 30b4dcecbc | |||
| 954835123f |
@@ -102,12 +102,6 @@ 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'
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# 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,9 +130,7 @@ 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`) and core
|
||||
[MCP resource tools](../tools/mcp-resources.md) (`list_mcp_resources`,
|
||||
`read_mcp_resource`) are allowed.
|
||||
example, `github_read_issue`, `postgres_read_schema`) 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,21 +24,20 @@ 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 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"` |
|
||||
| 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"` |
|
||||
|
||||
### Output
|
||||
|
||||
|
||||
@@ -134,15 +134,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.enableNotifications`** (boolean):
|
||||
- **Description:** Enable terminal run-event notifications for action-required
|
||||
prompts and session completion.
|
||||
- **Description:** Enable 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,12 +120,6 @@ 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**.
|
||||
@@ -133,13 +127,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 | **(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). |
|
||||
| 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). |
|
||||
|
||||
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:
|
||||
@@ -220,11 +214,11 @@ User, and (if configured) Admin directories.
|
||||
|
||||
### Policy locations
|
||||
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :------------------------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | **(Disabled)** `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
| Tier | Type | Location |
|
||||
| :------------ | :----- | :---------------------------------------- |
|
||||
| **User** | Custom | `~/.gemini/policies/*.toml` |
|
||||
| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` |
|
||||
| **Admin** | System | _See below (OS specific)_ |
|
||||
|
||||
#### System-wide policies (Admin)
|
||||
|
||||
|
||||
@@ -92,13 +92,6 @@ 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,14 +122,7 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "MCP servers",
|
||||
"collapsed": true,
|
||||
"items": [
|
||||
{ "label": "Overview", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Resource tools", "slug": "docs/tools/mcp-resources" }
|
||||
]
|
||||
},
|
||||
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
|
||||
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
|
||||
{ "label": "Model selection", "slug": "docs/cli/model" },
|
||||
{
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# 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,8 +64,7 @@ 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. For more information on the tools used to
|
||||
interact with these resources, see [MCP resource tools](mcp-resources.md).
|
||||
to reference them in the chat.
|
||||
|
||||
### Discovery and listing
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@ 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.",
|
||||
@@ -52,8 +50,6 @@ 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,8 +298,6 @@ 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,
|
||||
@@ -335,7 +333,7 @@ describe('plan_mode', () => {
|
||||
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but got error: ${(planWrite?.toolRequest as any).error}`,
|
||||
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
@@ -343,8 +341,6 @@ 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,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import {
|
||||
TRACKER_CREATE_TASK_TOOL_NAME,
|
||||
TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { evalTest, TEST_AGENTS } from './test-helper.js';
|
||||
|
||||
describe('subtask delegation eval test cases', () => {
|
||||
@@ -19,8 +22,6 @@ 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: {
|
||||
@@ -89,8 +90,6 @@ 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,8 +119,6 @@ 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 } },
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"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,8 +7,6 @@
|
||||
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,8 +21,6 @@ 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.
|
||||
@@ -119,8 +117,6 @@ 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:
|
||||
@@ -146,8 +142,6 @@ 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:
|
||||
@@ -175,8 +169,6 @@ 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.',
|
||||
@@ -220,9 +212,7 @@ 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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -234,8 +224,6 @@ 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,11 +39,7 @@ describe('web-fetch rate limiting', () => {
|
||||
const rateLimitedCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === 'web_fetch' &&
|
||||
(
|
||||
('error' in log.toolRequest
|
||||
? (log.toolRequest as unknown as Record<string, string>)['error']
|
||||
: '') as string
|
||||
)?.includes('Rate limit exceeded'),
|
||||
log.toolRequest.error?.includes('Rate limit exceeded'),
|
||||
);
|
||||
|
||||
expect(rateLimitedCalls.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -164,8 +164,7 @@ describe.skipIf(skipFlaky)(
|
||||
);
|
||||
expect(blockHook).toBeDefined();
|
||||
expect(
|
||||
(blockHook?.hookCall.stdout || '') +
|
||||
(blockHook?.hookCall.stderr || ''),
|
||||
blockHook?.hookCall.stdout + blockHook?.hookCall.stderr,
|
||||
).toContain(blockMsg);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{"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}}]}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"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}}]}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"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}}]}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* @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: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -221,7 +221,7 @@ describe('Plan Mode', () => {
|
||||
).toBeDefined();
|
||||
expect(
|
||||
planWrite?.toolRequest.success,
|
||||
`Expected write_file to succeed, but it failed with error: ${'error' in (planWrite?.toolRequest || {}) ? (planWrite?.toolRequest as unknown as Record<string, string>)['error'] : 'unknown'}`,
|
||||
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
|
||||
).toBe(true);
|
||||
});
|
||||
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
|
||||
@@ -270,24 +270,13 @@ describe('Plan Mode', () => {
|
||||
);
|
||||
|
||||
const apiRequests = rig.readAllApiRequest();
|
||||
const modelNames = apiRequests.map(
|
||||
(r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown') || 'unknown',
|
||||
);
|
||||
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
|
||||
|
||||
const proRequests = apiRequests.filter((r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('pro'),
|
||||
r.attributes?.model?.includes('pro'),
|
||||
);
|
||||
const flashRequests = apiRequests.filter((r) =>
|
||||
('model' in (r.attributes || {})
|
||||
? (r.attributes as unknown as Record<string, string>)['model']
|
||||
: 'unknown'
|
||||
)?.includes('flash'),
|
||||
r.attributes?.model?.includes('flash'),
|
||||
);
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* @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 * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
describe('run_shell_command streaming to file regression', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should stream large outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream large outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 20000;
|
||||
const testFileName = 'large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~20MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(1000) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(20000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should stream very large (50MB) outputs to a file and verify full content presence', async () => {
|
||||
await rig.setup(
|
||||
'should stream very large (50MB) outputs to a file and verify full content presence',
|
||||
{
|
||||
settings: { tools: { core: ['run_shell_command'] } },
|
||||
},
|
||||
);
|
||||
|
||||
const numLines = 1000000;
|
||||
const testFileName = 'very_large_output_test.txt';
|
||||
const testFilePath = path.join(rig.testDir!, testFileName);
|
||||
|
||||
// Create a ~50MB file with unique content at start and end
|
||||
const startMarker = 'START_OF_FILE_MARKER';
|
||||
const endMarker = 'END_OF_FILE_MARKER';
|
||||
|
||||
const stream = fs.createWriteStream(testFilePath);
|
||||
stream.write(startMarker + '\n');
|
||||
for (let i = 0; i < numLines; i++) {
|
||||
stream.write(`Line ${i + 1}: ` + 'A'.repeat(40) + '\n');
|
||||
}
|
||||
stream.write(endMarker + '\n');
|
||||
await new Promise((resolve) => stream.end(resolve));
|
||||
|
||||
const fileSize = fs.statSync(testFilePath).size;
|
||||
expect(fileSize).toBeGreaterThan(45000000);
|
||||
|
||||
const prompt = `Use run_shell_command to cat ${testFileName} and say 'Done.'`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
} else if (file.isFile() && file.name.endsWith('.log')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
for (const p of files) {
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.size >= 20000000) {
|
||||
savedFilePath = p;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (!savedFilePath) {
|
||||
const fileStats = files.map((p) => {
|
||||
try {
|
||||
return { p, size: fs.statSync(p).size };
|
||||
} catch {
|
||||
return { p, size: 'error' };
|
||||
}
|
||||
});
|
||||
rig.log('Available files:', JSON.stringify(fileStats, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
savedFilePath,
|
||||
`Expected to find a saved output file >= 20MB in ${tmpdir}`,
|
||||
).toBeTruthy();
|
||||
|
||||
const savedContent = fs.readFileSync(savedFilePath, 'utf8');
|
||||
expect(savedContent).toContain(startMarker);
|
||||
expect(savedContent).toContain(endMarker);
|
||||
expect(savedContent.length).toBeGreaterThanOrEqual(fileSize);
|
||||
|
||||
fs.unlinkSync(savedFilePath);
|
||||
}, 120000);
|
||||
|
||||
it('should produce clean output resolving carriage returns and backspaces', async () => {
|
||||
await rig.setup(
|
||||
'should produce clean output resolving carriage returns and backspaces',
|
||||
{
|
||||
settings: {
|
||||
tools: { core: ['run_shell_command'] },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const script = `
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Fill buffer to force file streaming/truncation
|
||||
# 45000 chars to be safe (default threshold is 40000)
|
||||
print('A' * 45000)
|
||||
sys.stdout.flush()
|
||||
|
||||
# Test sequence
|
||||
print('XXXXX', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\rYYYYY', end='', flush=True)
|
||||
time.sleep(0.5)
|
||||
print('\\nNext Line', end='', flush=True)
|
||||
`;
|
||||
const scriptPath = path.join(rig.testDir!, 'test_script.py');
|
||||
fs.writeFileSync(scriptPath, script);
|
||||
|
||||
const prompt = `run_shell_command python3 "${scriptPath}"`;
|
||||
await rig.run({ args: prompt });
|
||||
|
||||
let savedFilePath = '';
|
||||
const tmpdir = path.join(rig.homeDir!, '.gemini', 'tmp');
|
||||
if (fs.existsSync(tmpdir)) {
|
||||
const findFiles = (dir: string): string[] => {
|
||||
let results: string[] = [];
|
||||
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const file of list) {
|
||||
const fullPath = path.join(dir, file.name);
|
||||
if (file.isDirectory()) {
|
||||
results = results.concat(findFiles(fullPath));
|
||||
} else if (file.isFile() && file.name.endsWith('.txt')) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const files = findFiles(tmpdir);
|
||||
files.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
|
||||
if (files.length > 0) {
|
||||
savedFilePath = files[0];
|
||||
}
|
||||
}
|
||||
|
||||
expect(savedFilePath, 'Output file should exist').toBeTruthy();
|
||||
const content = fs.readFileSync(savedFilePath, 'utf8');
|
||||
|
||||
// Verify it contains the large chunk
|
||||
expect(content).toContain('AAAA');
|
||||
|
||||
// Verify cleanup logic:
|
||||
// 1. The final text "YYYYY" should be present.
|
||||
expect(content).toContain('YYYYY');
|
||||
// 2. The next line should be present.
|
||||
expect(content).toContain('Next Line');
|
||||
|
||||
// 3. Verify overwrite happened.
|
||||
// In raw output, we would have "XXXXX...YYYYY".
|
||||
// In processed output, "YYYYY" overwrites "XXXXX".
|
||||
// We confirm that escape codes are stripped (processed text).
|
||||
|
||||
// 4. Check for ANSI escape codes (like \\x1b) just in case
|
||||
expect(content).not.toContain('\x1b');
|
||||
}, 60000);
|
||||
});
|
||||
@@ -5,9 +5,5 @@
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../packages/core" },
|
||||
{ "path": "../packages/test-utils" },
|
||||
{ "path": "../packages/cli" }
|
||||
]
|
||||
"references": [{ "path": "../packages/core" }]
|
||||
}
|
||||
|
||||
@@ -489,12 +489,8 @@ async function generateSharedLargeChatData(tempDir: string) {
|
||||
|
||||
// Wait for streams to finish
|
||||
await Promise.all([
|
||||
new Promise((res) =>
|
||||
activeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
new Promise((res) =>
|
||||
resumeResponsesStream.on('finish', () => res(undefined)),
|
||||
),
|
||||
new Promise((res) => activeResponsesStream.on('finish', res)),
|
||||
new Promise((res) => resumeResponsesStream.on('finish', res)),
|
||||
]);
|
||||
|
||||
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 && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"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,7 +94,6 @@
|
||||
],
|
||||
"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",
|
||||
@@ -138,7 +137,6 @@
|
||||
"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,24 +278,6 @@ 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',
|
||||
@@ -927,25 +909,6 @@ 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,7 +8,6 @@ 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';
|
||||
@@ -80,7 +79,6 @@ export interface CliArgs {
|
||||
model: string | undefined;
|
||||
sandbox: boolean | string | undefined;
|
||||
debug: boolean | undefined;
|
||||
disableStreaming?: boolean;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
worktree?: string;
|
||||
@@ -108,8 +106,6 @@ export interface CliArgs {
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
simulateUser: boolean | undefined;
|
||||
knowledgeSource: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,10 +418,6 @@ 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',
|
||||
@@ -451,24 +443,6 @@ 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
|
||||
@@ -923,7 +897,6 @@ export async function loadCliConfig(
|
||||
return new Config({
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
disableStreaming: argv.disableStreaming,
|
||||
sessionId,
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
@@ -975,10 +948,6 @@ 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,29 +256,14 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
enableNotifications: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Terminal Notifications',
|
||||
label: 'Enable Notifications',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable terminal run-event notifications for action-required prompts and session completion.',
|
||||
'Enable 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,8 +555,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
knowledgeSource: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -615,8 +613,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
simulateUser: undefined,
|
||||
knowledgeSource: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -9,19 +9,11 @@ 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,
|
||||
@@ -143,12 +135,6 @@ 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>
|
||||
@@ -160,20 +146,12 @@ export async function startInteractiveUI(
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
// 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,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
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);
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
@@ -210,21 +188,6 @@ 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: 'Use the skill test-skill',
|
||||
postSubmitPrompt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -46,10 +46,7 @@ export class SkillCommandLoader implements ICommandLoader {
|
||||
type: 'tool',
|
||||
toolName: ACTIVATE_SKILL_TOOL_NAME,
|
||||
toolArgs: { name: skill.name },
|
||||
postSubmitPrompt:
|
||||
args.trim().length > 0
|
||||
? args.trim()
|
||||
: `Use the skill ${skill.name}`,
|
||||
postSubmitPrompt: args.trim().length > 0 ? args.trim() : undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* @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,7 +44,6 @@ 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' })),
|
||||
@@ -65,7 +64,6 @@ 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,7 +53,6 @@ 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',
|
||||
@@ -195,7 +194,6 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
|
||||
vi.mock('../utils/terminalNotifications.js', () => ({
|
||||
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
|
||||
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
|
||||
getNotificationMethod: terminalNotificationsMocks.getNotificationMethod,
|
||||
buildRunEventNotificationContent:
|
||||
terminalNotificationsMocks.buildRunEventNotificationContent,
|
||||
}));
|
||||
|
||||
@@ -181,10 +181,7 @@ 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,
|
||||
getNotificationMethod,
|
||||
} from '../utils/terminalNotifications.js';
|
||||
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
|
||||
import {
|
||||
getLastTurnToolCallIds,
|
||||
isToolExecuting,
|
||||
@@ -228,7 +225,6 @@ 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);
|
||||
@@ -1407,13 +1403,7 @@ 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) {
|
||||
@@ -2294,7 +2284,6 @@ 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),
|
||||
resetNewSessionState: vi.fn(),
|
||||
setSessionId: vi.fn(),
|
||||
getMessageBus: vi.fn().mockReturnValue(undefined),
|
||||
getHookSystem: vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -74,9 +74,6 @@ 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.resetNewSessionState(newSessionId);
|
||||
config.setSessionId(newSessionId);
|
||||
}
|
||||
|
||||
if (geminiClient) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -53,23 +53,26 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
export const AnsiLineText = React.memo<{ line: AnsiLine }>(
|
||||
({ line }: { line: AnsiLine }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
),
|
||||
);
|
||||
AnsiLineText.displayName = 'AnsiLineText';
|
||||
|
||||
@@ -868,28 +868,24 @@ 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.min(
|
||||
30,
|
||||
Math.max(1, listHeight - Math.min(selectionItems.length, 5) * 2),
|
||||
)
|
||||
: Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight))
|
||||
: undefined;
|
||||
|
||||
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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
const maxItemsToShow =
|
||||
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
|
||||
? Math.min(
|
||||
selectionItems.length,
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
|
||||
),
|
||||
)
|
||||
: selectionItems.length;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
|
||||
@@ -79,7 +79,6 @@ 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 {
|
||||
@@ -126,10 +125,6 @@ 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;
|
||||
|
||||
@@ -3877,8 +3877,9 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 1. Verify initial placeholder
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
// Simulate double-click to expand
|
||||
await simulateClick(5, 2);
|
||||
@@ -3886,8 +3887,9 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 2. Verify expanded content is visible
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
expect(stdout.lastFrame()).toContain('line10');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
// Simulate double-click to collapse
|
||||
await simulateClick(5, 2);
|
||||
@@ -3895,8 +3897,9 @@ describe('InputPrompt', () => {
|
||||
|
||||
// 3. Verify placeholder is restored
|
||||
await waitFor(() => {
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
expect(stdout.lastFrame()).toContain('[Pasted Text: 10 lines]');
|
||||
});
|
||||
expect(stdout.lastFrame()).toMatchSnapshot();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
@@ -375,7 +375,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleSubmitAndClear = useCallback(
|
||||
(submittedValue: string) => {
|
||||
debugLogger.log(`[InputPrompt] handleSubmitAndClear: \${submittedValue}`);
|
||||
let processedValue = submittedValue;
|
||||
if (buffer.pastedContent) {
|
||||
processedValue = expandPastePlaceholders(
|
||||
@@ -426,7 +425,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(submittedValue: string) => {
|
||||
debugLogger.log(`[InputPrompt] handleSubmit: \${submittedValue}`);
|
||||
const trimmedMessage = submittedValue.trim();
|
||||
const isSlash = isSlashCommand(trimmedMessage);
|
||||
|
||||
@@ -651,9 +649,6 @@ 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 &&
|
||||
@@ -1219,15 +1214,9 @@ 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.
|
||||
@@ -1245,15 +1234,8 @@ 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,24 +14,12 @@ Spinner Working...
|
||||
|
||||
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 1`] = `
|
||||
"
|
||||
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
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
|
||||
"
|
||||
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
|
||||
Spinner Working...
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -156,7 +156,16 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
> line1
|
||||
line2
|
||||
line3
|
||||
line4
|
||||
line5
|
||||
line6
|
||||
line7
|
||||
line8
|
||||
line9
|
||||
line10
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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="261" lengthAdjust="spacingAndGlyphs">Enable Terminal Notifications</text>
|
||||
<text x="45" y="308" fill="#ffffff" textLength="180" lengthAdjust="spacingAndGlyphs">Enable 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="756" lengthAdjust="spacingAndGlyphs">Enable terminal run-event notifications for action-required prompts and session com…</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="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="252" lengthAdjust="spacingAndGlyphs">Terminal Notification Method</text>
|
||||
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">Auto</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="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="315" lengthAdjust="spacingAndGlyphs">How to send terminal notifications.</text>
|
||||
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
|
||||
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</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="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="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</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="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="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
|
||||
<text x="792" y="461" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</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="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="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</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="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">Plan Model Routing</text>
|
||||
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</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="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
|
||||
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on "exception TypeError: fetch failed sending request" errors.</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,11 +19,8 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -34,6 +31,9 @@ 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,11 +65,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -80,6 +77,9 @@ 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,11 +111,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Enable Auto Update true* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -126,6 +123,9 @@ 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,11 +157,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -172,6 +169,9 @@ 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,11 +203,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -218,6 +215,9 @@ 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,11 +249,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -264,6 +261,9 @@ 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,11 +295,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -310,6 +307,9 @@ 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,11 +341,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Enable Auto Update true │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -356,6 +353,9 @@ 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,11 +387,8 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Enable Auto Update false* │
|
||||
│ Enable automatic updates. │
|
||||
│ │
|
||||
│ 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 Notifications false │
|
||||
│ Enable run-event notifications for action-required prompts and session completion. │
|
||||
│ │
|
||||
│ Enable Plan Mode true │
|
||||
│ Enable Plan Mode for read-only safety during planning. │
|
||||
@@ -402,6 +399,9 @@ 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,10 +246,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
(showClosingBorder ? 1 : 0);
|
||||
} else if (isTopicToolCall) {
|
||||
// Topic Message Spacing Breakdown:
|
||||
// 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);
|
||||
// 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);
|
||||
} else if (isCompact) {
|
||||
// Compact Tool: Always renders as a single dense line.
|
||||
height += 1;
|
||||
@@ -438,7 +439,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
{isCompact ? (
|
||||
<DenseToolMessage {...commonProps} />
|
||||
) : isTopicToolCall ? (
|
||||
<Box marginBottom={1}>
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<TopicMessage {...commonProps} />
|
||||
</Box>
|
||||
) : isShellToolCall ? (
|
||||
|
||||
@@ -77,7 +77,8 @@ 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 │
|
||||
@@ -142,7 +143,8 @@ 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,7 +8,6 @@ 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';
|
||||
@@ -57,9 +56,6 @@ 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,15 +873,8 @@ 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() &&
|
||||
!config?.getSimulateUser()
|
||||
) {
|
||||
if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||
processor = bufferFastReturn(processor);
|
||||
}
|
||||
processor = bufferBackslashEnter(processor);
|
||||
|
||||
@@ -76,7 +76,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
isBinary: mockIsBinary,
|
||||
};
|
||||
});
|
||||
vi.mock('node:fs');
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
const mocked = {
|
||||
...actual,
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
createWriteStream: vi.fn(),
|
||||
promises: {
|
||||
...actual.promises,
|
||||
unlink: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...mocked,
|
||||
default: mocked,
|
||||
};
|
||||
});
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
const mocked = {
|
||||
@@ -144,6 +160,10 @@ describe('useExecutionLifecycle', () => {
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
}),
|
||||
getTruncateToolOutputThreshold: () => 40000,
|
||||
storage: {
|
||||
getProjectTempDir: () => '/tmp/project',
|
||||
},
|
||||
} as unknown as Config;
|
||||
mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient;
|
||||
|
||||
@@ -155,6 +175,16 @@ describe('useExecutionLifecycle', () => {
|
||||
mockIsBinary.mockReturnValue(false);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
vi.mocked(fs.createWriteStream).mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
bytesWritten: 0,
|
||||
closed: false,
|
||||
} as unknown as fs.WriteStream);
|
||||
|
||||
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
|
||||
mockShellOutputCallback = callback;
|
||||
return Promise.resolve({
|
||||
@@ -646,7 +676,7 @@ describe('useExecutionLifecycle', () => {
|
||||
});
|
||||
const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp');
|
||||
// Verify that the temporary file was cleaned up
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(setShellInputFocusedMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
@@ -674,7 +704,7 @@ describe('useExecutionLifecycle', () => {
|
||||
expect(finalHistoryItem.tools[0].resultDisplay).toContain(
|
||||
"WARNING: shell mode is stateless; the directory change to '/test/dir/new' will not persist.",
|
||||
);
|
||||
expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile);
|
||||
expect(vi.mocked(fs.promises.unlink)).toHaveBeenCalledWith(tmpFile);
|
||||
});
|
||||
|
||||
it('should NOT show a warning if the directory does not change', async () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
ShellExecutionService,
|
||||
ExecutionLifecycleService,
|
||||
CoreToolCallStatus,
|
||||
moveToolOutputToFile,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type PartListUnion } from '@google/genai';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
@@ -39,16 +41,15 @@ export { type BackgroundTask };
|
||||
|
||||
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
||||
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
||||
const MAX_OUTPUT_LENGTH = 10000;
|
||||
|
||||
function addShellCommandToGeminiHistory(
|
||||
geminiClient: GeminiClient,
|
||||
rawQuery: string,
|
||||
resultText: string,
|
||||
maxOutputLength: number,
|
||||
) {
|
||||
const modelContent =
|
||||
resultText.length > MAX_OUTPUT_LENGTH
|
||||
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
|
||||
maxOutputLength > 0 && resultText.length > maxOutputLength
|
||||
? resultText.substring(0, maxOutputLength) + '\n... (truncated)'
|
||||
: resultText;
|
||||
|
||||
// Escape backticks to prevent prompt injection breakouts
|
||||
@@ -424,6 +425,9 @@ export const useExecutionLifecycle = (
|
||||
let shouldUpdate = false;
|
||||
|
||||
switch (event.type) {
|
||||
case 'raw_data':
|
||||
case 'file_data':
|
||||
break;
|
||||
case 'data':
|
||||
if (isBinaryStream) break;
|
||||
if (typeof event.chunk === 'string') {
|
||||
@@ -533,6 +537,24 @@ export const useExecutionLifecycle = (
|
||||
} else {
|
||||
mainContent =
|
||||
result.output.trim() || '(Command produced no output)';
|
||||
if (result.fullOutputFilePath) {
|
||||
const { outputFile: savedPath } = await moveToolOutputToFile(
|
||||
result.fullOutputFilePath,
|
||||
SHELL_COMMAND_NAME,
|
||||
callId,
|
||||
config.storage.getProjectTempDir(),
|
||||
config.getSessionId(),
|
||||
);
|
||||
const warning = `[Full command output saved to: ${savedPath}]`;
|
||||
mainContent = mainContent.includes(
|
||||
'[GEMINI_CLI_WARNING: Output truncated.',
|
||||
)
|
||||
? mainContent.replace(
|
||||
/\[GEMINI_CLI_WARNING: Output truncated\..*?\]/,
|
||||
warning,
|
||||
)
|
||||
: `${mainContent}\n\n${warning}`;
|
||||
}
|
||||
}
|
||||
|
||||
let finalOutput: string | AnsiOutput =
|
||||
@@ -617,7 +639,12 @@ export const useExecutionLifecycle = (
|
||||
);
|
||||
}
|
||||
|
||||
addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent);
|
||||
addShellCommandToGeminiHistory(
|
||||
geminiClient,
|
||||
rawQuery,
|
||||
mainContent,
|
||||
config.getTruncateToolOutputThreshold(),
|
||||
);
|
||||
} catch (err) {
|
||||
setPendingHistoryItem(null);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
@@ -630,8 +657,13 @@ export const useExecutionLifecycle = (
|
||||
);
|
||||
} finally {
|
||||
abortSignal.removeEventListener('abort', abortHandler);
|
||||
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
||||
fs.unlinkSync(pwdFilePath);
|
||||
if (pwdFilePath) {
|
||||
fs.promises.unlink(pwdFilePath).catch((err) => {
|
||||
debugLogger.warn(
|
||||
`Failed to cleanup pwd file: ${pwdFilePath}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
||||
|
||||
@@ -15,14 +15,12 @@ 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;
|
||||
@@ -38,7 +36,6 @@ interface RunEventNotificationParams {
|
||||
|
||||
export function useRunEventNotifications({
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
streamingState,
|
||||
@@ -127,13 +124,11 @@ export function useRunEventNotifications({
|
||||
void notifyViaTerminal(
|
||||
notificationsEnabled,
|
||||
buildRunEventNotificationContent(pendingAttentionNotification.event),
|
||||
notificationMethod,
|
||||
);
|
||||
}, [
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
pendingAttentionNotification,
|
||||
]);
|
||||
|
||||
@@ -164,14 +159,12 @@ export function useRunEventNotifications({
|
||||
type: 'session_complete',
|
||||
detail: 'Gemini CLI finished responding.',
|
||||
}),
|
||||
notificationMethod,
|
||||
);
|
||||
}, [
|
||||
streamingState,
|
||||
isFocused,
|
||||
hasReceivedFocusEvent,
|
||||
notificationsEnabled,
|
||||
notificationMethod,
|
||||
hasPendingActionRequired,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* 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,
|
||||
@@ -212,7 +210,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,
|
||||
);
|
||||
|
||||
@@ -265,7 +263,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,123 +365,76 @@ describe('TerminalCapabilityManager', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('isTmux', () => {
|
||||
describe('supportsOsc9Notifications', () => {
|
||||
const manager = TerminalCapabilityManager.getInstance();
|
||||
|
||||
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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,43 +284,31 @@ export class TerminalCapabilityManager {
|
||||
);
|
||||
}
|
||||
|
||||
isTmux(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!env['TMUX'];
|
||||
}
|
||||
supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (env['WT_SESSION']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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')
|
||||
return (
|
||||
this.hasOsc9TerminalSignature(this.getTerminalName()) ||
|
||||
this.hasOsc9TerminalSignature(env['TERM_PROGRAM']) ||
|
||||
this.hasOsc9TerminalSignature(env['TERM'])
|
||||
);
|
||||
}
|
||||
|
||||
isAlacritty(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return !!(
|
||||
this.getTerminalName()?.toLowerCase().includes('alacritty') ||
|
||||
env['ALACRITTY_WINDOW_ID'] ||
|
||||
env['TERM']?.toLowerCase().includes('alacritty')
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
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,13 +8,8 @@ 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,
|
||||
homedir,
|
||||
type SandboxConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { FatalSandboxError, type SandboxConfig } from '@google/gemini-cli-core';
|
||||
import { createMockSandboxConfig } from '@google/gemini-cli-test-utils';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
@@ -138,7 +133,6 @@ describe('sandbox', () => {
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
process.argv = originalArgv;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('start_sandbox', () => {
|
||||
@@ -177,105 +171,6 @@ 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,17 +68,9 @@ export async function start_sandbox(
|
||||
let profileFile = fileURLToPath(
|
||||
new URL(`sandbox-macos-${profile}.sb`, import.meta.url),
|
||||
);
|
||||
// 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 profile name is not recognized, then look for file under project settings directory
|
||||
if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) {
|
||||
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;
|
||||
profileFile = path.join(GEMINI_DIR, `sandbox-macos-${profile}.sb`);
|
||||
}
|
||||
if (!fs.existsSync(profileFile)) {
|
||||
throw new FatalSandboxError(
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
MAX_NOTIFICATION_SUBTITLE_CHARS,
|
||||
MAX_NOTIFICATION_TITLE_CHARS,
|
||||
notifyViaTerminal,
|
||||
TerminalNotificationMethod,
|
||||
} from './terminalNotifications.js';
|
||||
|
||||
const writeToStdout = vi.hoisted(() => vi.fn());
|
||||
@@ -25,19 +24,38 @@ vi.mock('@google/gemini-cli-core', () => ({
|
||||
}));
|
||||
|
||||
describe('terminal notifications', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv('TMUX', '');
|
||||
vi.stubEnv('STY', '');
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
vi.stubEnv('ALACRITTY_WINDOW_ID', '');
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -50,7 +68,8 @@ describe('terminal notifications', () => {
|
||||
expect(writeToStdout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits OSC 9 notification when iTerm2 is detected', async () => {
|
||||
it('emits OSC 9 notification when supported terminal is detected', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
@@ -66,7 +85,10 @@ describe('terminal notifications', () => {
|
||||
expect(emitted.endsWith('\x07')).toBe(true);
|
||||
});
|
||||
|
||||
it('emits OSC 777 for unknown terminals', async () => {
|
||||
it('emits BEL fallback when OSC 9 is not supported', async () => {
|
||||
vi.stubEnv('TERM_PROGRAM', '');
|
||||
vi.stubEnv('TERM', '');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
subtitle: 'Subtitle',
|
||||
@@ -74,49 +96,12 @@ describe('terminal notifications', () => {
|
||||
});
|
||||
|
||||
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(writeToStdout).toHaveBeenCalledWith('\x07');
|
||||
});
|
||||
|
||||
it('uses BEL when Windows Terminal is detected', async () => {
|
||||
it('uses BEL fallback when WT_SESSION is set', 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 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');
|
||||
vi.stubEnv('TERM_PROGRAM', 'WezTerm');
|
||||
|
||||
const shown = await notifyViaTerminal(true, {
|
||||
title: 'Title',
|
||||
@@ -142,6 +127,7 @@ 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, {
|
||||
@@ -176,124 +162,4 @@ 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,8 +15,12 @@ export const MAX_NOTIFICATION_BODY_CHARS = 180;
|
||||
|
||||
const BEL = '\x07';
|
||||
const OSC9_PREFIX = '\x1b]9;';
|
||||
const OSC777_PREFIX = '\x1b]777;notify;';
|
||||
const OSC_TEXT_SEPARATOR = ' | ';
|
||||
const OSC9_SEPARATOR = ' | ';
|
||||
const MAX_OSC9_MESSAGE_CHARS =
|
||||
MAX_NOTIFICATION_TITLE_CHARS +
|
||||
MAX_NOTIFICATION_SUBTITLE_CHARS +
|
||||
MAX_NOTIFICATION_BODY_CHARS +
|
||||
OSC9_SEPARATOR.length * 2;
|
||||
|
||||
export interface RunEventNotificationContent {
|
||||
title: string;
|
||||
@@ -77,100 +81,36 @@ export function isNotificationsEnabled(settings: LoadedSettings): boolean {
|
||||
return general?.enableNotifications === true;
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
function emitOsc9Notification(content: RunEventNotificationContent): void {
|
||||
const sanitized = sanitizeNotificationContent(content);
|
||||
const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter(
|
||||
Boolean,
|
||||
);
|
||||
const combined = pieces.join(OSC_TEXT_SEPARATOR);
|
||||
const message = buildTerminalNotificationMessage(content);
|
||||
if (!TerminalCapabilityManager.getInstance().supportsOsc9Notifications()) {
|
||||
writeToStdout(BEL);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
writeToStdout(`${OSC9_PREFIX}${message}${BEL}`);
|
||||
}
|
||||
|
||||
export async function notifyViaTerminal(
|
||||
notificationsEnabled: boolean,
|
||||
content: RunEventNotificationContent,
|
||||
method: TerminalNotificationMethod = TerminalNotificationMethod.Auto,
|
||||
): Promise<boolean> {
|
||||
if (!notificationsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
emitOsc9Notification(sanitizeNotificationContent(content));
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLogger.debug('Failed to emit terminal notification:', error);
|
||||
|
||||
@@ -1774,95 +1774,6 @@ 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,8 +30,6 @@ 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';
|
||||
@@ -626,7 +624,6 @@ export interface ConfigParameters {
|
||||
bugCommand?: BugCommandSettings;
|
||||
model: string;
|
||||
disableLoopDetection?: boolean;
|
||||
disableStreaming?: boolean;
|
||||
maxSessionTurns?: number;
|
||||
acpMode?: boolean;
|
||||
listSessions?: boolean;
|
||||
@@ -729,8 +726,6 @@ export interface ConfigParameters {
|
||||
billing?: {
|
||||
overageStrategy?: OverageStrategy;
|
||||
};
|
||||
simulateUser?: boolean;
|
||||
knowledgeSource?: string;
|
||||
}
|
||||
|
||||
export class Config implements McpContext, AgentLoopContext {
|
||||
@@ -961,9 +956,6 @@ 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;
|
||||
@@ -1284,9 +1276,6 @@ 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);
|
||||
@@ -1771,22 +1760,7 @@ 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 {
|
||||
@@ -2075,37 +2049,6 @@ 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;
|
||||
}
|
||||
@@ -2879,18 +2822,6 @@ 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;
|
||||
}
|
||||
@@ -3648,12 +3579,6 @@ 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: true, multimodalToolUse: true },
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
|
||||
@@ -211,27 +211,6 @@ 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 sessionId: string | undefined;
|
||||
private readonly sessionId: string | undefined;
|
||||
private projectIdentifier: string | undefined;
|
||||
private initPromise: Promise<void> | undefined;
|
||||
private customPlansDir: string | undefined;
|
||||
@@ -42,14 +42,6 @@ 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) {
|
||||
|
||||
@@ -661,4 +661,82 @@ describe('ToolOutputMaskingService', () => {
|
||||
)['output'],
|
||||
).toContain(MASKING_INDICATOR_TAG);
|
||||
});
|
||||
|
||||
it('should use existing outputFile if available in the tool response', async () => {
|
||||
// Setup: Create a large history to trigger masking
|
||||
const largeContent = 'a'.repeat(60000);
|
||||
const existingOutputFile = path.join(testTempDir, 'truly_full_output.txt');
|
||||
await fs.promises.writeFile(existingOutputFile, 'truly full content');
|
||||
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Old turn' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'shell',
|
||||
id: 'call-1',
|
||||
response: {
|
||||
output: largeContent,
|
||||
outputFile: existingOutputFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
// Protection buffer
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'padding',
|
||||
response: { output: 'B'.repeat(60000) },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Newest turn' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockedEstimateTokenCountSync.mockImplementation((parts: Part[]) => {
|
||||
const resp = parts[0].functionResponse?.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const content = (resp?.['output'] as string) ?? JSON.stringify(resp);
|
||||
if (content.includes(`<${MASKING_INDICATOR_TAG}`)) return 100;
|
||||
|
||||
const name = parts[0].functionResponse?.name;
|
||||
if (name === 'shell') return 60000;
|
||||
if (name === 'padding') return 60000;
|
||||
return 10;
|
||||
});
|
||||
|
||||
// Trigger masking
|
||||
const result = await service.mask(history, mockConfig);
|
||||
|
||||
expect(result.maskedCount).toBe(2);
|
||||
const maskedPart = result.newHistory[1].parts![0];
|
||||
const maskedResponse = maskedPart.functionResponse?.response as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const maskedOutput = maskedResponse['output'] as string;
|
||||
|
||||
// Verify the masked snippet points to the existing file
|
||||
expect(maskedOutput).toContain(
|
||||
`Full output available at: ${existingOutputFile}`,
|
||||
);
|
||||
|
||||
// Verify the path in maskedOutput is exactly the one we provided
|
||||
expect(maskedOutput).toContain(existingOutputFile);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,18 @@ export interface MaskingResult {
|
||||
tokensSaved: number;
|
||||
}
|
||||
|
||||
interface HasOutputFile {
|
||||
outputFile: string;
|
||||
}
|
||||
|
||||
function hasOutputFile(obj: unknown): obj is HasOutputFile {
|
||||
if (typeof obj !== 'object' || obj === null || !('outputFile' in obj)) {
|
||||
return false;
|
||||
}
|
||||
const val = (obj as Record<string, unknown>)['outputFile'];
|
||||
return typeof val === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Service to manage context window efficiency by masking bulky tool outputs (Tool Output Masking).
|
||||
*
|
||||
@@ -182,25 +194,44 @@ export class ToolOutputMaskingService {
|
||||
|
||||
const toolName = part.functionResponse.name || 'unknown_tool';
|
||||
const callId = part.functionResponse.id || Date.now().toString();
|
||||
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
|
||||
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
|
||||
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(7)}.txt`;
|
||||
const filePath = path.join(toolOutputsDir, fileName);
|
||||
|
||||
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const originalResponse =
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(part.functionResponse.response as Record<string, unknown>) || {};
|
||||
|
||||
const totalLines = content.split('\n').length;
|
||||
const fileSizeMB = (
|
||||
Buffer.byteLength(content, 'utf8') /
|
||||
1024 /
|
||||
1024
|
||||
).toFixed(2);
|
||||
let filePath = '';
|
||||
let fileSizeMB = '0.00';
|
||||
let totalLines = 0;
|
||||
|
||||
if (hasOutputFile(originalResponse) && originalResponse.outputFile) {
|
||||
filePath = originalResponse.outputFile;
|
||||
try {
|
||||
const stats = await fsPromises.stat(filePath);
|
||||
fileSizeMB = (stats.size / 1024 / 1024).toFixed(2);
|
||||
// For truly full files, we don't count lines as it's too slow.
|
||||
// We just indicate it's the full file.
|
||||
totalLines = -1;
|
||||
} catch {
|
||||
// Fallback if file is gone
|
||||
filePath = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
const safeToolName = sanitizeFilenamePart(toolName).toLowerCase();
|
||||
const safeCallId = sanitizeFilenamePart(callId).toLowerCase();
|
||||
const fileName = `${safeToolName}_${safeCallId}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(7)}.txt`;
|
||||
filePath = path.join(toolOutputsDir, fileName);
|
||||
|
||||
await fsPromises.writeFile(filePath, content, 'utf-8');
|
||||
|
||||
totalLines = content.split('\n').length;
|
||||
fileSizeMB = (Buffer.byteLength(content, 'utf8') / 1024 / 1024).toFixed(
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
let preview = '';
|
||||
if (toolName === SHELL_TOOL_NAME) {
|
||||
|
||||
@@ -656,23 +656,6 @@ 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,10 +47,7 @@ toolName = [
|
||||
# Topic grouping tool is innocuous and used for UI organization.
|
||||
"update_topic",
|
||||
# Core agent lifecycle tool
|
||||
"complete_task",
|
||||
# MCP resource tools
|
||||
"read_mcp_resource",
|
||||
"list_mcp_resources"
|
||||
"complete_task"
|
||||
]
|
||||
decision = "allow"
|
||||
priority = 50
|
||||
|
||||
@@ -1057,25 +1057,6 @@ 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,6 +25,7 @@ import {
|
||||
import {
|
||||
isStrictlyApproved,
|
||||
verifySandboxOverrides,
|
||||
getCommandName,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { assertValidPathString } from '../../utils/paths.js';
|
||||
import {
|
||||
@@ -39,11 +40,6 @@ 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;
|
||||
|
||||
@@ -222,15 +218,7 @@ export class LinuxSandboxManager implements SandboxManager {
|
||||
args = ['-c', 'cat > "$1"', '_', ...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 commandName = await getCommandName({ ...req, command, args });
|
||||
const isApproved = allowOverrides
|
||||
? await isStrictlyApproved(
|
||||
{ ...req, command, args },
|
||||
@@ -265,15 +253,6 @@ 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,7 +14,6 @@ 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.
|
||||
@@ -64,101 +63,58 @@ export async function buildBwrapArgs(
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
type MountType =
|
||||
| '--bind'
|
||||
| '--ro-bind'
|
||||
| '--bind-try'
|
||||
| '--ro-bind-try'
|
||||
| '--symlink';
|
||||
const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try';
|
||||
|
||||
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,
|
||||
});
|
||||
bwrapArgs.push(bindFlag, workspace.original, workspace.original);
|
||||
if (workspace.resolved !== workspace.original) {
|
||||
mounts.push({
|
||||
type: bindFlag,
|
||||
src: workspace.resolved,
|
||||
dest: workspace.resolved,
|
||||
});
|
||||
bwrapArgs.push(bindFlag, workspace.resolved, workspace.resolved);
|
||||
}
|
||||
|
||||
for (const includeDir of resolvedPaths.globalIncludes) {
|
||||
mounts.push({ type: '--ro-bind-try', src: includeDir, dest: includeDir });
|
||||
bwrapArgs.push('--ro-bind-try', includeDir, includeDir);
|
||||
}
|
||||
|
||||
for (const allowedPath of resolvedPaths.policyAllowed) {
|
||||
if (fs.existsSync(allowedPath)) {
|
||||
mounts.push({ type: '--bind-try', src: allowedPath, dest: allowedPath });
|
||||
bwrapArgs.push('--bind-try', allowedPath, 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);
|
||||
mounts.push({
|
||||
type: isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
|
||||
src: parent,
|
||||
dest: parent,
|
||||
});
|
||||
bwrapArgs.push(
|
||||
isReadOnlyCommand ? '--ro-bind-try' : '--bind-try',
|
||||
parent,
|
||||
parent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const p of resolvedPaths.policyRead) {
|
||||
mounts.push({ type: '--ro-bind-try', src: p, dest: p });
|
||||
bwrapArgs.push('--ro-bind-try', p, p);
|
||||
}
|
||||
|
||||
// Collect explicit additional write permissions.
|
||||
for (const p of resolvedPaths.policyWrite) {
|
||||
mounts.push({ type: '--bind-try', src: p, dest: p });
|
||||
bwrapArgs.push('--bind-try', p, 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);
|
||||
|
||||
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 });
|
||||
}
|
||||
bwrapArgs.push('--ro-bind', filePath, filePath);
|
||||
if (realPath !== filePath) {
|
||||
bwrapArgs.push('--ro-bind', realPath, realPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Grant read-only access to git worktrees/submodules.
|
||||
// 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.
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
|
||||
if (worktreeGitDir && !policyWriteKeys.has(toPathKey(worktreeGitDir))) {
|
||||
mounts.push({
|
||||
type: '--ro-bind-try',
|
||||
src: worktreeGitDir,
|
||||
dest: worktreeGitDir,
|
||||
});
|
||||
if (worktreeGitDir) {
|
||||
bwrapArgs.push('--ro-bind-try', worktreeGitDir, worktreeGitDir);
|
||||
}
|
||||
if (mainGitDir && !policyWriteKeys.has(toPathKey(mainGitDir))) {
|
||||
mounts.push({
|
||||
type: '--ro-bind-try',
|
||||
src: mainGitDir,
|
||||
dest: mainGitDir,
|
||||
});
|
||||
if (mainGitDir) {
|
||||
bwrapArgs.push('--ro-bind-try', mainGitDir, mainGitDir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,23 +123,37 @@ export async function buildBwrapArgs(
|
||||
try {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
mounts.push({ type: '--tmpfs-ro', dest: p });
|
||||
bwrapArgs.push('--tmpfs', p, '--remount-ro', p);
|
||||
} else {
|
||||
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', p);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (isErrnoException(e) && e.code === 'ENOENT') {
|
||||
mounts.push({ type: '--symlink', src: '/dev/null', dest: p });
|
||||
bwrapArgs.push('--symlink', '/dev/null', p);
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Failed to secure forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
mounts.push({ type: '--ro-bind', src: '/dev/null', dest: p });
|
||||
bwrapArgs.push('--ro-bind', '/dev/null', 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,
|
||||
@@ -194,6 +164,9 @@ export async function buildBwrapArgs(
|
||||
|
||||
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',
|
||||
@@ -230,7 +203,7 @@ export async function buildBwrapArgs(
|
||||
const files = findResult.stdout.toString().split('\0');
|
||||
for (const file of files) {
|
||||
if (file.trim()) {
|
||||
mounts.push({ type: '--bind', src: maskFilePath, dest: file.trim() });
|
||||
args.push('--bind', maskPath, file.trim());
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -240,19 +213,5 @@ export async function buildBwrapArgs(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -22,11 +22,7 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { buildSeatbeltProfile } from './seatbeltArgsBuilder.js';
|
||||
import {
|
||||
initializeShellParsers,
|
||||
getCommandRoots,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import { initializeShellParsers } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
@@ -137,22 +133,6 @@ 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 { isSubpath, resolveToRealPath } from '../../utils/paths.js';
|
||||
import { resolveToRealPath } from '../../utils/paths.js';
|
||||
|
||||
/**
|
||||
* Options for building macOS Seatbelt profile.
|
||||
@@ -37,47 +37,6 @@ 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.
|
||||
@@ -149,84 +108,6 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
profile += `(allow file-read* file-write* (subpath "${escapeSchemeString(allowedPath)}"))\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).
|
||||
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
|
||||
const governanceFile = path.join(
|
||||
resolvedPaths.workspace.resolved,
|
||||
GOVERNANCE_FILES[i].path,
|
||||
);
|
||||
const realGovernanceFile = resolveToRealPath(governanceFile);
|
||||
|
||||
// Determine if it should be treated as a directory (subpath) or a file (literal).
|
||||
// .git is generally a directory, while ignore files are literals.
|
||||
let isDirectory = GOVERNANCE_FILES[i].isDirectory;
|
||||
try {
|
||||
if (fs.existsSync(realGovernanceFile)) {
|
||||
isDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, use default guess
|
||||
}
|
||||
|
||||
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
|
||||
// ensure that these rules aren't overwritten by broader write policies.
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
|
||||
if (worktreeGitDir) {
|
||||
profile += denyUnlessExplicitlyAllowed(
|
||||
worktreeGitDir,
|
||||
'subpath',
|
||||
resolvedPaths.policyWrite,
|
||||
);
|
||||
}
|
||||
if (mainGitDir) {
|
||||
profile += denyUnlessExplicitlyAllowed(
|
||||
mainGitDir,
|
||||
'subpath',
|
||||
resolvedPaths.policyWrite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths.
|
||||
// We use regex rules to avoid expensive file discovery scans.
|
||||
// Anchoring to workspace/allowed paths to avoid over-blocking.
|
||||
const searchPaths = [
|
||||
resolvedPaths.workspace.resolved,
|
||||
resolvedPaths.workspace.original,
|
||||
...resolvedPaths.policyAllowed,
|
||||
...resolvedPaths.globalIncludes,
|
||||
];
|
||||
|
||||
for (const basePath of searchPaths) {
|
||||
for (const secret of SECRET_FILES) {
|
||||
// Map pattern to Seatbelt regex
|
||||
let regexPattern: string;
|
||||
const escapedBase = escapeRegex(basePath);
|
||||
if (secret.pattern.endsWith('*')) {
|
||||
// .env.* -> .env\..+ (match .env followed by dot and something)
|
||||
// We anchor the secret file name to either a directory separator or the start of the relative path.
|
||||
const basePattern = secret.pattern.slice(0, -1).replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}[^/]+$`;
|
||||
} else {
|
||||
// .env -> \.env$
|
||||
const basePattern = secret.pattern.replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}$`;
|
||||
}
|
||||
profile += `(deny file-read* file-write* (regex #"${regexPattern}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle granular additional read permissions
|
||||
for (let i = 0; i < resolvedPaths.policyRead.length; i++) {
|
||||
const resolved = resolvedPaths.policyRead[i];
|
||||
@@ -259,6 +140,77 @@ export function buildSeatbeltProfile(options: SeatbeltArgsOptions): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
|
||||
const governanceFile = path.join(
|
||||
resolvedPaths.workspace.resolved,
|
||||
GOVERNANCE_FILES[i].path,
|
||||
);
|
||||
const realGovernanceFile = resolveToRealPath(governanceFile);
|
||||
|
||||
// Determine if it should be treated as a directory (subpath) or a file (literal).
|
||||
// .git is generally a directory, while ignore files are literals.
|
||||
let isDirectory = GOVERNANCE_FILES[i].isDirectory;
|
||||
try {
|
||||
if (fs.existsSync(realGovernanceFile)) {
|
||||
isDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
|
||||
}
|
||||
} catch {
|
||||
// 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`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if (resolvedPaths.gitWorktree) {
|
||||
const { worktreeGitDir, mainGitDir } = resolvedPaths.gitWorktree;
|
||||
if (worktreeGitDir) {
|
||||
profile += `(deny file-write* (subpath "${escapeSchemeString(worktreeGitDir)}"))\n`;
|
||||
}
|
||||
if (mainGitDir) {
|
||||
profile += `(deny file-write* (subpath "${escapeSchemeString(mainGitDir)}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths.
|
||||
// We use regex rules to avoid expensive file discovery scans.
|
||||
// Anchoring to workspace/allowed paths to avoid over-blocking.
|
||||
const searchPaths = [
|
||||
resolvedPaths.workspace.resolved,
|
||||
resolvedPaths.workspace.original,
|
||||
...resolvedPaths.policyAllowed,
|
||||
...resolvedPaths.globalIncludes,
|
||||
];
|
||||
|
||||
for (const basePath of searchPaths) {
|
||||
for (const secret of SECRET_FILES) {
|
||||
// Map pattern to Seatbelt regex
|
||||
let regexPattern: string;
|
||||
const escapedBase = escapeRegex(basePath);
|
||||
if (secret.pattern.endsWith('*')) {
|
||||
// .env.* -> .env\..+ (match .env followed by dot and something)
|
||||
// We anchor the secret file name to either a directory separator or the start of the relative path.
|
||||
const basePattern = secret.pattern.slice(0, -1).replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}[^/]+$`;
|
||||
} else {
|
||||
// .env -> \.env$
|
||||
const basePattern = secret.pattern.replace(/\./g, '\\\\.');
|
||||
regexPattern = `^${escapedBase}/(.*/)?${basePattern}$`;
|
||||
}
|
||||
profile += `(deny file-read* file-write* (regex #"${regexPattern}"))\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle forbiddenPaths
|
||||
const forbiddenPaths = resolvedPaths.forbidden;
|
||||
for (let i = 0; i < forbiddenPaths.length; i++) {
|
||||
|
||||
@@ -25,13 +25,7 @@ import {
|
||||
getSecureSanitizationConfig,
|
||||
} from '../../services/environmentSanitization.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import {
|
||||
spawnAsync,
|
||||
getCommandName,
|
||||
initializeShellParsers,
|
||||
getCommandRoots,
|
||||
stripShellWrapper,
|
||||
} from '../../utils/shell-utils.js';
|
||||
import { spawnAsync, getCommandName } from '../../utils/shell-utils.js';
|
||||
import {
|
||||
isKnownSafeCommand,
|
||||
isDangerousCommand,
|
||||
@@ -267,14 +261,6 @@ 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,
|
||||
@@ -362,13 +348,6 @@ 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
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { ToolExecutor } from './tool-executor.js';
|
||||
import {
|
||||
type Config,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
vi.mock('../utils/fileUtils.js', () => ({
|
||||
saveTruncatedToolOutput: vi.fn(),
|
||||
formatTruncatedToolOutput: vi.fn(),
|
||||
moveToolOutputToFile: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock executeToolWithHooks
|
||||
@@ -436,73 +438,10 @@ describe('ToolExecutor', () => {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
|
||||
expect(response).toEqual({ output: 'TruncatedContent...' });
|
||||
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should truncate large MCP tool output with single text Part', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
|
||||
const mcpToolName = 'get_big_text';
|
||||
const messageBus = createMockMessageBus();
|
||||
const mcpTool = new DiscoveredMCPTool(
|
||||
{} as CallableTool,
|
||||
'my-server',
|
||||
'get_big_text',
|
||||
'A test MCP tool',
|
||||
{},
|
||||
messageBus,
|
||||
);
|
||||
const invocation = mcpTool.build({});
|
||||
const longText = 'This is a very long MCP output that should be truncated.';
|
||||
|
||||
// 2. Mock execution returning Part[] with single text Part
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: [{ text: longText }],
|
||||
returnDisplay: longText,
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-mcp-trunc',
|
||||
name: mcpToolName,
|
||||
args: { query: 'test' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-mcp-trunc',
|
||||
},
|
||||
tool: mcpTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify Truncation Logic
|
||||
expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longText,
|
||||
mcpToolName,
|
||||
'call-mcp-trunc',
|
||||
expect.any(String),
|
||||
'test-session-id',
|
||||
);
|
||||
|
||||
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longText,
|
||||
'/tmp/truncated_output.txt',
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
expect(response).toEqual({
|
||||
output: 'TruncatedContent...',
|
||||
outputFile: '/tmp/truncated_output.txt',
|
||||
});
|
||||
expect(result.response.outputFile).toBe('/tmp/truncated_output.txt');
|
||||
}
|
||||
});
|
||||
@@ -597,6 +536,228 @@ describe('ToolExecutor', () => {
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
});
|
||||
|
||||
it('should truncate large output and move file when fullOutputFilePath is provided', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
|
||||
outputFile: '/tmp/moved_output.txt',
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
|
||||
const invocation = mockTool.build({});
|
||||
const longOutput = 'This is a very long output that should be truncated.';
|
||||
|
||||
// 2. Mock execution returning long content AND fullOutputFilePath
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: longOutput,
|
||||
returnDisplay: longOutput,
|
||||
fullOutputFilePath: '/tmp/temp_full_output.txt',
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-trunc-full',
|
||||
name: SHELL_TOOL_NAME,
|
||||
args: { command: 'echo long' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-trunc-full',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify Truncation Logic
|
||||
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
|
||||
'/tmp/temp_full_output.txt',
|
||||
SHELL_TOOL_NAME,
|
||||
'call-trunc-full',
|
||||
expect.any(String), // temp dir
|
||||
'test-session-id', // session id from makeFakeConfig
|
||||
);
|
||||
|
||||
expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith(
|
||||
longOutput,
|
||||
'/tmp/moved_output.txt',
|
||||
10, // threshold (maxChars)
|
||||
);
|
||||
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
// The content should be the *truncated* version returned by the mock formatTruncatedToolOutput
|
||||
expect(response).toEqual({
|
||||
output: 'TruncatedContent...',
|
||||
outputFile: '/tmp/moved_output.txt',
|
||||
});
|
||||
expect(result.response.outputFile).toBe('/tmp/moved_output.txt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should preserve temporary file when fullOutputFilePath is provided but output is not truncated', async () => {
|
||||
// 1. Setup Config for Truncation
|
||||
vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(100);
|
||||
vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp');
|
||||
vi.spyOn(config, 'getSessionId').mockReturnValue('test-session-id');
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.spyOn(fileUtils, 'moveToolOutputToFile').mockResolvedValue({
|
||||
outputFile: '/tmp/moved_output_short.txt',
|
||||
});
|
||||
|
||||
const mockTool = new MockTool({ name: SHELL_TOOL_NAME });
|
||||
const invocation = mockTool.build({});
|
||||
const shortOutput = 'Short';
|
||||
|
||||
// 2. Mock execution returning short content AND fullOutputFilePath
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: shortOutput,
|
||||
returnDisplay: shortOutput,
|
||||
fullOutputFilePath: '/tmp/temp_full_output_short.txt',
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-short-full',
|
||||
name: SHELL_TOOL_NAME,
|
||||
args: { command: 'echo short' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-short-full',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
// 3. Execute
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
// 4. Verify file preservation
|
||||
expect(fileUtils.moveToolOutputToFile).toHaveBeenCalledWith(
|
||||
'/tmp/temp_full_output_short.txt',
|
||||
SHELL_TOOL_NAME,
|
||||
'call-short-full',
|
||||
expect.any(String),
|
||||
'test-session-id',
|
||||
);
|
||||
expect(unlinkSpy).not.toHaveBeenCalled();
|
||||
expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled();
|
||||
|
||||
// We should save it since fullOutputFilePath was provided
|
||||
expect(result.status).toBe(CoreToolCallStatus.Success);
|
||||
if (result.status === CoreToolCallStatus.Success) {
|
||||
const response = result.response.responseParts[0]?.functionResponse
|
||||
?.response as Record<string, unknown>;
|
||||
expect(response).toEqual({
|
||||
output: 'Short',
|
||||
outputFile: '/tmp/moved_output_short.txt',
|
||||
});
|
||||
expect(result.response.outputFile).toBe('/tmp/moved_output_short.txt');
|
||||
}
|
||||
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should delete temporary file on error if fullOutputFilePath is provided', async () => {
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
const mockTool = new MockTool({ name: 'failTool' });
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockResolvedValue({
|
||||
llmContent: 'partial',
|
||||
returnDisplay: 'partial',
|
||||
fullOutputFilePath: '/tmp/temp_error.txt',
|
||||
error: { message: 'Tool Failed' },
|
||||
});
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-err',
|
||||
name: 'failTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-err',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: new AbortController().signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_error.txt');
|
||||
expect(result.status).toBe(CoreToolCallStatus.Error);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should delete temporary file on abort if fullOutputFilePath is provided', async () => {
|
||||
const unlinkSpy = vi
|
||||
.spyOn(fsPromises, 'unlink')
|
||||
.mockResolvedValue(undefined);
|
||||
const mockTool = new MockTool({ name: 'slowTool' });
|
||||
const invocation = mockTool.build({});
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation(
|
||||
async () => {
|
||||
controller.abort();
|
||||
return {
|
||||
llmContent: 'partial',
|
||||
returnDisplay: 'partial',
|
||||
fullOutputFilePath: '/tmp/temp_abort.txt',
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const scheduledCall: ScheduledToolCall = {
|
||||
status: CoreToolCallStatus.Scheduled,
|
||||
request: {
|
||||
callId: 'call-abort',
|
||||
name: 'slowTool',
|
||||
args: {},
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt-abort',
|
||||
},
|
||||
tool: mockTool,
|
||||
invocation: invocation as unknown as AnyToolInvocation,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
call: scheduledCall,
|
||||
signal: controller.signal,
|
||||
onUpdateToolCall: vi.fn(),
|
||||
});
|
||||
|
||||
expect(unlinkSpy).toHaveBeenCalledWith('/tmp/temp_abort.txt');
|
||||
expect(result.status).toBe(CoreToolCallStatus.Cancelled);
|
||||
unlinkSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should report execution ID updates for backgroundable tools', async () => {
|
||||
// 1. Setup ShellToolInvocation
|
||||
const messageBus = createMockMessageBus();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import {
|
||||
ToolErrorType,
|
||||
ToolOutputTruncatedEvent,
|
||||
@@ -24,6 +26,7 @@ import { executeToolWithHooks } from '../core/coreToolHookTriggers.js';
|
||||
import {
|
||||
saveTruncatedToolOutput,
|
||||
formatTruncatedToolOutput,
|
||||
moveToolOutputToFile,
|
||||
} from '../utils/fileUtils.js';
|
||||
import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js';
|
||||
import {
|
||||
@@ -138,6 +141,16 @@ export class ToolExecutor {
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
await fsPromises
|
||||
.unlink(toolResult.fullOutputFilePath)
|
||||
.catch((error) => {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file on abort: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
completedToolCall = await this.createCancelledResult(
|
||||
call,
|
||||
'User cancelled tool execution.',
|
||||
@@ -149,6 +162,16 @@ export class ToolExecutor {
|
||||
toolResult,
|
||||
);
|
||||
} else {
|
||||
if (toolResult.fullOutputFilePath) {
|
||||
await fsPromises
|
||||
.unlink(toolResult.fullOutputFilePath)
|
||||
.catch((error) => {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file on error: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
const displayText =
|
||||
typeof toolResult.returnDisplay === 'string'
|
||||
? toolResult.returnDisplay
|
||||
@@ -197,33 +220,80 @@ export class ToolExecutor {
|
||||
private async truncateOutputIfNeeded(
|
||||
call: ToolCall,
|
||||
content: PartListUnion,
|
||||
fullOutputFilePath?: string,
|
||||
): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> {
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
|
||||
if (this.config.isContextManagementEnabled()) {
|
||||
const distiller = new ToolOutputDistillationService(
|
||||
this.config,
|
||||
this.context.geminiClient,
|
||||
this.context.promptId,
|
||||
);
|
||||
return distiller.distill(call.request.name, call.request.callId, content);
|
||||
const result = await distiller.distill(
|
||||
call.request.name,
|
||||
call.request.callId,
|
||||
content,
|
||||
);
|
||||
|
||||
let finalOutputFile = result.outputFile;
|
||||
if (fullOutputFilePath) {
|
||||
const { outputFile: movedPath } = await moveToolOutputToFile(
|
||||
fullOutputFilePath,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
finalOutputFile = movedPath;
|
||||
if (result.outputFile) {
|
||||
try {
|
||||
await fsPromises.unlink(result.outputFile);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to delete distiller's temporary tool output file: ${result.outputFile}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
truncatedContent: result.truncatedContent,
|
||||
outputFile: finalOutputFile,
|
||||
};
|
||||
}
|
||||
|
||||
const toolName = call.request.name;
|
||||
const callId = call.request.callId;
|
||||
let outputFile: string | undefined;
|
||||
|
||||
if (fullOutputFilePath) {
|
||||
const { outputFile: movedPath } = await moveToolOutputToFile(
|
||||
fullOutputFilePath,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.config.getSessionId(),
|
||||
);
|
||||
outputFile = movedPath;
|
||||
}
|
||||
|
||||
if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) {
|
||||
const threshold = this.config.getTruncateToolOutputThreshold();
|
||||
|
||||
if (threshold > 0 && content.length > threshold) {
|
||||
const originalContentLength = content.length;
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
content,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
|
||||
if (!outputFile) {
|
||||
const { outputFile: writtenPath } = await saveTruncatedToolOutput(
|
||||
content,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = writtenPath;
|
||||
}
|
||||
|
||||
const truncatedContent = formatTruncatedToolOutput(
|
||||
content,
|
||||
outputFile,
|
||||
@@ -255,14 +325,16 @@ export class ToolExecutor {
|
||||
|
||||
if (threshold > 0 && textContent.length > threshold) {
|
||||
const originalContentLength = textContent.length;
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
textContent,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
if (!outputFile) {
|
||||
const { outputFile: savedPath } = await saveTruncatedToolOutput(
|
||||
textContent,
|
||||
toolName,
|
||||
callId,
|
||||
this.config.storage.getProjectTempDir(),
|
||||
this.context.promptId,
|
||||
);
|
||||
outputFile = savedPath;
|
||||
}
|
||||
const truncatedText = formatTruncatedToolOutput(
|
||||
textContent,
|
||||
outputFile,
|
||||
@@ -314,7 +386,11 @@ export class ToolExecutor {
|
||||
// Attempt to truncate and save output if we have content, even in cancellation case
|
||||
// This is to handle cases where the tool may have produced output before cancellation
|
||||
const { truncatedContent: output, outputFile: truncatedOutputFile } =
|
||||
await this.truncateOutputIfNeeded(call, toolResult?.llmContent);
|
||||
await this.truncateOutputIfNeeded(
|
||||
call,
|
||||
toolResult.llmContent,
|
||||
toolResult.fullOutputFilePath,
|
||||
);
|
||||
|
||||
outputFile = truncatedOutputFile;
|
||||
responseParts = convertToFunctionResponse(
|
||||
@@ -323,6 +399,7 @@ export class ToolExecutor {
|
||||
output,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
outputFile,
|
||||
);
|
||||
|
||||
// Inject the cancellation error into the response object
|
||||
@@ -332,6 +409,16 @@ export class ToolExecutor {
|
||||
respObj['error'] = errorMessage;
|
||||
}
|
||||
} else {
|
||||
if (toolResult?.fullOutputFilePath) {
|
||||
try {
|
||||
await fsPromises.unlink(toolResult.fullOutputFilePath);
|
||||
} catch (error) {
|
||||
debugLogger.warn(
|
||||
`Failed to delete temporary tool output file: ${toolResult.fullOutputFilePath}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
responseParts = [
|
||||
{
|
||||
functionResponse: {
|
||||
@@ -369,8 +456,11 @@ export class ToolExecutor {
|
||||
toolResult: ToolResult,
|
||||
): Promise<SuccessfulToolCall> {
|
||||
const { truncatedContent: content, outputFile } =
|
||||
await this.truncateOutputIfNeeded(call, toolResult.llmContent);
|
||||
|
||||
await this.truncateOutputIfNeeded(
|
||||
call,
|
||||
toolResult.llmContent,
|
||||
toolResult.fullOutputFilePath,
|
||||
);
|
||||
const toolName = call.request.originalRequestName || call.request.name;
|
||||
const callId = call.request.callId;
|
||||
|
||||
@@ -380,6 +470,7 @@ export class ToolExecutor {
|
||||
content,
|
||||
this.config.getActiveModel(),
|
||||
this.config,
|
||||
outputFile,
|
||||
);
|
||||
|
||||
const successResponse: ToolCallResponseInfo = {
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface ExecutionResult {
|
||||
pid: number | undefined;
|
||||
executionMethod: ExecutionMethod;
|
||||
backgrounded?: boolean;
|
||||
fullOutputFilePath?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionHandle {
|
||||
@@ -35,6 +36,14 @@ export interface ExecutionHandle {
|
||||
}
|
||||
|
||||
export type ExecutionOutputEvent =
|
||||
| {
|
||||
type: 'raw_data';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'file_data';
|
||||
chunk: string;
|
||||
}
|
||||
| {
|
||||
type: 'data';
|
||||
chunk: string | AnsiOutput;
|
||||
|
||||
@@ -865,336 +865,6 @@ 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,23 +409,6 @@ 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).
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,16 @@ const mockIsBinary = vi.hoisted(() => vi.fn());
|
||||
const mockPlatform = vi.hoisted(() => vi.fn());
|
||||
const mockHomedir = vi.hoisted(() => vi.fn());
|
||||
const mockMkdirSync = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWriteStream = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWriteStream = vi.hoisted(() =>
|
||||
vi.fn().mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb?: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
}),
|
||||
);
|
||||
const mockGetPty = vi.hoisted(() => vi.fn());
|
||||
const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn());
|
||||
const mockResolveExecutable = vi.hoisted(() => vi.fn());
|
||||
@@ -92,6 +101,7 @@ vi.mock('node:os', () => ({
|
||||
default: {
|
||||
platform: mockPlatform,
|
||||
homedir: mockHomedir,
|
||||
tmpdir: () => '/tmp',
|
||||
constants: {
|
||||
signals: {
|
||||
SIGTERM: 15,
|
||||
@@ -208,6 +218,15 @@ describe('ShellExecutionService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateWriteStream.mockReturnValue({
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb?: () => void) => {
|
||||
if (cb) cb();
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
on: vi.fn(),
|
||||
});
|
||||
ExecutionLifecycleService.resetForTest();
|
||||
ShellExecutionService.resetForTest();
|
||||
mockSerializeTerminalToObject.mockReturnValue([]);
|
||||
@@ -489,6 +508,7 @@ describe('ShellExecutionService', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
headlessTerminal: mockHeadlessTerminal as any,
|
||||
command: 'some-command',
|
||||
lastCommittedLine: -1,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -620,7 +640,7 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
it('should handle a synchronous spawn error', async () => {
|
||||
mockGetPty.mockImplementation(() => null);
|
||||
mockGetPty.mockResolvedValue(null);
|
||||
|
||||
mockCpSpawn.mockImplementation(() => {
|
||||
throw new Error('Simulated PTY spawn error');
|
||||
@@ -724,7 +744,13 @@ describe('ShellExecutionService', () => {
|
||||
});
|
||||
|
||||
describe('Backgrounding', () => {
|
||||
let mockWriteStream: { write: Mock; end: Mock; on: Mock };
|
||||
let mockWriteStream: {
|
||||
write: Mock;
|
||||
end: Mock;
|
||||
on: Mock;
|
||||
destroy: Mock;
|
||||
closed: boolean;
|
||||
};
|
||||
let mockBgChildProcess: EventEmitter & Partial<ChildProcess>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -732,6 +758,8 @@ describe('ShellExecutionService', () => {
|
||||
write: vi.fn(),
|
||||
end: vi.fn().mockImplementation((cb) => cb?.()),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
closed: false,
|
||||
};
|
||||
|
||||
mockMkdirSync.mockReturnValue(undefined);
|
||||
@@ -989,6 +1017,8 @@ describe('ShellExecutionService', () => {
|
||||
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
|
||||
});
|
||||
|
||||
// We don't check result here because result is not available in the test body as written
|
||||
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
@@ -1301,7 +1331,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const { result, handle } = await simulateExecution('ls -l', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from('file1.txt\n'));
|
||||
cp.stderr?.emit('data', Buffer.from('a warning'));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1338,7 +1368,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should strip ANSI color codes from output', async () => {
|
||||
const { result } = await simulateExecution('ls --color=auto', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword'));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1359,7 +1389,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const multiByteChar = Buffer.from('你好', 'utf-8');
|
||||
cp.stdout?.emit('data', multiByteChar.slice(0, 2));
|
||||
cp.stdout?.emit('data', multiByteChar.slice(2));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
expect(result.output.trim()).toBe('你好');
|
||||
@@ -1367,7 +1397,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should handle commands with no output', async () => {
|
||||
const { result } = await simulateExecution('touch file', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1389,7 +1419,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
cp.stdout?.emit('data', Buffer.from(chunk1));
|
||||
cp.stdout?.emit('data', Buffer.from(chunk2));
|
||||
cp.stdout?.emit('data', Buffer.from(chunk3));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
const truncationMessage =
|
||||
@@ -1414,7 +1444,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should capture a non-zero exit code and format output correctly', async () => {
|
||||
const { result } = await simulateExecution('a-bad-command', (cp) => {
|
||||
cp.stderr?.emit('data', Buffer.from('command not found'));
|
||||
cp.emit('exit', 127, null);
|
||||
cp.emit('close', 127, null);
|
||||
cp.emit('close', 127, null);
|
||||
});
|
||||
|
||||
@@ -1425,7 +1455,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
|
||||
it('should capture a termination signal', async () => {
|
||||
const { result } = await simulateExecution('long-process', (cp) => {
|
||||
cp.emit('exit', null, 'SIGTERM');
|
||||
cp.emit('close', null, 'SIGTERM');
|
||||
cp.emit('close', null, 'SIGTERM');
|
||||
});
|
||||
|
||||
@@ -1437,7 +1467,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
const spawnError = new Error('spawn EACCES');
|
||||
const { result } = await simulateExecution('protected-cmd', (cp) => {
|
||||
cp.emit('error', spawnError);
|
||||
cp.emit('exit', 1, null);
|
||||
cp.emit('close', 1, null);
|
||||
cp.emit('close', 1, null);
|
||||
});
|
||||
|
||||
@@ -1483,11 +1513,11 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await new Promise(process.nextTick);
|
||||
await new Promise(process.nextTick);
|
||||
if (expectedExit.signal) {
|
||||
cp.emit('exit', null, expectedExit.signal);
|
||||
cp.emit('close', null, expectedExit.signal);
|
||||
cp.emit('close', null, expectedExit.signal);
|
||||
}
|
||||
if (typeof expectedExit.code === 'number') {
|
||||
cp.emit('exit', expectedExit.code, null);
|
||||
cp.emit('close', expectedExit.code, null);
|
||||
cp.emit('close', expectedExit.code, null);
|
||||
}
|
||||
},
|
||||
@@ -1556,7 +1586,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
);
|
||||
|
||||
// Finally, simulate the process exiting and await the result
|
||||
mockChildProcess.emit('exit', null, 'SIGKILL');
|
||||
mockChildProcess.emit('close', null, 'SIGKILL');
|
||||
mockChildProcess.emit('close', null, 'SIGKILL');
|
||||
const result = await handle.result;
|
||||
|
||||
@@ -1576,9 +1606,11 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await simulateExecution('cat image.png', (cp) => {
|
||||
cp.stdout?.emit('data', binaryChunk1);
|
||||
cp.stdout?.emit('data', binaryChunk2);
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
// We don't check result here because result is not available in the test body as written
|
||||
// The test body doesn't capture the return value of simulateExecution correctly for this assertion.
|
||||
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
|
||||
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
|
||||
type: 'binary_detected',
|
||||
@@ -1604,7 +1636,6 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
await simulateExecution('cat mixed_file', (cp) => {
|
||||
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
|
||||
cp.stdout?.emit('data', Buffer.from('more text'));
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
@@ -1640,7 +1671,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should use powershell.exe on Windows', async () => {
|
||||
mockPlatform.mockReturnValue('win32');
|
||||
await simulateExecution('dir "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1657,7 +1688,7 @@ describe('ShellExecutionService child_process fallback', () => {
|
||||
it('should use bash and detached process group on Linux', async () => {
|
||||
mockPlatform.mockReturnValue('linux');
|
||||
await simulateExecution('ls "foo bar"', (cp) => {
|
||||
cp.emit('exit', 0, null);
|
||||
cp.emit('close', 0, null);
|
||||
});
|
||||
|
||||
expect(mockCpSpawn).toHaveBeenCalledWith(
|
||||
@@ -1771,7 +1802,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
);
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).not.toHaveBeenCalled();
|
||||
@@ -1794,7 +1825,7 @@ describe('ShellExecutionService execution method selection', () => {
|
||||
);
|
||||
|
||||
// Simulate exit to allow promise to resolve
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
const result = await handle.result;
|
||||
|
||||
expect(mockGetPty).toHaveBeenCalled();
|
||||
@@ -1860,10 +1891,6 @@ describe('ShellExecutionService environment variables', () => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockPtyProcess.emit('exit', { exitCode, signal }), 0);
|
||||
});
|
||||
mockChildProcess.on('exit', (code, signal) => {
|
||||
// Small delay to allow async ops to complete
|
||||
setTimeout(() => mockChildProcess.emit('close', code, signal), 0);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1926,7 +1953,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -1986,7 +2013,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI_TEST_VAR', 'test-value');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -2034,7 +2061,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GEMINI_CLI', '1');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
});
|
||||
@@ -2091,7 +2118,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
);
|
||||
|
||||
// Clean up
|
||||
mockChild.emit('exit', 0, null);
|
||||
mockChild.emit('close', 0, null);
|
||||
mockChild.emit('close', 0, null);
|
||||
await handle.result;
|
||||
});
|
||||
@@ -2140,7 +2167,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).toHaveProperty('GIT_CONFIG_VALUE_2', '');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
@@ -2180,7 +2207,7 @@ describe('ShellExecutionService environment variables', () => {
|
||||
expect(cpEnv).not.toHaveProperty('GIT_CONFIG_COUNT');
|
||||
|
||||
// Ensure child_process exits
|
||||
mockChildProcess.emit('exit', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
mockChildProcess.emit('close', 0, null);
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Writable } from 'node:stream';
|
||||
import os from 'node:os';
|
||||
import fs, { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import type { IPty } from '@lydell/node-pty';
|
||||
import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js';
|
||||
import {
|
||||
@@ -120,6 +121,8 @@ interface ActivePty {
|
||||
maxSerializedLines?: number;
|
||||
command: string;
|
||||
sessionId?: string;
|
||||
lastSerializedOutput?: AnsiOutput;
|
||||
lastCommittedLine: number;
|
||||
}
|
||||
|
||||
interface ActiveChildProcess {
|
||||
@@ -148,6 +151,48 @@ const findLastContentLine = (
|
||||
return -1;
|
||||
};
|
||||
|
||||
const emitPendingLines = (
|
||||
activePty: ActivePty,
|
||||
pid: number,
|
||||
onOutputEvent: (event: ShellOutputEvent) => void,
|
||||
forceAll = false,
|
||||
) => {
|
||||
const buffer = activePty.headlessTerminal.buffer.active;
|
||||
const limit = forceAll ? buffer.length : buffer.baseY;
|
||||
|
||||
let chunks = '';
|
||||
for (let i = activePty.lastCommittedLine + 1; i < limit; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) continue;
|
||||
|
||||
let trimRight = true;
|
||||
let isNextLineWrapped = false;
|
||||
if (i + 1 < buffer.length) {
|
||||
const nextLine = buffer.getLine(i + 1);
|
||||
if (nextLine?.isWrapped) {
|
||||
isNextLineWrapped = true;
|
||||
trimRight = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineContent = line.translateToString(trimRight);
|
||||
chunks += lineContent;
|
||||
if (!isNextLineWrapped) {
|
||||
chunks += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length > 0) {
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: chunks,
|
||||
};
|
||||
onOutputEvent(event);
|
||||
ExecutionLifecycleService.emitEvent(pid, event);
|
||||
activePty.lastCommittedLine = limit - 1;
|
||||
}
|
||||
};
|
||||
|
||||
const getFullBufferText = (terminal: pkg.Terminal, startLine = 0): string => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const lines: string[] = [];
|
||||
@@ -326,32 +371,110 @@ export class ShellExecutionService {
|
||||
shouldUseNodePty: boolean,
|
||||
shellExecutionConfig: ShellExecutionConfig,
|
||||
): Promise<ShellExecutionHandle> {
|
||||
const outputFileName = `gemini_shell_output_${crypto.randomBytes(6).toString('hex')}.log`;
|
||||
const outputFilePath = path.join(os.tmpdir(), outputFileName);
|
||||
const outputStream = fs.createWriteStream(outputFilePath);
|
||||
|
||||
let totalBytesWritten = 0;
|
||||
|
||||
const interceptedOnOutputEvent = (event: ShellOutputEvent) => {
|
||||
switch (event.type) {
|
||||
case 'raw_data':
|
||||
break;
|
||||
case 'file_data':
|
||||
outputStream.write(event.chunk);
|
||||
totalBytesWritten += Buffer.byteLength(event.chunk);
|
||||
break;
|
||||
case 'binary_detected':
|
||||
case 'binary_progress':
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
onOutputEvent(event);
|
||||
};
|
||||
|
||||
let handlePromise: Promise<ShellExecutionHandle>;
|
||||
|
||||
if (shouldUseNodePty) {
|
||||
const ptyInfo = await getPty();
|
||||
if (ptyInfo) {
|
||||
try {
|
||||
return await this.executeWithPty(
|
||||
handlePromise = getPty().then((ptyInfo) => {
|
||||
if (ptyInfo) {
|
||||
return this.executeWithPty(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
onOutputEvent,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
ptyInfo,
|
||||
).catch(() =>
|
||||
this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Fallback to child_process
|
||||
}
|
||||
}
|
||||
return this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
handlePromise = this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
interceptedOnOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
}
|
||||
|
||||
return this.childProcessFallback(
|
||||
commandToExecute,
|
||||
cwd,
|
||||
onOutputEvent,
|
||||
abortSignal,
|
||||
shellExecutionConfig,
|
||||
shouldUseNodePty,
|
||||
);
|
||||
const handle = await handlePromise;
|
||||
|
||||
const wrappedResultPromise = handle.result
|
||||
.then(async (result) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
outputStream.end(resolve);
|
||||
});
|
||||
// The threshold logic is handled later by ToolExecutor/caller, so we just return the full file path if anything was written
|
||||
if (
|
||||
totalBytesWritten > 0 &&
|
||||
!result.backgrounded &&
|
||||
!abortSignal.aborted &&
|
||||
!result.error
|
||||
) {
|
||||
return {
|
||||
...result,
|
||||
fullOutputFilePath: outputFilePath,
|
||||
};
|
||||
} else {
|
||||
if (!outputStream.closed) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
await fs.promises.unlink(outputFilePath).catch(() => undefined);
|
||||
return result;
|
||||
}
|
||||
})
|
||||
.catch(async (err) => {
|
||||
if (!outputStream.closed) {
|
||||
outputStream.destroy();
|
||||
}
|
||||
await fs.promises.unlink(outputFilePath).catch(() => undefined);
|
||||
throw err;
|
||||
});
|
||||
|
||||
return {
|
||||
pid: handle.pid,
|
||||
result: wrappedResultPromise,
|
||||
};
|
||||
}
|
||||
|
||||
private static appendAndTruncate(
|
||||
@@ -661,6 +784,24 @@ export class ShellExecutionService {
|
||||
}
|
||||
|
||||
if (decodedChunk) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: decodedChunk,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(decodedChunk),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: decodedChunk,
|
||||
@@ -772,7 +913,7 @@ export class ShellExecutionService {
|
||||
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child.on('close', (code, signal) => {
|
||||
handleExit(code, signal);
|
||||
});
|
||||
|
||||
@@ -784,6 +925,15 @@ export class ShellExecutionService {
|
||||
if (remaining) {
|
||||
state.output += remaining;
|
||||
if (isStreamingRawContent) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
@@ -792,6 +942,15 @@ export class ShellExecutionService {
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, event);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(remaining),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -800,6 +959,15 @@ export class ShellExecutionService {
|
||||
if (remaining) {
|
||||
state.output += remaining;
|
||||
if (isStreamingRawContent) {
|
||||
const rawEvent: ShellOutputEvent = {
|
||||
type: 'raw_data',
|
||||
chunk: remaining,
|
||||
};
|
||||
onOutputEvent(rawEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, rawEvent);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'data',
|
||||
chunk: remaining,
|
||||
@@ -808,6 +976,15 @@ export class ShellExecutionService {
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, event);
|
||||
}
|
||||
|
||||
const fileEvent: ShellOutputEvent = {
|
||||
type: 'file_data',
|
||||
chunk: stripAnsi(remaining),
|
||||
};
|
||||
onOutputEvent(fileEvent);
|
||||
if (child.pid) {
|
||||
ExecutionLifecycleService.emitEvent(child.pid, fileEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -934,6 +1111,7 @@ export class ShellExecutionService {
|
||||
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
|
||||
command: shellExecutionConfig.originalCommand ?? commandToExecute,
|
||||
sessionId: shellExecutionConfig.sessionId,
|
||||
lastCommittedLine: -1,
|
||||
});
|
||||
|
||||
const result = ExecutionLifecycleService.attachExecution(ptyPid, {
|
||||
@@ -1099,10 +1277,38 @@ export class ShellExecutionService {
|
||||
}, 68);
|
||||
};
|
||||
|
||||
headlessTerminal.onScroll(() => {
|
||||
let lastYdisp = 0;
|
||||
let hasReachedMax = false;
|
||||
const scrollbackLimit =
|
||||
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
|
||||
|
||||
headlessTerminal.onScroll((ydisp) => {
|
||||
if (!isWriting) {
|
||||
render();
|
||||
}
|
||||
|
||||
if (
|
||||
ydisp === scrollbackLimit &&
|
||||
lastYdisp === scrollbackLimit &&
|
||||
hasReachedMax
|
||||
) {
|
||||
const activePty = this.activePtys.get(ptyPid);
|
||||
if (activePty) {
|
||||
activePty.lastCommittedLine--;
|
||||
}
|
||||
}
|
||||
if (
|
||||
ydisp === scrollbackLimit &&
|
||||
headlessTerminal.buffer.active.length === scrollbackLimit + rows
|
||||
) {
|
||||
hasReachedMax = true;
|
||||
}
|
||||
lastYdisp = ydisp;
|
||||
|
||||
const activePtyForEmit = this.activePtys.get(ptyPid);
|
||||
if (activePtyForEmit) {
|
||||
emitPendingLines(activePtyForEmit, ptyPid, onOutputEvent);
|
||||
}
|
||||
});
|
||||
|
||||
const handleOutput = (data: Buffer) => {
|
||||
@@ -1187,6 +1393,11 @@ export class ShellExecutionService {
|
||||
render(true);
|
||||
cmdCleanup?.();
|
||||
|
||||
const activePty = ShellExecutionService.activePtys.get(ptyPid);
|
||||
if (activePty && isStreamingRawContent) {
|
||||
emitPendingLines(activePty, ptyPid, onOutputEvent, true);
|
||||
}
|
||||
|
||||
const event: ShellOutputEvent = {
|
||||
type: 'exit',
|
||||
exitCode,
|
||||
@@ -1490,6 +1701,7 @@ export class ShellExecutionService {
|
||||
startLine,
|
||||
endLine,
|
||||
);
|
||||
activePty.lastSerializedOutput = bufferData;
|
||||
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
|
||||
ExecutionLifecycleService.emitEvent(pid, event);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,5 @@ 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,7 +137,3 @@ 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,8 +43,6 @@ 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,
|
||||
@@ -282,17 +280,3 @@ 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,8 +25,6 @@ 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,
|
||||
@@ -758,37 +756,4 @@ 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||