mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6efa6f61f | |||
| 6c1773170e | |||
| 00966062b8 | |||
| 4138667bae | |||
| bfa791e13d | |||
| e9a9474810 | |||
| 02adfe2bca | |||
| 2a08456ed0 | |||
| 2dac98dc8d | |||
| 77849cadac | |||
| 84ce53aafa | |||
| b19b026f30 | |||
| eb9223b6a4 | |||
| 65d26e73a2 | |||
| 0080589939 | |||
| 34a47a51f4 | |||
| f5dd1068f6 | |||
| c202cf3145 | |||
| 5baad108d9 | |||
| 63e9d5d15f | |||
| 3776c4d613 | |||
| 2af5a3a01e | |||
| 0d034b8c18 | |||
| 49d55d972e | |||
| 7a02d7261a | |||
| 9c11ff2d58 |
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"experimental": {
|
||||
"toolOutputMasking": {
|
||||
"enabled": true
|
||||
},
|
||||
"plan": true
|
||||
},
|
||||
"general": {
|
||||
|
||||
@@ -131,7 +131,8 @@ available combinations.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
the panel and insert a `?` into the prompt. You can hide only the hint text
|
||||
via `ui.showShortcutsHint`, without changing this keyboard behavior.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
+87
-7
@@ -30,6 +30,8 @@ implementation strategy.
|
||||
- [The Planning Workflow](#the-planning-workflow)
|
||||
- [Exiting Plan Mode](#exiting-plan-mode)
|
||||
- [Tool Restrictions](#tool-restrictions)
|
||||
- [Customizing Planning with Skills](#customizing-planning-with-skills)
|
||||
- [Customizing Policies](#customizing-policies)
|
||||
|
||||
## Starting in Plan Mode
|
||||
|
||||
@@ -61,15 +63,18 @@ You can enter Plan Mode in three ways:
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
|
||||
(`Default` -> `Plan` -> `Auto-Edit`).
|
||||
2. **Command:** Type `/plan` in the input box.
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...".
|
||||
3. **Natural Language:** Ask the agent to "start a plan for...". The agent will
|
||||
then call the [`enter_plan_mode`] tool to switch modes.
|
||||
|
||||
### The Planning Workflow
|
||||
|
||||
1. **Requirements:** The agent clarifies goals using `ask_user`.
|
||||
1. **Requirements:** The agent clarifies goals using [`ask_user`].
|
||||
2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map
|
||||
the codebase and validate assumptions.
|
||||
3. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
4. **Review:** You review the plan.
|
||||
3. **Design:** The agent proposes alternative approaches with a recommended
|
||||
solution for you to choose from.
|
||||
4. **Planning:** A detailed plan is written to a temporary Markdown file.
|
||||
5. **Review:** You review the plan.
|
||||
- **Approve:** Exit Plan Mode and start implementation (switching to
|
||||
Auto-Edit or Default approval mode).
|
||||
- **Iterate:** Provide feedback to refine the plan.
|
||||
@@ -79,8 +84,8 @@ You can enter Plan Mode in three ways:
|
||||
To exit Plan Mode:
|
||||
|
||||
1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
1. **Tool:** The agent calls the `exit_plan_mode` tool to present the finalized
|
||||
plan for your approval.
|
||||
2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the
|
||||
finalized plan for your approval.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
@@ -90,11 +95,80 @@ These are the only allowed tools:
|
||||
|
||||
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
|
||||
- **Search:** [`grep_search`], [`google_web_search`]
|
||||
- **Interaction:** `ask_user`
|
||||
- **Interaction:** [`ask_user`]
|
||||
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
|
||||
`postgres_read_schema`) are allowed.
|
||||
- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md`
|
||||
files in the `~/.gemini/tmp/<project>/plans/` directory.
|
||||
- **Skills:** [`activate_skill`] (allows loading specialized instructions and
|
||||
resources in a read-only manner)
|
||||
|
||||
### Customizing Planning with Skills
|
||||
|
||||
You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI
|
||||
approaches planning for specific types of tasks. When a skill is activated
|
||||
during Plan Mode, its specialized instructions and procedural workflows will
|
||||
guide the research and design phases.
|
||||
|
||||
For example:
|
||||
|
||||
- A **"Database Migration"** skill could ensure the plan includes data safety
|
||||
checks and rollback strategies.
|
||||
- A **"Security Audit"** skill could prompt the agent to look for specific
|
||||
vulnerabilities during codebase exploration.
|
||||
- A **"Frontend Design"** skill could guide the agent to use specific UI
|
||||
components and accessibility standards in its proposal.
|
||||
|
||||
To use a skill in Plan Mode, you can explicitly ask the agent to "use the
|
||||
[skill-name] skill to plan..." or the agent may autonomously activate it based
|
||||
on the task description.
|
||||
|
||||
### Customizing Policies
|
||||
|
||||
Plan Mode is designed to be read-only by default to ensure safety during the
|
||||
research phase. However, you may occasionally need to allow specific tools to
|
||||
assist in your planning.
|
||||
|
||||
Because user policies (Tier 2) have a higher base priority than built-in
|
||||
policies (Tier 1), you can override Plan Mode's default restrictions by creating
|
||||
a rule in your `~/.gemini/policies/` directory.
|
||||
|
||||
#### Example: Allow `git status` and `git diff` in Plan Mode
|
||||
|
||||
This rule allows you to check the repository status and see changes while in
|
||||
Plan Mode.
|
||||
|
||||
`~/.gemini/policies/git-research.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
#### Example: Enable research sub-agents in Plan Mode
|
||||
|
||||
You can enable [experimental research sub-agents] like `codebase_investigator`
|
||||
to help gather architecture details during the planning phase.
|
||||
|
||||
`~/.gemini/policies/research-subagents.toml`
|
||||
|
||||
```toml
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
```
|
||||
|
||||
Tell the agent it can use these tools in your prompt, for example: _"You can
|
||||
check ongoing changes in git."_
|
||||
|
||||
For more information on how the policy engine works, see the [Policy Engine
|
||||
Guide].
|
||||
|
||||
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
|
||||
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
|
||||
@@ -104,3 +178,9 @@ These are the only allowed tools:
|
||||
[`google_web_search`]: /docs/tools/web-search.md
|
||||
[`replace`]: /docs/tools/file-system.md#6-replace-edit
|
||||
[MCP tools]: /docs/tools/mcp-server.md
|
||||
[`activate_skill`]: /docs/cli/skills.md
|
||||
[experimental research sub-agents]: /docs/core/subagents.md
|
||||
[Policy Engine Guide]: /docs/core/policy-engine.md
|
||||
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
|
||||
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
|
||||
[`ask_user`]: /docs/tools/ask-user.md
|
||||
|
||||
@@ -49,6 +49,7 @@ they appear in the UI.
|
||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
|
||||
@@ -124,10 +125,11 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------- | ---------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| -------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 sequence for pasting instead of clipboardy (useful for remote sessions). | `false` |
|
||||
| Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -119,9 +119,17 @@ For example:
|
||||
|
||||
Approval modes allow the policy engine to apply different sets of rules based on
|
||||
the CLI's operational mode. A rule can be associated with one or more modes
|
||||
(e.g., `yolo`, `autoEdit`). The rule will only be active if the CLI is running
|
||||
in one of its specified modes. If a rule has no modes specified, it is always
|
||||
active.
|
||||
(e.g., `yolo`, `autoEdit`, `plan`). The rule will only be active if the CLI is
|
||||
running in one of its specified modes. If a rule has no modes specified, it is
|
||||
always active.
|
||||
|
||||
- `default`: The standard interactive mode where most write tools require
|
||||
confirmation.
|
||||
- `autoEdit`: Optimized for automated code editing; some write tools may be
|
||||
auto-approved.
|
||||
- `plan`: A strict, read-only mode for research and design. See [Customizing
|
||||
Plan Mode Policies].
|
||||
- `yolo`: A mode where all tools are auto-approved (use with extreme caution).
|
||||
|
||||
## Rule matching
|
||||
|
||||
@@ -303,3 +311,5 @@ out-of-the-box experience.
|
||||
- In **`yolo`** mode, a high-priority rule allows all tools.
|
||||
- In **`autoEdit`** mode, rules allow certain write operations to happen without
|
||||
prompting.
|
||||
|
||||
[Customizing Plan Mode Policies]: /docs/cli/plan-mode.md#customizing-policies
|
||||
|
||||
@@ -179,9 +179,6 @@ precedence.
|
||||
|
||||
### Settings
|
||||
|
||||
_Note: This is an experimental feature. We do not yet recommend extension
|
||||
authors introduce settings as part of their core flows._
|
||||
|
||||
Extensions can define settings that the user will be prompted to provide upon
|
||||
installation. This is useful for things like API keys, URLs, or other
|
||||
configuration that the extension needs to function.
|
||||
|
||||
@@ -220,6 +220,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`ui.hideBanner`** (boolean):
|
||||
- **Description:** Hide the application banner
|
||||
- **Default:** `false`
|
||||
@@ -443,6 +447,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash"
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -498,7 +508,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -510,7 +520,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {
|
||||
"generateContentConfig": {
|
||||
"tools": [
|
||||
@@ -522,25 +532,25 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"extends": "gemini-2.5-flash-base",
|
||||
"extends": "gemini-3-flash-base",
|
||||
"modelConfig": {}
|
||||
},
|
||||
"chat-compression-3-pro": {
|
||||
@@ -570,7 +580,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"modelConfig": {
|
||||
"model": "gemini-2.5-pro"
|
||||
"model": "gemini-3-pro-preview"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,6 +858,28 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.toolOutputMasking.enabled`** (boolean):
|
||||
- **Description:** Enables tool output masking to save tokens.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.toolProtectionThreshold`** (number):
|
||||
- **Description:** Minimum number of tokens to protect from masking (most
|
||||
recent tool outputs).
|
||||
- **Default:** `50000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.minPrunableTokensThreshold`** (number):
|
||||
- **Description:** Minimum prunable tokens required to trigger a masking pass.
|
||||
- **Default:** `30000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.toolOutputMasking.protectLatestTurn`** (boolean):
|
||||
- **Description:** Ensures the absolute latest turn is never masked,
|
||||
regardless of token count.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableAgents`** (boolean):
|
||||
- **Description:** Enable local and remote subagents. Warning: Experimental
|
||||
feature, uses YOLO mode for subagents
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Ask User Tool
|
||||
|
||||
The `ask_user` tool allows the agent to ask you one or more questions to gather
|
||||
preferences, clarify requirements, or make decisions. It supports multiple
|
||||
question types including multiple-choice, free-form text, and Yes/No
|
||||
confirmation.
|
||||
|
||||
## `ask_user` (Ask User)
|
||||
|
||||
- **Tool name:** `ask_user`
|
||||
- **Display name:** Ask User
|
||||
- **File:** `ask-user.ts`
|
||||
- **Parameters:**
|
||||
- `questions` (array of objects, required): A list of 1 to 4 questions to ask.
|
||||
Each question object has the following properties:
|
||||
- `question` (string, required): The complete question text.
|
||||
- `header` (string, required): A short label (max 16 chars) displayed as a
|
||||
chip/tag (e.g., "Auth", "Database").
|
||||
- `type` (string, optional): The type of question. Defaults to `'choice'`.
|
||||
- `'choice'`: Multiple-choice with options (supports multi-select).
|
||||
- `'text'`: Free-form text input.
|
||||
- `'yesno'`: Yes/No confirmation.
|
||||
- `options` (array of objects, optional): Required for `'choice'` type. 2-4
|
||||
selectable options.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
- Presents an interactive dialog to the user with the specified questions.
|
||||
- Pauses execution until the user provides answers or dismisses the dialog.
|
||||
- Returns the user's answers to the model.
|
||||
|
||||
- **Output (`llmContent`):** A JSON string containing the user's answers,
|
||||
indexed by question position (e.g.,
|
||||
`{"answers":{"0": "Option A", "1": "Some text"}}`).
|
||||
|
||||
- **Confirmation:** Yes. The tool inherently involves user interaction.
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Multiple Choice Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Database",
|
||||
"question": "Which database would you like to use?",
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{
|
||||
"label": "PostgreSQL",
|
||||
"description": "Powerful, open source object-relational database system."
|
||||
},
|
||||
{
|
||||
"label": "SQLite",
|
||||
"description": "C-library that implements a SQL database engine."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Text Input Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Project Name",
|
||||
"question": "What is the name of your new project?",
|
||||
"type": "text",
|
||||
"placeholder": "e.g., my-awesome-app"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Yes/No Question
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": [
|
||||
{
|
||||
"header": "Deploy",
|
||||
"question": "Do you want to deploy the application now?",
|
||||
"type": "yesno"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -86,6 +86,9 @@ Gemini CLI's built-in tools can be broadly categorized as follows:
|
||||
information across sessions.
|
||||
- **[Todo Tool](./todos.md) (`write_todos`):** For managing subtasks of complex
|
||||
requests.
|
||||
- **[Planning Tools](./planning.md):** For entering and exiting Plan Mode.
|
||||
- **[Ask User Tool](./ask-user.md) (`ask_user`):** For gathering user input and
|
||||
making decisions.
|
||||
|
||||
Additionally, these tools incorporate:
|
||||
|
||||
|
||||
@@ -739,21 +739,10 @@ The MCP integration tracks several states:
|
||||
cautiously and only for servers you completely control
|
||||
- **Access tokens:** Be security-aware when configuring environment variables
|
||||
containing API keys or tokens
|
||||
- **Environment variable redaction:** By default, the Gemini CLI redacts
|
||||
sensitive environment variables (such as `GEMINI_API_KEY`, `GOOGLE_API_KEY`,
|
||||
and variables matching patterns like `*TOKEN*`, `*SECRET*`, `*PASSWORD*`) when
|
||||
spawning MCP servers using the `stdio` transport. This prevents unintended
|
||||
exposure of your credentials to third-party servers.
|
||||
- **Explicit environment variables:** If you need to pass a specific environment
|
||||
variable to an MCP server, you should define it explicitly in the `env`
|
||||
property of the server configuration in `settings.json`.
|
||||
- **Sandbox compatibility:** When using sandboxing, ensure MCP servers are
|
||||
available within the sandbox environment.
|
||||
available within the sandbox environment
|
||||
- **Private data:** Using broadly scoped personal access tokens can lead to
|
||||
information leakage between repositories.
|
||||
- **Untrusted servers:** Be extremely cautious when adding MCP servers from
|
||||
untrusted or third-party sources. Malicious servers could attempt to
|
||||
exfiltrate data or perform unauthorized actions through the tools they expose.
|
||||
|
||||
### Performance and resource management
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Gemini CLI planning tools
|
||||
|
||||
Planning tools allow the Gemini model to switch into a safe, read-only "Plan
|
||||
Mode" for researching and planning complex changes, and to signal the
|
||||
finalization of a plan to the user.
|
||||
|
||||
## 1. `enter_plan_mode` (EnterPlanMode)
|
||||
|
||||
`enter_plan_mode` switches the CLI to Plan Mode. This tool is typically called
|
||||
by the agent when you ask it to "start a plan" using natural language. In this
|
||||
mode, the agent is restricted to read-only tools to allow for safe exploration
|
||||
and planning.
|
||||
|
||||
- **Tool name:** `enter_plan_mode`
|
||||
- **Display name:** Enter Plan Mode
|
||||
- **File:** `enter-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `reason` (string, optional): A short reason explaining why the agent is
|
||||
entering plan mode (e.g., "Starting a complex feature implementation").
|
||||
- **Behavior:**
|
||||
- Switches the CLI's approval mode to `PLAN`.
|
||||
- Notifies the user that the agent has entered Plan Mode.
|
||||
- **Output (`llmContent`):** A message indicating the switch, e.g.,
|
||||
`Switching to Plan mode.`
|
||||
- **Confirmation:** Yes. The user is prompted to confirm entering Plan Mode.
|
||||
|
||||
## 2. `exit_plan_mode` (ExitPlanMode)
|
||||
|
||||
`exit_plan_mode` signals that the planning phase is complete. It presents the
|
||||
finalized plan to the user and requests approval to start the implementation.
|
||||
|
||||
- **Tool name:** `exit_plan_mode`
|
||||
- **Display name:** Exit Plan Mode
|
||||
- **File:** `exit-plan-mode.ts`
|
||||
- **Parameters:**
|
||||
- `plan_path` (string, required): The path to the finalized Markdown plan
|
||||
file. This file MUST be located within the project's temporary plans
|
||||
directory (e.g., `~/.gemini/tmp/<project>/plans/`).
|
||||
- **Behavior:**
|
||||
- Validates that the `plan_path` is within the allowed directory and that the
|
||||
file exists and has content.
|
||||
- Presents the plan to the user for review.
|
||||
- If the user approves the plan:
|
||||
- Switches the CLI's approval mode to the user's chosen approval mode (
|
||||
`DEFAULT` or `AUTO_EDIT`).
|
||||
- Marks the plan as approved for implementation.
|
||||
- If the user rejects the plan:
|
||||
- Stays in Plan Mode.
|
||||
- Returns user feedback to the model to refine the plan.
|
||||
- **Output (`llmContent`):**
|
||||
- On approval: A message indicating the plan was approved and the new approval
|
||||
mode.
|
||||
- On rejection: A message containing the user's feedback.
|
||||
- **Confirmation:** Yes. Shows the finalized plan and asks for user approval to
|
||||
proceed with implementation.
|
||||
@@ -114,6 +114,27 @@ npm run test:all_evals
|
||||
This command sets the `RUN_EVALS` environment variable to `1`, which enables the
|
||||
`USUALLY_PASSES` tests.
|
||||
|
||||
### All Evals (All Models)
|
||||
|
||||
To run the full evaluation suite across all supported models and generate a
|
||||
local markdown report (mirroring the nightly CI workflow):
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models
|
||||
```
|
||||
|
||||
This script will:
|
||||
1. Build the project.
|
||||
2. Run `test:all_evals` for each model in the nightly rotation.
|
||||
3. Collect logs and aggregate them using `scripts/aggregate_evals.js`.
|
||||
4. Generate a `local_evals_summary.md` file with the results.
|
||||
|
||||
You can also filter by test name and specify the number of attempts:
|
||||
|
||||
```bash
|
||||
npm run test:all_evals_all_models -- "my-test-pattern" --attempts 3
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
Results for evaluations are available on GitHub Actions:
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { evalTest } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* Evals to verify that the agent uses search tools efficiently (frugally)
|
||||
* by utilizing limiting parameters like `total_max_matches` and `max_matches_per_file`.
|
||||
* This ensures the agent doesn't flood the context window with unnecessary search results.
|
||||
*/
|
||||
describe('Frugal Search', () => {
|
||||
const getGrepParams = (call: any): any => {
|
||||
let args = call.toolRequest.args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
args = JSON.parse(args);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should use targeted search with limit',
|
||||
prompt: 'find me a sample usage of path.resolve() in the codebase',
|
||||
files: {
|
||||
'package.json': JSON.stringify({
|
||||
name: 'test-project',
|
||||
version: '1.0.0',
|
||||
main: 'dist/index.js',
|
||||
scripts: {
|
||||
build: 'tsc',
|
||||
test: 'vitest',
|
||||
},
|
||||
dependencies: {
|
||||
typescript: '^5.0.0',
|
||||
'@types/node': '^20.0.0',
|
||||
vitest: '^1.0.0',
|
||||
},
|
||||
}),
|
||||
'src/index.ts': `
|
||||
import { App } from './app.ts';
|
||||
|
||||
const app = new App();
|
||||
app.start();
|
||||
`,
|
||||
'src/app.ts': `
|
||||
import * as path from 'path';
|
||||
import { UserController } from './controllers/user.ts';
|
||||
|
||||
export class App {
|
||||
constructor() {
|
||||
console.log('App initialized');
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
const userController = new UserController();
|
||||
console.log('Static path:', path.resolve(__dirname, '../public'));
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/utils.ts': `
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export function resolvePath(p: string): string {
|
||||
return path.resolve(process.cwd(), p);
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
const absolutePath = path.resolve(dirPath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
fs.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/config.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export const config = {
|
||||
dbPath: path.resolve(process.cwd(), 'data/db.sqlite'),
|
||||
logLevel: 'info',
|
||||
};
|
||||
`,
|
||||
'src/controllers/user.ts': `
|
||||
import * as path from 'path';
|
||||
|
||||
export class UserController {
|
||||
public getUsers(): any[] {
|
||||
console.log('Loading users from:', path.resolve('data/users.json'));
|
||||
return [{ id: 1, name: 'Alice' }];
|
||||
}
|
||||
}
|
||||
`,
|
||||
'tests/app.test.ts': `
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('App', () => {
|
||||
it('should resolve paths', () => {
|
||||
const p = path.resolve('test');
|
||||
expect(p).toBeDefined();
|
||||
});
|
||||
});
|
||||
`,
|
||||
},
|
||||
assert: async (rig) => {
|
||||
const toolCalls = rig.readToolLogs();
|
||||
const grepCalls = toolCalls.filter(
|
||||
(call) => call.toolRequest.name === 'grep_search',
|
||||
);
|
||||
|
||||
expect(grepCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const grepParams = grepCalls.map(getGrepParams);
|
||||
|
||||
const hasTotalMaxLimit = grepParams.some(
|
||||
(p) => p.total_max_matches !== undefined && p.total_max_matches <= 100,
|
||||
);
|
||||
expect(
|
||||
hasTotalMaxLimit,
|
||||
`Expected agent to use a small total_max_matches (<= 100) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.total_max_matches),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
|
||||
const hasMaxMatchesPerFileLimit = grepParams.some(
|
||||
(p) =>
|
||||
p.max_matches_per_file !== undefined && p.max_matches_per_file <= 5,
|
||||
);
|
||||
expect(
|
||||
hasMaxMatchesPerFileLimit,
|
||||
`Expected agent to use a small max_matches_per_file (<= 5) for a sample usage request. Actual values: ${JSON.stringify(
|
||||
grepParams.map((p) => p.max_matches_per_file),
|
||||
)}`,
|
||||
).toBe(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
+11
-11
@@ -14,7 +14,7 @@ import {
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -36,7 +36,7 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -57,7 +57,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingWorkflow = 'Agent remembers workflow preferences';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -79,7 +79,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringTemporaryInformation =
|
||||
'Agent ignores temporary conversation details';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -104,7 +104,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingPetName = "Agent remembers user's pet's name";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -125,7 +125,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingCommandAlias = 'Agent remembers custom command aliases';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -147,7 +147,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringDbSchemaLocation =
|
||||
"Agent ignores workspace's database schema location";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringDbSchemaLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -178,7 +178,7 @@ describe('save_memory', () => {
|
||||
|
||||
const rememberingCodingStyle =
|
||||
"Agent remembers user's coding style preference";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
@@ -200,7 +200,7 @@ describe('save_memory', () => {
|
||||
|
||||
const ignoringBuildArtifactLocation =
|
||||
'Agent ignores workspace build artifact location';
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringBuildArtifactLocation,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -230,7 +230,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: ignoringMainEntryPoint,
|
||||
params: {
|
||||
settings: {
|
||||
@@ -260,7 +260,7 @@ describe('save_memory', () => {
|
||||
});
|
||||
|
||||
const rememberingBirthday = "Agent remembers user's birthday";
|
||||
evalTest('ALWAYS_PASSES', {
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: { tools: { core: ['save_memory'] } },
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
|
||||
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
|
||||
"test:all_evals_all_models": "node scripts/run_local_evals.js",
|
||||
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
|
||||
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
|
||||
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
|
||||
|
||||
@@ -128,13 +128,6 @@ async function addMcpServer(
|
||||
|
||||
settings.setValue(settingsScope, 'mcpServers', mcpServers);
|
||||
|
||||
if (transport === 'stdio') {
|
||||
debugLogger.warn(
|
||||
'Security Warning: Running MCP servers with stdio transport can expose inherited environment variables. ' +
|
||||
'While the Gemini CLI redacts common API keys and secrets by default, you should only run servers from trusted sources.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isExistingServer) {
|
||||
debugLogger.log(`MCP server "${name}" updated in ${scope} settings.`);
|
||||
} else {
|
||||
|
||||
@@ -186,6 +186,9 @@ describe('SettingsSchema', () => {
|
||||
expect(getSettingsSchema().ui.properties.hideTips.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().ui.properties.hideBanner.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
@@ -328,6 +331,28 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have showShortcutsHint setting in schema', () => {
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint).toBeDefined();
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.category).toBe(
|
||||
'UI',
|
||||
);
|
||||
expect(getSettingsSchema().ui.properties.showShortcutsHint.default).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showShortcutsHint.description,
|
||||
).toBe('Show the "? for shortcuts" hint above the input.');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -462,6 +462,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Show the "? for shortcuts" hint above the input.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideBanner: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Banner',
|
||||
@@ -1462,7 +1471,7 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
ignoreInDocs: true,
|
||||
ignoreInDocs: false,
|
||||
default: {},
|
||||
description:
|
||||
'Advanced settings for tool output masking to manage context window efficiency.',
|
||||
@@ -1473,9 +1482,9 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Enable Tool Output Masking',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
default: true,
|
||||
description: 'Enables tool output masking to save tokens.',
|
||||
showInDialog: false,
|
||||
showInDialog: true,
|
||||
},
|
||||
toolProtectionThreshold: {
|
||||
type: 'number',
|
||||
|
||||
@@ -238,18 +238,15 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalEnvGeminiSandbox: string | undefined;
|
||||
let originalEnvSandbox: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Store and clear sandbox-related env variables to ensure a consistent test environment
|
||||
originalEnvGeminiSandbox = process.env['GEMINI_SANDBOX'];
|
||||
originalEnvSandbox = process.env['SANDBOX'];
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
delete process.env['SANDBOX'];
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -260,18 +257,6 @@ describe('gemini.tsx main function', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env variables
|
||||
if (originalEnvGeminiSandbox !== undefined) {
|
||||
process.env['GEMINI_SANDBOX'] = originalEnvGeminiSandbox;
|
||||
} else {
|
||||
delete process.env['GEMINI_SANDBOX'];
|
||||
}
|
||||
if (originalEnvSandbox !== undefined) {
|
||||
process.env['SANDBOX'] = originalEnvSandbox;
|
||||
} else {
|
||||
delete process.env['SANDBOX'];
|
||||
}
|
||||
|
||||
const currentListeners = process.listeners('unhandledRejection');
|
||||
currentListeners.forEach((listener) => {
|
||||
if (!initialUnhandledRejectionListeners.includes(listener)) {
|
||||
@@ -282,6 +267,7 @@ describe('gemini.tsx main function', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1209,7 +1195,12 @@ describe('startInteractiveUI', () => {
|
||||
registerTelemetryConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -1308,7 +1299,7 @@ describe('startInteractiveUI', () => {
|
||||
|
||||
// Verify all startup tasks were called
|
||||
expect(getVersion).toHaveBeenCalledTimes(1);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(3);
|
||||
expect(registerCleanup).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify cleanup handler is registered with unmount function
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0];
|
||||
|
||||
+33
-20
@@ -57,8 +57,8 @@ import {
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
enterAlternateScreen,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
@@ -89,6 +89,7 @@ import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { useTerminalSize } from './ui/hooks/useTerminalSize.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -214,9 +215,13 @@ export async function startInteractiveUI(
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
const { columns, rows } = useTerminalSize();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
@@ -234,6 +239,7 @@ export async function startInteractiveUI(
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AppContainer
|
||||
key={`${columns}-${rows}`}
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
@@ -250,6 +256,17 @@ export async function startInteractiveUI(
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
// shpool is a persistence tool that restores terminal state by replaying it.
|
||||
// This delay gives shpool time to finish its restoration replay and send
|
||||
// the actual terminal size (often via an immediate SIGWINCH) before we
|
||||
// render the first TUI frame. Without this, the first frame may be
|
||||
// garbled or rendered at an incorrect size, which disabling incremental
|
||||
// rendering alone cannot fix for the initial frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
@@ -273,10 +290,19 @@ export async function startInteractiveUI(
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false && useAlternateBuffer,
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
@@ -590,26 +616,13 @@ export async function main() {
|
||||
// input showing up in the output.
|
||||
process.stdin.setRawMode(true);
|
||||
|
||||
if (
|
||||
shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(settings),
|
||||
config.getScreenReader(),
|
||||
)
|
||||
) {
|
||||
enterAlternateScreen();
|
||||
disableLineWrapping();
|
||||
|
||||
// Ink will cleanup so there is no need for us to manually cleanup.
|
||||
}
|
||||
|
||||
// This cleanup isn't strictly needed but may help in certain situations.
|
||||
const restoreRawMode = () => {
|
||||
process.on('SIGTERM', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
};
|
||||
process.off('SIGTERM', restoreRawMode);
|
||||
process.on('SIGTERM', restoreRawMode);
|
||||
process.off('SIGINT', restoreRawMode);
|
||||
process.on('SIGINT', restoreRawMode);
|
||||
});
|
||||
process.on('SIGINT', () => {
|
||||
process.stdin.setRawMode(wasRaw);
|
||||
});
|
||||
}
|
||||
|
||||
await setupTerminalAndTheme(config, settings);
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
import ansiEscapes from 'ansi-escapes';
|
||||
import { type LoadedSettings, mergeSettings } from '../config/settings.js';
|
||||
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
|
||||
@@ -92,6 +92,7 @@ import {
|
||||
UIActionsContext,
|
||||
type UIActions,
|
||||
} from './contexts/UIActionsContext.js';
|
||||
import { KeypressProvider } from './contexts/KeypressContext.js';
|
||||
|
||||
// Mock useStdout to capture terminal title writes
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -133,7 +134,6 @@ vi.mock('./hooks/useGeminiStream.js');
|
||||
vi.mock('./hooks/vim.js');
|
||||
vi.mock('./hooks/useFocus.js');
|
||||
vi.mock('./hooks/useBracketedPaste.js');
|
||||
vi.mock('./hooks/useKeypress.js');
|
||||
vi.mock('./hooks/useLoadingIndicator.js');
|
||||
vi.mock('./hooks/useFolderTrust.js');
|
||||
vi.mock('./hooks/useIdeTrustListener.js');
|
||||
@@ -197,7 +197,7 @@ import { useTextBuffer } from './components/shared/text-buffer.js';
|
||||
import { useLogger } from './hooks/useLogger.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { useKeypress } from './hooks/useKeypress.js';
|
||||
import { measureElement } from 'ink';
|
||||
import { useTerminalSize } from './hooks/useTerminalSize.js';
|
||||
import {
|
||||
@@ -232,13 +232,15 @@ describe('AppContainer State Management', () => {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
} = {}) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
<KeypressProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
version={version}
|
||||
initializationResult={initResult}
|
||||
startupWarnings={startupWarnings}
|
||||
resumedSessionData={resumedSessionData}
|
||||
/>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -268,7 +270,6 @@ describe('AppContainer State Management', () => {
|
||||
const mockedUseTextBuffer = useTextBuffer as Mock;
|
||||
const mockedUseLogger = useLogger as Mock;
|
||||
const mockedUseLoadingIndicator = useLoadingIndicator as Mock;
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
|
||||
const mockedUseHookDisplayState = useHookDisplayState as Mock;
|
||||
const mockedUseTerminalTheme = useTerminalTheme as Mock;
|
||||
@@ -1770,47 +1771,36 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Keyboard Input Handling (CTRL+C / CTRL+D)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let mockHandleSlashCommand: Mock;
|
||||
let mockCancelOngoingRequest: Mock;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
// Helper function to reduce boilerplate in tests
|
||||
const setupKeypressTest = async () => {
|
||||
const renderResult = renderAppContainer();
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () => renderResult.rerender(getAppContainer());
|
||||
rerender = () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
};
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
const pressKey = (key: Partial<Key>, times = 1) => {
|
||||
const pressKey = (sequence: string, times = 1) => {
|
||||
for (let i = 0; i < times; i++) {
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'c',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
stdin.write(sequence);
|
||||
});
|
||||
rerender();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Capture the keypress handler from the AppContainer
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock slash command handler
|
||||
mockHandleSlashCommand = vi.fn();
|
||||
mockedUseSlashCommandProcessor.mockReturnValue({
|
||||
@@ -1855,7 +1845,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
@@ -1865,7 +1855,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true }, 2);
|
||||
pressKey('\x03', 2); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
@@ -1880,7 +1870,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1888,7 +1878,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'c', ctrl: true });
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1898,7 +1888,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should quit on second press if buffer is empty', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true }, 2);
|
||||
pressKey('\x04', 2); // Ctrl+D
|
||||
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1909,7 +1899,7 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should NOT quit if buffer is not empty (bubbles from InputPrompt)', async () => {
|
||||
it('should NOT quit if buffer is not empty', async () => {
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'some text',
|
||||
setText: vi.fn(),
|
||||
@@ -1919,30 +1909,12 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
// Capture return value
|
||||
let result = true;
|
||||
const originalPressKey = (key: Partial<Key>) => {
|
||||
act(() => {
|
||||
result = handleGlobalKeypress({
|
||||
name: 'd',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
...key,
|
||||
} as Key);
|
||||
});
|
||||
rerender();
|
||||
};
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
|
||||
// AppContainer's handler should return true if it reaches it
|
||||
expect(result).toBe(true);
|
||||
// But it should only be called once, so count is 1, not quitting yet.
|
||||
// Should only be called once, so count is 1, not quitting yet.
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
originalPressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
// Now count is 2, it should quit.
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
@@ -1956,7 +1928,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should reset press count after a timeout', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timer past the reset threshold
|
||||
@@ -1964,7 +1936,7 @@ describe('AppContainer State Management', () => {
|
||||
vi.advanceTimersByTime(WARNING_PROMPT_DURATION_MS + 1);
|
||||
});
|
||||
|
||||
pressKey({ name: 'd', ctrl: true });
|
||||
pressKey('\x04'); // Ctrl+D
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
@@ -1982,7 +1954,7 @@ describe('AppContainer State Management', () => {
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
@@ -1992,11 +1964,11 @@ describe('AppContainer State Management', () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
pressKey('\x1b[Z');
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
@@ -2015,13 +1987,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
renderResult.stdin.write('\t');
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
@@ -2056,7 +2022,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
pressKey('\t');
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
@@ -2084,7 +2050,7 @@ describe('AppContainer State Management', () => {
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2113,7 +2079,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
pressKey('\x02');
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
@@ -2126,11 +2092,14 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
let handleGlobalKeypress: (key: Key) => boolean;
|
||||
let rerender: () => void;
|
||||
let unmount: () => void;
|
||||
let stdin: ReturnType<typeof render>['stdin'];
|
||||
|
||||
const setupCopyModeTest = async (isAlternateMode = false) => {
|
||||
const setupCopyModeTest = async (
|
||||
isAlternateMode = false,
|
||||
childHandler?: Mock,
|
||||
) => {
|
||||
// Update settings for this test run
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
const testSettings = {
|
||||
@@ -2144,23 +2113,39 @@ describe('AppContainer State Management', () => {
|
||||
},
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
const renderResult = renderAppContainer({ settings: testSettings });
|
||||
function TestChild() {
|
||||
useKeypress(childHandler || (() => {}), {
|
||||
isActive: !!childHandler,
|
||||
priority: true,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTree = (settings: LoadedSettings) => (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider config={mockConfig}>
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>
|
||||
<TestChild />
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
const renderResult = render(getTree(testSettings));
|
||||
stdin = renderResult.stdin;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
rerender = () =>
|
||||
renderResult.rerender(getAppContainer({ settings: testSettings }));
|
||||
rerender = () => renderResult.rerender(getTree(testSettings));
|
||||
unmount = renderResult.unmount;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.mockStdout.write.mockClear();
|
||||
mockedUseKeypress.mockImplementation(
|
||||
(callback: (key: Key) => boolean) => {
|
||||
handleGlobalKeypress = callback;
|
||||
},
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -2186,15 +2171,7 @@ describe('AppContainer State Management', () => {
|
||||
mocks.mockStdout.write.mockClear(); // Clear initial enable call
|
||||
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2213,30 +2190,14 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Turn it on (disable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// Turn it off (enable mouse)
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'any', // Any key should exit copy mode
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a'); // Any key should exit copy mode
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2249,15 +2210,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Enter copy mode
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 's',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: true,
|
||||
cmd: false,
|
||||
insertable: false,
|
||||
sequence: '\x13',
|
||||
});
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2265,15 +2218,7 @@ describe('AppContainer State Management', () => {
|
||||
|
||||
// Press any other key
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'a',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
insertable: true,
|
||||
sequence: 'a',
|
||||
});
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
@@ -2281,6 +2226,37 @@ describe('AppContainer State Management', () => {
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should have higher priority than other priority listeners when enabled', async () => {
|
||||
// 1. Initial state with a child component's priority listener (already subscribed)
|
||||
// It should NOT handle Ctrl+S so we can enter copy mode.
|
||||
const childHandler = vi.fn().mockReturnValue(false);
|
||||
await setupCopyModeTest(true, childHandler);
|
||||
|
||||
// 2. Enter copy mode
|
||||
act(() => {
|
||||
stdin.write('\x13'); // Ctrl+S
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 3. Verify we are in copy mode
|
||||
expect(disableMouseEvents).toHaveBeenCalled();
|
||||
|
||||
// 4. Press any key
|
||||
childHandler.mockClear();
|
||||
// Now childHandler should return true for other keys, simulating a greedy listener
|
||||
childHandler.mockReturnValue(true);
|
||||
|
||||
act(() => {
|
||||
stdin.write('a');
|
||||
});
|
||||
rerender();
|
||||
|
||||
// 5. Verify that the exit handler took priority and childHandler was NOT called
|
||||
expect(childHandler).not.toHaveBeenCalled();
|
||||
expect(enableMouseEvents).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
|
||||
import { type InitializationResult } from '../core/initializer.js';
|
||||
import { useFocus } from './hooks/useFocus.js';
|
||||
import { useKeypress, type Key } from './hooks/useKeypress.js';
|
||||
import { KeypressPriority } from './contexts/KeypressContext.js';
|
||||
import { keyMatchers, Command } from './keyMatchers.js';
|
||||
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
|
||||
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
|
||||
@@ -363,7 +364,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
(async () => {
|
||||
// Note: the program will not work if this fails so let errors be
|
||||
// handled by the global catch.
|
||||
await config.initialize();
|
||||
if (!config.isInitialized()) {
|
||||
await config.initialize();
|
||||
}
|
||||
setConfigInitialized(true);
|
||||
startupProfiler.flush(config);
|
||||
|
||||
@@ -1481,13 +1484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const handleGlobalKeypress = useCallback(
|
||||
(key: Key): boolean => {
|
||||
if (copyModeEnabled) {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
// We don't want to process any other keys if we're in copy mode.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Debug log keystrokes if enabled
|
||||
if (settings.merged.general.debugKeystrokeLogging) {
|
||||
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
@@ -1520,28 +1516,15 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
|
||||
if (settings.merged.general.devtools) {
|
||||
void (async () => {
|
||||
try {
|
||||
const { startDevToolsServer } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const url = await startDevToolsServer(config);
|
||||
if (shouldLaunchBrowser()) {
|
||||
try {
|
||||
await openBrowserSecurely(url);
|
||||
} catch (e) {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
debugLogger.warn('Failed to open browser securely:', e);
|
||||
}
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
}
|
||||
} catch (e) {
|
||||
setShowErrorDetails(true);
|
||||
debugLogger.error('Failed to start DevTools server:', e);
|
||||
}
|
||||
const { toggleDevToolsPanel } = await import(
|
||||
'../utils/devtoolsService.js'
|
||||
);
|
||||
await toggleDevToolsPanel(
|
||||
config,
|
||||
showErrorDetails,
|
||||
() => setShowErrorDetails((prev) => !prev),
|
||||
() => setShowErrorDetails(true),
|
||||
);
|
||||
})();
|
||||
} else {
|
||||
setShowErrorDetails((prev) => !prev);
|
||||
@@ -1668,7 +1651,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
copyModeEnabled,
|
||||
isAlternateBuffer,
|
||||
backgroundCurrentShell,
|
||||
toggleBackgroundShell,
|
||||
@@ -1679,11 +1661,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
tabFocusTimeoutRef,
|
||||
showTransientMessage,
|
||||
settings.merged.general.devtools,
|
||||
showErrorDetails,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useKeypress(
|
||||
() => {
|
||||
setCopyModeEnabled(false);
|
||||
enableMouseEvents();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
isActive: copyModeEnabled,
|
||||
// We need to receive keypresses first so they do not bubble to other
|
||||
// handlers.
|
||||
priority: KeypressPriority.Critical,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
if (settings.merged.ui.hideWindowTitle) return;
|
||||
|
||||
@@ -110,6 +110,7 @@ describe('ApiAuthDialog', () => {
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
@@ -21,6 +21,14 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
};
|
||||
|
||||
describe('AskUserDialog', () => {
|
||||
// Ensure keystrokes appear spaced in time to avoid bufferFastReturn
|
||||
// converting Enter into Shift+Enter during synchronous test execution.
|
||||
let mockTime: number;
|
||||
beforeEach(() => {
|
||||
mockTime = 0;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => (mockTime += 50));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -158,6 +166,57 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('supports multi-line input for "Other" option in choice questions', async () => {
|
||||
const authQuestionWithOther: Question[] = [
|
||||
{
|
||||
question: 'Which authentication method?',
|
||||
header: 'Auth',
|
||||
options: [{ label: 'OAuth 2.0', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestionWithOther}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to "Other" option
|
||||
writeKey(stdin, '\x1b[B'); // Down to "Other"
|
||||
|
||||
// Type first line
|
||||
for (const char of 'Line 1') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
// Insert newline using \ + Enter (handled by bufferBackslashEnter)
|
||||
writeKey(stdin, '\\');
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// Type second line
|
||||
for (const char of 'Line 2') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Line 1');
|
||||
expect(lastFrame()).toContain('Line 2');
|
||||
});
|
||||
|
||||
// Press Enter to submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Line 1\nLine 2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ useAlternateBuffer: true, expectedArrows: false },
|
||||
{ useAlternateBuffer: false, expectedArrows: true },
|
||||
@@ -763,7 +822,7 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit empty text', () => {
|
||||
it('submits empty text as unanswered', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
@@ -785,8 +844,9 @@ describe('AskUserDialog', () => {
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// onSubmit should not be called for empty text
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
it('clears text on Ctrl+C', async () => {
|
||||
|
||||
@@ -93,6 +93,7 @@ type AskUserDialogAction =
|
||||
payload: {
|
||||
index: number;
|
||||
answer: string;
|
||||
submit?: boolean;
|
||||
};
|
||||
}
|
||||
| { type: 'SET_EDITING_CUSTOM'; payload: { isEditing: boolean } }
|
||||
@@ -114,7 +115,7 @@ function askUserDialogReducerLogic(
|
||||
|
||||
switch (action.type) {
|
||||
case 'SET_ANSWER': {
|
||||
const { index, answer } = action.payload;
|
||||
const { index, answer, submit } = action.payload;
|
||||
const hasAnswer =
|
||||
answer !== undefined && answer !== null && answer.trim() !== '';
|
||||
const newAnswers = { ...state.answers };
|
||||
@@ -128,6 +129,7 @@ function askUserDialogReducerLogic(
|
||||
return {
|
||||
...state,
|
||||
answers: newAnswers,
|
||||
submitted: submit ? true : state.submitted,
|
||||
};
|
||||
}
|
||||
case 'SET_EDITING_CUSTOM': {
|
||||
@@ -283,8 +285,8 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: initialAnswer,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
});
|
||||
|
||||
const { text: textValue } = buffer;
|
||||
@@ -317,9 +319,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(val: string) => {
|
||||
if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
onAnswer(val.trim());
|
||||
},
|
||||
[onAnswer],
|
||||
);
|
||||
@@ -561,8 +561,8 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
|
||||
const customBuffer = useTextBuffer({
|
||||
initialText: initialCustomText,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 1 },
|
||||
singleLine: true,
|
||||
viewport: { width: Math.max(1, bufferWidth), height: 3 },
|
||||
singleLine: false,
|
||||
});
|
||||
|
||||
const customOptionText = customBuffer.text;
|
||||
@@ -850,9 +850,22 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
buffer={customBuffer}
|
||||
placeholder={placeholder}
|
||||
focus={context.isSelected}
|
||||
onSubmit={() => handleSelect(optionItem)}
|
||||
onSubmit={(val) => {
|
||||
if (question.multiSelect) {
|
||||
const fullAnswer = buildAnswerString(
|
||||
selectedIndices,
|
||||
true,
|
||||
val,
|
||||
);
|
||||
if (fullAnswer) {
|
||||
onAnswer(fullAnswer);
|
||||
}
|
||||
} else if (val.trim()) {
|
||||
onAnswer(val.trim());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isChecked && !question.multiSelect && (
|
||||
{isChecked && !question.multiSelect && !context.isSelected && (
|
||||
<Text color={theme.status.success}> ✓</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1012,21 +1025,27 @@ export const AskUserDialog: React.FC<AskUserDialogProps> = ({
|
||||
(answer: string) => {
|
||||
if (submitted) return;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
|
||||
if (questions.length > 1) {
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
},
|
||||
});
|
||||
goToNextTab();
|
||||
} else {
|
||||
dispatch({ type: 'SUBMIT' });
|
||||
dispatch({
|
||||
type: 'SET_ANSWER',
|
||||
payload: {
|
||||
index: currentQuestionIndex,
|
||||
answer,
|
||||
submit: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[currentQuestionIndex, questions.length, submitted, goToNextTab],
|
||||
[currentQuestionIndex, questions, submitted, goToNextTab],
|
||||
);
|
||||
|
||||
const handleReviewSubmit = useCallback(() => {
|
||||
|
||||
@@ -650,6 +650,19 @@ describe('Composer', () => {
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('hides shortcuts hint when showShortcutsHint setting is false', () => {
|
||||
const uiState = createMockUIState();
|
||||
const settings = createMockSettings({
|
||||
ui: {
|
||||
showShortcutsHint: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState, settings);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
|
||||
const uiState = createMockUIState({
|
||||
customDialog: (
|
||||
|
||||
@@ -133,7 +133,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!hasPendingActionRequired && <ShortcutsHint />}
|
||||
{settings.merged.ui.showShortcutsHint &&
|
||||
!hasPendingActionRequired && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
|
||||
|
||||
@@ -47,6 +47,13 @@ const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
// Advance timers to simulate time passing between keystrokes.
|
||||
// This avoids bufferFastReturn converting Enter to Shift+Enter.
|
||||
if (vi.isFakeTimers()) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe('ExitPlanModeDialog', () => {
|
||||
@@ -234,7 +241,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type feedback
|
||||
for (const char of 'Add tests') {
|
||||
@@ -512,7 +518,6 @@ Implement a comprehensive authentication system with multiple providers.
|
||||
// Navigate to feedback option and start typing
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\x1b[B'); // Down arrow
|
||||
writeKey(stdin, '\r'); // Select to focus input
|
||||
|
||||
// Type some feedback
|
||||
for (const char of 'test') {
|
||||
|
||||
@@ -4296,6 +4296,30 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
it('opens shortcuts help with ? on empty prompt even when showShortcutsHint is false', async () => {
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const settings = createMockSettings({
|
||||
ui: { showShortcutsHint: false },
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
settings,
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write('?');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
|
||||
@@ -54,14 +54,3 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > selects the current process and closes the list when Ctrl+L is pressed in list view 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
@@ -21,7 +21,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -47,7 +47,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -127,7 +127,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Type your feedback... ✓
|
||||
● 3. Type your feedback...
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
@@ -153,7 +153,7 @@ Files to Modify
|
||||
Approves plan and allows tools to run automatically
|
||||
2. Yes, manually accept edits
|
||||
Approves plan but requires confirmation for each tool
|
||||
● 3. Add tests ✓
|
||||
● 3. Add tests
|
||||
|
||||
Enter to submit · Esc to cancel"
|
||||
`;
|
||||
|
||||
@@ -13,6 +13,7 @@ import chalk from 'chalk';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { keyMatchers, Command } from '../../keyMatchers.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
buffer: TextBuffer;
|
||||
@@ -45,7 +46,7 @@ export function TextInput({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.name === 'return' && onSubmit) {
|
||||
if (keyMatchers[Command.SUBMIT](key) && onSubmit) {
|
||||
onSubmit(text);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { debugLogger, type Config } from '@google/gemini-cli-core';
|
||||
import { useStdin } from 'ink';
|
||||
import { MultiMap } from 'mnemonist';
|
||||
import type React from 'react';
|
||||
import {
|
||||
createContext,
|
||||
@@ -26,6 +27,13 @@ export const ESC_TIMEOUT = 50;
|
||||
export const PASTE_TIMEOUT = 30_000;
|
||||
export const FAST_RETURN_TIMEOUT = 30;
|
||||
|
||||
export enum KeypressPriority {
|
||||
Low = -100,
|
||||
Normal = 0,
|
||||
High = 100,
|
||||
Critical = 200,
|
||||
}
|
||||
|
||||
// Parse the key itself
|
||||
const KEY_INFO_MAP: Record<
|
||||
string,
|
||||
@@ -645,7 +653,10 @@ export interface Key {
|
||||
export type KeypressHandler = (key: Key) => boolean | void;
|
||||
|
||||
interface KeypressContextValue {
|
||||
subscribe: (handler: KeypressHandler, priority?: boolean) => void;
|
||||
subscribe: (
|
||||
handler: KeypressHandler,
|
||||
priority?: KeypressPriority | boolean,
|
||||
) => void;
|
||||
unsubscribe: (handler: KeypressHandler) => void;
|
||||
}
|
||||
|
||||
@@ -674,44 +685,75 @@ export function KeypressProvider({
|
||||
}) {
|
||||
const { stdin, setRawMode } = useStdin();
|
||||
|
||||
const prioritySubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const normalSubscribers = useRef<Set<KeypressHandler>>(new Set()).current;
|
||||
const subscribersToPriority = useRef<Map<KeypressHandler, number>>(
|
||||
new Map(),
|
||||
).current;
|
||||
const subscribers = useRef(
|
||||
new MultiMap<number, KeypressHandler>(Set),
|
||||
).current;
|
||||
const sortedPriorities = useRef<number[]>([]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(handler: KeypressHandler, priority = false) => {
|
||||
const set = priority ? prioritySubscribers : normalSubscribers;
|
||||
set.add(handler);
|
||||
(
|
||||
handler: KeypressHandler,
|
||||
priority: KeypressPriority | boolean = KeypressPriority.Normal,
|
||||
) => {
|
||||
const p =
|
||||
typeof priority === 'boolean'
|
||||
? priority
|
||||
? KeypressPriority.High
|
||||
: KeypressPriority.Normal
|
||||
: priority;
|
||||
|
||||
subscribersToPriority.set(handler, p);
|
||||
const hadPriority = subscribers.has(p);
|
||||
subscribers.set(p, handler);
|
||||
|
||||
if (!hadPriority) {
|
||||
// Cache sorted priorities only when a new priority level is added
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers, subscribersToPriority],
|
||||
);
|
||||
|
||||
const unsubscribe = useCallback(
|
||||
(handler: KeypressHandler) => {
|
||||
prioritySubscribers.delete(handler);
|
||||
normalSubscribers.delete(handler);
|
||||
const p = subscribersToPriority.get(handler);
|
||||
if (p !== undefined) {
|
||||
subscribers.remove(p, handler);
|
||||
subscribersToPriority.delete(handler);
|
||||
|
||||
if (!subscribers.has(p)) {
|
||||
// Cache sorted priorities only when a priority level is completely removed
|
||||
sortedPriorities.current = Array.from(subscribers.keys()).sort(
|
||||
(a, b) => b - a,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers, subscribersToPriority],
|
||||
);
|
||||
|
||||
const broadcast = useCallback(
|
||||
(key: Key) => {
|
||||
// Process priority subscribers first, in reverse order (stack behavior: last subscribed is first to handle)
|
||||
const priorityHandlers = Array.from(prioritySubscribers).reverse();
|
||||
for (const handler of priorityHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Use cached sorted priorities to avoid sorting on every keypress
|
||||
for (const p of sortedPriorities.current) {
|
||||
const set = subscribers.get(p);
|
||||
if (!set) continue;
|
||||
|
||||
// Then process normal subscribers, also in reverse order
|
||||
const normalHandlers = Array.from(normalSubscribers).reverse();
|
||||
for (const handler of normalHandlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
// Within a priority level, use stack behavior (last subscribed is first to handle)
|
||||
const handlers = Array.from(set).reverse();
|
||||
for (const handler of handlers) {
|
||||
if (handler(key) === true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[prioritySubscribers, normalSubscribers],
|
||||
[subscribers],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -31,6 +31,7 @@ function consoleMessagesReducer(
|
||||
state: ConsoleMessageItem[],
|
||||
action: Action,
|
||||
): ConsoleMessageItem[] {
|
||||
const MAX_CONSOLE_MESSAGES = 1000;
|
||||
switch (action.type) {
|
||||
case 'ADD_MESSAGES': {
|
||||
const newMessages = [...state];
|
||||
@@ -51,6 +52,12 @@ function consoleMessagesReducer(
|
||||
newMessages.push({ ...queuedMessage, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// Limit the number of messages to prevent memory issues
|
||||
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
|
||||
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
|
||||
}
|
||||
|
||||
return newMessages;
|
||||
}
|
||||
case 'CLEAR':
|
||||
@@ -91,9 +98,17 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
|
||||
useEffect(() => {
|
||||
const handleConsoleLog = (payload: ConsoleLogPayload) => {
|
||||
let content = payload.content;
|
||||
const MAX_CONSOLE_MSG_LENGTH = 10000;
|
||||
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
handleNewMessage({
|
||||
type: payload.type,
|
||||
content: payload.content,
|
||||
content,
|
||||
count: 1,
|
||||
});
|
||||
};
|
||||
@@ -102,10 +117,18 @@ export function useConsoleMessages(): UseConsoleMessagesReturn {
|
||||
isStderr: boolean;
|
||||
chunk: Uint8Array | string;
|
||||
}) => {
|
||||
const content =
|
||||
let content =
|
||||
typeof payload.chunk === 'string'
|
||||
? payload.chunk
|
||||
: new TextDecoder().decode(payload.chunk);
|
||||
|
||||
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
|
||||
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
|
||||
content =
|
||||
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
|
||||
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
|
||||
}
|
||||
|
||||
// It would be nice if we could show stderr as 'warn' but unfortunately
|
||||
// we log non warning info to stderr before the app starts so that would
|
||||
// be misleading.
|
||||
|
||||
@@ -5,8 +5,12 @@
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
|
||||
import { useKeypressContext } from '../contexts/KeypressContext.js';
|
||||
import {
|
||||
useKeypressContext,
|
||||
type KeypressHandler,
|
||||
type Key,
|
||||
type KeypressPriority,
|
||||
} from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
@@ -16,11 +20,14 @@ export type { Key };
|
||||
* @param onKeypress - The callback function to execute on each keypress.
|
||||
* @param options - Options to control the hook's behavior.
|
||||
* @param options.isActive - Whether the hook should be actively listening for input.
|
||||
* @param options.priority - Whether the hook should have priority over normal subscribers.
|
||||
* @param options.priority - Priority level (integer or KeypressPriority enum) or boolean for backward compatibility.
|
||||
*/
|
||||
export function useKeypress(
|
||||
onKeypress: KeypressHandler,
|
||||
{ isActive, priority }: { isActive: boolean; priority?: boolean },
|
||||
{
|
||||
isActive,
|
||||
priority,
|
||||
}: { isActive: boolean; priority?: KeypressPriority | boolean },
|
||||
) {
|
||||
const { subscribe, unsubscribe } = useKeypressContext();
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { getPlainTextLength } from './InlineMarkdownRenderer.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('getPlainTextLength', () => {
|
||||
it.each([
|
||||
['**Primary Go', 12],
|
||||
['*Primary Go', 11],
|
||||
['**Primary Go**', 10],
|
||||
['*Primary Go*', 10],
|
||||
['**', 2],
|
||||
['*', 1],
|
||||
['compile-time**', 14],
|
||||
])(
|
||||
'should measure markdown text length correctly for "%s"',
|
||||
(input, expected) => {
|
||||
expect(getPlainTextLength(input)).toBe(expected);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import stringWidth from 'string-width';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
// Constants for Markdown parsing
|
||||
@@ -171,19 +170,3 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
|
||||
};
|
||||
|
||||
export const RenderInline = React.memo(RenderInlineInternal);
|
||||
|
||||
/**
|
||||
* Utility function to get the plain text length of a string with markdown formatting
|
||||
* This is useful for calculating column widths in tables
|
||||
*/
|
||||
export const getPlainTextLength = (text: string): number => {
|
||||
const cleanText = text
|
||||
.replace(/\*\*(.*?)\*\*/g, '$1')
|
||||
.replace(/\*(.+?)\*/g, '$1')
|
||||
.replace(/_(.*?)_/g, '$1')
|
||||
.replace(/~~(.*?)~~/g, '$1')
|
||||
.replace(/`(.*?)`/g, '$1')
|
||||
.replace(/<u>(.*?)<\/u>/g, '$1')
|
||||
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
|
||||
return stringWidth(cleanText);
|
||||
};
|
||||
|
||||
@@ -314,4 +314,35 @@ describe('TableRenderer', () => {
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'renders correctly when headers are empty but rows have data',
|
||||
headers: [] as string[],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Data 1', 'Data 2'],
|
||||
},
|
||||
{
|
||||
name: 'renders correctly when there are more headers than columns in rows',
|
||||
headers: ['Header 1', 'Header 2', 'Header 3'],
|
||||
rows: [['Data 1', 'Data 2']],
|
||||
expected: ['Header 1', 'Header 2', 'Header 3', 'Data 1', 'Data 2'],
|
||||
},
|
||||
])('$name', ({ headers, rows, expected }) => {
|
||||
const terminalWidth = 50;
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expected.forEach((text) => {
|
||||
expect(output).toContain(text);
|
||||
});
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,7 @@ const MIN_COLUMN_WIDTH = 5;
|
||||
const COLUMN_PADDING = 2;
|
||||
const TABLE_MARGIN = 2;
|
||||
|
||||
const calculateWidths = (text: string) => {
|
||||
const styledChars = toStyledCharacters(text);
|
||||
const calculateWidths = (styledChars: StyledChar[]) => {
|
||||
const contentWidth = styledCharsWidth(styledChars);
|
||||
|
||||
const words: StyledChar[][] = wordBreakStyledChars(styledChars);
|
||||
@@ -60,31 +59,48 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
[headers],
|
||||
);
|
||||
|
||||
const styledHeaders = useMemo(
|
||||
() => cleanedHeaders.map((header) => toStyledCharacters(header)),
|
||||
[cleanedHeaders],
|
||||
);
|
||||
|
||||
const styledRows = useMemo(
|
||||
() => rows.map((row) => row.map((cell) => toStyledCharacters(cell))),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const { wrappedHeaders, wrappedRows, adjustedWidths } = useMemo(() => {
|
||||
const numColumns = styledRows.reduce(
|
||||
(max, row) => Math.max(max, row.length),
|
||||
styledHeaders.length,
|
||||
);
|
||||
|
||||
// --- Define Constraints per Column ---
|
||||
const constraints = cleanedHeaders.map((header, colIndex) => {
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(header);
|
||||
const constraints = Array.from({ length: numColumns }).map(
|
||||
(_, colIndex) => {
|
||||
const headerStyledChars = styledHeaders[colIndex] || [];
|
||||
let { contentWidth: maxContentWidth, maxWordWidth } =
|
||||
calculateWidths(headerStyledChars);
|
||||
|
||||
rows.forEach((row) => {
|
||||
const cell = row[colIndex] || '';
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cell);
|
||||
styledRows.forEach((row) => {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const { contentWidth: cellWidth, maxWordWidth: cellWordWidth } =
|
||||
calculateWidths(cellStyledChars);
|
||||
|
||||
maxContentWidth = Math.max(maxContentWidth, cellWidth);
|
||||
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
|
||||
});
|
||||
maxContentWidth = Math.max(maxContentWidth, cellWidth);
|
||||
maxWordWidth = Math.max(maxWordWidth, cellWordWidth);
|
||||
});
|
||||
|
||||
const minWidth = maxWordWidth;
|
||||
const maxWidth = Math.max(minWidth, maxContentWidth);
|
||||
const minWidth = maxWordWidth;
|
||||
const maxWidth = Math.max(minWidth, maxContentWidth);
|
||||
|
||||
return { minWidth, maxWidth };
|
||||
});
|
||||
return { minWidth, maxWidth };
|
||||
},
|
||||
);
|
||||
|
||||
// --- Calculate Available Space ---
|
||||
// Fixed overhead: borders (n+1) + padding (2n)
|
||||
const fixedOverhead =
|
||||
cleanedHeaders.length + 1 + cleanedHeaders.length * COLUMN_PADDING;
|
||||
const fixedOverhead = numColumns + 1 + numColumns * COLUMN_PADDING;
|
||||
const availableWidth = Math.max(
|
||||
0,
|
||||
terminalWidth - fixedOverhead - TABLE_MARGIN,
|
||||
@@ -136,17 +152,18 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
}
|
||||
|
||||
// --- Pre-wrap and Optimize Widths ---
|
||||
const actualColumnWidths = new Array(cleanedHeaders.length).fill(0);
|
||||
const actualColumnWidths = new Array(numColumns).fill(0);
|
||||
|
||||
const wrapAndProcessRow = (row: string[]) => {
|
||||
const wrapAndProcessRow = (row: StyledChar[][]) => {
|
||||
const rowResult: ProcessedLine[][] = [];
|
||||
row.forEach((cell, colIndex) => {
|
||||
// Ensure we iterate up to numColumns, filling with empty cells if needed
|
||||
for (let colIndex = 0; colIndex < numColumns; colIndex++) {
|
||||
const cellStyledChars = row[colIndex] || [];
|
||||
const allocatedWidth = finalContentWidths[colIndex];
|
||||
const contentWidth = Math.max(1, allocatedWidth);
|
||||
|
||||
const contentStyledChars = toStyledCharacters(cell);
|
||||
const wrappedStyledLines = wrapStyledChars(
|
||||
contentStyledChars,
|
||||
cellStyledChars,
|
||||
contentWidth,
|
||||
);
|
||||
|
||||
@@ -161,19 +178,18 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
width: styledCharsWidth(line),
|
||||
}));
|
||||
rowResult.push(lines);
|
||||
});
|
||||
}
|
||||
return rowResult;
|
||||
};
|
||||
|
||||
const wrappedHeaders = wrapAndProcessRow(cleanedHeaders);
|
||||
const wrappedRows = rows.map((row) => wrapAndProcessRow(row));
|
||||
const wrappedHeaders = wrapAndProcessRow(styledHeaders);
|
||||
const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row));
|
||||
|
||||
// Use the TIGHTEST widths that fit the wrapped content + padding
|
||||
const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING);
|
||||
|
||||
return { wrappedHeaders, wrappedRows, adjustedWidths };
|
||||
}, [cleanedHeaders, rows, terminalWidth]);
|
||||
|
||||
}, [styledHeaders, styledRows, terminalWidth]);
|
||||
// Helper function to render a cell with proper width
|
||||
const renderCell = (
|
||||
content: ProcessedLine,
|
||||
|
||||
@@ -44,6 +44,26 @@ exports[`TableRenderer > 'renders a table with only emojis and …' 1`] = `
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when headers are em…' 1`] = `
|
||||
"
|
||||
┌────────┬────────┐
|
||||
│ │ │
|
||||
├────────┼────────┤
|
||||
│ Data 1 │ Data 2 │
|
||||
└────────┴────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > 'renders correctly when there are more…' 1`] = `
|
||||
"
|
||||
┌──────────┬──────────┬──────────┐
|
||||
│ Header 1 │ Header 2 │ Header 3 │
|
||||
├──────────┼──────────┼──────────┤
|
||||
│ Data 1 │ Data 2 │ │
|
||||
└──────────┴──────────┴──────────┘
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`TableRenderer > handles wrapped bold headers without showing markers 1`] = `
|
||||
"
|
||||
┌─────────────┬───────┬─────────┐
|
||||
|
||||
@@ -70,12 +70,20 @@ vi.mock('./activityLogger.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockShouldLaunchBrowser = vi.hoisted(() => vi.fn(() => true));
|
||||
const mockOpenBrowserSecurely = vi.hoisted(() =>
|
||||
vi.fn(() => Promise.resolve()),
|
||||
);
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
debugLogger: {
|
||||
log: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
},
|
||||
shouldLaunchBrowser: mockShouldLaunchBrowser,
|
||||
openBrowserSecurely: mockOpenBrowserSecurely,
|
||||
}));
|
||||
|
||||
vi.mock('ws', () => ({
|
||||
@@ -92,6 +100,7 @@ vi.mock('gemini-cli-devtools', () => ({
|
||||
import {
|
||||
setupInitialActivityLogger,
|
||||
startDevToolsServer,
|
||||
toggleDevToolsPanel,
|
||||
resetForTesting,
|
||||
} from './devtoolsService.js';
|
||||
|
||||
@@ -426,4 +435,98 @@ describe('devtoolsService', () => {
|
||||
expect(mockAddNetworkTransport).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDevToolsPanel', () => {
|
||||
it('calls toggle (to close) when already open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
const promise = toggleDevToolsPanel(config, true, toggle, setOpen);
|
||||
await promise;
|
||||
|
||||
expect(toggle).toHaveBeenCalledTimes(1);
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT call toggle or setOpen when browser opens successfully', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockResolvedValue(undefined);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls setOpen when browser fails to open', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(true);
|
||||
mockOpenBrowserSecurely.mockRejectedValue(new Error('no browser'));
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when shouldLaunchBrowser returns false', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockShouldLaunchBrowser.mockReturnValue(false);
|
||||
mockDevToolsInstance.start.mockResolvedValue('http://127.0.0.1:25417');
|
||||
mockDevToolsInstance.getPort.mockReturnValue(25417);
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls setOpen when DevTools server fails to start', async () => {
|
||||
const config = createMockConfig();
|
||||
const toggle = vi.fn();
|
||||
const setOpen = vi.fn();
|
||||
|
||||
mockDevToolsInstance.start.mockRejectedValue(new Error('fail'));
|
||||
|
||||
const promise = toggleDevToolsPanel(config, false, toggle, setOpen);
|
||||
|
||||
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
||||
MockWebSocket.instances[0].simulateError();
|
||||
|
||||
await promise;
|
||||
|
||||
expect(toggle).not.toHaveBeenCalled();
|
||||
expect(setOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,6 +210,48 @@ async function startDevToolsServerImpl(config: Config): Promise<string> {
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the F12 key toggle for the DevTools panel.
|
||||
* Starts the DevTools server, attempts to open the browser.
|
||||
* If the panel is already open, it closes it.
|
||||
* If the panel is closed:
|
||||
* - Attempts to open the browser.
|
||||
* - If browser opening is successful, the panel remains closed.
|
||||
* - If browser opening fails or is not possible, the panel is opened.
|
||||
*/
|
||||
export async function toggleDevToolsPanel(
|
||||
config: Config,
|
||||
isOpen: boolean,
|
||||
toggle: () => void,
|
||||
setOpen: () => void,
|
||||
): Promise<void> {
|
||||
if (isOpen) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
|
||||
'@google/gemini-cli-core'
|
||||
);
|
||||
const url = await startDevToolsServer(config);
|
||||
if (shouldLaunchBrowser()) {
|
||||
try {
|
||||
await openBrowserSecurely(url);
|
||||
// Browser opened successfully, don't open drawer.
|
||||
return;
|
||||
} catch (e) {
|
||||
debugLogger.warn('Failed to open browser securely:', e);
|
||||
}
|
||||
}
|
||||
// If we can't launch browser or it failed, open drawer.
|
||||
setOpen();
|
||||
} catch (e) {
|
||||
setOpen();
|
||||
debugLogger.error('Failed to start DevTools server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset module-level state — test only. */
|
||||
export function resetForTesting() {
|
||||
promotionAttempts = 0;
|
||||
|
||||
@@ -363,4 +363,171 @@ Hidden`,
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote agent auth configuration', () => {
|
||||
it('should parse remote agent with apiKey auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: api-key-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
in: header
|
||||
name: X-Custom-Key
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'api-key-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$MY_API_KEY',
|
||||
in: 'header',
|
||||
name: 'X-Custom-Key',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Bearer auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: bearer-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
token: $BEARER_TOKEN
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'bearer-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: '$BEARER_TOKEN',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse remote agent with http Basic auth', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: basic-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: $AUTH_USER
|
||||
password: $AUTH_PASS
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'basic-agent',
|
||||
auth: {
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: '$AUTH_USER',
|
||||
password: '$AUTH_PASS',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error for Bearer auth without token', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-bearer
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Bearer
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Bearer scheme requires "token"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for Basic auth without credentials', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-basic
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: http
|
||||
scheme: Basic
|
||||
username: user
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/Basic scheme requires "username" and "password"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for apiKey auth without key', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: invalid-apikey
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
---
|
||||
`);
|
||||
await expect(parseAgentMarkdown(filePath)).rejects.toThrow(
|
||||
/auth\.key.*Required/,
|
||||
);
|
||||
});
|
||||
|
||||
it('should convert auth config in markdownToAgentDefinition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
name: 'auth-agent',
|
||||
agent_card_url: 'https://example.com/card',
|
||||
auth: {
|
||||
type: 'apiKey' as const,
|
||||
key: '$API_KEY',
|
||||
in: 'header' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const result = markdownToAgentDefinition(markdown);
|
||||
expect(result).toMatchObject({
|
||||
kind: 'remote',
|
||||
name: 'auth-agent',
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
key: '$API_KEY',
|
||||
location: 'header',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse auth with agent_card_requires_auth flag', async () => {
|
||||
const filePath = await writeAgentMarkdown(`---
|
||||
kind: remote
|
||||
name: protected-card-agent
|
||||
agent_card_url: https://example.com/card
|
||||
auth:
|
||||
type: apiKey
|
||||
key: $MY_API_KEY
|
||||
agent_card_requires_auth: true
|
||||
---
|
||||
`);
|
||||
const result = await parseAgentMarkdown(filePath);
|
||||
expect(result[0]).toMatchObject({
|
||||
auth: {
|
||||
type: 'apiKey',
|
||||
agent_card_requires_auth: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DEFAULT_MAX_TURNS,
|
||||
DEFAULT_MAX_TIME_MINUTES,
|
||||
} from './types.js';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { isValidToolName } from '../tools/tool-names.js';
|
||||
import { FRONTMATTER_REGEX } from '../skills/skillLoader.js';
|
||||
import { getErrorMessage } from '../utils/errors.js';
|
||||
@@ -39,11 +40,29 @@ interface FrontmatterLocalAgentDefinition
|
||||
timeout_mins?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http';
|
||||
agent_card_requires_auth?: boolean;
|
||||
// API Key
|
||||
key?: string;
|
||||
in?: 'header' | 'query' | 'cookie';
|
||||
name?: string;
|
||||
// HTTP
|
||||
scheme?: 'Bearer' | 'Basic';
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
interface FrontmatterRemoteAgentDefinition
|
||||
extends FrontmatterBaseAgentDefinition {
|
||||
kind: 'remote';
|
||||
description?: string;
|
||||
agent_card_url: string;
|
||||
auth?: FrontmatterAuthConfig;
|
||||
}
|
||||
|
||||
type FrontmatterAgentDefinition =
|
||||
@@ -95,6 +114,66 @@ const localAgentSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Base fields shared by all auth configs.
|
||||
*/
|
||||
const baseAuthFields = {
|
||||
agent_card_requires_auth: z.boolean().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* API Key auth schema.
|
||||
* Supports sending key in header, query parameter, or cookie.
|
||||
*/
|
||||
const apiKeyAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('apiKey'),
|
||||
key: z.string().min(1, 'API key is required'),
|
||||
in: z.enum(['header', 'query', 'cookie']).optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP auth schema (Bearer or Basic).
|
||||
* Note: Validation for scheme-specific fields is applied in authConfigSchema
|
||||
* since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const httpAuthSchemaBase = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('http'),
|
||||
scheme: z.enum(['Bearer', 'Basic']),
|
||||
token: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined auth schema - discriminated union of all auth types.
|
||||
* Note: We use the base schema for discriminatedUnion, then apply refinements
|
||||
* via superRefine since discriminatedUnion doesn't support refined schemas directly.
|
||||
*/
|
||||
const authConfigSchema = z
|
||||
.discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase])
|
||||
.superRefine((data, ctx) => {
|
||||
// Apply HTTP auth validation after union parsing
|
||||
if (data.type === 'http') {
|
||||
if (data.scheme === 'Bearer' && !data.token) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Bearer scheme requires "token"',
|
||||
path: ['token'],
|
||||
});
|
||||
}
|
||||
if (data.scheme === 'Basic' && (!data.username || !data.password)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Basic scheme requires "username" and "password"',
|
||||
path: data.username ? ['password'] : ['username'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const remoteAgentSchema = z
|
||||
.object({
|
||||
kind: z.literal('remote').optional().default('remote'),
|
||||
@@ -102,6 +181,7 @@ const remoteAgentSchema = z
|
||||
description: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
agent_card_url: z.string().url(),
|
||||
auth: authConfigSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -238,6 +318,76 @@ export async function parseAgentMarkdown(
|
||||
return [agentDef];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts frontmatter auth config to the internal A2AAuthConfig type.
|
||||
* This handles the mapping from snake_case YAML to the internal type structure.
|
||||
*/
|
||||
function convertFrontmatterAuthToConfig(
|
||||
frontmatter: FrontmatterAuthConfig,
|
||||
): A2AAuthConfig {
|
||||
const base = {
|
||||
agent_card_requires_auth: frontmatter.agent_card_requires_auth,
|
||||
};
|
||||
|
||||
switch (frontmatter.type) {
|
||||
case 'apiKey':
|
||||
if (!frontmatter.key) {
|
||||
throw new Error('Internal error: API key missing after validation.');
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'apiKey',
|
||||
key: frontmatter.key,
|
||||
location: frontmatter.in,
|
||||
name: frontmatter.name,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
'Internal error: HTTP scheme missing after validation.',
|
||||
);
|
||||
}
|
||||
switch (frontmatter.scheme) {
|
||||
case 'Bearer':
|
||||
if (!frontmatter.token) {
|
||||
throw new Error(
|
||||
'Internal error: Bearer token missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Bearer',
|
||||
token: frontmatter.token,
|
||||
};
|
||||
case 'Basic':
|
||||
if (!frontmatter.username || !frontmatter.password) {
|
||||
throw new Error(
|
||||
'Internal error: Basic auth credentials missing after validation.',
|
||||
);
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
type: 'http',
|
||||
scheme: 'Basic',
|
||||
username: frontmatter.username,
|
||||
password: frontmatter.password,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.scheme;
|
||||
throw new Error(`Unknown HTTP scheme: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustive: never = frontmatter.type;
|
||||
throw new Error(`Unknown auth type: ${exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||
*
|
||||
@@ -270,6 +420,9 @@ export function markdownToAgentDefinition(
|
||||
description: markdown.description || '(Loading description...)',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
};
|
||||
|
||||
@@ -9,17 +9,33 @@ import type { A2AAuthProvider, A2AAuthProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for A2A authentication providers.
|
||||
* Provides default implementations for optional methods.
|
||||
*/
|
||||
export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
/**
|
||||
* The type of authentication provider.
|
||||
*/
|
||||
abstract readonly type: A2AAuthProviderType;
|
||||
|
||||
/**
|
||||
* Get the HTTP headers to include in requests.
|
||||
* Subclasses must implement this method.
|
||||
*/
|
||||
abstract headers(): Promise<HttpHeaders>;
|
||||
|
||||
private static readonly MAX_AUTH_RETRIES = 2;
|
||||
private authRetryCount = 0;
|
||||
|
||||
/**
|
||||
* Default: retry on 401/403 with fresh headers.
|
||||
* Subclasses with cached tokens must override to force-refresh to avoid infinite retries.
|
||||
* Check if a request should be retried with new headers.
|
||||
*
|
||||
* The default implementation checks for 401/403 status codes and
|
||||
* returns fresh headers for retry. Subclasses can override for
|
||||
* custom retry logic.
|
||||
*
|
||||
* @param _req The original request init
|
||||
* @param res The response from the server
|
||||
* @returns New headers for retry, or undefined if no retry should be made
|
||||
*/
|
||||
async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
@@ -32,10 +48,15 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider {
|
||||
this.authRetryCount++;
|
||||
return this.headers();
|
||||
}
|
||||
// Reset on success
|
||||
// Reset count if not an auth error
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {}
|
||||
/**
|
||||
* Initialize the provider. Override in subclasses that need async setup.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
// Default: no-op
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
resolveAuthValue,
|
||||
needsResolution,
|
||||
maskSensitiveValue,
|
||||
} from './value-resolver.js';
|
||||
|
||||
describe('value-resolver', () => {
|
||||
describe('resolveAuthValue', () => {
|
||||
describe('environment variables', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should resolve environment variable with $ prefix', async () => {
|
||||
vi.stubEnv('TEST_API_KEY', 'secret-key-123');
|
||||
const result = await resolveAuthValue('$TEST_API_KEY');
|
||||
expect(result).toBe('secret-key-123');
|
||||
});
|
||||
|
||||
it('should throw error for unset environment variable', async () => {
|
||||
await expect(resolveAuthValue('$UNSET_VAR_12345')).rejects.toThrow(
|
||||
"Environment variable 'UNSET_VAR_12345' is not set or is empty",
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for empty environment variable', async () => {
|
||||
vi.stubEnv('EMPTY_VAR', '');
|
||||
await expect(resolveAuthValue('$EMPTY_VAR')).rejects.toThrow(
|
||||
"Environment variable 'EMPTY_VAR' is not set or is empty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shell commands', () => {
|
||||
it('should execute shell command with ! prefix', async () => {
|
||||
const result = await resolveAuthValue('!echo hello');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should trim whitespace from command output', async () => {
|
||||
const result = await resolveAuthValue('!echo " hello "');
|
||||
expect(result).toBe('hello');
|
||||
});
|
||||
|
||||
it('should throw error for empty command', async () => {
|
||||
await expect(resolveAuthValue('!')).rejects.toThrow(
|
||||
'Empty command in auth value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for command that returns empty output', async () => {
|
||||
await expect(resolveAuthValue('!echo -n ""')).rejects.toThrow(
|
||||
'returned empty output',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for failed command', async () => {
|
||||
await expect(
|
||||
resolveAuthValue('!nonexistent-command-12345'),
|
||||
).rejects.toThrow(/Command.*failed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('literal values', () => {
|
||||
it('should return literal value as-is', async () => {
|
||||
const result = await resolveAuthValue('literal-api-key');
|
||||
expect(result).toBe('literal-api-key');
|
||||
});
|
||||
|
||||
it('should return empty string as-is', async () => {
|
||||
const result = await resolveAuthValue('');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should not treat values starting with other characters as special', async () => {
|
||||
const result = await resolveAuthValue('api-key-123');
|
||||
expect(result).toBe('api-key-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaped literals', () => {
|
||||
it('should return $ literal when value starts with $$', async () => {
|
||||
const result = await resolveAuthValue('$$LITERAL');
|
||||
expect(result).toBe('$LITERAL');
|
||||
});
|
||||
|
||||
it('should return ! literal when value starts with !!', async () => {
|
||||
const result = await resolveAuthValue('!!not-a-command');
|
||||
expect(result).toBe('!not-a-command');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsResolution', () => {
|
||||
it('should return true for environment variable reference', () => {
|
||||
expect(needsResolution('$ENV_VAR')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for command reference', () => {
|
||||
expect(needsResolution('!command')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for literal value', () => {
|
||||
expect(needsResolution('literal')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(needsResolution('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('maskSensitiveValue', () => {
|
||||
it('should mask value longer than 12 characters', () => {
|
||||
expect(maskSensitiveValue('1234567890abcd')).toBe('12****cd');
|
||||
});
|
||||
|
||||
it('should return **** for short values', () => {
|
||||
expect(maskSensitiveValue('short')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for exactly 12 characters', () => {
|
||||
expect(maskSensitiveValue('123456789012')).toBe('****');
|
||||
});
|
||||
|
||||
it('should return **** for empty string', () => {
|
||||
expect(maskSensitiveValue('')).toBe('****');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { getShellConfiguration, spawnAsync } from '../../utils/shell-utils.js';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* Resolves a value that may be an environment variable reference,
|
||||
* a shell command, or a literal value.
|
||||
*
|
||||
* Supported formats:
|
||||
* - `$ENV_VAR`: Read from environment variable
|
||||
* - `!command`: Execute shell command and use output (trimmed)
|
||||
* - `$$` or `!!`: Escape prefix, returns rest as literal
|
||||
* - Any other string: Use as literal value
|
||||
*
|
||||
* @param value The value to resolve
|
||||
* @returns The resolved value
|
||||
* @throws Error if environment variable is not set or command fails
|
||||
*/
|
||||
export async function resolveAuthValue(value: string): Promise<string> {
|
||||
// Support escaping with double prefix (e.g. $$ or !!).
|
||||
// Strips one prefix char: $$FOO → $FOO, !!cmd → !cmd (literal, not resolved).
|
||||
if (value.startsWith('$$') || value.startsWith('!!')) {
|
||||
return value.slice(1);
|
||||
}
|
||||
|
||||
// Environment variable: $MY_VAR
|
||||
if (value.startsWith('$')) {
|
||||
const envVar = value.slice(1);
|
||||
const resolved = process.env[envVar];
|
||||
if (resolved === undefined || resolved === '') {
|
||||
throw new Error(
|
||||
`Environment variable '${envVar}' is not set or is empty. ` +
|
||||
`Please set it before using this agent.`,
|
||||
);
|
||||
}
|
||||
debugLogger.debug(`[AuthValueResolver] Resolved env var: ${envVar}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// Shell command: !command arg1 arg2
|
||||
if (value.startsWith('!')) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
throw new Error('Empty command in auth value. Expected format: !command');
|
||||
}
|
||||
|
||||
debugLogger.debug(`[AuthValueResolver] Executing command for auth value`);
|
||||
|
||||
const shellConfig = getShellConfiguration();
|
||||
try {
|
||||
const { stdout } = await spawnAsync(
|
||||
shellConfig.executable,
|
||||
[...shellConfig.argsPrefix, command],
|
||||
{
|
||||
signal: AbortSignal.timeout(COMMAND_TIMEOUT_MS),
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
|
||||
const trimmed = stdout.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Command '${command}' returned empty output`);
|
||||
}
|
||||
return trimmed;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
`Command '${command}' timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Literal value - return as-is
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value needs resolution (is an env var or command reference).
|
||||
*/
|
||||
export function needsResolution(value: string): boolean {
|
||||
return value.startsWith('$') || value.startsWith('!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask a sensitive value for logging purposes.
|
||||
* Shows the first and last 2 characters with asterisks in between.
|
||||
*/
|
||||
export function maskSensitiveValue(value: string): string {
|
||||
if (value.length <= 12) {
|
||||
return '****';
|
||||
}
|
||||
return `${value.slice(0, 2)}****${value.slice(-2)}`;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ describe('Fallback Integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT fallback if config is NOT in AUTO mode', () => {
|
||||
it('should fallback for Gemini 3 models even if config is NOT in AUTO mode', () => {
|
||||
// 1. Config is explicitly set to Pro, not Auto
|
||||
vi.spyOn(config, 'getModel').mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('Fallback Integration', () => {
|
||||
// 4. Apply model selection
|
||||
const result = applyModelSelection(config, { model: requestedModel });
|
||||
|
||||
// 5. Expect it to stay on Pro (because single model chain)
|
||||
expect(result.model).toBe(PREVIEW_GEMINI_MODEL);
|
||||
// 5. Expect it to fallback to Flash (because Gemini 3 uses PREVIEW_CHAIN)
|
||||
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,19 @@ describe('policyHelpers', () => {
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-flash');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-pro');
|
||||
});
|
||||
|
||||
it('proactively returns Gemini 2.5 chain if Gemini 3 requested but user lacks access', () => {
|
||||
const config = createMockConfig({
|
||||
getModel: () => 'auto-gemini-3',
|
||||
getHasAccessToPreviewModel: () => false,
|
||||
});
|
||||
const chain = resolvePolicyChain(config);
|
||||
|
||||
// Should downgrade to [Pro 2.5, Flash 2.5]
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]?.model).toBe('gemini-2.5-pro');
|
||||
expect(chain[1]?.model).toBe('gemini-2.5-flash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFallbackPolicyContext', () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
isAutoModel,
|
||||
isGemini3Model,
|
||||
resolveModel,
|
||||
} from '../config/models.js';
|
||||
import type { ModelSelectionResult } from './modelAvailabilityService.js';
|
||||
@@ -46,17 +47,32 @@ export function resolvePolicyChain(
|
||||
const resolvedModel = resolveModel(modelFromConfig);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (isAutoPreferred || isAutoConfigured) {
|
||||
const previewEnabled =
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
previewEnabled,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
} else {
|
||||
// User requested Gemini 3 but has no access. Proactively downgrade
|
||||
// to the stable Gemini 2.5 chain.
|
||||
return getModelPolicyChain({
|
||||
previewEnabled: false,
|
||||
userTier: config.getUserTier(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chain = createSingleModelChain(modelFromConfig);
|
||||
}
|
||||
|
||||
@@ -758,7 +758,7 @@ export class Config {
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.toolOutputMasking = {
|
||||
enabled: params.toolOutputMasking?.enabled ?? false,
|
||||
enabled: params.toolOutputMasking?.enabled ?? true,
|
||||
toolProtectionThreshold:
|
||||
params.toolOutputMasking?.toolProtectionThreshold ??
|
||||
DEFAULT_TOOL_PROTECTION_THRESHOLD,
|
||||
@@ -905,6 +905,10 @@ export class Config {
|
||||
);
|
||||
}
|
||||
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Must only be called once, throws if called again.
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
model: 'gemini-2.5-flash',
|
||||
},
|
||||
},
|
||||
'gemini-3-flash-base': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-3-flash-preview',
|
||||
},
|
||||
},
|
||||
classifier: {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
@@ -151,7 +157,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
'web-search': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {
|
||||
generateContentConfig: {
|
||||
tools: [{ googleSearch: {} }],
|
||||
@@ -159,7 +165,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
'web-fetch': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {
|
||||
generateContentConfig: {
|
||||
tools: [{ urlContext: {} }],
|
||||
@@ -168,25 +174,25 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
// TODO(joshualitt): During cleanup, make modelConfig optional.
|
||||
'web-fetch-fallback': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'loop-detection': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'loop-detection-double-check': {
|
||||
extends: 'base',
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-pro',
|
||||
model: 'gemini-3-pro-preview',
|
||||
},
|
||||
},
|
||||
'llm-edit-fixer': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'next-speaker-checker': {
|
||||
extends: 'gemini-2.5-flash-base',
|
||||
extends: 'gemini-3-flash-base',
|
||||
modelConfig: {},
|
||||
},
|
||||
'chat-compression-3-pro': {
|
||||
@@ -216,7 +222,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
'chat-compression-default': {
|
||||
modelConfig: {
|
||||
model: 'gemini-2.5-pro',
|
||||
model: 'gemini-3-pro-preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -519,6 +519,10 @@ exports[`Core System Prompt (prompts.ts) > should append userMemory with separat
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -646,6 +650,10 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -738,6 +746,10 @@ exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator wi
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1299,6 +1311,10 @@ exports[`Core System Prompt (prompts.ts) > should include available_skills with
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1422,6 +1438,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1536,6 +1556,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1650,6 +1674,10 @@ exports[`Core System Prompt (prompts.ts) > should include correct sandbox instru
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1760,6 +1788,10 @@ exports[`Core System Prompt (prompts.ts) > should include planning phase suggest
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -1870,6 +1902,10 @@ exports[`Core System Prompt (prompts.ts) > should include sub-agents in XML for
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2219,6 +2255,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2329,6 +2369,10 @@ exports[`Core System Prompt (prompts.ts) > should return the base prompt when us
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2550,6 +2594,10 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -2660,6 +2708,10 @@ exports[`Core System Prompt (prompts.ts) > should use chatty system prompt for p
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in \`GEMINI.md\` files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { loadApiKey } from './apiKeyCredentialStorage.js';
|
||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||
import { resetVersionCache } from '../utils/version.js';
|
||||
|
||||
vi.mock('../code_assist/codeAssist.js');
|
||||
vi.mock('@google/genai');
|
||||
@@ -35,6 +36,7 @@ const mockConfig = {
|
||||
|
||||
describe('createContentGenerator', () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -951,4 +951,107 @@ name = "invalid-name"
|
||||
|
||||
vi.doUnmock('node:fs/promises');
|
||||
});
|
||||
|
||||
it('should allow overriding Plan Mode deny with user policy', async () => {
|
||||
const actualFs =
|
||||
await vi.importActual<typeof import('node:fs/promises')>(
|
||||
'node:fs/promises',
|
||||
);
|
||||
|
||||
const mockReaddir = vi.fn(
|
||||
async (
|
||||
path: string | Buffer | URL,
|
||||
options?: Parameters<typeof actualFs.readdir>[1],
|
||||
) => {
|
||||
const normalizedPath = nodePath.normalize(path.toString());
|
||||
if (normalizedPath.includes(nodePath.normalize('.gemini/policies'))) {
|
||||
return [
|
||||
{
|
||||
name: 'user-plan.toml',
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
},
|
||||
] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>;
|
||||
}
|
||||
return actualFs.readdir(
|
||||
path,
|
||||
options as Parameters<typeof actualFs.readdir>[1],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const mockReadFile = vi.fn(
|
||||
async (
|
||||
path: Parameters<typeof actualFs.readFile>[0],
|
||||
options: Parameters<typeof actualFs.readFile>[1],
|
||||
) => {
|
||||
const normalizedPath = nodePath.normalize(path.toString());
|
||||
if (normalizedPath.includes('user-plan.toml')) {
|
||||
return `
|
||||
[[rule]]
|
||||
toolName = "run_shell_command"
|
||||
commandPrefix = ["git status", "git diff"]
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
|
||||
[[rule]]
|
||||
toolName = "codebase_investigator"
|
||||
decision = "allow"
|
||||
priority = 100
|
||||
modes = ["plan"]
|
||||
`;
|
||||
}
|
||||
return actualFs.readFile(path, options);
|
||||
},
|
||||
);
|
||||
|
||||
vi.doMock('node:fs/promises', () => ({
|
||||
...actualFs,
|
||||
default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir },
|
||||
readFile: mockReadFile,
|
||||
readdir: mockReaddir,
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
const { createPolicyEngineConfig } = await import('./config.js');
|
||||
|
||||
const settings: PolicySettings = {};
|
||||
const config = await createPolicyEngineConfig(
|
||||
settings,
|
||||
ApprovalMode.PLAN,
|
||||
nodePath.join(__dirname, 'policies'),
|
||||
);
|
||||
|
||||
const shellRules = config.rules?.filter(
|
||||
(r) =>
|
||||
r.toolName === 'run_shell_command' &&
|
||||
r.decision === PolicyDecision.ALLOW &&
|
||||
r.modes?.includes(ApprovalMode.PLAN) &&
|
||||
r.argsPattern,
|
||||
);
|
||||
expect(shellRules).toHaveLength(2);
|
||||
expect(
|
||||
shellRules?.some((r) => r.argsPattern?.test('{"command":"git status"}')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shellRules?.some((r) => r.argsPattern?.test('{"command":"git diff"}')),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shellRules?.every(
|
||||
(r) => !r.argsPattern?.test('{"command":"git commit"}'),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const subagentRule = config.rules?.find(
|
||||
(r) =>
|
||||
r.toolName === 'codebase_investigator' &&
|
||||
r.decision === PolicyDecision.ALLOW &&
|
||||
r.modes?.includes(ApprovalMode.PLAN),
|
||||
);
|
||||
expect(subagentRule).toBeDefined();
|
||||
expect(subagentRule?.priority).toBeCloseTo(2.1, 5);
|
||||
|
||||
vi.doUnmock('node:fs/promises');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
decision = "deny"
|
||||
priority = 60
|
||||
modes = ["plan"]
|
||||
deny_message = "You are in Plan Mode - adjust your prompt to only use read and search tools."
|
||||
deny_message = "You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked."
|
||||
|
||||
# Explicitly Allow Read-Only Tools in Plan mode.
|
||||
|
||||
[[rule]]
|
||||
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search"]
|
||||
toolName = ["glob", "grep_search", "list_directory", "read_file", "google_web_search", "activate_skill"]
|
||||
decision = "allow"
|
||||
priority = 70
|
||||
modes = ["plan"]
|
||||
|
||||
@@ -2086,4 +2086,44 @@ describe('PolicyEngine', () => {
|
||||
expect(result.decision).toBe(PolicyDecision.ALLOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Plan Mode', () => {
|
||||
it('should allow activate_skill but deny shell commands in Plan Mode', async () => {
|
||||
const rules: PolicyRule[] = [
|
||||
{
|
||||
decision: PolicyDecision.DENY,
|
||||
priority: 60,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
denyMessage:
|
||||
'You are in Plan Mode with access to read-only tools. Execution of scripts (including those from skills) is blocked.',
|
||||
},
|
||||
{
|
||||
toolName: 'activate_skill',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: 70,
|
||||
modes: [ApprovalMode.PLAN],
|
||||
},
|
||||
];
|
||||
|
||||
engine = new PolicyEngine({
|
||||
rules,
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
});
|
||||
|
||||
const skillResult = await engine.check(
|
||||
{ name: 'activate_skill', args: { name: 'test' } },
|
||||
undefined,
|
||||
);
|
||||
expect(skillResult.decision).toBe(PolicyDecision.ALLOW);
|
||||
|
||||
const shellResult = await engine.check(
|
||||
{ name: 'run_shell_command', args: { command: 'ls' } },
|
||||
undefined,
|
||||
);
|
||||
expect(shellResult.decision).toBe(PolicyDecision.DENY);
|
||||
expect(shellResult.rule?.denyMessage).toContain(
|
||||
'Execution of scripts (including those from skills) is blocked',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,10 @@ export function renderCoreMandates(options?: CoreMandatesOptions): string {
|
||||
- **Credential Protection:** Never log, print, or commit secrets, API keys, or sensitive credentials. Rigorously protect \`.env\` files, \`.git\`, and system configuration folders.
|
||||
- **Source Control:** Do not stage or commit changes unless specifically requested by the user.
|
||||
|
||||
## Context Efficiency:
|
||||
- Always scope and limit your searches to avoid context window exhaustion and ensure high-signal results. Use include to target relevant files and strictly limit results using total_max_matches and max_matches_per_file, especially during the research phase.
|
||||
- For broad discovery, use names_only=true or max_matches_per_file=1 to identify files without retrieving their context.
|
||||
|
||||
## Engineering Standards
|
||||
- **Contextual Precedence:** Instructions found in ${formattedFilenames} files are foundational mandates. They take absolute precedence over the general workflows and tool defaults described in this system prompt.
|
||||
- **Conventions & Style:** Rigorously adhere to existing workspace conventions, architectural patterns, and style (naming, formatting, typing, commenting). During the research phase, analyze surrounding files, tests, and configuration to ensure your changes are seamless, idiomatic, and consistent with the local context. Never compromise idiomatic quality or completeness (e.g., proper declarations, type safety, documentation) to minimize tool calls; all supporting changes required by local conventions are part of a surgical update.
|
||||
@@ -428,34 +432,50 @@ ${options.planModeToolsList}
|
||||
- You are restricted to writing files within this directory while in Plan Mode.
|
||||
- Use descriptive filenames: \`feature-name.md\` or \`bugfix-description.md\`
|
||||
|
||||
## Workflow Rules
|
||||
1. Sequential Execution: Complete ONE phase at a time. Do NOT skip ahead or combine phases.
|
||||
2. User Confirmation: Wait for user input/approval before proceeding to the next phase.
|
||||
3. Step Back Protocol: If new information discovered during Exploration or Design invalidates previous assumptions or requirements, you MUST pause, inform the user, and request to return to the appropriate previous phase.
|
||||
|
||||
## Workflow Phases
|
||||
|
||||
**IMPORTANT: Complete ONE phase at a time. Do NOT skip ahead or combine phases. Wait for user input before proceeding to the next phase.**
|
||||
### Phase 1: Requirements
|
||||
- Analyze the user's request to identify core requirements and constraints.
|
||||
- Proactively identify ambiguities, implicit assumptions, and edge cases.
|
||||
- Categorize questions: functional requirements, non-functional constraints (performance, compatibility), and scope boundaries.
|
||||
- Use the ${formatToolName(ASK_USER_TOOL_NAME)} tool with well-structured options to clarify ambiguities. Prefer providing multiple-choice options for the user to select from when possible.
|
||||
|
||||
### Phase 1: Requirements Understanding
|
||||
- Analyze the user's request to identify core requirements and constraints
|
||||
- If critical information is missing or ambiguous, ask clarifying questions using the ${formatToolName(ASK_USER_TOOL_NAME)} tool
|
||||
- When using ${formatToolName(ASK_USER_TOOL_NAME)}, prefer providing multiple-choice options for the user to select from when possible
|
||||
- Do NOT explore the project or create a plan yet
|
||||
### Phase 2: Exploration
|
||||
- Only begin this phase after requirements are clear.
|
||||
- Use the available read-only tools to explore the project.
|
||||
- Map relevant code paths, dependencies, and architectural patterns.
|
||||
- Identify existing utilities, patterns, and abstractions that can be reused.
|
||||
- Note potential constraints (e.g., existing conventions, test infrastructure).
|
||||
- Output: Summarize key findings to the user before proceeding to design.
|
||||
|
||||
### Phase 2: Project Exploration
|
||||
- Only begin this phase after requirements are clear
|
||||
- Use the available read-only tools to explore the project
|
||||
- Identify existing patterns, conventions, and architectural decisions
|
||||
### Phase 3: Design
|
||||
- Only begin this phase after exploration is complete.
|
||||
- **Identify Approaches:**
|
||||
- For Complex Tasks: Identify at least 2 viable implementation approaches. Document the approach summary, pros, cons, complexity estimate, and risk factors for each.
|
||||
- For Canonical Tasks: If there is only one reasonable, standard approach (e.g., a standard library pattern or specific bug fix), detail it and explicitly explain why no other viable alternatives were considered.
|
||||
- Mandatory User Interaction: Present the analysis to the user via ${formatToolName(ASK_USER_TOOL_NAME)} and recommend a preferred approach.
|
||||
- Wait for Selection: You MUST pause and wait for the user to select an approach before proceeding. Do NOT assume the user will agree with your recommendation.
|
||||
|
||||
### Phase 3: Design & Planning
|
||||
- Only begin this phase after exploration is complete
|
||||
- Create a detailed implementation plan with clear steps
|
||||
- The plan MUST include:
|
||||
- Iterative development steps (e.g., "Implement X, then verify with test Y")
|
||||
- Specific verification steps (unit tests, manual checks, build commands)
|
||||
- File paths, function signatures, and code snippets where helpful
|
||||
- Save the implementation plan to the designated plans directory
|
||||
### Phase 4: Planning
|
||||
- Pre-requisite: You MUST have a user-selected approach from Phase 3 before generating the plan.
|
||||
- Create a detailed implementation plan and save it to the designated plans directory.
|
||||
- **Document Structure:** The plan MUST be a structured Markdown document (focused on implementation guidance, not workflow logging) using exactly these H2 headings:
|
||||
- \`## Problem Statement\` - Describe the problem or need this change addresses.
|
||||
- \`## Proposed Solution\` - Provide technical details of the implementation.
|
||||
- \`## Implementation Plan\` - List ordered steps with specific file paths and the nature of each change.
|
||||
- \`## Verification Plan\` - Define specific tests or manual steps to verify the change works and breaks nothing else.
|
||||
- \`## Risks & Mitigations\` - Identify potential failure modes and mitigation strategies.
|
||||
- \`## Alternatives Considered\` - Provide a brief analysis of other approaches considered and why they were rejected.
|
||||
|
||||
### Phase 4: Review & Approval
|
||||
### Phase 5: Approval
|
||||
- Present the plan and request approval for the finalized plan using the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool
|
||||
- If plan is approved, you can begin implementation
|
||||
- If plan is rejected, address the feedback and iterate on the plan
|
||||
- If plan is approved, you can begin implementation.
|
||||
- If plan is rejected, address the feedback and iterate on the plan.
|
||||
|
||||
${renderApprovedPlanSection(options.approvedPlanPath)}
|
||||
|
||||
|
||||
@@ -46,9 +46,6 @@ describe('sanitizeEnvironment', () => {
|
||||
CLIENT_ID: 'sensitive-id',
|
||||
DB_URI: 'sensitive-uri',
|
||||
DATABASE_URL: 'sensitive-url',
|
||||
GEMINI_API_KEY: 'sensitive-gemini-key',
|
||||
GOOGLE_API_KEY: 'sensitive-google-key',
|
||||
GOOGLE_APPLICATION_CREDENTIALS: '/path/to/creds.json',
|
||||
SAFE_VAR: 'is-safe',
|
||||
};
|
||||
const sanitized = sanitizeEnvironment(env, EMPTY_OPTIONS);
|
||||
|
||||
@@ -103,9 +103,6 @@ export const NEVER_ALLOWED_ENVIRONMENT_VARIABLES: ReadonlySet<string> = new Set(
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_ACCOUNT',
|
||||
'FIREBASE_PROJECT_ID',
|
||||
'GEMINI_API_KEY',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -104,6 +104,13 @@
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
@@ -153,7 +160,7 @@
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -165,7 +172,7 @@
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -177,35 +184,35 @@
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
@@ -232,7 +239,7 @@
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,13 @@
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-base": {
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"classifier": {
|
||||
"model": "gemini-2.5-flash-lite",
|
||||
"generateContentConfig": {
|
||||
@@ -153,7 +160,7 @@
|
||||
}
|
||||
},
|
||||
"web-search": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -165,7 +172,7 @@
|
||||
}
|
||||
},
|
||||
"web-fetch": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1,
|
||||
@@ -177,35 +184,35 @@
|
||||
}
|
||||
},
|
||||
"web-fetch-fallback": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"loop-detection-double-check": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"llm-edit-fixer": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
}
|
||||
},
|
||||
"next-speaker-checker": {
|
||||
"model": "gemini-2.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"generateContentConfig": {
|
||||
"temperature": 0,
|
||||
"topP": 1
|
||||
@@ -232,7 +239,7 @@
|
||||
"generateContentConfig": {}
|
||||
},
|
||||
"chat-compression-default": {
|
||||
"model": "gemini-2.5-pro",
|
||||
"model": "gemini-3-pro-preview",
|
||||
"generateContentConfig": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,11 @@ export class AskUserInvocation extends BaseToolInvocation<
|
||||
.map(([index, answer]) => {
|
||||
const question = this.params.questions[parseInt(index, 10)];
|
||||
const category = question?.header ?? `Q${index}`;
|
||||
return ` ${category} → ${answer}`;
|
||||
const prefix = ` ${category} → `;
|
||||
const indent = ' '.repeat(prefix.length);
|
||||
|
||||
const lines = answer.split('\n');
|
||||
return prefix + lines.join('\n' + indent);
|
||||
})
|
||||
.join('\n')}`
|
||||
: 'User submitted without answering questions.';
|
||||
|
||||
+36
@@ -45,14 +45,32 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps
|
||||
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.",
|
||||
"type": "string",
|
||||
},
|
||||
"exclude_pattern": {
|
||||
"description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.",
|
||||
"type": "string",
|
||||
},
|
||||
"include": {
|
||||
"description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
"type": "string",
|
||||
},
|
||||
"max_matches_per_file": {
|
||||
"description": "Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.",
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"names_only": {
|
||||
"description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.",
|
||||
"type": "boolean",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
"type": "string",
|
||||
},
|
||||
"total_max_matches": {
|
||||
"description": "Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.",
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"pattern",
|
||||
@@ -244,14 +262,32 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview >
|
||||
"description": "Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.",
|
||||
"type": "string",
|
||||
},
|
||||
"exclude_pattern": {
|
||||
"description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.",
|
||||
"type": "string",
|
||||
},
|
||||
"include": {
|
||||
"description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).",
|
||||
"type": "string",
|
||||
},
|
||||
"max_matches_per_file": {
|
||||
"description": "Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.",
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
"names_only": {
|
||||
"description": "Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.",
|
||||
"type": "boolean",
|
||||
},
|
||||
"pattern": {
|
||||
"description": "The regular expression (regex) pattern to search for within file contents (e.g., 'function\\s+myFunction', 'import\\s+\\{.*\\}\\s+from\\s+.*').",
|
||||
"type": "string",
|
||||
},
|
||||
"total_max_matches": {
|
||||
"description": "Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.",
|
||||
"minimum": 1,
|
||||
"type": "integer",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"pattern",
|
||||
|
||||
@@ -98,6 +98,28 @@ export const GREP_DEFINITION: ToolDefinition = {
|
||||
description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`,
|
||||
type: 'string',
|
||||
},
|
||||
exclude_pattern: {
|
||||
description:
|
||||
'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
names_only: {
|
||||
description:
|
||||
'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.',
|
||||
type: 'boolean',
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
},
|
||||
|
||||
@@ -458,6 +458,81 @@ describe('GrepTool', () => {
|
||||
// Clean up
|
||||
await fs.rm(secondDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should respect total_max_matches and truncate results', async () => {
|
||||
// Use 'world' pattern which has 3 matches across fileA.txt and sub/fileC.txt
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'world',
|
||||
total_max_matches: 2,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'results limited to 2 matches for performance',
|
||||
);
|
||||
// It should find matches in fileA.txt first (2 matches)
|
||||
expect(result.llmContent).toContain('File: fileA.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).toContain('L2: second line with world');
|
||||
// And sub/fileC.txt should be excluded because limit reached
|
||||
expect(result.llmContent).not.toContain('File: sub/fileC.txt');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
|
||||
});
|
||||
|
||||
it('should respect max_matches_per_file in JS fallback', async () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'world',
|
||||
max_matches_per_file: 1,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
// fileA.txt has 2 worlds, but should only return 1.
|
||||
// sub/fileC.txt has 1 world, so total matches = 2.
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain('File: fileA.txt');
|
||||
expect(result.llmContent).toContain('L1: hello world');
|
||||
expect(result.llmContent).not.toContain('L2: second line with world');
|
||||
expect(result.llmContent).toContain('File: sub/fileC.txt');
|
||||
expect(result.llmContent).toContain('L1: another world in sub dir');
|
||||
});
|
||||
|
||||
it('should return only file paths when names_only is true', async () => {
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'world',
|
||||
names_only: true,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 files with matches');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('sub/fileC.txt');
|
||||
expect(result.llmContent).not.toContain('L1:');
|
||||
expect(result.llmContent).not.toContain('hello world');
|
||||
});
|
||||
|
||||
it('should filter out matches based on exclude_pattern', async () => {
|
||||
await fs.writeFile(
|
||||
path.join(tempRootDir, 'copyright.txt'),
|
||||
'Copyright 2025 Google LLC\nCopyright 2026 Google LLC',
|
||||
);
|
||||
|
||||
const params: GrepToolParams = {
|
||||
pattern: 'Copyright .* Google LLC',
|
||||
exclude_pattern: '2026',
|
||||
dir_path: '.',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('copyright.txt');
|
||||
expect(result.llmContent).toContain('Copyright 2025 Google LLC');
|
||||
expect(result.llmContent).not.toContain('Copyright 2026 Google LLC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDescription', () => {
|
||||
|
||||
@@ -48,6 +48,26 @@ export interface GrepToolParams {
|
||||
* File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")
|
||||
*/
|
||||
include?: string;
|
||||
|
||||
/**
|
||||
* Optional: A regular expression pattern to exclude from the search results.
|
||||
*/
|
||||
exclude_pattern?: string;
|
||||
|
||||
/**
|
||||
* Optional: If true, only the file paths of the matches will be returned.
|
||||
*/
|
||||
names_only?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.
|
||||
*/
|
||||
max_matches_per_file?: number;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.
|
||||
*/
|
||||
total_max_matches?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,7 +209,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
// Collect matches from all search directories
|
||||
let allMatches: GrepMatch[] = [];
|
||||
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
|
||||
const totalMaxMatches =
|
||||
this.params.total_max_matches ?? DEFAULT_TOTAL_MAX_MATCHES;
|
||||
|
||||
// Create a timeout controller to prevent indefinitely hanging searches
|
||||
const timeoutController = new AbortController();
|
||||
@@ -214,7 +235,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: this.params.pattern,
|
||||
path: searchDir,
|
||||
include: this.params.include,
|
||||
exclude_pattern: this.params.exclude_pattern,
|
||||
maxMatches: remainingLimit,
|
||||
max_matches_per_file: this.params.max_matches_per_file,
|
||||
signal: timeoutController.signal,
|
||||
});
|
||||
|
||||
@@ -268,6 +291,16 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
const matchCount = allMatches.length;
|
||||
const matchTerm = matchCount === 1 ? 'match' : 'matches';
|
||||
|
||||
if (this.params.names_only) {
|
||||
const filePaths = Object.keys(matchesByFile).sort();
|
||||
let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`;
|
||||
llmContent += filePaths.join('\n');
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`;
|
||||
|
||||
if (wasTruncated) {
|
||||
@@ -342,13 +375,27 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: string;
|
||||
path: string; // Expects absolute path
|
||||
include?: string;
|
||||
exclude_pattern?: string;
|
||||
maxMatches: number;
|
||||
max_matches_per_file?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const { pattern, path: absolutePath, include, maxMatches } = options;
|
||||
const {
|
||||
pattern,
|
||||
path: absolutePath,
|
||||
include,
|
||||
exclude_pattern,
|
||||
maxMatches,
|
||||
max_matches_per_file,
|
||||
} = options;
|
||||
let strategyUsed = 'none';
|
||||
|
||||
try {
|
||||
let excludeRegex: RegExp | null = null;
|
||||
if (exclude_pattern) {
|
||||
excludeRegex = new RegExp(exclude_pattern, 'i');
|
||||
}
|
||||
|
||||
// --- Strategy 1: git grep ---
|
||||
const isGit = isGitRepository(absolutePath);
|
||||
const gitAvailable = isGit && (await this.isCommandAvailable('git'));
|
||||
@@ -363,6 +410,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
'--ignore-case',
|
||||
pattern,
|
||||
];
|
||||
if (max_matches_per_file) {
|
||||
gitArgs.push('--max-count', max_matches_per_file.toString());
|
||||
}
|
||||
if (include) {
|
||||
gitArgs.push('--', include);
|
||||
}
|
||||
@@ -378,6 +428,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
for await (const line of generator) {
|
||||
const match = this.parseGrepLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
results.push(match);
|
||||
if (results.length >= maxMatches) {
|
||||
break;
|
||||
@@ -425,6 +478,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
})
|
||||
.filter((dir): dir is string => !!dir);
|
||||
commonExcludes.forEach((dir) => grepArgs.push(`--exclude-dir=${dir}`));
|
||||
if (max_matches_per_file) {
|
||||
grepArgs.push('--max-count', max_matches_per_file.toString());
|
||||
}
|
||||
if (include) {
|
||||
grepArgs.push(`--include=${include}`);
|
||||
}
|
||||
@@ -442,6 +498,9 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
for await (const line of generator) {
|
||||
const match = this.parseGrepLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
results.push(match);
|
||||
if (results.length >= maxMatches) {
|
||||
break;
|
||||
@@ -499,9 +558,13 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
try {
|
||||
const content = await fsPromises.readFile(fileAbsolutePath, 'utf8');
|
||||
const lines = content.split(/\r?\n/);
|
||||
let matchesInFile = 0;
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index];
|
||||
if (regex.test(line)) {
|
||||
if (excludeRegex && excludeRegex.test(line)) {
|
||||
continue;
|
||||
}
|
||||
allMatches.push({
|
||||
filePath:
|
||||
path.relative(absolutePath, fileAbsolutePath) ||
|
||||
@@ -509,7 +572,14 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
lineNumber: index + 1,
|
||||
line,
|
||||
});
|
||||
matchesInFile++;
|
||||
if (allMatches.length >= maxMatches) break;
|
||||
if (
|
||||
max_matches_per_file &&
|
||||
matchesInFile >= max_matches_per_file
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (readError: unknown) {
|
||||
@@ -604,6 +674,28 @@ export class GrepTool extends BaseDeclarativeTool<GrepToolParams, ToolResult> {
|
||||
return `Invalid regular expression pattern provided: ${params.pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
|
||||
if (params.exclude_pattern) {
|
||||
try {
|
||||
new RegExp(params.exclude_pattern);
|
||||
} catch (error) {
|
||||
return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.max_matches_per_file !== undefined &&
|
||||
params.max_matches_per_file < 1
|
||||
) {
|
||||
return 'max_matches_per_file must be at least 1.';
|
||||
}
|
||||
|
||||
if (
|
||||
params.total_max_matches !== undefined &&
|
||||
params.total_max_matches < 1
|
||||
) {
|
||||
return 'total_max_matches must be at least 1.';
|
||||
}
|
||||
|
||||
// Only validate dir_path if one is provided
|
||||
if (params.dir_path) {
|
||||
const resolvedPath = path.resolve(
|
||||
|
||||
@@ -103,6 +103,43 @@ describe('McpClientManager', () => {
|
||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||
});
|
||||
|
||||
it('should mark discovery completed when all configured servers are user-disabled', async () => {
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'test-server': {},
|
||||
});
|
||||
mockConfig.getMcpEnablementCallbacks.mockReturnValue({
|
||||
isSessionDisabled: vi.fn().mockReturnValue(false),
|
||||
isFileEnabled: vi.fn().mockResolvedValue(false),
|
||||
});
|
||||
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
const promise = manager.startConfiguredMcpServers();
|
||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
|
||||
await promise;
|
||||
|
||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||
expect(manager.getMcpServerCount()).toBe(0);
|
||||
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
|
||||
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should mark discovery completed when all configured servers are blocked', async () => {
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'test-server': {},
|
||||
});
|
||||
mockConfig.getBlockedMcpServers.mockReturnValue(['test-server']);
|
||||
|
||||
const manager = new McpClientManager('0.0.1', toolRegistry, mockConfig);
|
||||
const promise = manager.startConfiguredMcpServers();
|
||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.IN_PROGRESS);
|
||||
await promise;
|
||||
|
||||
expect(manager.getDiscoveryState()).toBe(MCPDiscoveryState.COMPLETED);
|
||||
expect(manager.getMcpServerCount()).toBe(0);
|
||||
expect(mockedMcpClient.connect).not.toHaveBeenCalled();
|
||||
expect(mockedMcpClient.discover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not discover tools if folder is not trusted', async () => {
|
||||
mockConfig.getMcpServers.mockReturnValue({
|
||||
'test-server': {},
|
||||
|
||||
@@ -337,6 +337,15 @@ export class McpClientManager {
|
||||
this.maybeDiscoverMcpServer(name, config),
|
||||
),
|
||||
);
|
||||
|
||||
// If every configured server was skipped (for example because all are
|
||||
// disabled by user settings), no discovery promise is created. In that
|
||||
// case we must still mark discovery complete or the UI will wait forever.
|
||||
if (this.discoveryState === MCPDiscoveryState.IN_PROGRESS) {
|
||||
this.discoveryState = MCPDiscoveryState.COMPLETED;
|
||||
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||
}
|
||||
|
||||
await this.cliConfig.refreshMcpContext();
|
||||
}
|
||||
|
||||
|
||||
@@ -1623,7 +1623,7 @@ describe('mcp-client', () => {
|
||||
{
|
||||
command: 'test-command',
|
||||
args: ['--foo', 'bar'],
|
||||
env: { GEMINI_CLI_FOO: 'bar' },
|
||||
env: { FOO: 'bar' },
|
||||
cwd: 'test/cwd',
|
||||
},
|
||||
false,
|
||||
@@ -1634,80 +1634,11 @@ describe('mcp-client', () => {
|
||||
command: 'test-command',
|
||||
args: ['--foo', 'bar'],
|
||||
cwd: 'test/cwd',
|
||||
env: expect.objectContaining({ GEMINI_CLI_FOO: 'bar' }),
|
||||
env: expect.objectContaining({ FOO: 'bar' }),
|
||||
stderr: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
it('should redact sensitive environment variables for command transport', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
const originalEnv = process.env;
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
GEMINI_API_KEY: 'sensitive-key',
|
||||
GEMINI_CLI_SAFE_VAR: 'safe-value',
|
||||
};
|
||||
// Ensure strict sanitization is not triggered for this test
|
||||
delete process.env['GITHUB_SHA'];
|
||||
delete process.env['SURFACE'];
|
||||
|
||||
try {
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_SAFE_VAR']).toBe('safe-value');
|
||||
expect(callArgs.env!['GEMINI_API_KEY']).toBeUndefined();
|
||||
} finally {
|
||||
process.env = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it('should include extension settings in environment', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
.mockReturnValue({} as SdkClientStdioLib.StdioClientTransport);
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
{
|
||||
command: 'test-command',
|
||||
extension: {
|
||||
name: 'test-ext',
|
||||
resolvedSettings: [
|
||||
{
|
||||
envVar: 'GEMINI_CLI_EXT_VAR',
|
||||
value: 'ext-value',
|
||||
sensitive: false,
|
||||
name: 'ext-setting',
|
||||
},
|
||||
],
|
||||
version: '',
|
||||
isActive: false,
|
||||
path: '',
|
||||
contextFiles: [],
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
false,
|
||||
EMPTY_CONFIG,
|
||||
);
|
||||
|
||||
const callArgs = mockedTransport.mock.calls[0][0];
|
||||
expect(callArgs.env).toBeDefined();
|
||||
expect(callArgs.env!['GEMINI_CLI_EXT_VAR']).toBe('ext-value');
|
||||
});
|
||||
|
||||
it('should exclude extension settings with undefined values from environment', async () => {
|
||||
const mockedTransport = vi
|
||||
.spyOn(SdkClientStdioLib, 'StdioClientTransport')
|
||||
|
||||
@@ -34,11 +34,7 @@ import {
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { ApprovalMode, PolicyDecision } from '../policy/types.js';
|
||||
import { parse } from 'shell-quote';
|
||||
import type {
|
||||
Config,
|
||||
GeminiCLIExtension,
|
||||
MCPServerConfig,
|
||||
} from '../config/config.js';
|
||||
import type { Config, MCPServerConfig } from '../config/config.js';
|
||||
import { AuthProviderType } from '../config/config.js';
|
||||
import { GoogleCredentialProvider } from '../mcp/google-auth-provider.js';
|
||||
import { ServiceAccountImpersonationProvider } from '../mcp/sa-impersonation-provider.js';
|
||||
@@ -1902,23 +1898,10 @@ export async function createTransport(
|
||||
command: mcpServerConfig.command,
|
||||
args: mcpServerConfig.args || [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
env: sanitizeEnvironment(
|
||||
{
|
||||
...process.env,
|
||||
...getExtensionEnvironment(mcpServerConfig.extension),
|
||||
...(mcpServerConfig.env || {}),
|
||||
},
|
||||
{
|
||||
...sanitizationConfig,
|
||||
allowedEnvironmentVariables: [
|
||||
...(sanitizationConfig.allowedEnvironmentVariables ?? []),
|
||||
...(mcpServerConfig.extension?.resolvedSettings?.map(
|
||||
(s) => s.envVar,
|
||||
) ?? []),
|
||||
],
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
},
|
||||
) as Record<string, string>,
|
||||
env: {
|
||||
...sanitizeEnvironment(process.env, sanitizationConfig),
|
||||
...(mcpServerConfig.env || {}),
|
||||
} as Record<string, string>,
|
||||
cwd: mcpServerConfig.cwd,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
@@ -1993,17 +1976,3 @@ export function isEnabled(
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getExtensionEnvironment(
|
||||
extension?: GeminiCLIExtension,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (extension?.resolvedSettings) {
|
||||
for (const setting of extension.resolvedSettings) {
|
||||
if (setting.value) {
|
||||
env[setting.envVar] = setting.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -1848,6 +1848,168 @@ describe('RipGrepTool', () => {
|
||||
expect(invocation.getDescription()).toContain(path.join('src', 'app'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('new parameters', () => {
|
||||
it('should pass --max-count when max_matches_per_file is provided', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'hello world\n' },
|
||||
},
|
||||
}) + '\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'world',
|
||||
max_matches_per_file: 1,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
await invocation.execute(abortSignal);
|
||||
|
||||
const spawnArgs = mockSpawn.mock.calls[0][1];
|
||||
expect(spawnArgs).toContain('--max-count');
|
||||
expect(spawnArgs).toContain('1');
|
||||
});
|
||||
|
||||
it('should respect total_max_matches and truncate results', async () => {
|
||||
// Return 3 matches, but set total_max_matches to 2
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'match 1\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 2,
|
||||
lines: { text: 'match 2\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 3,
|
||||
lines: { text: 'match 3\n' },
|
||||
},
|
||||
}) +
|
||||
'\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'match',
|
||||
total_max_matches: 2,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 matches');
|
||||
expect(result.llmContent).toContain(
|
||||
'results limited to 2 matches for performance',
|
||||
);
|
||||
expect(result.llmContent).toContain('L1: match 1');
|
||||
expect(result.llmContent).toContain('L2: match 2');
|
||||
expect(result.llmContent).not.toContain('L3: match 3');
|
||||
expect(result.returnDisplay).toBe('Found 2 matches (limited)');
|
||||
});
|
||||
|
||||
it('should return only file paths when names_only is true', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'hello world\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileB.txt' },
|
||||
line_number: 5,
|
||||
lines: { text: 'hello again\n' },
|
||||
},
|
||||
}) +
|
||||
'\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'hello',
|
||||
names_only: true,
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 2 files with matches');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).toContain('fileB.txt');
|
||||
expect(result.llmContent).not.toContain('L1:');
|
||||
expect(result.llmContent).not.toContain('hello world');
|
||||
});
|
||||
|
||||
it('should filter out matches based on exclude_pattern', async () => {
|
||||
mockSpawn.mockImplementationOnce(
|
||||
createMockSpawn({
|
||||
outputData:
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileA.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'Copyright 2025 Google LLC\n' },
|
||||
},
|
||||
}) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
type: 'match',
|
||||
data: {
|
||||
path: { text: 'fileB.txt' },
|
||||
line_number: 1,
|
||||
lines: { text: 'Copyright 2026 Google LLC\n' },
|
||||
},
|
||||
}) +
|
||||
'\n',
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const params: RipGrepToolParams = {
|
||||
pattern: 'Copyright .* Google LLC',
|
||||
exclude_pattern: '2026',
|
||||
};
|
||||
const invocation = grepTool.build(params);
|
||||
const result = await invocation.execute(abortSignal);
|
||||
|
||||
expect(result.llmContent).toContain('Found 1 match');
|
||||
expect(result.llmContent).toContain('fileA.txt');
|
||||
expect(result.llmContent).not.toContain('fileB.txt');
|
||||
expect(result.llmContent).toContain('Copyright 2025 Google LLC');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -102,6 +102,16 @@ export interface RipGrepToolParams {
|
||||
*/
|
||||
include?: string;
|
||||
|
||||
/**
|
||||
* Optional: A regular expression pattern to exclude from the search results.
|
||||
*/
|
||||
exclude_pattern?: string;
|
||||
|
||||
/**
|
||||
* Optional: If true, only the file paths of the matches will be returned.
|
||||
*/
|
||||
names_only?: boolean;
|
||||
|
||||
/**
|
||||
* If true, searches case-sensitively. Defaults to false.
|
||||
*/
|
||||
@@ -131,6 +141,16 @@ export interface RipGrepToolParams {
|
||||
* If true, does not respect .gitignore or default ignores (like build/dist).
|
||||
*/
|
||||
no_ignore?: boolean;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.
|
||||
*/
|
||||
max_matches_per_file?: number;
|
||||
|
||||
/**
|
||||
* Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.
|
||||
*/
|
||||
total_max_matches?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +228,8 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const searchDirDisplay = pathParam;
|
||||
|
||||
const totalMaxMatches = DEFAULT_TOTAL_MAX_MATCHES;
|
||||
const totalMaxMatches =
|
||||
this.params.total_max_matches ?? DEFAULT_TOTAL_MAX_MATCHES;
|
||||
if (this.config.getDebugMode()) {
|
||||
debugLogger.log(`[GrepTool] Total result limit: ${totalMaxMatches}`);
|
||||
}
|
||||
@@ -233,6 +254,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: this.params.pattern,
|
||||
path: searchDirAbs,
|
||||
include: this.params.include,
|
||||
exclude_pattern: this.params.exclude_pattern,
|
||||
case_sensitive: this.params.case_sensitive,
|
||||
fixed_strings: this.params.fixed_strings,
|
||||
context: this.params.context,
|
||||
@@ -240,6 +262,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before: this.params.before,
|
||||
no_ignore: this.params.no_ignore,
|
||||
maxMatches: totalMaxMatches,
|
||||
max_matches_per_file: this.params.max_matches_per_file,
|
||||
signal: timeoutController.signal,
|
||||
});
|
||||
} finally {
|
||||
@@ -287,6 +310,16 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
|
||||
const wasTruncated = matchCount >= totalMaxMatches;
|
||||
|
||||
if (this.params.names_only) {
|
||||
const filePaths = Object.keys(matchesByFile).sort();
|
||||
let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`;
|
||||
llmContent += filePaths.join('\n');
|
||||
return {
|
||||
llmContent: llmContent.trim(),
|
||||
returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`;
|
||||
|
||||
for (const filePath in matchesByFile) {
|
||||
@@ -318,6 +351,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
pattern: string;
|
||||
path: string;
|
||||
include?: string;
|
||||
exclude_pattern?: string;
|
||||
case_sensitive?: boolean;
|
||||
fixed_strings?: boolean;
|
||||
context?: number;
|
||||
@@ -325,12 +359,14 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before?: number;
|
||||
no_ignore?: boolean;
|
||||
maxMatches: number;
|
||||
max_matches_per_file?: number;
|
||||
signal: AbortSignal;
|
||||
}): Promise<GrepMatch[]> {
|
||||
const {
|
||||
pattern,
|
||||
path: absolutePath,
|
||||
include,
|
||||
exclude_pattern,
|
||||
case_sensitive,
|
||||
fixed_strings,
|
||||
context,
|
||||
@@ -338,6 +374,7 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
before,
|
||||
no_ignore,
|
||||
maxMatches,
|
||||
max_matches_per_file,
|
||||
} = options;
|
||||
|
||||
const rgArgs = ['--json'];
|
||||
@@ -366,6 +403,10 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
rgArgs.push('--no-ignore');
|
||||
}
|
||||
|
||||
if (max_matches_per_file) {
|
||||
rgArgs.push('--max-count', max_matches_per_file.toString());
|
||||
}
|
||||
|
||||
if (include) {
|
||||
rgArgs.push('--glob', include);
|
||||
}
|
||||
@@ -405,9 +446,18 @@ class GrepToolInvocation extends BaseToolInvocation<
|
||||
});
|
||||
|
||||
let matchesFound = 0;
|
||||
let excludeRegex: RegExp | null = null;
|
||||
if (exclude_pattern) {
|
||||
excludeRegex = new RegExp(exclude_pattern, case_sensitive ? '' : 'i');
|
||||
}
|
||||
|
||||
for await (const line of generator) {
|
||||
const match = this.parseRipgrepJsonLine(line, absolutePath);
|
||||
if (match) {
|
||||
if (excludeRegex && excludeRegex.test(match.line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push(match);
|
||||
if (!match.isContext) {
|
||||
matchesFound++;
|
||||
@@ -509,7 +559,7 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
super(
|
||||
RipGrepTool.Name,
|
||||
'SearchText',
|
||||
'Searches for a regular expression pattern within file contents. Max 100 matches.',
|
||||
'Searches for a regular expression pattern within file contents.',
|
||||
Kind.Search,
|
||||
{
|
||||
properties: {
|
||||
@@ -528,6 +578,16 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
"Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.",
|
||||
type: 'string',
|
||||
},
|
||||
exclude_pattern: {
|
||||
description:
|
||||
'Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
names_only: {
|
||||
description:
|
||||
'Optional: If true, only the file paths of the matches will be returned, without the line content or line numbers. This is useful for gathering a list of files.',
|
||||
type: 'boolean',
|
||||
},
|
||||
case_sensitive: {
|
||||
description:
|
||||
'If true, search is case-sensitive. Defaults to false (ignore case) if omitted.',
|
||||
@@ -547,17 +607,31 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
description:
|
||||
'Show this many lines after each match (equivalent to grep -A). Defaults to 0 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
before: {
|
||||
description:
|
||||
'Show this many lines before each match (equivalent to grep -B). Defaults to 0 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
no_ignore: {
|
||||
description:
|
||||
'If true, searches all files including those usually ignored (like in .gitignore, build/, dist/, etc). Defaults to false if omitted.',
|
||||
type: 'boolean',
|
||||
},
|
||||
max_matches_per_file: {
|
||||
description:
|
||||
'Optional: Maximum number of matches to return per file. Use this to prevent being overwhelmed by repetitive matches in large files.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
total_max_matches: {
|
||||
description:
|
||||
'Optional: Maximum number of total matches to return. Use this to limit the overall size of the response. Defaults to 100 if omitted.',
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['pattern'],
|
||||
type: 'object',
|
||||
@@ -588,6 +662,28 @@ export class RipGrepTool extends BaseDeclarativeTool<
|
||||
}
|
||||
}
|
||||
|
||||
if (params.exclude_pattern) {
|
||||
try {
|
||||
new RegExp(params.exclude_pattern);
|
||||
} catch (error) {
|
||||
return `Invalid exclude regular expression pattern provided: ${params.exclude_pattern}. Error: ${getErrorMessage(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.max_matches_per_file !== undefined &&
|
||||
params.max_matches_per_file < 1
|
||||
) {
|
||||
return 'max_matches_per_file must be at least 1.';
|
||||
}
|
||||
|
||||
if (
|
||||
params.total_max_matches !== undefined &&
|
||||
params.total_max_matches < 1
|
||||
) {
|
||||
return 'total_max_matches must be at least 1.';
|
||||
}
|
||||
|
||||
// Only validate path if one is provided
|
||||
if (params.dir_path) {
|
||||
const resolvedPath = path.resolve(
|
||||
|
||||
@@ -108,6 +108,7 @@ export const PLAN_MODE_TOOLS = [
|
||||
LS_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { getVersion } from './version.js';
|
||||
import { getVersion, resetVersionCache } from './version.js';
|
||||
import { getPackageJson } from './package.js';
|
||||
|
||||
vi.mock('./package.js', () => ({
|
||||
@@ -17,6 +17,8 @@ describe('version', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
resetVersionCache();
|
||||
process.env = { ...originalEnv };
|
||||
vi.mocked(getPackageJson).mockResolvedValue({ version: '1.0.0' });
|
||||
});
|
||||
@@ -43,4 +45,20 @@ describe('version', () => {
|
||||
const version = await getVersion();
|
||||
expect(version).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should cache the version and only call getPackageJson once', async () => {
|
||||
delete process.env['CLI_VERSION'];
|
||||
vi.mocked(getPackageJson).mockResolvedValue({ version: '1.2.3' });
|
||||
|
||||
const version1 = await getVersion();
|
||||
expect(version1).toBe('1.2.3');
|
||||
expect(getPackageJson).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change the mock value to simulate an update on disk
|
||||
vi.mocked(getPackageJson).mockResolvedValue({ version: '2.0.0' });
|
||||
|
||||
const version2 = await getVersion();
|
||||
expect(version2).toBe('1.2.3'); // Should still be the cached version
|
||||
expect(getPackageJson).toHaveBeenCalledTimes(1); // Should not have been called again
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,20 @@ import path from 'node:path';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export async function getVersion(): Promise<string> {
|
||||
const pkgJson = await getPackageJson(__dirname);
|
||||
return process.env['CLI_VERSION'] || pkgJson?.version || 'unknown';
|
||||
let versionPromise: Promise<string> | undefined;
|
||||
|
||||
export function getVersion(): Promise<string> {
|
||||
if (versionPromise) {
|
||||
return versionPromise;
|
||||
}
|
||||
versionPromise = (async () => {
|
||||
const pkgJson = await getPackageJson(__dirname);
|
||||
return process.env['CLI_VERSION'] || pkgJson?.version || 'unknown';
|
||||
})();
|
||||
return versionPromise;
|
||||
}
|
||||
|
||||
/** For testing purposes only */
|
||||
export function resetVersionCache(): void {
|
||||
versionPromise = undefined;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+19
-15
@@ -12,6 +12,7 @@ import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
|
||||
const artifactsDir = process.argv[2] || '.';
|
||||
const outputFile = process.argv[3];
|
||||
const MAX_HISTORY = 10;
|
||||
|
||||
// Find all report.json files recursively
|
||||
@@ -145,10 +146,8 @@ function fetchHistoricalData() {
|
||||
}
|
||||
|
||||
function generateMarkdown(currentStatsByModel, history) {
|
||||
console.log('### Evals Nightly Summary\n');
|
||||
console.log(
|
||||
'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n',
|
||||
);
|
||||
let md = '### Evals Nightly Summary\n\n';
|
||||
md += 'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n\n';
|
||||
|
||||
// Reverse history to show oldest first
|
||||
const reversedHistory = [...history].reverse();
|
||||
@@ -171,8 +170,8 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%'
|
||||
: 'N/A';
|
||||
|
||||
console.log(`#### Model: ${model}`);
|
||||
console.log(`**Total Pass Rate: ${totalPassRate}**\n`);
|
||||
md += `#### Model: ${model}\n`;
|
||||
md += `**Total Pass Rate: ${totalPassRate}**\n\n`;
|
||||
|
||||
// Header
|
||||
let header = '| Test Name |';
|
||||
@@ -187,8 +186,8 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
header += ' Current |';
|
||||
separator += ' :---: |';
|
||||
|
||||
console.log(header);
|
||||
console.log(separator);
|
||||
md += header + '\n';
|
||||
md += separator + '\n';
|
||||
|
||||
// Collect all test names for this model
|
||||
const allTestNames = new Set(Object.keys(currentStats));
|
||||
@@ -224,23 +223,28 @@ function generateMarkdown(currentStatsByModel, history) {
|
||||
row += ' - |';
|
||||
}
|
||||
|
||||
console.log(row);
|
||||
md += row + '\n';
|
||||
}
|
||||
console.log('\n');
|
||||
md += '\n';
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
|
||||
const currentReports = findReports(artifactsDir);
|
||||
if (currentReports.length === 0) {
|
||||
console.log('No reports found.');
|
||||
// We don't exit here because we might still want to see history if available,
|
||||
// but practically if current has no reports, something is wrong.
|
||||
// Sticking to original behavior roughly, but maybe we can continue.
|
||||
console.error('No reports found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const currentStats = getStats(currentReports);
|
||||
const history = fetchHistoricalData();
|
||||
generateMarkdown(currentStats, history);
|
||||
const markdown = generateMarkdown(currentStats, history);
|
||||
|
||||
if (outputFile) {
|
||||
fs.writeFileSync(outputFile, markdown);
|
||||
console.log(`Summary written to ${outputFile}`);
|
||||
} else {
|
||||
console.log(markdown);
|
||||
}
|
||||
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const models = [
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
];
|
||||
|
||||
const artifactsDir = path.resolve('artifacts');
|
||||
const logsDir = path.resolve('evals/logs');
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
let testPattern = '';
|
||||
let attempts = 1;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--attempts') {
|
||||
attempts = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
testPattern = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure GEMINI_API_KEY is set
|
||||
if (!process.env.GEMINI_API_KEY) {
|
||||
console.error('Error: GEMINI_API_KEY environment variable is not set.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prepare artifacts directory
|
||||
if (fs.existsSync(artifactsDir)) {
|
||||
console.log(`Cleaning artifacts directory: ${artifactsDir}`);
|
||||
fs.rmSync(artifactsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(artifactsDir);
|
||||
|
||||
// Build project
|
||||
console.log('Building project...');
|
||||
try {
|
||||
execSync('npm run build', { stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.error('Build failed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Starting evals with ${attempts} attempt(s) per model.`);
|
||||
|
||||
for (const model of models) {
|
||||
for (let attempt = 1; attempt <= attempts; attempt++) {
|
||||
console.log(`
|
||||
--------------------------------------------------`);
|
||||
console.log(`Running evals for ${model} (Attempt ${attempt}/${attempts})`);
|
||||
console.log(`--------------------------------------------------
|
||||
`);
|
||||
|
||||
// Clean logs directory for this run
|
||||
if (fs.existsSync(logsDir)) {
|
||||
fs.rmSync(logsDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Construct command
|
||||
let cmd = 'npm run test:all_evals';
|
||||
if (testPattern) {
|
||||
if (
|
||||
testPattern.endsWith('.ts') ||
|
||||
testPattern.endsWith('.js') ||
|
||||
testPattern.includes('/')
|
||||
) {
|
||||
cmd += ` -- "${testPattern}"`;
|
||||
} else {
|
||||
cmd += ` -- -t "${testPattern}"`;
|
||||
}
|
||||
}
|
||||
|
||||
// Run evals
|
||||
execSync(cmd, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
GEMINI_MODEL: model,
|
||||
RUN_EVALS: 'true',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`
|
||||
Evals for ${model} (Attempt ${attempt}) finished with failures.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Copy logs to artifacts
|
||||
// Format: eval-logs-<model>-<attempt>
|
||||
const artifactName = `eval-logs-${model}-${attempt}`;
|
||||
const artifactPath = path.join(artifactsDir, artifactName);
|
||||
|
||||
// Ensure parent dir exists (though artifactsDir should exist)
|
||||
if (fs.existsSync(logsDir)) {
|
||||
console.log(`Copying logs to ${artifactPath}`);
|
||||
fs.cpSync(logsDir, artifactPath, { recursive: true });
|
||||
} else {
|
||||
console.error(`Warning: No logs found in ${logsDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--------------------------------------------------');
|
||||
console.log('Aggregating results...');
|
||||
console.log('--------------------------------------------------\n');
|
||||
|
||||
try {
|
||||
const summaryFile = 'local_evals_summary.md';
|
||||
execSync(`node scripts/aggregate_evals.js "${artifactsDir}" "${summaryFile}"`, {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\nSummary written to ${summaryFile}`);
|
||||
console.log('\nPreview:\n');
|
||||
console.log(fs.readFileSync(summaryFile, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error('Aggregation failed:', e);
|
||||
}
|
||||
Reference in New Issue
Block a user