mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f32ff7d6 | |||
| 1693afe027 | |||
| d179e3673b | |||
| e4d6a5cc7c | |||
| fc4fdc3cbe | |||
| bdbb996a2e | |||
| 2d05396dd2 | |||
| 7b4a822b0e | |||
| d44615ac2f | |||
| de656f01d7 | |||
| 1d2585dba6 | |||
| 97bc3f28c5 | |||
| 3038fdce2e | |||
| bb060d7a98 | |||
| 9a73aa4072 | |||
| d7d53981f3 | |||
| 19e0b1ff7d | |||
| 4d393f9dca | |||
| 5abc170b08 | |||
| ceb4c5f6a7 | |||
| b6beab9480 | |||
| c2691f44b6 | |||
| 663d9c0537 | |||
| 4863816b81 | |||
| 829c532703 | |||
| c68303c553 | |||
| 7242d71c01 | |||
| 391715c33c | |||
| 8a537d85e9 | |||
| cd7dced951 | |||
| 73c589f9e3 | |||
| e700a9220b | |||
| 4b76fe0061 |
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.33.0
|
||||
# Latest stable release: v0.33.1
|
||||
|
||||
Released: March 11, 2026
|
||||
Released: March 12, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
|
||||
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
|
||||
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
|
||||
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
|
||||
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
|
||||
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
|
||||
@@ -228,4 +231,4 @@ npm install -g @google/gemini-cli
|
||||
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.34.0-preview.0
|
||||
# Preview release: v0.34.0-preview.1
|
||||
|
||||
Released: March 11, 2026
|
||||
Released: March 12, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -28,6 +28,9 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
|
||||
- feat(cli): add chat resume footer on session quit by @lordshashank in
|
||||
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
|
||||
- Support bold and other styles in svg snapshots by @jacob314 in
|
||||
@@ -465,4 +468,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.1
|
||||
|
||||
@@ -26,6 +26,20 @@ policies.
|
||||
the CLI will use an available fallback model for the current turn or the
|
||||
remainder of the session.
|
||||
|
||||
### Local Model Routing (Experimental)
|
||||
|
||||
Gemini CLI supports using a local model for routing decisions. When configured,
|
||||
Gemini CLI will use a locally-running **Gemma** model to make routing decisions
|
||||
(instead of sending routing decisions to a hosted model). This feature can help
|
||||
reduce costs associated with hosted model usage while offering similar routing
|
||||
decision latency and quality.
|
||||
|
||||
In order to use this feature, the local Gemma model **must** be served behind a
|
||||
Gemini API and accessible via HTTP at an endpoint configured in `settings.json`.
|
||||
|
||||
For more details on how to configure local model routing, see
|
||||
[Local Model Routing](../core/local-model-routing.md).
|
||||
|
||||
### Model selection precedence
|
||||
|
||||
The model used by Gemini CLI is determined by the following order of precedence:
|
||||
@@ -38,5 +52,8 @@ The model used by Gemini CLI is determined by the following order of precedence:
|
||||
3. **`model.name` in `settings.json`:** If neither of the above are set, the
|
||||
model specified in the `model.name` property of your `settings.json` file
|
||||
will be used.
|
||||
4. **Default model:** If none of the above are set, the default model will be
|
||||
4. **Local model (experimental):** If the Gemma local model router is enabled
|
||||
in your `settings.json` file, the CLI will use the local Gemma model
|
||||
(instead of Gemini models) to route the request to an appropriate model.
|
||||
5. **Default model:** If none of the above are set, the default model will be
|
||||
used. The default model is `auto`
|
||||
|
||||
+71
-10
@@ -109,16 +109,6 @@ switch back to another mode.
|
||||
- **Keyboard shortcut:** Press `Shift+Tab` to cycle to the desired mode.
|
||||
- **Natural language:** Ask Gemini CLI to "exit plan mode" or "stop planning."
|
||||
|
||||
## Customization and best practices
|
||||
|
||||
Plan Mode is secure by default, but you can adapt it to fit your specific
|
||||
workflows. You can customize how Gemini CLI plans by using skills, adjusting
|
||||
safety policies, or changing where plans are stored.
|
||||
|
||||
## Commands
|
||||
|
||||
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
|
||||
|
||||
## Tool Restrictions
|
||||
|
||||
Plan Mode enforces strict safety policies to prevent accidental changes.
|
||||
@@ -146,6 +136,12 @@ These are the only allowed tools:
|
||||
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
|
||||
instructions and resources in a read-only manner)
|
||||
|
||||
## Customization and best practices
|
||||
|
||||
Plan Mode is secure by default, but you can adapt it to fit your specific
|
||||
workflows. You can customize how Gemini CLI plans by using skills, adjusting
|
||||
safety policies, changing where plans are stored, or adding hooks.
|
||||
|
||||
### Custom planning with skills
|
||||
|
||||
You can use [Agent Skills](../cli/skills.md) to customize how Gemini CLI
|
||||
@@ -294,6 +290,71 @@ modes = ["plan"]
|
||||
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
|
||||
```
|
||||
|
||||
### Using hooks with Plan Mode
|
||||
|
||||
You can use the [hook system](../hooks/writing-hooks.md) to automate parts of
|
||||
the planning workflow or enforce additional checks when Gemini CLI transitions
|
||||
into or out of Plan Mode.
|
||||
|
||||
Hooks such as `BeforeTool` or `AfterTool` can be configured to intercept the
|
||||
`enter_plan_mode` and `exit_plan_mode` tool calls.
|
||||
|
||||
> [!WARNING] When hooks are triggered by **tool executions**, they do **not**
|
||||
> run when you manually toggle Plan Mode using the `/plan` command or the
|
||||
> `Shift+Tab` keyboard shortcut. If you need hooks to execute on mode changes,
|
||||
> ensure the transition is initiated by the agent (e.g., by asking "start a plan
|
||||
> for...").
|
||||
|
||||
#### Example: Archive approved plans to GCS (`AfterTool`)
|
||||
|
||||
If your organizational policy requires a record of all execution plans, you can
|
||||
use an `AfterTool` hook to securely copy the plan artifact to Google Cloud
|
||||
Storage whenever Gemini CLI exits Plan Mode to start the implementation.
|
||||
|
||||
**`.gemini/hooks/archive-plan.sh`:**
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Extract the plan path from the tool input JSON
|
||||
plan_path=$(jq -r '.tool_input.plan_path // empty')
|
||||
|
||||
if [ -f "$plan_path" ]; then
|
||||
# Generate a unique filename using a timestamp
|
||||
filename="$(date +%s)_$(basename "$plan_path")"
|
||||
|
||||
# Upload the plan to GCS in the background so it doesn't block the CLI
|
||||
gsutil cp "$plan_path" "gs://my-audit-bucket/gemini-plans/$filename" > /dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
# AfterTool hooks should generally allow the flow to continue
|
||||
echo '{"decision": "allow"}'
|
||||
```
|
||||
|
||||
To register this `AfterTool` hook, add it to your `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"AfterTool": [
|
||||
{
|
||||
"matcher": "exit_plan_mode",
|
||||
"hooks": [
|
||||
{
|
||||
"name": "archive-plan",
|
||||
"type": "command",
|
||||
"command": "./.gemini/hooks/archive-plan.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
|
||||
|
||||
## Planning workflows
|
||||
|
||||
Plan Mode provides building blocks for structured research and design. These are
|
||||
|
||||
@@ -55,6 +55,7 @@ they appear in the UI.
|
||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `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` |
|
||||
|
||||
@@ -15,6 +15,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
|
||||
modular GEMINI.md import feature using @file.md syntax.
|
||||
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
|
||||
fine-grained control over tool execution.
|
||||
- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how
|
||||
to enable use of a local Gemma model for model routing decisions.
|
||||
|
||||
## Role of the core
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# Local Model Routing (experimental)
|
||||
|
||||
Gemini CLI supports using a local model for
|
||||
[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will
|
||||
use a locally-running **Gemma** model to make routing decisions (instead of
|
||||
sending routing decisions to a hosted model).
|
||||
|
||||
This feature can help reduce costs associated with hosted model usage while
|
||||
offering similar routing decision latency and quality.
|
||||
|
||||
> **Note: Local model routing is currently an experimental feature.**
|
||||
|
||||
## Setup
|
||||
|
||||
Using a Gemma model for routing decisions requires that an implementation of a
|
||||
Gemma model be running locally on your machine, served behind an HTTP endpoint
|
||||
and accessed via the Gemini API.
|
||||
|
||||
To serve the Gemma model, follow these steps:
|
||||
|
||||
### Download the LiteRT-LM runtime
|
||||
|
||||
The [LiteRT-LM](https://github.com/google-ai-edge/LiteRT-LM) runtime offers
|
||||
pre-built binaries for locally-serving models. Download the binary appropriate
|
||||
for your system.
|
||||
|
||||
#### Windows
|
||||
|
||||
1. Download
|
||||
[lit.windows_x86_64.exe](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.windows_x86_64.exe).
|
||||
2. Using GPU on Windows requires the DirectXShaderCompiler. Download the
|
||||
[dxc zip from the latest release](https://github.com/microsoft/DirectXShaderCompiler/releases/download/v1.8.2505.1/dxc_2025_07_14.zip).
|
||||
Unzip the archive and from the architecture-appropriate `bin\` directory, and
|
||||
copy the `dxil.dll` and `dxcompiler.dll` into the same location as you saved
|
||||
`lit.windows_x86_64.exe`.
|
||||
3. (Optional) Test starting the runtime:
|
||||
`.\lit.windows_x86_64.exe serve --verbose`
|
||||
|
||||
#### Linux
|
||||
|
||||
1. Download
|
||||
[lit.linux_x86_64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.linux_x86_64).
|
||||
2. Ensure the binary is executable: `chmod a+x lit.linux_x86_64`
|
||||
3. (Optional) Test starting the runtime: `./lit.linux_x86_64 serve --verbose`
|
||||
|
||||
#### MacOS
|
||||
|
||||
1. Download
|
||||
[lit-macos-arm64](https://github.com/google-ai-edge/LiteRT-LM/releases/download/v0.9.0-alpha03/lit.macos_arm64).
|
||||
2. Ensure the binary is executable: `chmod a+x lit.macos_arm64`
|
||||
3. (Optional) Test starting the runtime: `./lit.macos_arm64 serve --verbose`
|
||||
|
||||
> **Note**: MacOS can be configured to only allows binaries from "App Store &
|
||||
> Known Developers". If you encounter an error message when attempting to run
|
||||
> the binary, you will need to allow the application. One option is to visit
|
||||
> `System Settings -> Privacy & Security`, scroll to `Security`, and click
|
||||
> `"Allow Anyway"` for `"lit.macos_arm64"`. Another option is to run
|
||||
> `xattr -d com.apple.quarantine lit.macos_arm64` from the commandline.
|
||||
|
||||
### Download the Gemma Model
|
||||
|
||||
Before using Gemma, you will need to download the model (and agree to the Terms
|
||||
of Service).
|
||||
|
||||
This can be done via the LiteRT-LM runtime.
|
||||
|
||||
#### Windows
|
||||
|
||||
```bash
|
||||
$ .\lit.windows_x86_64.exe pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
#### Linux
|
||||
|
||||
```bash
|
||||
$ ./lit.linux_x86_64 pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
#### MacOS
|
||||
|
||||
```bash
|
||||
$ ./lit.lit.macos_arm64 pull gemma3-1b-gpu-custom
|
||||
|
||||
[Legal] The model you are about to download is governed by
|
||||
the Gemma Terms of Use and Prohibited Use Policy. Please review these terms and ensure you agree before continuing.
|
||||
|
||||
Full Terms: https://ai.google.dev/gemma/terms
|
||||
Prohibited Use Policy: https://ai.google.dev/gemma/prohibited_use_policy
|
||||
|
||||
Do you accept these terms? (Y/N): Y
|
||||
|
||||
Terms accepted.
|
||||
Downloading model 'gemma3-1b-gpu-custom' ...
|
||||
Downloading... 968.6 MB
|
||||
Download complete.
|
||||
```
|
||||
|
||||
### Start LiteRT-LM Runtime
|
||||
|
||||
Using the command appropriate to your system, start the LiteRT-LM runtime.
|
||||
Configure the port that you want to use for your Gemma model. For the purposes
|
||||
of this document, we will use port `9379`.
|
||||
|
||||
Example command for MacOS: `./lit.macos_arm64 serve --port=9379 --verbose`
|
||||
|
||||
### (Optional) Verify Model Serving
|
||||
|
||||
Send a quick prompt to the model via HTTP to validate successful model serving.
|
||||
This will cause the runtime to download the model and run it once.
|
||||
|
||||
You should see a short joke in the server output as an indicator of success.
|
||||
|
||||
#### Windows
|
||||
|
||||
```
|
||||
# Run this in PowerShell to send a request to the server
|
||||
|
||||
$uri = "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent"
|
||||
$body = @{contents = @( @{
|
||||
role = "user"
|
||||
parts = @( @{ text = "Tell me a joke." } )
|
||||
})} | ConvertTo-Json -Depth 10
|
||||
|
||||
Invoke-RestMethod -Uri $uri -Method Post -Body $body -ContentType "application/json"
|
||||
```
|
||||
|
||||
#### Linux/MacOS
|
||||
|
||||
```bash
|
||||
$ curl "http://localhost:9379/v1beta/models/gemma3-1b-gpu-custom:generateContent" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-X POST \
|
||||
-d '{"contents":[{"role":"user","parts":[{"text":"Tell me a joke."}]}]}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
To use a local Gemma model for routing, you must explicitly enable it in your
|
||||
`settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"gemmaModelRouter": {
|
||||
"enabled": true,
|
||||
"classifier": {
|
||||
"host": "http://localhost:9379",
|
||||
"model": "gemma3-1b-gpu-custom"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Use the port you started your LiteRT-LM runtime on in the setup steps.
|
||||
|
||||
### Configuration schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| :----------------- | :------ | :------- | :----------------------------------------------------------------------------------------- |
|
||||
| `enabled` | boolean | Yes | Must be `true` to enable the feature. |
|
||||
| `classifier` | object | Yes | The configuration for the local model endpoint. It includes the host and model specifiers. |
|
||||
| `classifier.host` | string | Yes | The URL to the local model server. Should be `http://localhost:<port>`. |
|
||||
| `classifier.model` | string | Yes | The model name to use for decisions. Must be `"gemma3-1b-gpu-custom"`. |
|
||||
|
||||
> **Note: You will need to restart after configuration changes for local model
|
||||
> routing to take effect.**
|
||||
@@ -245,6 +245,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Description:** Hide helpful tips in the UI
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.escapePastedAtSymbols`** (boolean):
|
||||
- **Description:** When enabled, @ symbols in pasted text are escaped to
|
||||
prevent unintended @path expansion.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`ui.showShortcutsHint`** (boolean):
|
||||
- **Description:** Show the "? for shortcuts" hint above the input.
|
||||
- **Default:** `true`
|
||||
@@ -672,6 +677,141 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
used.
|
||||
- **Default:** `[]`
|
||||
|
||||
- **`modelConfigs.modelDefinitions`** (object):
|
||||
- **Description:** Registry of model metadata, including tier, family, and
|
||||
features.
|
||||
- **Default:**
|
||||
|
||||
```json
|
||||
{
|
||||
"gemini-3.1-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview-customtools": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-pro-preview": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-3-flash-preview": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-3",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": true
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"tier": "pro",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
"tier": "flash",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"family": "gemini-2.5",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "manual",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"tier": "pro",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash": {
|
||||
"tier": "flash",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"flash-lite": {
|
||||
"tier": "flash-lite",
|
||||
"isPreview": false,
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-3": {
|
||||
"displayName": "Auto (Gemini 3)",
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto-gemini-2.5": {
|
||||
"displayName": "Auto (Gemini 2.5)",
|
||||
"tier": "auto",
|
||||
"isPreview": false,
|
||||
"dialogLocation": "main",
|
||||
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
|
||||
"features": {
|
||||
"thinking": false,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Requires restart:** Yes
|
||||
|
||||
#### `agents`
|
||||
|
||||
- **`agents.overrides`** (object):
|
||||
@@ -1063,6 +1203,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.dynamicModelConfiguration`** (boolean):
|
||||
- **Description:** Enable dynamic model configuration (definitions,
|
||||
resolutions, and chains) via settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.gemmaModelRouter.enabled`** (boolean):
|
||||
- **Description:** Enable the Gemma Model Router (experimental). Requires a
|
||||
local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.
|
||||
|
||||
@@ -342,7 +342,9 @@ policies, as it is much more robust than manually writing Fully Qualified Names
|
||||
|
||||
**1. Targeting a specific tool on a server**
|
||||
|
||||
Combine `mcpName` and `toolName` to target a single operation.
|
||||
Combine `mcpName` and `toolName` to target a single operation. When using
|
||||
`mcpName`, the `toolName` field should strictly be the simple name of the tool
|
||||
(e.g., `search`), **not** the Fully Qualified Name (e.g., `mcp_server_search`).
|
||||
|
||||
```toml
|
||||
# Allows the `search` tool on the `my-jira-server` MCP
|
||||
|
||||
@@ -120,6 +120,14 @@ tools to detect if they are being run from within the Gemini CLI.
|
||||
|
||||
## Command restrictions
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> [!WARNING]
|
||||
> The `tools.core` setting is an **allowlist for _all_ built-in
|
||||
> tools**, not just shell commands. When you set `tools.core` to any value,
|
||||
> _only_ the tools explicitly listed will be enabled. This includes all built-in
|
||||
> tools like `read_file`, `write_file`, `glob`, `grep_search`, `list_directory`,
|
||||
> `replace`, etc.
|
||||
|
||||
You can restrict the commands that can be executed by the `run_shell_command`
|
||||
tool by using the `tools.core` and `tools.exclude` settings in your
|
||||
configuration file.
|
||||
|
||||
+9
-4
@@ -82,11 +82,14 @@ const commonAliases = {
|
||||
const cliConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
|
||||
},
|
||||
entryPoints: ['packages/cli/index.ts'],
|
||||
outfile: 'bundle/gemini.js',
|
||||
entryPoints: { gemini: 'packages/cli/index.ts' },
|
||||
outdir: 'bundle',
|
||||
splitting: true,
|
||||
define: {
|
||||
__filename: '__chunk_filename',
|
||||
__dirname: '__chunk_dirname',
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
|
||||
pkg.config?.sandboxImageUri,
|
||||
@@ -103,11 +106,13 @@ const cliConfig = {
|
||||
const a2aServerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
|
||||
},
|
||||
entryPoints: ['packages/a2a-server/src/http/server.ts'],
|
||||
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
|
||||
define: {
|
||||
__filename: '__chunk_filename',
|
||||
__dirname: '__chunk_dirname',
|
||||
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: createWasmPlugins(),
|
||||
|
||||
@@ -35,11 +35,6 @@ const commonRestrictedSyntaxRules = [
|
||||
message:
|
||||
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
|
||||
},
|
||||
{
|
||||
selector: 'CallExpression[callee.name="fetch"]',
|
||||
message:
|
||||
'Use safeFetch() from "@/utils/fetch" instead of the global fetch() to ensure SSRF protection. If you are implementing a custom security layer, use an eslint-disable comment and explain why.',
|
||||
},
|
||||
];
|
||||
|
||||
export default tseslint.config(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
/**
|
||||
* integration test to ensure no node.js deprecation warnings are emitted.
|
||||
* must run for all supported node versions as warnings may vary by version.
|
||||
*/
|
||||
describe('deprecation-warnings', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it.each([
|
||||
{ command: '--version', description: 'running --version' },
|
||||
{ command: '--help', description: 'running with --help' },
|
||||
])(
|
||||
'should not emit any deprecation warnings when $description',
|
||||
async ({ command, description }) => {
|
||||
await rig.setup(
|
||||
`should not emit any deprecation warnings when ${description}`,
|
||||
);
|
||||
|
||||
const { stderr, exitCode } = await rig.runWithStreams([command]);
|
||||
|
||||
// node.js deprecation warnings: (node:12345) [DEP0040] DeprecationWarning: ...
|
||||
const deprecationWarningPattern = /\[DEP\d+\].*DeprecationWarning/i;
|
||||
const hasDeprecationWarning = deprecationWarningPattern.test(stderr);
|
||||
|
||||
if (hasDeprecationWarning) {
|
||||
const deprecationMatches = stderr.match(
|
||||
/\[DEP\d+\].*DeprecationWarning:.*/gi,
|
||||
);
|
||||
const warnings = deprecationMatches
|
||||
? deprecationMatches.map((m) => m.trim()).join('\n')
|
||||
: 'Unknown deprecation warning format';
|
||||
|
||||
throw new Error(
|
||||
`Deprecation warnings detected in CLI output:\n${warnings}\n\n` +
|
||||
`Full stderr:\n${stderr}\n\n` +
|
||||
`This test ensures no deprecated Node.js modules are used. ` +
|
||||
`Please update dependencies to use non-deprecated alternatives.`,
|
||||
);
|
||||
}
|
||||
|
||||
// only check exit code if no deprecation warnings found
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`CLI exited with code ${exitCode} (expected 0). This may indicate a setup issue.\n` +
|
||||
`Stderr: ${stderr}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
Generated
+225
-168
@@ -1318,9 +1318,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@google-cloud/storage": {
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.17.0.tgz",
|
||||
"integrity": "sha512-5m9GoZqKh52a1UqkxDBu/+WVFDALNtHg5up5gNmNbXQWBcV813tzJKsyDtKjOPrlR1em1TxtD7NSPCrObH7koQ==",
|
||||
"version": "7.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz",
|
||||
"integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google-cloud/paginator": "^5.0.0",
|
||||
@@ -1329,7 +1329,7 @@
|
||||
"abort-controller": "^3.0.0",
|
||||
"async-retry": "^1.3.3",
|
||||
"duplexify": "^4.1.3",
|
||||
"fast-xml-parser": "^4.4.1",
|
||||
"fast-xml-parser": "^5.3.4",
|
||||
"gaxios": "^6.0.2",
|
||||
"google-auth-library": "^9.6.3",
|
||||
"html-entities": "^2.5.2",
|
||||
@@ -1516,9 +1516,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.11",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
@@ -2089,9 +2089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -2195,7 +2195,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2376,7 +2375,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2426,7 +2424,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2801,7 +2798,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2835,7 +2831,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2890,7 +2885,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -3045,9 +3039,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz",
|
||||
"integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3058,9 +3052,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3071,9 +3065,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3084,9 +3078,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3097,9 +3091,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3110,9 +3104,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz",
|
||||
"integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3123,9 +3117,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3136,9 +3130,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz",
|
||||
"integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3149,9 +3143,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3162,9 +3156,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3175,9 +3169,22 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -3188,9 +3195,22 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -3201,9 +3221,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3214,9 +3234,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3227,9 +3247,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -3240,9 +3260,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3253,9 +3273,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz",
|
||||
"integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3265,10 +3285,23 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz",
|
||||
"integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3279,9 +3312,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3292,9 +3325,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3305,9 +3338,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz",
|
||||
"integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3318,9 +3351,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz",
|
||||
"integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3375,9 +3408,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@secretlint/config-loader/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4054,7 +4087,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4329,7 +4361,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5203,7 +5234,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5240,9 +5270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5274,9 +5304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -7735,7 +7765,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8246,7 +8275,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -8286,12 +8314,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz",
|
||||
"integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -8433,10 +8461,10 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "4.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
||||
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
|
||||
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -8445,7 +8473,24 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^1.1.1"
|
||||
"path-expression-matcher": "^1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
|
||||
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.1.2",
|
||||
"path-expression-matcher": "^1.1.3",
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
@@ -9510,11 +9555,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.9",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz",
|
||||
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -9794,7 +9838,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
|
||||
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -9963,9 +10006,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -10749,6 +10792,7 @@
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
@@ -12827,6 +12871,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-expression-matcher": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
|
||||
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
@@ -13381,7 +13440,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13392,7 +13450,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -13862,9 +13919,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz",
|
||||
"integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -13877,28 +13934,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.2",
|
||||
"@rollup/rollup-android-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.2",
|
||||
"@rollup/rollup-darwin-x64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -14432,9 +14492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/simple-git": {
|
||||
"version": "3.28.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz",
|
||||
"integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==",
|
||||
"version": "3.33.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.33.0.tgz",
|
||||
"integrity": "sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kwsites/file-exists": "^1.1.1",
|
||||
@@ -14937,9 +14997,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -15119,9 +15179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/systeminformation": {
|
||||
"version": "5.30.2",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.2.tgz",
|
||||
"integrity": "sha512-Rrt5oFTWluUVuPlbtn3o9ja+nvjdF3Um4DG0KxqfYvpzcx7Q9plZBTjJiJy9mAouua4+OI7IUGBaG9Zyt9NgxA==",
|
||||
"version": "5.31.4",
|
||||
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.4.tgz",
|
||||
"integrity": "sha512-lZppDyQx91VdS5zJvAyGkmwe+Mq6xY978BDUG2wRkWE+jkmUF5ti8cvOovFQoN5bvSFKCXVkyKEaU5ec3SJiRg==",
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
@@ -15162,9 +15222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/table/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15437,7 +15497,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15661,8 +15720,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -15670,7 +15728,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -15830,7 +15887,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15889,9 +15945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.7",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==",
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -16053,7 +16109,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16167,7 +16222,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16180,7 +16234,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -16822,7 +16875,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -17289,9 +17341,9 @@
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -17339,6 +17391,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"packages/core/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/core/node_modules/mime": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
||||
@@ -17359,7 +17417,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
mockConfig = createMockConfig({
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
}) as Config;
|
||||
messageBus = mockConfig.getMessageBus();
|
||||
messageBus = mockConfig.messageBus;
|
||||
mockEventBus = {
|
||||
publish: vi.fn(),
|
||||
on: vi.fn(),
|
||||
@@ -360,7 +360,7 @@ describe('Task Event-Driven Scheduler', () => {
|
||||
isEventDrivenSchedulerEnabled: () => true,
|
||||
getApprovalMode: () => ApprovalMode.YOLO,
|
||||
}) as Config;
|
||||
const yoloMessageBus = yoloConfig.getMessageBus();
|
||||
const yoloMessageBus = yoloConfig.messageBus;
|
||||
|
||||
// @ts-expect-error - Calling private constructor
|
||||
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentLoopContext,
|
||||
Scheduler,
|
||||
type GeminiClient,
|
||||
GeminiEventType,
|
||||
@@ -114,7 +115,8 @@ export class Task {
|
||||
|
||||
this.scheduler = this.setupEventDrivenScheduler();
|
||||
|
||||
this.geminiClient = this.config.getGeminiClient();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
this.geminiClient = loopContext.geminiClient;
|
||||
this.pendingToolConfirmationDetails = new Map();
|
||||
this.taskState = 'submitted';
|
||||
this.eventBus = eventBus;
|
||||
@@ -143,7 +145,8 @@ export class Task {
|
||||
// process. This is not scoped to the individual task but reflects the global connection
|
||||
// state managed within the @gemini-cli/core module.
|
||||
async getMetadata(): Promise<TaskMetadata> {
|
||||
const toolRegistry = this.config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {};
|
||||
const serverStatuses = getAllMCPServerStatuses();
|
||||
const servers = Object.keys(mcpServers).map((serverName) => ({
|
||||
@@ -376,7 +379,8 @@ export class Task {
|
||||
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
|
||||
|
||||
private setupEventDrivenScheduler(): Scheduler {
|
||||
const messageBus = this.config.getMessageBus();
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
const messageBus = loopContext.messageBus;
|
||||
const scheduler = new Scheduler({
|
||||
schedulerId: this.id,
|
||||
context: this.config,
|
||||
@@ -395,9 +399,11 @@ export class Task {
|
||||
|
||||
dispose(): void {
|
||||
if (this.messageBusListener) {
|
||||
this.config
|
||||
.getMessageBus()
|
||||
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
loopContext.messageBus.unsubscribe(
|
||||
MessageBusType.TOOL_CALLS_UPDATE,
|
||||
this.messageBusListener,
|
||||
);
|
||||
this.messageBusListener = undefined;
|
||||
}
|
||||
|
||||
@@ -948,7 +954,8 @@ export class Task {
|
||||
|
||||
try {
|
||||
if (correlationId) {
|
||||
await this.config.getMessageBus().publish({
|
||||
const loopContext: AgentLoopContext = this.config;
|
||||
await loopContext.messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId,
|
||||
confirmed:
|
||||
|
||||
@@ -59,6 +59,9 @@ describe('a2a-server memory commands', () => {
|
||||
} as unknown as ToolRegistry;
|
||||
|
||||
mockConfig = {
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
} as unknown as Config;
|
||||
|
||||
@@ -168,7 +171,6 @@ describe('a2a-server memory commands', () => {
|
||||
]);
|
||||
|
||||
expect(mockAddMemory).toHaveBeenCalledWith(fact);
|
||||
expect(mockConfig.getToolRegistry).toHaveBeenCalled();
|
||||
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
|
||||
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
|
||||
{ fact },
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
CommandContext,
|
||||
CommandExecutionResponse,
|
||||
} from './types.js';
|
||||
import type { AgentLoopContext } from '@google/gemini-cli-core';
|
||||
|
||||
const DEFAULT_SANITIZATION_CONFIG = {
|
||||
allowedEnvironmentVariables: [],
|
||||
@@ -95,7 +96,8 @@ export class AddMemoryCommand implements Command {
|
||||
return { name: this.name, data: result.content };
|
||||
}
|
||||
|
||||
const toolRegistry = context.config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = context.config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
const tool = toolRegistry.getTool(result.toolName);
|
||||
if (tool) {
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
type MessageBus,
|
||||
PolicyDecision,
|
||||
tmpdir,
|
||||
type Config,
|
||||
@@ -31,9 +32,27 @@ export function createMockConfig(
|
||||
const tmpDir = tmpdir();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const mockConfig = {
|
||||
get toolRegistry(): ToolRegistry {
|
||||
get config() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return (this as unknown as Config).getToolRegistry();
|
||||
return this as unknown as Config;
|
||||
},
|
||||
get toolRegistry() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = this as unknown as Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return config.getToolRegistry?.() as unknown as ToolRegistry;
|
||||
},
|
||||
get messageBus() {
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(this as unknown as Config).getMessageBus?.() as unknown as MessageBus
|
||||
);
|
||||
},
|
||||
get geminiClient() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const config = this as unknown as Config;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return config.getGeminiClient?.() as unknown as GeminiClient;
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getTool: vi.fn(),
|
||||
@@ -81,9 +100,6 @@ export function createMockConfig(
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).config =
|
||||
mockConfig;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
|
||||
'test-prompt-id';
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
type MergedSettings,
|
||||
createTestMergedSettings,
|
||||
} from './settings.js';
|
||||
import * as SettingsModule from './settings.js';
|
||||
import * as ServerConfig from '@google/gemini-cli-core';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -814,7 +813,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
|
||||
{
|
||||
@@ -863,7 +862,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
@@ -891,7 +890,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
});
|
||||
|
||||
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
|
||||
process.argv = ['node', 'script.js', '--prompt', 'test'];
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings({
|
||||
context: {
|
||||
includeDirectories: ['/path/to/include'],
|
||||
@@ -916,39 +915,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip extension, memory, and PTY discovery in bootstrap mode', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
const loadSettingsSpy = vi.spyOn(SettingsModule, 'loadSettings');
|
||||
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||
});
|
||||
|
||||
expect(loadSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(getPtySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should defer extension and memory discovery during interactive startup', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const settings = createTestMergedSettings();
|
||||
const argv = await parseArguments(settings);
|
||||
const getPtySpy = vi.spyOn(ServerConfig, 'getPty');
|
||||
|
||||
await loadCliConfig(settings, 'session-id', argv, {
|
||||
loadedSettings: { merged: settings } as SettingsModule.LoadedSettings,
|
||||
});
|
||||
|
||||
expect(ExtensionManager.prototype.loadExtensions).not.toHaveBeenCalled();
|
||||
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
expect(getPtySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeMcpServers', () => {
|
||||
@@ -3666,6 +3632,8 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
it('should set acpMode to true and detect clientName when --acp flag is used', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
vi.stubEnv('TERM_PROGRAM', 'vscode');
|
||||
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
@@ -3679,6 +3647,8 @@ describe('loadCliConfig acpMode and clientName', () => {
|
||||
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
|
||||
process.argv = ['node', 'script.js', '--acp'];
|
||||
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
|
||||
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const config = await loadCliConfig(
|
||||
createTestMergedSettings(),
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
resolveToRealPath,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
startupProfiler,
|
||||
type HookDefinition,
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
@@ -48,7 +47,6 @@ import {
|
||||
import {
|
||||
type Settings,
|
||||
type MergedSettings,
|
||||
type LoadedSettings,
|
||||
saveModelChange,
|
||||
loadSettings,
|
||||
} from './settings.js';
|
||||
@@ -419,8 +417,6 @@ export function isDebugMode(argv: CliArgs): boolean {
|
||||
|
||||
export interface LoadCliConfigOptions {
|
||||
cwd?: string;
|
||||
mode?: 'bootstrap' | 'full';
|
||||
loadedSettings?: LoadedSettings;
|
||||
projectHooks?: { [K in HookEventName]?: HookDefinition[] } & {
|
||||
disabled?: string[];
|
||||
};
|
||||
@@ -432,15 +428,10 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
mode = 'full',
|
||||
loadedSettings,
|
||||
projectHooks,
|
||||
} = options;
|
||||
const { cwd = process.cwd(), projectHooks } = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
const resolvedLoadedSettings = loadedSettings ?? loadSettings(cwd);
|
||||
const isBootstrap = mode === 'bootstrap';
|
||||
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
|
||||
if (argv.sandbox) {
|
||||
process.env['GEMINI_SANDBOX'] = 'true';
|
||||
@@ -448,18 +439,6 @@ export async function loadCliConfig(
|
||||
|
||||
const memoryImportFormat = settings.context?.importFormat || 'tree';
|
||||
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.acp ||
|
||||
!!argv.experimentalAcp ||
|
||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||
!argv.isCommand);
|
||||
const shouldDeferInteractiveStartupWork =
|
||||
!isBootstrap &&
|
||||
interactive &&
|
||||
!argv.listExtensions &&
|
||||
!argv.listSessions &&
|
||||
!argv.deleteSession;
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
@@ -501,7 +480,6 @@ export async function loadCliConfig(
|
||||
.map(resolvePath)
|
||||
.concat((argv.includeDirectories || []).map(resolvePath));
|
||||
|
||||
const clientVersion = await getVersion();
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings,
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
@@ -510,15 +488,9 @@ export async function loadCliConfig(
|
||||
enabledExtensionOverrides: argv.extensions,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
eventEmitter: coreEvents as EventEmitter<ExtensionEvents>,
|
||||
clientVersion,
|
||||
clientVersion: await getVersion(),
|
||||
});
|
||||
if (!isBootstrap && !shouldDeferInteractiveStartupWork) {
|
||||
const loadExtensionsHandle = startupProfiler.start(
|
||||
'load_extensions_during_config',
|
||||
);
|
||||
await extensionManager.loadExtensions();
|
||||
loadExtensionsHandle?.end();
|
||||
}
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
const extensionPlanSettings = extensionManager
|
||||
.getExtensions()
|
||||
@@ -539,12 +511,7 @@ export async function loadCliConfig(
|
||||
let fileCount = 0;
|
||||
let filePaths: string[] = [];
|
||||
|
||||
if (
|
||||
!experimentalJitContext &&
|
||||
!isBootstrap &&
|
||||
!shouldDeferInteractiveStartupWork
|
||||
) {
|
||||
const loadMemoryHandle = startupProfiler.start('load_memory');
|
||||
if (!experimentalJitContext) {
|
||||
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
|
||||
const result = await loadServerHierarchicalMemory(
|
||||
cwd,
|
||||
@@ -561,7 +528,6 @@ export async function loadCliConfig(
|
||||
memoryContent = result.memoryContent;
|
||||
fileCount = result.fileCount;
|
||||
filePaths = result.filePaths;
|
||||
loadMemoryHandle?.end();
|
||||
}
|
||||
|
||||
const question = argv.promptInteractive || argv.prompt || '';
|
||||
@@ -651,6 +617,15 @@ export async function loadCliConfig(
|
||||
throw err;
|
||||
}
|
||||
|
||||
// -p/--prompt forces non-interactive (headless) mode
|
||||
// -i/--prompt-interactive forces interactive mode with an initial prompt
|
||||
const interactive =
|
||||
!!argv.promptInteractive ||
|
||||
!!argv.acp ||
|
||||
!!argv.experimentalAcp ||
|
||||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
|
||||
!argv.isCommand);
|
||||
|
||||
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
|
||||
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
@@ -720,7 +695,7 @@ export async function loadCliConfig(
|
||||
? argv.screenReader
|
||||
: (settings.ui?.accessibility?.screenReader ?? false);
|
||||
|
||||
const ptyInfo = isBootstrap ? null : await getPty();
|
||||
const ptyInfo = await getPty();
|
||||
|
||||
const mcpEnabled = settings.admin?.mcp?.enabled ?? true;
|
||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||
@@ -766,7 +741,7 @@ export async function loadCliConfig(
|
||||
acpMode: isAcpMode,
|
||||
clientName,
|
||||
sessionId,
|
||||
clientVersion,
|
||||
clientVersion: await getVersion(),
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: sandboxConfig,
|
||||
targetDir: cwd,
|
||||
@@ -847,8 +822,6 @@ export async function loadCliConfig(
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
deferInitialMemoryLoad:
|
||||
shouldDeferInteractiveStartupWork && !experimentalJitContext,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
@@ -884,6 +857,7 @@ export async function loadCliConfig(
|
||||
disableLLMCorrection: settings.tools?.disableLLMCorrection,
|
||||
rawOutput: argv.rawOutput,
|
||||
acceptRawOutputRisk: argv.acceptRawOutputRisk,
|
||||
dynamicModelConfiguration: settings.experimental?.dynamicModelConfiguration,
|
||||
modelConfigServiceConfig: settings.modelConfigs,
|
||||
// TODO: loading of hooks based on workspace trust
|
||||
enableHooks: settings.hooksConfig.enabled,
|
||||
@@ -891,8 +865,7 @@ export async function loadCliConfig(
|
||||
hooks: settings.hooks || {},
|
||||
disabledHooks: settings.hooksConfig?.disabled || [],
|
||||
projectHooks: projectHooks || {},
|
||||
onModelChange: (model: string) =>
|
||||
saveModelChange(resolvedLoadedSettings, model),
|
||||
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
|
||||
onReload: async () => {
|
||||
const refreshedSettings = loadSettings(cwd);
|
||||
return {
|
||||
|
||||
@@ -94,7 +94,7 @@ interface ExtensionManagerParams {
|
||||
/**
|
||||
* Actual implementation of an ExtensionLoader.
|
||||
*
|
||||
* Extension metadata is loaded lazily via `loadExtensions`/`ensureLoaded`.
|
||||
* You must call `loadExtensions` prior to calling other methods on this class.
|
||||
*/
|
||||
export class ExtensionManager extends ExtensionLoader {
|
||||
private extensionEnablementManager: ExtensionEnablementManager;
|
||||
@@ -146,24 +146,12 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
}
|
||||
|
||||
getExtensions(): GeminiCLIExtension[] {
|
||||
return this.loadedExtensions ?? [];
|
||||
}
|
||||
|
||||
override isLoaded(): boolean {
|
||||
return this.loadedExtensions !== undefined;
|
||||
}
|
||||
|
||||
override async ensureLoaded(): Promise<void> {
|
||||
if (this.loadedExtensions) {
|
||||
return;
|
||||
if (!this.loadedExtensions) {
|
||||
throw new Error(
|
||||
'Extensions not yet loaded, must call `loadExtensions` first',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.loadingPromise) {
|
||||
await this.loadingPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadExtensions();
|
||||
return this.loadedExtensions;
|
||||
}
|
||||
|
||||
async installOrUpdateExtension(
|
||||
|
||||
@@ -289,11 +289,6 @@ export function createTestMergedSettings(
|
||||
) as MergedSettings;
|
||||
}
|
||||
|
||||
export function createDefaultMergedSettings(): MergedSettings {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return getDefaultsFromSchema() as MergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* An immutable snapshot of settings state.
|
||||
* Used with useSyncExternalStore for reactive updates.
|
||||
|
||||
@@ -540,6 +540,16 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide helpful tips in the UI',
|
||||
showInDialog: true,
|
||||
},
|
||||
escapePastedAtSymbols: {
|
||||
type: 'boolean',
|
||||
label: 'Escape Pasted @ Symbols',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion.',
|
||||
showInDialog: true,
|
||||
},
|
||||
showShortcutsHint: {
|
||||
type: 'boolean',
|
||||
label: 'Show Shortcuts Hint',
|
||||
@@ -1029,6 +1039,20 @@ const SETTINGS_SCHEMA = {
|
||||
'Apply specific configuration overrides based on matches, with a primary key of model (or alias). The most specific match will be used.',
|
||||
showInDialog: false,
|
||||
},
|
||||
modelDefinitions: {
|
||||
type: 'object',
|
||||
label: 'Model Definitions',
|
||||
category: 'Model',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
description:
|
||||
'Registry of model metadata, including tier, family, and features.',
|
||||
showInDialog: false,
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
ref: 'ModelDefinition',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1900,6 +1924,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable web fetch behavior that bypasses LLM summarization.',
|
||||
showInDialog: true,
|
||||
},
|
||||
dynamicModelConfiguration: {
|
||||
type: 'boolean',
|
||||
label: 'Dynamic Model Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable dynamic model configuration (definitions, resolutions, and chains) via settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
gemmaModelRouter: {
|
||||
type: 'object',
|
||||
label: 'Gemma Model Router',
|
||||
@@ -2716,6 +2750,25 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
},
|
||||
},
|
||||
},
|
||||
ModelDefinition: {
|
||||
type: 'object',
|
||||
description: 'Model metadata registry entry.',
|
||||
properties: {
|
||||
displayName: { type: 'string' },
|
||||
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
|
||||
family: { type: 'string' },
|
||||
isPreview: { type: 'boolean' },
|
||||
dialogLocation: { enum: ['main', 'manual'] },
|
||||
dialogDescription: { type: 'string' },
|
||||
features: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
thinking: { type: 'boolean' },
|
||||
multimodalToolUse: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getSettingsSchema(): SettingsSchemaType {
|
||||
|
||||
@@ -269,7 +269,6 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalIsTTY: boolean | undefined;
|
||||
const originalArgv = [...process.argv];
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
[];
|
||||
|
||||
@@ -285,7 +284,6 @@ describe('gemini.tsx main function', () => {
|
||||
originalIsTTY = process.stdin.isTTY;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = true;
|
||||
process.argv = ['node', 'script.js'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -298,70 +296,11 @@ describe('gemini.tsx main function', () => {
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(process.stdin as any).isTTY = originalIsTTY;
|
||||
process.argv = [...originalArgv];
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fast-path --version without loading settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--version'];
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
expect(parseArguments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fast-path --help without loading settings', async () => {
|
||||
process.argv = ['node', 'script.js', '--help'];
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
expect(parseArguments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not relaunch a child process when sandboxing and memory relaunch are unnecessary', async () => {
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: { advanced: {}, security: { auth: {} }, ui: {} },
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
prompt: 'test',
|
||||
} as unknown as CliArgs);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => false,
|
||||
getQuestion: () => 'test',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(0);
|
||||
} finally {
|
||||
processExitSpy.mockRestore();
|
||||
}
|
||||
|
||||
const { relaunchAppInChildProcess } = await import('./utils/relaunch.js');
|
||||
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log unhandled promise rejections and open debug console on first error', async () => {
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
@@ -726,42 +665,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use bootstrap config for the pre-relaunch startup pass', async () => {
|
||||
const mockSettings = createMockSettings({
|
||||
merged: {
|
||||
advanced: {},
|
||||
security: { auth: {} },
|
||||
ui: {},
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
setValue: vi.fn(),
|
||||
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
|
||||
});
|
||||
vi.mocked(loadSettings).mockReturnValue(mockSettings);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
promptInteractive: false,
|
||||
} as unknown as CliArgs);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
isInteractive: () => true,
|
||||
getQuestion: () => '',
|
||||
getSandbox: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
await main();
|
||||
|
||||
expect(loadCliConfig).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(loadCliConfig).mock.calls[0][3]).toMatchObject({
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: mockSettings,
|
||||
});
|
||||
expect(vi.mocked(loadCliConfig).mock.calls[1][3]).toMatchObject({
|
||||
loadedSettings: mockSettings,
|
||||
});
|
||||
});
|
||||
|
||||
it('should log warning when theme is not found', async () => {
|
||||
const { themeManager } = await import('./ui/themes/theme-manager.js');
|
||||
const debugLoggerWarnSpy = vi
|
||||
|
||||
+52
-283
@@ -4,20 +4,44 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
UserPromptEvent,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
getOauthClient,
|
||||
patchStdio,
|
||||
writeToStdout,
|
||||
writeToStderr,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { loadCliConfig, parseArguments } from './config/config.js';
|
||||
import * as cliConfig from './config/config.js';
|
||||
import { readStdin } from './utils/readStdin.js';
|
||||
import { basename } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import v8 from 'node:v8';
|
||||
import os from 'node:os';
|
||||
import dns from 'node:dns';
|
||||
import { start_sandbox } from './utils/sandbox.js';
|
||||
import {
|
||||
createDefaultMergedSettings,
|
||||
loadSettings,
|
||||
SettingScope,
|
||||
type DnsResolutionOrder,
|
||||
@@ -38,47 +62,11 @@ import {
|
||||
runExitCleanup,
|
||||
registerTelemetryConfig,
|
||||
setupSignalHandlers,
|
||||
setupTtyCheck,
|
||||
} from './utils/cleanup.js';
|
||||
import {
|
||||
cleanupToolOutputFiles,
|
||||
cleanupExpiredSessions,
|
||||
} from './utils/sessionCleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
WarningPriority,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
type OutputPayload,
|
||||
type ConsoleLogPayload,
|
||||
type UserFeedbackPayload,
|
||||
sessionId,
|
||||
logUserPrompt,
|
||||
AuthType,
|
||||
getOauthClient,
|
||||
UserPromptEvent,
|
||||
debugLogger,
|
||||
recordSlowRender,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
patchStdio,
|
||||
writeToStdout,
|
||||
writeToStderr,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
startupProfiler,
|
||||
ExitCodes,
|
||||
SessionStartSource,
|
||||
SessionEndReason,
|
||||
getVersion,
|
||||
ValidationCancelledError,
|
||||
ValidationRequiredError,
|
||||
type AdminControlsSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
initializeApp,
|
||||
type InitializationResult,
|
||||
@@ -86,21 +74,9 @@ import {
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runAcpClient } from './acp/acpClient.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
import { StreamingState } from './ui/types.js';
|
||||
import { computeTerminalTitle } from './utils/windowTitle.js';
|
||||
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
|
||||
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import {
|
||||
relaunchAppInChildProcess,
|
||||
relaunchOnExitCode,
|
||||
@@ -108,37 +84,13 @@ import {
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
import { cleanupBackgroundLogs } from './utils/logCleanup.js';
|
||||
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
const HELP_FLAGS = new Set(['--help', '-h']);
|
||||
const VERSION_FLAGS = new Set(['--version', '-v']);
|
||||
|
||||
function getStartupFastPath(args: string[]): 'help' | 'version' | null {
|
||||
if (args.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.every((arg) => HELP_FLAGS.has(arg))) {
|
||||
return 'help';
|
||||
}
|
||||
|
||||
if (args.every((arg) => VERSION_FLAGS.has(arg))) {
|
||||
return 'version';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateDnsResolutionOrder(
|
||||
order: string | undefined,
|
||||
): DnsResolutionOrder {
|
||||
@@ -217,180 +169,19 @@ export async function startInteractiveUI(
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
// Dynamically import the heavy UI module so React/Ink are only parsed when needed
|
||||
const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js');
|
||||
await doStartUI(
|
||||
config,
|
||||
settings,
|
||||
startupWarnings,
|
||||
workspaceRoot,
|
||||
resumedSessionData,
|
||||
initializationResult,
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
if (mouseEventsEnabled) {
|
||||
enableMouseEvents();
|
||||
registerCleanup(() => {
|
||||
disableMouseEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const { matchers, errors } = await loadKeyMatchers();
|
||||
errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error);
|
||||
});
|
||||
|
||||
const version = await getVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeyMatchersProvider value={matchers}>
|
||||
<KeypressProvider
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</KeyMatchersProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<AppWrapper />
|
||||
),
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.warn('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
}
|
||||
|
||||
async function runStartupCleanup(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): Promise<void> {
|
||||
const projectTempDir = config.storage.getProjectTempDir();
|
||||
try {
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(projectTempDir),
|
||||
cleanupToolOutputFiles(
|
||||
settings.merged,
|
||||
config.getDebugMode(),
|
||||
projectTempDir,
|
||||
),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
} catch (error) {
|
||||
debugLogger.warn('Deferred startup cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function main() {
|
||||
const fastPath = getStartupFastPath(process.argv.slice(2));
|
||||
if (fastPath === 'version') {
|
||||
writeToStdout(`${await getVersion()}\n`);
|
||||
return;
|
||||
}
|
||||
if (fastPath === 'help') {
|
||||
await parseArguments(createDefaultMergedSettings());
|
||||
return;
|
||||
}
|
||||
|
||||
const cliStartupHandle = startupProfiler.start('cli_startup');
|
||||
|
||||
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
|
||||
@@ -424,10 +215,6 @@ export async function main() {
|
||||
coreEvents.emitFeedback('warning', error.message);
|
||||
});
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
const trustedFolders = loadTrustedFolders();
|
||||
trustedFolders.errors.forEach((error: TrustedFoldersError) => {
|
||||
coreEvents.emitFeedback(
|
||||
@@ -435,6 +222,17 @@ export async function main() {
|
||||
`Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
cleanupCheckpoints(),
|
||||
cleanupToolOutputFiles(settings.merged),
|
||||
cleanupBackgroundLogs(),
|
||||
]);
|
||||
|
||||
const parseArgsHandle = startupProfiler.start('parse_arguments');
|
||||
const argv = await parseArguments(settings.merged);
|
||||
parseArgsHandle?.end();
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
(settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0)
|
||||
@@ -502,13 +300,9 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapConfigHandle = startupProfiler.start('load_bootstrap_config');
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
mode: 'bootstrap',
|
||||
loadedSettings: settings,
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
bootstrapConfigHandle?.end();
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
@@ -618,7 +412,9 @@ export async function main() {
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
} else if (memoryArgs.length > 0) {
|
||||
} else {
|
||||
// Relaunch app so we always have a child process that can be internally
|
||||
// restarted if needed.
|
||||
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
|
||||
}
|
||||
}
|
||||
@@ -629,7 +425,6 @@ export async function main() {
|
||||
{
|
||||
const loadConfigHandle = startupProfiler.start('load_cli_config');
|
||||
const config = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
loadedSettings: settings,
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
@@ -707,13 +502,6 @@ export async function main() {
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
const startupCleanup = runStartupCleanup(config, settings);
|
||||
if (config.isInteractive()) {
|
||||
void startupCleanup;
|
||||
} else {
|
||||
await startupCleanup;
|
||||
}
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
|
||||
// Set this as early as possible to avoid spurious characters from
|
||||
@@ -897,25 +685,6 @@ export async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
if (!settings.merged.ui.hideWindowTitle) {
|
||||
// Initial state before React loop starts
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
writeToStdout(`\x1b]0;${windowTitle}\x07`);
|
||||
|
||||
process.on('exit', () => {
|
||||
writeToStdout(`\x1b]0;\x07`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeOutputListenersAndFlush() {
|
||||
// If there are no listeners for output, make sure we flush so output is not
|
||||
// lost.
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { basename } from 'node:path';
|
||||
import { AppContainer } from './ui/AppContainer.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
coreEvents,
|
||||
createWorkingStdio,
|
||||
disableMouseEvents,
|
||||
enableMouseEvents,
|
||||
disableLineWrapping,
|
||||
enableLineWrapping,
|
||||
shouldEnterAlternateScreen,
|
||||
recordSlowRender,
|
||||
writeToStdout,
|
||||
getVersion,
|
||||
debugLogger,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { InitializationResult } from './core/initializer.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||
import { SettingsContext } from './ui/contexts/SettingsContext.js';
|
||||
import { MouseProvider } from './ui/contexts/MouseContext.js';
|
||||
import { StreamingState } from './ui/types.js';
|
||||
import { computeTerminalTitle } from './utils/windowTitle.js';
|
||||
|
||||
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
|
||||
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
|
||||
import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js';
|
||||
import { loadKeyMatchers } from './ui/key/keyMatchers.js';
|
||||
import { KeypressProvider } from './ui/contexts/KeypressContext.js';
|
||||
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
|
||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
|
||||
import { profiler } from './ui/components/DebugProfiler.js';
|
||||
|
||||
const SLOW_RENDER_MS = 200;
|
||||
|
||||
export async function startInteractiveUI(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
startupWarnings: StartupWarning[],
|
||||
workspaceRoot: string = process.cwd(),
|
||||
resumedSessionData: ResumedSessionData | undefined,
|
||||
initializationResult: InitializationResult,
|
||||
) {
|
||||
// Never enter Ink alternate buffer mode when screen reader mode is enabled
|
||||
// as there is no benefit of alternate buffer mode when using a screen reader
|
||||
// and the Ink alternate buffer mode requires line wrapping harmful to
|
||||
// screen readers.
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const mouseEventsEnabled = useAlternateBuffer;
|
||||
if (mouseEventsEnabled) {
|
||||
enableMouseEvents();
|
||||
registerCleanup(() => {
|
||||
disableMouseEvents();
|
||||
});
|
||||
}
|
||||
|
||||
const { matchers, errors } = await loadKeyMatchers();
|
||||
errors.forEach((error) => {
|
||||
coreEvents.emitFeedback('warning', error);
|
||||
});
|
||||
|
||||
const version = await getVersion();
|
||||
setWindowTitle(basename(workspaceRoot), settings);
|
||||
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
debugMode: config.getDebugMode(),
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio();
|
||||
|
||||
const isShpool = !!process.env['SHPOOL_SESSION_NAME'];
|
||||
|
||||
// Create wrapper component to use hooks inside render
|
||||
const AppWrapper = () => {
|
||||
useKittyKeyboardProtocol();
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeyMatchersProvider value={matchers}>
|
||||
<KeypressProvider
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<MouseProvider
|
||||
mouseEventsEnabled={mouseEventsEnabled}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general.debugKeystrokeLogging
|
||||
}
|
||||
>
|
||||
<TerminalProvider>
|
||||
<ScrollProvider>
|
||||
<OverflowProvider>
|
||||
<SessionStatsProvider>
|
||||
<VimModeProvider>
|
||||
<AppContainer
|
||||
config={config}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
resumedSessionData={resumedSessionData}
|
||||
initializationResult={initializationResult}
|
||||
/>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</OverflowProvider>
|
||||
</ScrollProvider>
|
||||
</TerminalProvider>
|
||||
</MouseProvider>
|
||||
</KeypressProvider>
|
||||
</KeyMatchersProvider>
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
if (isShpool) {
|
||||
// Wait a moment for shpool to stabilize terminal size and state.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
const instance = render(
|
||||
process.env['DEBUG'] ? (
|
||||
<React.StrictMode>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<AppWrapper />
|
||||
),
|
||||
{
|
||||
stdout: inkStdout,
|
||||
stderr: inkStderr,
|
||||
stdin: process.stdin,
|
||||
exitOnCtrlC: false,
|
||||
isScreenReaderEnabled: config.getScreenReader(),
|
||||
onRender: ({ renderTime }: { renderTime: number }) => {
|
||||
if (renderTime > SLOW_RENDER_MS) {
|
||||
recordSlowRender(config, renderTime);
|
||||
}
|
||||
profiler.reportFrameRendered();
|
||||
},
|
||||
patchConsole: false,
|
||||
alternateBuffer: useAlternateBuffer,
|
||||
incrementalRendering:
|
||||
settings.merged.ui.incrementalRendering !== false &&
|
||||
useAlternateBuffer &&
|
||||
!isShpool,
|
||||
},
|
||||
);
|
||||
|
||||
if (useAlternateBuffer) {
|
||||
disableLineWrapping();
|
||||
registerCleanup(() => {
|
||||
enableLineWrapping();
|
||||
});
|
||||
}
|
||||
|
||||
checkForUpdates(settings)
|
||||
.then((info) => {
|
||||
handleAutoUpdate(info, settings, config.getProjectRoot());
|
||||
})
|
||||
.catch((err) => {
|
||||
// Silently ignore update check errors.
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.warn('Update check failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(() => instance.unmount());
|
||||
|
||||
registerCleanup(setupTtyCheck());
|
||||
}
|
||||
|
||||
function setWindowTitle(title: string, settings: LoadedSettings) {
|
||||
if (!settings.merged.ui.hideWindowTitle) {
|
||||
// Initial state before React loop starts
|
||||
const windowTitle = computeTerminalTitle({
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfirming: false,
|
||||
isSilentWorking: false,
|
||||
folderName: title,
|
||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||
});
|
||||
writeToStdout(`\x1b]0;${windowTitle}\x07`);
|
||||
|
||||
process.on('exit', () => {
|
||||
writeToStdout(`\x1b]0;\x07`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,8 +263,8 @@ export async function runNonInteractive({
|
||||
onDebugMessage: () => {},
|
||||
messageId: Date.now(),
|
||||
signal: abortController.signal,
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
|
||||
@@ -162,6 +162,7 @@ import {
|
||||
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
|
||||
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
|
||||
import { isSlashCommand } from './utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../utils/commands.js';
|
||||
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
|
||||
import { useTimedMessage } from './hooks/useTimedMessage.js';
|
||||
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
|
||||
@@ -796,7 +797,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
Logging in with Google... Restarting Gemini CLI to continue.
|
||||
----------------------------------------------------------------
|
||||
`);
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
await relaunchApp();
|
||||
}
|
||||
}
|
||||
setAuthState(AuthState.Authenticated);
|
||||
@@ -1289,6 +1290,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
...pendingGeminiHistoryItems,
|
||||
]);
|
||||
|
||||
if (isSlash && isAgentRunning) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
submittedValue,
|
||||
slashCommands ?? [],
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
void handleSlashCommand(submittedValue);
|
||||
addInput(submittedValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
|
||||
handleHintSubmit(submittedValue);
|
||||
addInput(submittedValue);
|
||||
@@ -1332,6 +1345,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
addMessage,
|
||||
addInput,
|
||||
submitQuery,
|
||||
handleSlashCommand,
|
||||
slashCommands,
|
||||
isMcpReady,
|
||||
streamingState,
|
||||
messageQueue.length,
|
||||
@@ -2478,7 +2493,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
onHintClear: () => {},
|
||||
onHintSubmit: () => {},
|
||||
handleRestart: async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
},
|
||||
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
|
||||
if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) {
|
||||
|
||||
@@ -22,8 +22,9 @@ import { AuthState } from '../types.js';
|
||||
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { validateAuthMethodWithSettings } from './useAuth.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import { Text } from 'ink';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
@@ -35,12 +36,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./useAuth.js', () => ({
|
||||
validateAuthMethodWithSettings: vi.fn(),
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/processUtils.js', () => ({
|
||||
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock('./useAuth.js', () => ({
|
||||
validateAuthMethodWithSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
@@ -63,7 +64,7 @@ vi.mock('../components/shared/RadioButtonSelect.js', () => ({
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRadioButtonSelect = RadioButtonSelect as Mock;
|
||||
const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
|
||||
const mockedRelaunchApp = relaunchApp as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('AuthDialog', () => {
|
||||
let props: {
|
||||
@@ -84,7 +85,6 @@ describe('AuthDialog', () => {
|
||||
props = {
|
||||
config: {
|
||||
isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as Config,
|
||||
settings: {
|
||||
merged: {
|
||||
@@ -351,8 +351,11 @@ describe('AuthDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('restarts for Sign in with Google when browser is suppressed', async () => {
|
||||
it('exits process for Sign in with Google when browser is suppressed', async () => {
|
||||
vi.useFakeTimers();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
@@ -368,8 +371,10 @@ describe('AuthDialog', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalled();
|
||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
|
||||
@@ -132,9 +132,7 @@ export function AuthDialog({
|
||||
config.isBrowserLaunchSuppressed()
|
||||
) {
|
||||
setExiting(true);
|
||||
setTimeout(async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
}, 100);
|
||||
setTimeout(relaunchApp, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,23 +8,30 @@ import { render } from '../../test-utils/render.js';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { runExitCleanup } from '../../utils/cleanup.js';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
_resetRelaunchStateForTesting,
|
||||
} from '../../utils/processUtils.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
|
||||
// Mocks
|
||||
vi.mock('../hooks/useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/processUtils.js', () => ({
|
||||
relaunchApp: vi.fn().mockResolvedValue(undefined),
|
||||
vi.mock('../../utils/cleanup.js', () => ({
|
||||
runExitCleanup: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseKeypress = useKeypress as Mock;
|
||||
const mockedRelaunchApp = relaunchApp as Mock;
|
||||
const mockedRunExitCleanup = runExitCleanup as Mock;
|
||||
|
||||
describe('LoginWithGoogleRestartDialog', () => {
|
||||
const onDismiss = vi.fn();
|
||||
const exitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const mockConfig = {
|
||||
getRemoteAdminSettings: vi.fn(),
|
||||
@@ -32,7 +39,9 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
exitSpy.mockClear();
|
||||
vi.useRealTimers();
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
it('renders correctly', async () => {
|
||||
@@ -69,33 +78,36 @@ describe('LoginWithGoogleRestartDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each(['r', 'R'])('restarts when %s is pressed', async (keyName) => {
|
||||
vi.useFakeTimers();
|
||||
it.each(['r', 'R'])(
|
||||
'calls runExitCleanup and process.exit when %s is pressed',
|
||||
async (keyName) => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
const { waitUntilReady, unmount } = render(
|
||||
<LoginWithGoogleRestartDialog
|
||||
onDismiss={onDismiss}
|
||||
config={mockConfig}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
|
||||
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: keyName,
|
||||
});
|
||||
keypressHandler({
|
||||
name: keyName,
|
||||
shift: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: keyName,
|
||||
});
|
||||
|
||||
// Advance timers to trigger the setTimeout callback
|
||||
await vi.runAllTimersAsync();
|
||||
// Advance timers to trigger the setTimeout callback
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRelaunchApp).toHaveBeenCalledWith(undefined);
|
||||
expect(mockedRunExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
});
|
||||
vi.useRealTimers();
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -26,7 +26,16 @@ export const LoginWithGoogleRestartDialog = ({
|
||||
return true;
|
||||
} else if (key.name === 'r' || key.name === 'R') {
|
||||
setTimeout(async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
if (process.send) {
|
||||
const remoteSettings = config.getRemoteAdminSettings();
|
||||
if (remoteSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteSettings,
|
||||
});
|
||||
}
|
||||
}
|
||||
await relaunchApp();
|
||||
}, 100);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const aboutCommand: SlashCommand = {
|
||||
description: 'Show version info',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context) => {
|
||||
const osVersion = process.platform;
|
||||
let sandboxEnv = 'no sandbox';
|
||||
|
||||
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
|
||||
description: 'View and edit Gemini CLI settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (_context, _args): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'settings',
|
||||
|
||||
@@ -123,7 +123,6 @@ async function downloadFiles({
|
||||
downloads.push(
|
||||
(async () => {
|
||||
const endpoint = `${REPO_DOWNLOAD_URL}/refs/tags/${releaseTag}/${SOURCE_DIR}/${fileBasename}`;
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
dispatcher: proxy ? new ProxyAgent(proxy) : undefined,
|
||||
|
||||
@@ -84,6 +84,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Check session stats. Usage: /stats [session|model|tools]',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: false,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context: CommandContext) => {
|
||||
await defaultSessionView(context);
|
||||
},
|
||||
@@ -93,6 +94,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show session-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context: CommandContext) => {
|
||||
await defaultSessionView(context);
|
||||
},
|
||||
@@ -102,6 +104,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show model-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context: CommandContext) => {
|
||||
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
|
||||
const currentModel = context.services.config?.getModel();
|
||||
@@ -125,6 +128,7 @@ export const statsCommand: SlashCommand = {
|
||||
description: 'Show tool-specific usage statistics',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: (context: CommandContext) => {
|
||||
context.ui.addItem({
|
||||
type: MessageType.TOOL_STATS,
|
||||
|
||||
@@ -207,6 +207,11 @@ export interface SlashCommand {
|
||||
*/
|
||||
autoExecute?: boolean;
|
||||
|
||||
/**
|
||||
* Whether this command can be safely executed while the agent is busy (e.g. streaming a response).
|
||||
*/
|
||||
isSafeConcurrent?: boolean;
|
||||
|
||||
// Optional metadata for extension commands
|
||||
extensionName?: string;
|
||||
extensionId?: string;
|
||||
|
||||
@@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = {
|
||||
description: 'Toggle vim mode on/off',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
isSafeConcurrent: true,
|
||||
action: async (context, _args) => {
|
||||
const newVimState = await context.ui.toggleVimEnabled();
|
||||
|
||||
|
||||
@@ -231,9 +231,7 @@ export const DialogManager = ({
|
||||
<Box flexDirection="column">
|
||||
<SettingsDialog
|
||||
onSelect={() => uiActions.closeSettingsDialog()}
|
||||
onRestartRequest={async () => {
|
||||
await relaunchApp(config.getRemoteAdminSettings());
|
||||
}}
|
||||
onRestartRequest={relaunchApp}
|
||||
availableTerminalHeight={terminalHeight - staticExtraHeight}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -94,6 +94,12 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const mockSlashCommands: SlashCommand[] = [
|
||||
{
|
||||
name: 'stats',
|
||||
description: 'Check stats',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
isSafeConcurrent: true,
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
@@ -3876,6 +3882,13 @@ describe('InputPrompt', () => {
|
||||
shouldSubmit: false,
|
||||
errorMessage: 'Slash commands cannot be queued',
|
||||
},
|
||||
{
|
||||
name: 'should allow concurrent-safe slash commands',
|
||||
bufferText: '/stats',
|
||||
shellMode: false,
|
||||
shouldSubmit: true,
|
||||
errorMessage: null,
|
||||
},
|
||||
{
|
||||
name: 'should prevent shell commands',
|
||||
bufferText: 'ls',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Box, Text, useStdout, type DOMElement } from 'ink';
|
||||
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||
import { escapeAtSymbols } from '../hooks/atCommandProcessor.js';
|
||||
import { HalfLinePaddedBox } from './shared/HalfLinePaddedBox.js';
|
||||
import {
|
||||
type TextBuffer,
|
||||
@@ -58,6 +59,7 @@ import {
|
||||
isAutoExecutableCommand,
|
||||
isSlashCommand,
|
||||
} from '../utils/commandUtils.js';
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
@@ -408,6 +410,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
(isSlash || isShell) &&
|
||||
streamingState === StreamingState.Responding
|
||||
) {
|
||||
if (isSlash) {
|
||||
const { commandToExecute } = parseSlashCommand(
|
||||
trimmedMessage,
|
||||
slashCommands,
|
||||
);
|
||||
if (commandToExecute?.isSafeConcurrent) {
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setQueueErrorMessage(
|
||||
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
|
||||
);
|
||||
@@ -415,7 +428,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
inputHistory.handleSubmit(trimmedMessage);
|
||||
},
|
||||
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
|
||||
[
|
||||
inputHistory,
|
||||
shellModeActive,
|
||||
streamingState,
|
||||
setQueueErrorMessage,
|
||||
slashCommands,
|
||||
],
|
||||
);
|
||||
|
||||
// Effect to reset completion if history navigation just occurred and set the text
|
||||
@@ -497,7 +516,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
stdout.write('\x1b]52;c;?\x07');
|
||||
} else {
|
||||
const textToInsert = await clipboardy.read();
|
||||
buffer.insert(textToInsert, { paste: true });
|
||||
const escapedText = settings.ui?.escapePastedAtSymbols
|
||||
? escapeAtSymbols(textToInsert)
|
||||
: textToInsert;
|
||||
buffer.insert(escapedText, { paste: true });
|
||||
|
||||
if (isLargePaste(textToInsert)) {
|
||||
appEvents.emit(AppEvent.TransientMessage, {
|
||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||
@@ -732,8 +755,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
pasteTimeoutRef.current = null;
|
||||
}, 40);
|
||||
}
|
||||
// Ensure we never accidentally interpret paste as regular input.
|
||||
buffer.handleInput(key);
|
||||
if (settings.ui?.escapePastedAtSymbols) {
|
||||
buffer.handleInput({
|
||||
...key,
|
||||
sequence: escapeAtSymbols(key.sequence || ''),
|
||||
});
|
||||
} else {
|
||||
buffer.handleInput(key);
|
||||
}
|
||||
|
||||
if (key.sequence && isLargePaste(key.sequence)) {
|
||||
appEvents.emit(AppEvent.TransientMessage, {
|
||||
message: `Press ${formatCommand(Command.EXPAND_PASTE)} to expand pasted text`,
|
||||
@@ -1273,6 +1303,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
forceShowShellSuggestions,
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -13,9 +13,8 @@ import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import path from 'node:path';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import type { SessionInfo, TextMatch } from '../../utils/sessionUtils.js';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import {
|
||||
cleanMessage,
|
||||
formatRelativeTime,
|
||||
getSessionFiles,
|
||||
} from '../../utils/sessionUtils.js';
|
||||
@@ -150,124 +149,7 @@ const SessionBrowserEmpty = (): React.JSX.Element => (
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* Sorts an array of sessions by the specified criteria.
|
||||
* @param sessions - Array of sessions to sort
|
||||
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
|
||||
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
|
||||
* @returns New sorted array of sessions
|
||||
*/
|
||||
const sortSessions = (
|
||||
sessions: SessionInfo[],
|
||||
sortBy: 'date' | 'messages' | 'name',
|
||||
reverse: boolean,
|
||||
): SessionInfo[] => {
|
||||
const sorted = [...sessions].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
|
||||
);
|
||||
case 'messages':
|
||||
return b.messageCount - a.messageCount;
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return reverse ? sorted.reverse() : sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all text matches for a search query within conversation messages.
|
||||
* Creates TextMatch objects with context (10 chars before/after) and role information.
|
||||
* @param messages - Array of messages to search through
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Array of TextMatch objects containing match context and metadata
|
||||
*/
|
||||
const findTextMatches = (
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
query: string,
|
||||
): TextMatch[] => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches: TextMatch[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const m = cleanMessage(message.content);
|
||||
const lowerContent = m.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
|
||||
if (matchIndex === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, matchIndex - 10);
|
||||
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
|
||||
|
||||
const snippet = m.slice(contextStart, contextEnd);
|
||||
const relativeMatchStart = matchIndex - contextStart;
|
||||
const relativeMatchEnd = relativeMatchStart + query.length;
|
||||
|
||||
let before = snippet.slice(0, relativeMatchStart);
|
||||
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
|
||||
let after = snippet.slice(relativeMatchEnd);
|
||||
|
||||
if (contextStart > 0) before = '…' + before;
|
||||
if (contextEnd < m.length) after = after + '…';
|
||||
|
||||
matches.push({ before, match, after, role: message.role });
|
||||
startIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters sessions based on a search query, checking titles, IDs, and full content.
|
||||
* Also populates matchSnippets and matchCount for sessions with content matches.
|
||||
* @param sessions - Array of sessions to filter
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Filtered array of sessions that match the query
|
||||
*/
|
||||
const filterSessions = (
|
||||
sessions: SessionInfo[],
|
||||
query: string,
|
||||
): SessionInfo[] => {
|
||||
if (!query.trim()) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
matchSnippets: undefined,
|
||||
matchCount: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return sessions.filter((session) => {
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
?.toLowerCase()
|
||||
.includes(lowerQuery);
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
if (session.messages) {
|
||||
session.matchSnippets = findTextMatches(session.messages, query);
|
||||
session.matchCount = session.matchSnippets.length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
|
||||
|
||||
/**
|
||||
* Search input display component.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sortSessions, findTextMatches, filterSessions } from './utils.js';
|
||||
import type { SessionInfo } from '../../../utils/sessionUtils.js';
|
||||
|
||||
describe('SessionBrowser utils', () => {
|
||||
const createTestSession = (overrides: Partial<SessionInfo>): SessionInfo => ({
|
||||
id: 'test-id',
|
||||
file: 'test-file',
|
||||
fileName: 'test-file.json',
|
||||
startTime: '2025-01-01T10:00:00Z',
|
||||
lastUpdated: '2025-01-01T10:00:00Z',
|
||||
messageCount: 1,
|
||||
displayName: 'Test Session',
|
||||
firstUserMessage: 'Hello',
|
||||
isCurrentSession: false,
|
||||
index: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('sortSessions', () => {
|
||||
it('sorts by date ascending/descending', () => {
|
||||
const older = createTestSession({
|
||||
id: '1',
|
||||
lastUpdated: '2025-01-01T10:00:00Z',
|
||||
});
|
||||
const newer = createTestSession({
|
||||
id: '2',
|
||||
lastUpdated: '2025-01-02T10:00:00Z',
|
||||
});
|
||||
|
||||
const desc = sortSessions([older, newer], 'date', false);
|
||||
expect(desc[0].id).toBe('2');
|
||||
|
||||
const asc = sortSessions([older, newer], 'date', true);
|
||||
expect(asc[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('sorts by message count ascending/descending', () => {
|
||||
const more = createTestSession({ id: '1', messageCount: 10 });
|
||||
const less = createTestSession({ id: '2', messageCount: 2 });
|
||||
|
||||
const desc = sortSessions([more, less], 'messages', false);
|
||||
expect(desc[0].id).toBe('1');
|
||||
|
||||
const asc = sortSessions([more, less], 'messages', true);
|
||||
expect(asc[0].id).toBe('2');
|
||||
});
|
||||
|
||||
it('sorts by name ascending/descending', () => {
|
||||
const apple = createTestSession({ id: '1', displayName: 'Apple' });
|
||||
const banana = createTestSession({ id: '2', displayName: 'Banana' });
|
||||
|
||||
const asc = sortSessions([apple, banana], 'name', true);
|
||||
expect(asc[0].id).toBe('2'); // Reversed alpha
|
||||
|
||||
const desc = sortSessions([apple, banana], 'name', false);
|
||||
expect(desc[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTextMatches', () => {
|
||||
it('returns empty array if query is practically empty', () => {
|
||||
expect(
|
||||
findTextMatches([{ role: 'user', content: 'hello world' }], ' '),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds simple matches with surrounding context', () => {
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: 'What is the capital of France?' },
|
||||
];
|
||||
|
||||
const matches = findTextMatches(messages, 'capital');
|
||||
expect(matches.length).toBe(1);
|
||||
expect(matches[0].match).toBe('capital');
|
||||
expect(matches[0].before.endsWith('the ')).toBe(true);
|
||||
expect(matches[0].after.startsWith(' of')).toBe(true);
|
||||
expect(matches[0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('finds multiple matches in a single message', () => {
|
||||
const messages: Array<{ role: 'user' | 'assistant'; content: string }> = [
|
||||
{ role: 'user', content: 'test here test there' },
|
||||
];
|
||||
|
||||
const matches = findTextMatches(messages, 'test');
|
||||
expect(matches.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterSessions', () => {
|
||||
it('returns all sessions when query is blank and clears existing snippets', () => {
|
||||
const sessions = [createTestSession({ id: '1', matchCount: 5 })];
|
||||
|
||||
const result = filterSessions(sessions, ' ');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].matchCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('filters by displayName', () => {
|
||||
const session1 = createTestSession({
|
||||
id: '1',
|
||||
displayName: 'Cats and Dogs',
|
||||
});
|
||||
const session2 = createTestSession({ id: '2', displayName: 'Fish' });
|
||||
|
||||
const result = filterSessions([session1, session2], 'cat');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('populates match snippets if it matches content inside messages array', () => {
|
||||
const sessionWithMessages = createTestSession({
|
||||
id: '1',
|
||||
displayName: 'Unrelated Title',
|
||||
fullContent: 'This mentions a giraffe',
|
||||
messages: [{ role: 'user', content: 'This mentions a giraffe' }],
|
||||
});
|
||||
|
||||
const result = filterSessions([sessionWithMessages], 'giraffe');
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].matchCount).toBe(1);
|
||||
expect(result[0].matchSnippets?.[0].match).toBe('giraffe');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
cleanMessage,
|
||||
type SessionInfo,
|
||||
type TextMatch,
|
||||
} from '../../../utils/sessionUtils.js';
|
||||
|
||||
/**
|
||||
* Sorts an array of sessions by the specified criteria.
|
||||
* @param sessions - Array of sessions to sort
|
||||
* @param sortBy - Sort criteria: 'date' (lastUpdated), 'messages' (messageCount), or 'name' (displayName)
|
||||
* @param reverse - Whether to reverse the sort order (ascending instead of descending)
|
||||
* @returns New sorted array of sessions
|
||||
*/
|
||||
export const sortSessions = (
|
||||
sessions: SessionInfo[],
|
||||
sortBy: 'date' | 'messages' | 'name',
|
||||
reverse: boolean,
|
||||
): SessionInfo[] => {
|
||||
const sorted = [...sessions].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date':
|
||||
return (
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
|
||||
);
|
||||
case 'messages':
|
||||
return b.messageCount - a.messageCount;
|
||||
case 'name':
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
return reverse ? sorted.reverse() : sorted;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds all text matches for a search query within conversation messages.
|
||||
* Creates TextMatch objects with context (10 chars before/after) and role information.
|
||||
* @param messages - Array of messages to search through
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Array of TextMatch objects containing match context and metadata
|
||||
*/
|
||||
export const findTextMatches = (
|
||||
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
query: string,
|
||||
): TextMatch[] => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const matches: TextMatch[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const m = cleanMessage(message.content);
|
||||
const lowerContent = m.toLowerCase();
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const matchIndex = lowerContent.indexOf(lowerQuery, startIndex);
|
||||
if (matchIndex === -1) break;
|
||||
|
||||
const contextStart = Math.max(0, matchIndex - 10);
|
||||
const contextEnd = Math.min(m.length, matchIndex + query.length + 10);
|
||||
|
||||
const snippet = m.slice(contextStart, contextEnd);
|
||||
const relativeMatchStart = matchIndex - contextStart;
|
||||
const relativeMatchEnd = relativeMatchStart + query.length;
|
||||
|
||||
let before = snippet.slice(0, relativeMatchStart);
|
||||
const match = snippet.slice(relativeMatchStart, relativeMatchEnd);
|
||||
let after = snippet.slice(relativeMatchEnd);
|
||||
|
||||
if (contextStart > 0) before = '…' + before;
|
||||
if (contextEnd < m.length) after = after + '…';
|
||||
|
||||
matches.push({ before, match, after, role: message.role });
|
||||
startIndex = matchIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters sessions based on a search query, checking titles, IDs, and full content.
|
||||
* Also populates matchSnippets and matchCount for sessions with content matches.
|
||||
* @param sessions - Array of sessions to filter
|
||||
* @param query - Search query string (case-insensitive)
|
||||
* @returns Filtered array of sessions that match the query
|
||||
*/
|
||||
export const filterSessions = (
|
||||
sessions: SessionInfo[],
|
||||
query: string,
|
||||
): SessionInfo[] => {
|
||||
if (!query.trim()) {
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
matchSnippets: undefined,
|
||||
matchCount: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return sessions.filter((session) => {
|
||||
const titleMatch =
|
||||
session.displayName.toLowerCase().includes(lowerQuery) ||
|
||||
session.id.toLowerCase().includes(lowerQuery) ||
|
||||
session.firstUserMessage.toLowerCase().includes(lowerQuery);
|
||||
|
||||
const contentMatch = session.fullContent
|
||||
?.toLowerCase()
|
||||
.includes(lowerQuery);
|
||||
|
||||
if (titleMatch || contentMatch) {
|
||||
if (session.messages) {
|
||||
session.matchSnippets = findTextMatches(session.messages, query);
|
||||
session.matchCount = session.matchSnippets.length;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
};
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from '../utils/displayUtils.js';
|
||||
import { computeSessionStats } from '../utils/computeStats.js';
|
||||
import {
|
||||
type Config,
|
||||
type RetrieveUserQuotaResponse,
|
||||
isActiveModel,
|
||||
getDisplayString,
|
||||
@@ -88,13 +89,16 @@ const Section: React.FC<SectionProps> = ({ title, children }) => (
|
||||
// Logic for building the unified list of table rows
|
||||
const buildModelRows = (
|
||||
models: Record<string, ModelMetrics>,
|
||||
config: Config,
|
||||
quotas?: RetrieveUserQuotaResponse,
|
||||
useGemini3_1 = false,
|
||||
useCustomToolModel = false,
|
||||
) => {
|
||||
const getBaseModelName = (name: string) => name.replace('-001', '');
|
||||
const usedModelNames = new Set(
|
||||
Object.keys(models).map(getBaseModelName).map(getDisplayString),
|
||||
Object.keys(models)
|
||||
.map(getBaseModelName)
|
||||
.map((name) => getDisplayString(name, config)),
|
||||
);
|
||||
|
||||
// 1. Models with active usage
|
||||
@@ -104,7 +108,7 @@ const buildModelRows = (
|
||||
const inputTokens = metrics.tokens.input;
|
||||
return {
|
||||
key: name,
|
||||
modelName: getDisplayString(modelName),
|
||||
modelName: getDisplayString(modelName, config),
|
||||
requests: metrics.api.totalRequests,
|
||||
cachedTokens: cachedTokens.toLocaleString(),
|
||||
inputTokens: inputTokens.toLocaleString(),
|
||||
@@ -121,11 +125,11 @@ const buildModelRows = (
|
||||
(b) =>
|
||||
b.modelId &&
|
||||
isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) &&
|
||||
!usedModelNames.has(getDisplayString(b.modelId)),
|
||||
!usedModelNames.has(getDisplayString(b.modelId, config)),
|
||||
)
|
||||
.map((bucket) => ({
|
||||
key: bucket.modelId!,
|
||||
modelName: getDisplayString(bucket.modelId!),
|
||||
modelName: getDisplayString(bucket.modelId!, config),
|
||||
requests: '-',
|
||||
cachedTokens: '-',
|
||||
inputTokens: '-',
|
||||
@@ -139,6 +143,7 @@ const buildModelRows = (
|
||||
|
||||
const ModelUsageTable: React.FC<{
|
||||
models: Record<string, ModelMetrics>;
|
||||
config: Config;
|
||||
quotas?: RetrieveUserQuotaResponse;
|
||||
cacheEfficiency: number;
|
||||
totalCachedTokens: number;
|
||||
@@ -150,6 +155,7 @@ const ModelUsageTable: React.FC<{
|
||||
useCustomToolModel?: boolean;
|
||||
}> = ({
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
cacheEfficiency,
|
||||
totalCachedTokens,
|
||||
@@ -162,7 +168,13 @@ const ModelUsageTable: React.FC<{
|
||||
}) => {
|
||||
const { stdout } = useStdout();
|
||||
const terminalWidth = stdout?.columns ?? 84;
|
||||
const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel);
|
||||
const rows = buildModelRows(
|
||||
models,
|
||||
config,
|
||||
quotas,
|
||||
useGemini3_1,
|
||||
useCustomToolModel,
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
@@ -676,6 +688,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
|
||||
</Section>
|
||||
<ModelUsageTable
|
||||
models={models}
|
||||
config={config}
|
||||
quotas={quotas}
|
||||
cacheEfficiency={computed.cacheEfficiency}
|
||||
totalCachedTokens={computed.totalCachedTokens}
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { handleAtCommand } from './atCommandProcessor.js';
|
||||
import {
|
||||
handleAtCommand,
|
||||
escapeAtSymbols,
|
||||
unescapeLiteralAt,
|
||||
} from './atCommandProcessor.js';
|
||||
import {
|
||||
FileDiscoveryService,
|
||||
GlobTool,
|
||||
@@ -1481,3 +1485,56 @@ describe('handleAtCommand', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeAtSymbols', () => {
|
||||
it('escapes a bare @ symbol', () => {
|
||||
expect(escapeAtSymbols('test@domain.com')).toBe('test\\@domain.com');
|
||||
});
|
||||
|
||||
it('escapes a leading @ symbol', () => {
|
||||
expect(escapeAtSymbols('@scope/pkg')).toBe('\\@scope/pkg');
|
||||
});
|
||||
|
||||
it('escapes multiple @ symbols', () => {
|
||||
expect(escapeAtSymbols('a@b and c@d')).toBe('a\\@b and c\\@d');
|
||||
});
|
||||
|
||||
it('does not double-escape an already escaped @', () => {
|
||||
expect(escapeAtSymbols('test\\@domain.com')).toBe('test\\@domain.com');
|
||||
});
|
||||
|
||||
it('returns text with no @ unchanged', () => {
|
||||
expect(escapeAtSymbols('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(escapeAtSymbols('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unescapeLiteralAt', () => {
|
||||
it('unescapes \\@ to @', () => {
|
||||
expect(unescapeLiteralAt('test\\@domain.com')).toBe('test@domain.com');
|
||||
});
|
||||
|
||||
it('unescapes a leading \\@', () => {
|
||||
expect(unescapeLiteralAt('\\@scope/pkg')).toBe('@scope/pkg');
|
||||
});
|
||||
|
||||
it('unescapes multiple \\@ sequences', () => {
|
||||
expect(unescapeLiteralAt('a\\@b and c\\@d')).toBe('a@b and c@d');
|
||||
});
|
||||
|
||||
it('returns text with no \\@ unchanged', () => {
|
||||
expect(unescapeLiteralAt('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(unescapeLiteralAt('')).toBe('');
|
||||
});
|
||||
|
||||
it('roundtrips correctly with escapeAtSymbols', () => {
|
||||
const input = 'user@example.com and @scope/pkg';
|
||||
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,26 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
const REF_CONTENT_HEADER = `\n${REFERENCE_CONTENT_START}`;
|
||||
const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
||||
|
||||
/**
|
||||
* Escapes unescaped @ symbols so they are not interpreted as @path commands.
|
||||
*/
|
||||
export function escapeAtSymbols(text: string): string {
|
||||
return text.replace(/(?<!\\)@/g, '\\@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes \@ back to @ correctly, preserving \\@ sequences.
|
||||
*/
|
||||
export function unescapeLiteralAt(text: string): string {
|
||||
return text.replace(/\\@/g, (match, offset, full) => {
|
||||
let backslashCount = 0;
|
||||
for (let i = offset - 1; i >= 0 && full[i] === '\\'; i--) {
|
||||
backslashCount++;
|
||||
}
|
||||
return backslashCount % 2 === 0 ? '@' : '\\@';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex source for the path/command part of an @ reference.
|
||||
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
||||
@@ -49,6 +69,7 @@ interface HandleAtCommandParams {
|
||||
onDebugMessage: (message: string) => void;
|
||||
messageId: number;
|
||||
signal: AbortSignal;
|
||||
escapePastedAtSymbols?: boolean;
|
||||
}
|
||||
|
||||
interface HandleAtCommandResult {
|
||||
@@ -65,7 +86,10 @@ interface AtCommandPart {
|
||||
* Parses a query string to find all '@<path>' commands and text segments.
|
||||
* Handles \ escaped spaces within paths.
|
||||
*/
|
||||
function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
function parseAllAtCommands(
|
||||
query: string,
|
||||
escapePastedAtSymbols = false,
|
||||
): AtCommandPart[] {
|
||||
const parts: AtCommandPart[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
@@ -85,7 +109,9 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
if (matchIndex > lastIndex) {
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: query.substring(lastIndex, matchIndex),
|
||||
content: escapePastedAtSymbols
|
||||
? unescapeLiteralAt(query.substring(lastIndex, matchIndex))
|
||||
: query.substring(lastIndex, matchIndex),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,7 +124,12 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
||||
|
||||
// Add remaining text
|
||||
if (lastIndex < query.length) {
|
||||
parts.push({ type: 'text', content: query.substring(lastIndex) });
|
||||
parts.push({
|
||||
type: 'text',
|
||||
content: escapePastedAtSymbols
|
||||
? unescapeLiteralAt(query.substring(lastIndex))
|
||||
: query.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out empty text parts that might result from consecutive @paths or leading/trailing spaces
|
||||
@@ -635,8 +666,9 @@ export async function handleAtCommand({
|
||||
onDebugMessage,
|
||||
messageId: userMessageTimestamp,
|
||||
signal,
|
||||
escapePastedAtSymbols = false,
|
||||
}: HandleAtCommandParams): Promise<HandleAtCommandResult> {
|
||||
const commandParts = parseAllAtCommands(query);
|
||||
const commandParts = parseAllAtCommands(query, escapePastedAtSymbols);
|
||||
|
||||
const { agentParts, resourceParts, fileParts } = categorizeAtCommands(
|
||||
commandParts,
|
||||
|
||||
@@ -837,8 +837,8 @@ export const useGeminiStream = (
|
||||
onDebugMessage,
|
||||
messageId: userMessageTimestamp,
|
||||
signal: abortSignal,
|
||||
escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
|
||||
});
|
||||
|
||||
if (atCommandResult.error) {
|
||||
onDebugMessage(atCommandResult.error);
|
||||
return { queryToSend: null, shouldProceed: false };
|
||||
@@ -874,6 +874,7 @@ export const useGeminiStream = (
|
||||
logger,
|
||||
shellModeActive,
|
||||
scheduleToolCalls,
|
||||
settings,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export type HighlightToken = {
|
||||
// It matches any character except strict delimiters (ASCII whitespace, comma, etc.).
|
||||
// This supports URIs like `@file:///example.txt` and filenames with Unicode spaces (like NNBSP).
|
||||
const HIGHLIGHT_REGEX = new RegExp(
|
||||
`(^/[a-zA-Z0-9_-]+|@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||
`(^/[a-zA-Z0-9_-]+|(?<!\\\\)@${AT_COMMAND_PATH_REGEX_SOURCE}|${PASTED_TEXT_PLACEHOLDER_REGEX.source})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
|
||||
@@ -103,21 +103,6 @@ async function drainStdin() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
export async function cleanupCheckpoints(projectTempDir?: string) {
|
||||
let tempDir = projectTempDir;
|
||||
if (!tempDir) {
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
tempDir = storage.getProjectTempDir();
|
||||
}
|
||||
const checkpointsDir = join(tempDir, 'checkpoints');
|
||||
try {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully shuts down the process, ensuring cleanup runs exactly once.
|
||||
* Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT)
|
||||
@@ -176,3 +161,15 @@ export function setupTtyCheck(): () => void {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupCheckpoints() {
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
const tempDir = storage.getProjectTempDir();
|
||||
const checkpointsDir = join(tempDir, 'checkpoints');
|
||||
try {
|
||||
await fs.rm(checkpointsDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore errors if the directory doesn't exist or fails to delete.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,6 @@ export const getLatestGitHubRelease = async (
|
||||
|
||||
const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
||||
@@ -11,56 +11,28 @@ import {
|
||||
_resetRelaunchStateForTesting,
|
||||
} from './processUtils.js';
|
||||
import * as cleanup from './cleanup.js';
|
||||
import * as relaunch from './relaunch.js';
|
||||
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
||||
|
||||
vi.mock('./handleAutoUpdate.js', () => ({
|
||||
waitForUpdateCompletion: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./relaunch.js', () => ({
|
||||
relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe('processUtils', () => {
|
||||
const processExit = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockReturnValue(undefined as never);
|
||||
const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup');
|
||||
const relaunchAppInChildProcess = vi.spyOn(
|
||||
relaunch,
|
||||
'relaunchAppInChildProcess',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetRelaunchStateForTesting();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env['SANDBOX'];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (process as any).send;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('should wait for updates, run cleanup, and exit with the relaunch code when a parent wrapper exists', async () => {
|
||||
process.env['SANDBOX'] = 'sandbox-exec';
|
||||
processExit.mockImplementationOnce(() => {
|
||||
throw new Error('PROCESS_EXIT_CALLED');
|
||||
});
|
||||
|
||||
await expect(relaunchApp()).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
expect(relaunchAppInChildProcess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should spawn a child wrapper on demand when no parent wrapper exists', async () => {
|
||||
it('should wait for updates, run cleanup, and exit with the relaunch code', async () => {
|
||||
await relaunchApp();
|
||||
expect(handleAutoUpdate.waitForUpdateCompletion).toHaveBeenCalledTimes(1);
|
||||
expect(runExitCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(relaunchAppInChildProcess).toHaveBeenCalledWith([], [], undefined);
|
||||
expect(processExit).not.toHaveBeenCalled();
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { AdminControlsSettings } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from './cleanup.js';
|
||||
import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||
|
||||
@@ -14,8 +13,7 @@ import { waitForUpdateCompletion } from './handleAutoUpdate.js';
|
||||
export const RELAUNCH_EXIT_CODE = 199;
|
||||
|
||||
/**
|
||||
* Restarts the CLI, either by signaling an existing parent wrapper or by
|
||||
* spawning one on demand.
|
||||
* Exits the process with a special code to signal that the parent process should relaunch it.
|
||||
*/
|
||||
let isRelaunching = false;
|
||||
|
||||
@@ -24,24 +22,10 @@ export function _resetRelaunchStateForTesting(): void {
|
||||
isRelaunching = false;
|
||||
}
|
||||
|
||||
export async function relaunchApp(
|
||||
remoteAdminSettings?: AdminControlsSettings,
|
||||
): Promise<void> {
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
if (isRelaunching) return;
|
||||
isRelaunching = true;
|
||||
await waitForUpdateCompletion();
|
||||
await runExitCleanup();
|
||||
|
||||
if (process.send || process.env['SANDBOX']) {
|
||||
if (process.send && remoteAdminSettings) {
|
||||
process.send({
|
||||
type: 'admin-settings-update',
|
||||
settings: remoteAdminSettings,
|
||||
});
|
||||
}
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
const { relaunchAppInChildProcess } = await import('./relaunch.js');
|
||||
await relaunchAppInChildProcess([], [], remoteAdminSettings);
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,8 @@
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
A2AClientManager,
|
||||
type SendMessageResult,
|
||||
} from './a2a-client-manager.js';
|
||||
import type { AgentCard, Task } from '@a2a-js/sdk';
|
||||
import { A2AClientManager } from './a2a-client-manager.js';
|
||||
import type { AgentCard } from '@a2a-js/sdk';
|
||||
import {
|
||||
ClientFactory,
|
||||
DefaultAgentCardResolver,
|
||||
@@ -18,83 +15,99 @@ import {
|
||||
type AuthenticationHandler,
|
||||
type Client,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
|
||||
interface MockClient {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
getTask: ReturnType<typeof vi.fn>;
|
||||
cancelTask: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
createAuthenticatingFetchWithRetry: vi.fn(),
|
||||
ClientFactory: vi.fn(),
|
||||
DefaultAgentCardResolver: vi.fn(),
|
||||
ClientFactoryOptions: {
|
||||
createFrom: vi.fn(),
|
||||
default: {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/debugLogger.js', () => ({
|
||||
debugLogger: {
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', () => {
|
||||
const ClientFactory = vi.fn();
|
||||
const DefaultAgentCardResolver = vi.fn();
|
||||
const RestTransportFactory = vi.fn();
|
||||
const JsonRpcTransportFactory = vi.fn();
|
||||
const ClientFactoryOptions = {
|
||||
default: {},
|
||||
createFrom: vi.fn(),
|
||||
};
|
||||
const createAuthenticatingFetchWithRetry = vi.fn();
|
||||
|
||||
DefaultAgentCardResolver.prototype.resolve = vi.fn();
|
||||
ClientFactory.prototype.createFromUrl = vi.fn();
|
||||
|
||||
return {
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
};
|
||||
});
|
||||
|
||||
describe('A2AClientManager', () => {
|
||||
let manager: A2AClientManager;
|
||||
const mockAgentCard: AgentCard = {
|
||||
name: 'test-agent',
|
||||
description: 'A test agent',
|
||||
url: 'http://test.agent',
|
||||
version: '1.0.0',
|
||||
protocolVersion: '0.1.0',
|
||||
capabilities: {},
|
||||
skills: [],
|
||||
defaultInputModes: [],
|
||||
defaultOutputModes: [],
|
||||
};
|
||||
|
||||
const mockClient: MockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
};
|
||||
|
||||
// Stable mocks initialized once
|
||||
const sendMessageStreamMock = vi.fn();
|
||||
const getTaskMock = vi.fn();
|
||||
const cancelTaskMock = vi.fn();
|
||||
const getAgentCardMock = vi.fn();
|
||||
const authFetchMock = vi.fn();
|
||||
|
||||
const mockClient = {
|
||||
sendMessageStream: sendMessageStreamMock,
|
||||
getTask: getTaskMock,
|
||||
cancelTask: cancelTaskMock,
|
||||
getAgentCard: getAgentCardMock,
|
||||
} as unknown as Client;
|
||||
|
||||
const mockAgentCard: Partial<AgentCard> = { name: 'TestAgent' };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
A2AClientManager.resetInstanceForTesting();
|
||||
manager = A2AClientManager.getInstance();
|
||||
|
||||
// Default mock implementations
|
||||
getAgentCardMock.mockResolvedValue({
|
||||
// Re-create the instances as plain objects that can be spied on
|
||||
const factoryInstance = {
|
||||
createFromUrl: vi.fn(),
|
||||
createFromAgentCard: vi.fn(),
|
||||
};
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked(ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as ClientFactory,
|
||||
);
|
||||
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
vi.spyOn(factoryInstance, 'createFromUrl').mockResolvedValue(
|
||||
mockClient as unknown as Client,
|
||||
);
|
||||
vi.spyOn(factoryInstance, 'createFromAgentCard').mockResolvedValue(
|
||||
mockClient as unknown as Client,
|
||||
);
|
||||
vi.spyOn(resolverInstance, 'resolve').mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactory.prototype.createFromUrl).mockResolvedValue(
|
||||
mockClient,
|
||||
vi.spyOn(ClientFactoryOptions, 'createFrom').mockImplementation(
|
||||
(_defaults, overrides) => overrides as unknown as ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(DefaultAgentCardResolver.prototype.resolve).mockResolvedValue({
|
||||
...mockAgentCard,
|
||||
url: 'http://test.agent/real/endpoint',
|
||||
} as AgentCard);
|
||||
|
||||
vi.mocked(ClientFactoryOptions.createFrom).mockImplementation(
|
||||
(_defaults, overrides) => overrides as ClientFactoryOptions,
|
||||
);
|
||||
|
||||
vi.mocked(createAuthenticatingFetchWithRetry).mockReturnValue(
|
||||
authFetchMock,
|
||||
vi.mocked(createAuthenticatingFetchWithRetry).mockImplementation(() =>
|
||||
authFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
} as Response),
|
||||
);
|
||||
|
||||
vi.stubGlobal(
|
||||
@@ -117,21 +130,70 @@ describe('A2AClientManager', () => {
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
describe('getInstance / dispatcher initialization', () => {
|
||||
it('should use UndiciAgent when no proxy is configured', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
await cardFetch('http://test.agent/card');
|
||||
|
||||
const fetchCall = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find((call) => call[0] === 'http://test.agent/card');
|
||||
expect(fetchCall).toBeDefined();
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).toBeInstanceOf(UndiciAgent);
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).not.toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
|
||||
it('should use ProxyAgent when a proxy is configured via Config', async () => {
|
||||
A2AClientManager.resetInstanceForTesting();
|
||||
const mockConfig = {
|
||||
getProxy: () => 'http://my-proxy:8080',
|
||||
} as Config;
|
||||
|
||||
manager = A2AClientManager.getInstance(mockConfig);
|
||||
await manager.loadAgent('TestProxyAgent', 'http://test.proxy.agent/card');
|
||||
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
const cardFetch = resolverOptions?.fetchImpl as typeof fetch;
|
||||
await cardFetch('http://test.proxy.agent/card');
|
||||
|
||||
const fetchCall = vi
|
||||
.mocked(fetch)
|
||||
.mock.calls.find((call) => call[0] === 'http://test.proxy.agent/card');
|
||||
expect(fetchCall).toBeDefined();
|
||||
expect(
|
||||
(fetchCall![1] as { dispatcher?: unknown })?.dispatcher,
|
||||
).toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAgent', () => {
|
||||
it('should create and cache an A2AClient', async () => {
|
||||
const agentCard = await manager.loadAgent(
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
);
|
||||
expect(agentCard).toMatchObject(mockAgentCard);
|
||||
expect(manager.getAgentCard('TestAgent')).toBe(agentCard);
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should configure ClientFactory with REST, JSON-RPC, and gRPC transports', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if an agent with the same name is already loaded', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
await expect(
|
||||
manager.loadAgent('TestAgent', 'http://another.agent/card'),
|
||||
manager.loadAgent('TestAgent', 'http://test.agent/card'),
|
||||
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
|
||||
});
|
||||
|
||||
@@ -146,20 +208,12 @@ describe('A2AClientManager', () => {
|
||||
shouldRetryWithHeaders: vi.fn(),
|
||||
};
|
||||
await manager.loadAgent(
|
||||
'CustomAuthAgent',
|
||||
'http://custom.agent/card',
|
||||
'TestAgent',
|
||||
'http://test.agent/card',
|
||||
customAuthHandler as unknown as AuthenticationHandler,
|
||||
);
|
||||
|
||||
expect(createAuthenticatingFetchWithRetry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
customAuthHandler,
|
||||
);
|
||||
|
||||
// Card resolver should NOT use the authenticated fetch by default.
|
||||
const resolverInstance = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.instances[0];
|
||||
expect(resolverInstance).toBeDefined();
|
||||
const resolverOptions = vi.mocked(DefaultAgentCardResolver).mock
|
||||
.calls[0][0];
|
||||
expect(resolverOptions?.fetchImpl).not.toBe(authFetchMock);
|
||||
@@ -220,106 +274,163 @@ describe('A2AClientManager', () => {
|
||||
it('should log a debug message upon loading an agent', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
"[A2AClientManager] Loaded agent 'TestAgent' from http://test.agent/card",
|
||||
expect.stringContaining("Loaded agent 'TestAgent'"),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clear the cache', async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
expect(manager.getAgentCard('TestAgent')).toBeDefined();
|
||||
expect(manager.getClient('TestAgent')).toBeDefined();
|
||||
|
||||
manager.clearCache();
|
||||
|
||||
expect(manager.getAgentCard('TestAgent')).toBeUndefined();
|
||||
expect(manager.getClient('TestAgent')).toBeUndefined();
|
||||
expect(debugLogger.debug).toHaveBeenCalledWith(
|
||||
'[A2AClientManager] Cache cleared.',
|
||||
});
|
||||
|
||||
it('should throw if resolveAgentCard fails', async () => {
|
||||
const resolverInstance = {
|
||||
resolve: vi.fn().mockRejectedValue(new Error('Resolution failed')),
|
||||
};
|
||||
vi.mocked(DefaultAgentCardResolver).mockReturnValue(
|
||||
resolverInstance as unknown as DefaultAgentCardResolver,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Resolution failed');
|
||||
});
|
||||
|
||||
it('should throw if factory.createFromAgentCard fails', async () => {
|
||||
const factoryInstance = {
|
||||
createFromAgentCard: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Factory failed')),
|
||||
};
|
||||
vi.mocked(ClientFactory).mockReturnValue(
|
||||
factoryInstance as unknown as ClientFactory,
|
||||
);
|
||||
|
||||
await expect(
|
||||
manager.loadAgent('FailAgent', 'http://fail.agent'),
|
||||
).rejects.toThrow('Factory failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentCard and getClient', () => {
|
||||
it('should return undefined if agent is not found', () => {
|
||||
expect(manager.getAgentCard('Unknown')).toBeUndefined();
|
||||
expect(manager.getClient('Unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageStream', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should send a message and return a stream', async () => {
|
||||
const mockResult = {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield mockResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const res of stream) {
|
||||
results.push(res);
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toEqual([mockResult]);
|
||||
expect(sendMessageStreamMock).toHaveBeenCalledWith(
|
||||
expect(results).toHaveLength(1);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
});
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
break;
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.anything(),
|
||||
message: expect.objectContaining({
|
||||
contextId: 'ctx123',
|
||||
taskId: 'task456',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use contextId and taskId when provided', async () => {
|
||||
sendMessageStreamMock.mockReturnValue(
|
||||
it('should correctly propagate AbortSignal to the stream', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield {
|
||||
kind: 'message',
|
||||
messageId: 'a',
|
||||
parts: [],
|
||||
role: 'agent',
|
||||
} as SendMessageResult;
|
||||
yield { kind: 'message' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const expectedContextId = 'user-context-id';
|
||||
const expectedTaskId = 'user-task-id';
|
||||
|
||||
const controller = new AbortController();
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello', {
|
||||
contextId: expectedContextId,
|
||||
taskId: expectedTaskId,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// trigger execution
|
||||
for await (const _ of stream) {
|
||||
// consume stream
|
||||
break;
|
||||
}
|
||||
|
||||
const call = sendMessageStreamMock.mock.calls[0][0];
|
||||
expect(call.message.contextId).toBe(expectedContextId);
|
||||
expect(call.message.taskId).toBe(expectedTaskId);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ signal: controller.signal }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should propagate the original error on failure', async () => {
|
||||
sendMessageStreamMock.mockImplementationOnce(() => {
|
||||
throw new Error('Network error');
|
||||
it('should handle a multi-chunk stream with different event types', async () => {
|
||||
mockClient.sendMessageStream.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { kind: 'message', messageId: 'm1' };
|
||||
yield { kind: 'status-update', taskId: 't1' };
|
||||
})(),
|
||||
);
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
const results = [];
|
||||
for await (const result of stream) {
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].kind).toBe('message');
|
||||
expect(results[1].kind).toBe('status-update');
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(() => {
|
||||
throw new Error('Network failure');
|
||||
});
|
||||
|
||||
const stream = manager.sendMessageStream('TestAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow('Network error');
|
||||
}).rejects.toThrow(
|
||||
'[A2AClientManager] sendMessageStream Error [TestAgent]: Network failure',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the agent is not found', async () => {
|
||||
const stream = manager.sendMessageStream('NonExistentAgent', 'Hello');
|
||||
await expect(async () => {
|
||||
for await (const _ of stream) {
|
||||
// consume
|
||||
// empty
|
||||
}
|
||||
}).rejects.toThrow("Agent 'NonExistentAgent' not found.");
|
||||
});
|
||||
@@ -327,28 +438,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('getTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should get a task from the correct agent', async () => {
|
||||
getTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'completed' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.getTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.getTask('TestAgent', 'task123');
|
||||
expect(getTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.getTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.getTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
getTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.getTask.mockRejectedValue(new Error('Not found'));
|
||||
|
||||
await expect(manager.getTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient getTask Error [TestAgent]: Network error',
|
||||
'A2AClient getTask Error [TestAgent]: Not found',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -361,28 +467,23 @@ describe('A2AClientManager', () => {
|
||||
|
||||
describe('cancelTask', () => {
|
||||
beforeEach(async () => {
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent');
|
||||
await manager.loadAgent('TestAgent', 'http://test.agent/card');
|
||||
});
|
||||
|
||||
it('should cancel a task on the correct agent', async () => {
|
||||
cancelTaskMock.mockResolvedValue({
|
||||
id: 'task123',
|
||||
contextId: 'a',
|
||||
kind: 'task',
|
||||
status: { state: 'canceled' },
|
||||
} as Task);
|
||||
const mockTask = { id: 'task123', kind: 'task' };
|
||||
mockClient.cancelTask.mockResolvedValue(mockTask);
|
||||
|
||||
await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(cancelTaskMock).toHaveBeenCalledWith({
|
||||
id: 'task123',
|
||||
});
|
||||
const result = await manager.cancelTask('TestAgent', 'task123');
|
||||
expect(result).toBe(mockTask);
|
||||
expect(mockClient.cancelTask).toHaveBeenCalledWith({ id: 'task123' });
|
||||
});
|
||||
|
||||
it('should throw prefixed error on failure', async () => {
|
||||
cancelTaskMock.mockRejectedValueOnce(new Error('Network error'));
|
||||
mockClient.cancelTask.mockRejectedValue(new Error('Cannot cancel'));
|
||||
|
||||
await expect(manager.cancelTask('TestAgent', 'task123')).rejects.toThrow(
|
||||
'A2AClient cancelTask Error [TestAgent]: Network error',
|
||||
'A2AClient cancelTask Error [TestAgent]: Cannot cancel',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,45 +12,41 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client';
|
||||
import {
|
||||
type Client,
|
||||
ClientFactory,
|
||||
ClientFactoryOptions,
|
||||
DefaultAgentCardResolver,
|
||||
RestTransportFactory,
|
||||
JsonRpcTransportFactory,
|
||||
type AuthenticationHandler,
|
||||
RestTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
} from '@a2a-js/sdk/client';
|
||||
import { GrpcTransportFactory } from '@a2a-js/sdk/client/grpc';
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Agent as UndiciAgent } from 'undici';
|
||||
import { Agent as UndiciAgent, ProxyAgent } from 'undici';
|
||||
import { normalizeAgentCard } from './a2aUtils.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeLookup } from '../utils/fetch.js';
|
||||
import { classifyAgentError } from './a2a-errors.js';
|
||||
|
||||
// Remote agents can take 10+ minutes (e.g. Deep Research).
|
||||
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
|
||||
const A2A_TIMEOUT = 1800000; // 30 minutes
|
||||
const a2aDispatcher = new UndiciAgent({
|
||||
headersTimeout: A2A_TIMEOUT,
|
||||
bodyTimeout: A2A_TIMEOUT,
|
||||
connect: {
|
||||
lookup: safeLookup, // SSRF protection at connection level
|
||||
},
|
||||
});
|
||||
const a2aFetch: typeof fetch = (input, init) =>
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
fetch(input, { ...init, dispatcher: a2aDispatcher } as RequestInit);
|
||||
|
||||
/**
|
||||
* Result of sending a message, which can be a full message, a task,
|
||||
* or an incremental status/artifact update.
|
||||
*/
|
||||
export type SendMessageResult =
|
||||
| Message
|
||||
| Task
|
||||
| TaskStatusUpdateEvent
|
||||
| TaskArtifactUpdateEvent;
|
||||
|
||||
// Remote agents can take 10+ minutes (e.g. Deep Research).
|
||||
// Use a dedicated dispatcher so the global 5-min timeout isn't affected.
|
||||
const A2A_TIMEOUT = 1800000; // 30 minutes
|
||||
|
||||
/**
|
||||
* Manages A2A clients and caches loaded agent information.
|
||||
* Follows a singleton pattern to ensure a single client instance.
|
||||
* Orchestrates communication with remote A2A agents.
|
||||
* Manages protocol negotiation, authentication, and transport selection.
|
||||
*/
|
||||
export class A2AClientManager {
|
||||
private static instance: A2AClientManager;
|
||||
@@ -59,14 +55,35 @@ export class A2AClientManager {
|
||||
private clients = new Map<string, Client>();
|
||||
private agentCards = new Map<string, AgentCard>();
|
||||
|
||||
private constructor() {}
|
||||
private a2aDispatcher: UndiciAgent | ProxyAgent;
|
||||
private a2aFetch: typeof fetch;
|
||||
|
||||
private constructor(config?: Config) {
|
||||
const proxyUrl = config?.getProxy();
|
||||
const agentOptions = {
|
||||
headersTimeout: A2A_TIMEOUT,
|
||||
bodyTimeout: A2A_TIMEOUT,
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
this.a2aDispatcher = new ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
...agentOptions,
|
||||
});
|
||||
} else {
|
||||
this.a2aDispatcher = new UndiciAgent(agentOptions);
|
||||
}
|
||||
|
||||
this.a2aFetch = (input, init) =>
|
||||
fetch(input, { ...init, dispatcher: this.a2aDispatcher } as RequestInit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the singleton instance of the A2AClientManager.
|
||||
*/
|
||||
static getInstance(): A2AClientManager {
|
||||
static getInstance(config?: Config): A2AClientManager {
|
||||
if (!A2AClientManager.instance) {
|
||||
A2AClientManager.instance = new A2AClientManager();
|
||||
A2AClientManager.instance = new A2AClientManager(config);
|
||||
}
|
||||
return A2AClientManager.instance;
|
||||
}
|
||||
@@ -97,9 +114,12 @@ export class A2AClientManager {
|
||||
}
|
||||
|
||||
// Authenticated fetch for API calls (transports).
|
||||
let authFetch: typeof fetch = a2aFetch;
|
||||
let authFetch: typeof fetch = this.a2aFetch;
|
||||
if (authHandler) {
|
||||
authFetch = createAuthenticatingFetchWithRetry(a2aFetch, authHandler);
|
||||
authFetch = createAuthenticatingFetchWithRetry(
|
||||
this.a2aFetch,
|
||||
authHandler,
|
||||
);
|
||||
}
|
||||
|
||||
// Use unauthenticated fetch for the agent card unless explicitly required.
|
||||
@@ -109,7 +129,7 @@ export class A2AClientManager {
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
// Try without auth first
|
||||
const response = await a2aFetch(input, init);
|
||||
const response = await this.a2aFetch(input, init);
|
||||
|
||||
// Retry with auth if we hit a 401/403
|
||||
if ((response.status === 401 || response.status === 403) && authFetch) {
|
||||
@@ -120,22 +140,35 @@ export class A2AClientManager {
|
||||
};
|
||||
|
||||
const resolver = new DefaultAgentCardResolver({ fetchImpl: cardFetch });
|
||||
const rawCard = await resolver.resolve(agentCardUrl, '');
|
||||
// TODO: Remove normalizeAgentCard once @a2a-js/sdk handles
|
||||
// proto field name aliases (supportedInterfaces → additionalInterfaces,
|
||||
// protocolBinding → transport).
|
||||
const agentCard = normalizeAgentCard(rawCard);
|
||||
|
||||
const options = ClientFactoryOptions.createFrom(
|
||||
const grpcUrl =
|
||||
agentCard.additionalInterfaces?.find((i) => i.transport === 'GRPC')
|
||||
?.url ?? agentCard.url;
|
||||
|
||||
const clientOptions = ClientFactoryOptions.createFrom(
|
||||
ClientFactoryOptions.default,
|
||||
{
|
||||
transports: [
|
||||
new RestTransportFactory({ fetchImpl: authFetch }),
|
||||
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
|
||||
new GrpcTransportFactory({
|
||||
grpcChannelCredentials: grpcUrl.startsWith('https://')
|
||||
? grpc.credentials.createSsl()
|
||||
: grpc.credentials.createInsecure(),
|
||||
}),
|
||||
],
|
||||
cardResolver: resolver,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const factory = new ClientFactory(options);
|
||||
const client = await factory.createFromUrl(agentCardUrl, '');
|
||||
const agentCard = await client.getAgentCard();
|
||||
const factory = new ClientFactory(clientOptions);
|
||||
const client = await factory.createFromAgentCard(agentCard);
|
||||
|
||||
this.clients.set(name, client);
|
||||
this.agentCards.set(name, agentCard);
|
||||
@@ -173,9 +206,7 @@ export class A2AClientManager {
|
||||
options?: { contextId?: string; taskId?: string; signal?: AbortSignal },
|
||||
): AsyncIterable<SendMessageResult> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
|
||||
const messageParams: MessageSendParams = {
|
||||
message: {
|
||||
@@ -188,9 +219,19 @@ export class A2AClientManager {
|
||||
},
|
||||
};
|
||||
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
try {
|
||||
yield* client.sendMessageStream(messageParams, {
|
||||
signal: options?.signal,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const prefix = `[A2AClientManager] sendMessageStream Error [${agentName}]`;
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`${prefix}: ${error.message}`, { cause: error });
|
||||
}
|
||||
throw new Error(
|
||||
`${prefix}: Unexpected error during sendMessageStream: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,9 +260,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async getTask(agentName: string, taskId: string): Promise<Task> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
try {
|
||||
return await client.getTask({ id: taskId });
|
||||
} catch (error: unknown) {
|
||||
@@ -241,9 +280,7 @@ export class A2AClientManager {
|
||||
*/
|
||||
async cancelTask(agentName: string, taskId: string): Promise<Task> {
|
||||
const client = this.clients.get(agentName);
|
||||
if (!client) {
|
||||
throw new Error(`Agent '${agentName}' not found.`);
|
||||
}
|
||||
if (!client) throw new Error(`Agent '${agentName}' not found.`);
|
||||
try {
|
||||
return await client.cancelTask({ id: taskId });
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
A2AResultReassembler,
|
||||
AUTH_REQUIRED_MSG,
|
||||
normalizeAgentCard,
|
||||
getGrpcCredentials,
|
||||
pinUrlToIp,
|
||||
splitAgentCardUrl,
|
||||
} from './a2aUtils.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
import type {
|
||||
@@ -26,12 +23,6 @@ import type {
|
||||
TaskStatusUpdateEvent,
|
||||
TaskArtifactUpdateEvent,
|
||||
} from '@a2a-js/sdk';
|
||||
import * as dnsPromises from 'node:dns/promises';
|
||||
import type { LookupAddress } from 'node:dns';
|
||||
|
||||
vi.mock('node:dns/promises', () => ({
|
||||
lookup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('a2aUtils', () => {
|
||||
beforeEach(() => {
|
||||
@@ -42,89 +33,6 @@ describe('a2aUtils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getGrpcCredentials', () => {
|
||||
it('should return secure credentials for https', () => {
|
||||
const credentials = getGrpcCredentials('https://test.agent');
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return insecure credentials for http', () => {
|
||||
const credentials = getGrpcCredentials('http://test.agent');
|
||||
expect(credentials).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pinUrlToIp', () => {
|
||||
it('should resolve and pin hostname to IP', async () => {
|
||||
vi.mocked(
|
||||
dnsPromises.lookup as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'http://example.com:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('example.com');
|
||||
expect(pinnedUrl).toBe('http://93.184.216.34:9000/');
|
||||
});
|
||||
|
||||
it('should handle raw host:port strings (standard for gRPC)', async () => {
|
||||
vi.mocked(
|
||||
dnsPromises.lookup as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'example.com:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('example.com');
|
||||
expect(pinnedUrl).toBe('93.184.216.34:9000');
|
||||
});
|
||||
|
||||
it('should throw error if resolution fails (fail closed)', async () => {
|
||||
vi.mocked(dnsPromises.lookup).mockRejectedValue(new Error('DNS Error'));
|
||||
|
||||
await expect(
|
||||
pinUrlToIp('http://unreachable.com', 'test-agent'),
|
||||
).rejects.toThrow("Failed to resolve host for agent 'test-agent'");
|
||||
});
|
||||
|
||||
it('should throw error if resolved to private IP', async () => {
|
||||
vi.mocked(
|
||||
dnsPromises.lookup as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '10.0.0.1', family: 4 }]);
|
||||
|
||||
await expect(
|
||||
pinUrlToIp('http://malicious.com', 'test-agent'),
|
||||
).rejects.toThrow('resolves to private IP range');
|
||||
});
|
||||
|
||||
it('should allow localhost/127.0.0.1/::1 exceptions', async () => {
|
||||
vi.mocked(
|
||||
dnsPromises.lookup as unknown as (
|
||||
hostname: string,
|
||||
options: { all: true },
|
||||
) => Promise<LookupAddress[]>,
|
||||
).mockResolvedValue([{ address: '127.0.0.1', family: 4 }]);
|
||||
|
||||
const { pinnedUrl, hostname } = await pinUrlToIp(
|
||||
'http://localhost:9000',
|
||||
'test-agent',
|
||||
);
|
||||
expect(hostname).toBe('localhost');
|
||||
expect(pinnedUrl).toBe('http://127.0.0.1:9000/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTerminalState', () => {
|
||||
it('should return true for completed, failed, canceled, and rejected', () => {
|
||||
expect(isTerminalState('completed')).toBe(true);
|
||||
@@ -365,12 +273,12 @@ describe('a2aUtils', () => {
|
||||
expect(normalized.name).toBe('my-agent');
|
||||
// @ts-expect-error - testing dynamic preservation
|
||||
expect(normalized.customField).toBe('keep-me');
|
||||
expect(normalized.description).toBe('');
|
||||
expect(normalized.skills).toEqual([]);
|
||||
expect(normalized.defaultInputModes).toEqual([]);
|
||||
expect(normalized.description).toBeUndefined();
|
||||
expect(normalized.skills).toBeUndefined();
|
||||
expect(normalized.defaultInputModes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize and synchronize interfaces while preserving other fields', () => {
|
||||
it('should map supportedInterfaces to additionalInterfaces with protocolBinding → transport', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
supportedInterfaces: [
|
||||
@@ -384,13 +292,7 @@ describe('a2aUtils', () => {
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
|
||||
// Should exist in both fields
|
||||
expect(normalized.additionalInterfaces).toHaveLength(1);
|
||||
expect(
|
||||
(normalized as unknown as Record<string, unknown>)[
|
||||
'supportedInterfaces'
|
||||
],
|
||||
).toHaveLength(1);
|
||||
|
||||
const intf = normalized.additionalInterfaces?.[0] as unknown as Record<
|
||||
string,
|
||||
@@ -399,43 +301,18 @@ describe('a2aUtils', () => {
|
||||
|
||||
expect(intf['transport']).toBe('GRPC');
|
||||
expect(intf['url']).toBe('grpc://test');
|
||||
|
||||
// Should fallback top-level url
|
||||
expect(normalized.url).toBe('grpc://test');
|
||||
});
|
||||
|
||||
it('should preserve existing top-level url if present', () => {
|
||||
it('should not overwrite additionalInterfaces if already present', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
url: 'http://existing',
|
||||
additionalInterfaces: [{ url: 'http://grpc', transport: 'GRPC' }],
|
||||
supportedInterfaces: [{ url: 'http://other', transport: 'REST' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.url).toBe('http://existing');
|
||||
});
|
||||
|
||||
it('should NOT prepend http:// scheme to raw IP:port strings for gRPC interfaces', () => {
|
||||
const raw = {
|
||||
name: 'raw-ip-grpc',
|
||||
supportedInterfaces: [{ url: '127.0.0.1:9000', transport: 'GRPC' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe('127.0.0.1:9000');
|
||||
expect(normalized.url).toBe('127.0.0.1:9000');
|
||||
});
|
||||
|
||||
it('should prepend http:// scheme to raw IP:port strings for REST interfaces', () => {
|
||||
const raw = {
|
||||
name: 'raw-ip-rest',
|
||||
supportedInterfaces: [{ url: '127.0.0.1:8080', transport: 'REST' }],
|
||||
};
|
||||
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe(
|
||||
'http://127.0.0.1:8080',
|
||||
);
|
||||
expect(normalized.additionalInterfaces).toHaveLength(1);
|
||||
expect(normalized.additionalInterfaces?.[0].url).toBe('http://grpc');
|
||||
});
|
||||
|
||||
it('should NOT override existing transport if protocolBinding is also present', () => {
|
||||
@@ -448,48 +325,20 @@ describe('a2aUtils', () => {
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized.additionalInterfaces?.[0].transport).toBe('GRPC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitAgentCardUrl', () => {
|
||||
const standard = '.well-known/agent-card.json';
|
||||
it('should not mutate the original card object', () => {
|
||||
const raw = {
|
||||
name: 'test',
|
||||
supportedInterfaces: [{ url: 'grpc://test', protocolBinding: 'GRPC' }],
|
||||
};
|
||||
|
||||
it('should return baseUrl as-is if it does not end with standard path', () => {
|
||||
const url = 'http://localhost:9001/custom/path';
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
});
|
||||
|
||||
it('should split correctly if URL ends with standard path', () => {
|
||||
const url = `http://localhost:9001/${standard}`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://localhost:9001/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle trailing slash in baseUrl when splitting', () => {
|
||||
const url = `http://example.com/api/${standard}`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://example.com/api/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore hashes and query params when splitting', () => {
|
||||
const url = `http://localhost:9001/${standard}?foo=bar#baz`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({
|
||||
baseUrl: 'http://localhost:9001/',
|
||||
path: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original URL if parsing fails', () => {
|
||||
const url = 'not-a-url';
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
});
|
||||
|
||||
it('should handle standard path appearing earlier in the path', () => {
|
||||
const url = `http://localhost:9001/${standard}/something-else`;
|
||||
expect(splitAgentCardUrl(url)).toEqual({ baseUrl: url });
|
||||
const normalized = normalizeAgentCard(raw);
|
||||
expect(normalized).not.toBe(raw);
|
||||
expect(normalized.additionalInterfaces).toBeDefined();
|
||||
// Original should not have additionalInterfaces added
|
||||
expect(
|
||||
(raw as Record<string, unknown>)['additionalInterfaces'],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
Message,
|
||||
Part,
|
||||
@@ -18,37 +15,10 @@ import type {
|
||||
AgentCard,
|
||||
AgentInterface,
|
||||
} from '@a2a-js/sdk';
|
||||
import { isAddressPrivate } from '../utils/fetch.js';
|
||||
import type { SendMessageResult } from './a2a-client-manager.js';
|
||||
|
||||
export const AUTH_REQUIRED_MSG = `[Authorization Required] The agent has indicated it requires authorization to proceed. Please follow the agent's instructions.`;
|
||||
|
||||
const AgentInterfaceSchema = z
|
||||
.object({
|
||||
url: z.string().default(''),
|
||||
transport: z.string().optional(),
|
||||
protocolBinding: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const AgentCardSchema = z
|
||||
.object({
|
||||
name: z.string().default('unknown'),
|
||||
description: z.string().default(''),
|
||||
url: z.string().default(''),
|
||||
version: z.string().default(''),
|
||||
protocolVersion: z.string().default(''),
|
||||
capabilities: z.record(z.unknown()).default({}),
|
||||
skills: z.array(z.union([z.string(), z.record(z.unknown())])).default([]),
|
||||
defaultInputModes: z.array(z.string()).default([]),
|
||||
defaultOutputModes: z.array(z.string()).default([]),
|
||||
|
||||
additionalInterfaces: z.array(AgentInterfaceSchema).optional(),
|
||||
supportedInterfaces: z.array(AgentInterfaceSchema).optional(),
|
||||
preferredTransport: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* Reassembles incremental A2A streaming updates into a coherent result.
|
||||
* Shows sequential status/messages followed by all reassembled artifacts.
|
||||
@@ -241,166 +211,45 @@ function extractPartText(part: Part): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an agent card by ensuring it has the required properties
|
||||
* and resolving any inconsistencies between protocol versions.
|
||||
* Normalizes proto field name aliases that the SDK doesn't handle yet.
|
||||
* The A2A proto spec uses `supported_interfaces` and `protocol_binding`,
|
||||
* while the SDK expects `additionalInterfaces` and `transport`.
|
||||
* TODO: Remove once @a2a-js/sdk handles these aliases natively.
|
||||
*/
|
||||
export function normalizeAgentCard(card: unknown): AgentCard {
|
||||
if (!isObject(card)) {
|
||||
throw new Error('Agent card is missing.');
|
||||
}
|
||||
|
||||
// Use Zod to validate and parse the card, ensuring safe defaults and narrowing types.
|
||||
const parsed = AgentCardSchema.parse(card);
|
||||
// Narrowing to AgentCard interface after runtime validation.
|
||||
// Shallow-copy to avoid mutating the SDK's cached object.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result = parsed as unknown as AgentCard;
|
||||
const result = { ...card } as unknown as AgentCard;
|
||||
|
||||
// Normalize interfaces and synchronize both interface fields.
|
||||
const normalizedInterfaces = extractNormalizedInterfaces(parsed);
|
||||
result.additionalInterfaces = normalizedInterfaces;
|
||||
|
||||
// Sync supportedInterfaces for backward compatibility.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const legacyResult = result as unknown as Record<string, AgentInterface[]>;
|
||||
legacyResult['supportedInterfaces'] = normalizedInterfaces;
|
||||
|
||||
// Fallback preferredTransport: If not specified, default to GRPC if available.
|
||||
if (
|
||||
!result.preferredTransport &&
|
||||
normalizedInterfaces.some((i) => i.transport === 'GRPC')
|
||||
) {
|
||||
result.preferredTransport = 'GRPC';
|
||||
// Map supportedInterfaces → additionalInterfaces if needed
|
||||
if (!result.additionalInterfaces) {
|
||||
const raw = card;
|
||||
if (Array.isArray(raw['supportedInterfaces'])) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
result.additionalInterfaces = raw[
|
||||
'supportedInterfaces'
|
||||
] as AgentInterface[];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: If top-level URL is missing, use the first interface's URL.
|
||||
if (result.url === '' && normalizedInterfaces.length > 0) {
|
||||
result.url = normalizedInterfaces[0].url;
|
||||
// Map protocolBinding → transport on each interface
|
||||
for (const intf of result.additionalInterfaces ?? []) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const raw = intf as unknown as Record<string, unknown>;
|
||||
const binding = raw['protocolBinding'];
|
||||
|
||||
if (!intf.transport && typeof binding === 'string') {
|
||||
intf.transport = binding;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns gRPC channel credentials based on the URL scheme.
|
||||
*/
|
||||
export function getGrpcCredentials(url: string): grpc.ChannelCredentials {
|
||||
return url.startsWith('https://')
|
||||
? grpc.credentials.createSsl()
|
||||
: grpc.credentials.createInsecure();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns gRPC channel options to ensure SSL/authority matches the original hostname
|
||||
* when connecting via a pinned IP address.
|
||||
*/
|
||||
export function getGrpcChannelOptions(
|
||||
hostname: string,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
'grpc.default_authority': hostname,
|
||||
'grpc.ssl_target_name_override': hostname,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a hostname to its IP address and validates it against SSRF.
|
||||
* Returns the pinned IP-based URL and the original hostname.
|
||||
*/
|
||||
export async function pinUrlToIp(
|
||||
url: string,
|
||||
agentName: string,
|
||||
): Promise<{ pinnedUrl: string; hostname: string }> {
|
||||
if (!url) return { pinnedUrl: url, hostname: '' };
|
||||
|
||||
// gRPC URLs in A2A can be 'host:port' or 'dns:///host:port' or have schemes.
|
||||
// We normalize to host:port for resolution.
|
||||
const hasScheme = url.includes('://');
|
||||
const normalizedUrl = hasScheme ? url : `http://${url}`;
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalizedUrl);
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
const sanitizedHost =
|
||||
hostname.startsWith('[') && hostname.endsWith(']')
|
||||
? hostname.slice(1, -1)
|
||||
: hostname;
|
||||
|
||||
// Resolve DNS to check the actual target IP and pin it
|
||||
const addresses = await lookup(hostname, { all: true });
|
||||
const publicAddresses = addresses.filter(
|
||||
(addr) =>
|
||||
!isAddressPrivate(addr.address) ||
|
||||
sanitizedHost === 'localhost' ||
|
||||
sanitizedHost === '127.0.0.1' ||
|
||||
sanitizedHost === '::1',
|
||||
);
|
||||
|
||||
if (publicAddresses.length === 0) {
|
||||
if (addresses.length > 0) {
|
||||
throw new Error(
|
||||
`Refusing to load agent '${agentName}': transport URL '${url}' resolves to private IP range.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to resolve any public IP addresses for host: ${hostname}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pinnedIp = publicAddresses[0].address;
|
||||
const pinnedHostname = pinnedIp.includes(':') ? `[${pinnedIp}]` : pinnedIp;
|
||||
|
||||
// Reconstruct URL with IP
|
||||
parsed.hostname = pinnedHostname;
|
||||
let pinnedUrl = parsed.toString();
|
||||
|
||||
// If original didn't have scheme, remove it (standard for gRPC targets)
|
||||
if (!hasScheme) {
|
||||
pinnedUrl = pinnedUrl.replace(/^http:\/\//, '');
|
||||
// URL.toString() might append a trailing slash
|
||||
if (pinnedUrl.endsWith('/') && !url.endsWith('/')) {
|
||||
pinnedUrl = pinnedUrl.slice(0, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return { pinnedUrl, hostname };
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('Refusing')) throw e;
|
||||
throw new Error(`Failed to resolve host for agent '${agentName}': ${url}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splts an agent card URL into a baseUrl and a standard path if it already
|
||||
* contains '.well-known/agent-card.json'.
|
||||
*/
|
||||
export function splitAgentCardUrl(url: string): {
|
||||
baseUrl: string;
|
||||
path?: string;
|
||||
} {
|
||||
const standardPath = '.well-known/agent-card.json';
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.pathname.endsWith(standardPath)) {
|
||||
// Reconstruct baseUrl from parsed components to avoid issues with hashes or query params.
|
||||
parsedUrl.pathname = parsedUrl.pathname.substring(
|
||||
0,
|
||||
parsedUrl.pathname.lastIndexOf(standardPath),
|
||||
);
|
||||
parsedUrl.search = '';
|
||||
parsedUrl.hash = '';
|
||||
// We return undefined for path if it's the standard one,
|
||||
// because the SDK's DefaultAgentCardResolver appends it automatically.
|
||||
return { baseUrl: parsedUrl.toString(), path: undefined };
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore URL parsing errors here, let the resolver handle them.
|
||||
}
|
||||
return { baseUrl: url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts contextId and taskId from a Message, Task, or Update response.
|
||||
* Follows the pattern from the A2A CLI sample to maintain conversational continuity.
|
||||
@@ -446,65 +295,6 @@ export function extractIdsFromResponse(result: SendMessageResult): {
|
||||
return { contextId, taskId, clearTaskId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and normalizes interfaces from the card, handling protocol version fallbacks.
|
||||
* Preserves all original fields to maintain SDK compatibility.
|
||||
*/
|
||||
function extractNormalizedInterfaces(
|
||||
card: Record<string, unknown>,
|
||||
): AgentInterface[] {
|
||||
const rawInterfaces =
|
||||
getArray(card, 'additionalInterfaces') ||
|
||||
getArray(card, 'supportedInterfaces');
|
||||
|
||||
if (!rawInterfaces) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mapped: AgentInterface[] = [];
|
||||
for (const i of rawInterfaces) {
|
||||
if (isObject(i)) {
|
||||
// Use schema to validate interface object.
|
||||
const parsed = AgentInterfaceSchema.parse(i);
|
||||
// Narrowing to AgentInterface after runtime validation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const normalized = parsed as unknown as AgentInterface & {
|
||||
protocolBinding?: string;
|
||||
};
|
||||
|
||||
// Normalize 'transport' from 'protocolBinding' if missing.
|
||||
if (!normalized.transport && normalized.protocolBinding) {
|
||||
normalized.transport = normalized.protocolBinding;
|
||||
}
|
||||
|
||||
// Robust URL: Ensure the URL has a scheme (except for gRPC).
|
||||
if (
|
||||
normalized.url &&
|
||||
!normalized.url.includes('://') &&
|
||||
!normalized.url.startsWith('/') &&
|
||||
normalized.transport !== 'GRPC'
|
||||
) {
|
||||
// Default to http:// for insecure REST/JSON-RPC if scheme is missing.
|
||||
normalized.url = `http://${normalized.url}`;
|
||||
}
|
||||
|
||||
mapped.push(normalized as AgentInterface);
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extracts an array property from an object.
|
||||
*/
|
||||
function getArray(
|
||||
obj: Record<string, unknown>,
|
||||
key: string,
|
||||
): unknown[] | undefined {
|
||||
const val = obj[key];
|
||||
return Array.isArray(val) ? val : undefined;
|
||||
}
|
||||
|
||||
// Type Guards
|
||||
|
||||
function isTextPart(part: Part): part is TextPart {
|
||||
|
||||
@@ -28,10 +28,10 @@ describe('agent-scheduler', () => {
|
||||
mockMessageBus = {} as Mocked<MessageBus>;
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn(),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as Mocked<ToolRegistry>;
|
||||
mockConfig = {
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
} as unknown as Mocked<Config>;
|
||||
(mockConfig as unknown as { messageBus: MessageBus }).messageBus =
|
||||
@@ -42,7 +42,7 @@ describe('agent-scheduler', () => {
|
||||
|
||||
it('should create a scheduler with agent-specific config', async () => {
|
||||
const mockConfig = {
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
messageBus: mockMessageBus,
|
||||
toolRegistry: mockToolRegistry,
|
||||
} as unknown as Mocked<Config>;
|
||||
|
||||
@@ -87,11 +87,11 @@ describe('agent-scheduler', () => {
|
||||
const mainRegistry = { _id: 'main' } as unknown as Mocked<ToolRegistry>;
|
||||
const agentRegistry = {
|
||||
_id: 'agent',
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as Mocked<ToolRegistry>;
|
||||
|
||||
const config = {
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
messageBus: mockMessageBus,
|
||||
} as unknown as Mocked<Config>;
|
||||
Object.defineProperty(config, 'toolRegistry', {
|
||||
get: () => mainRegistry,
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function scheduleAgentTools(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const agentConfig: Config = Object.create(config);
|
||||
agentConfig.getToolRegistry = () => toolRegistry;
|
||||
agentConfig.getMessageBus = () => toolRegistry.getMessageBus();
|
||||
agentConfig.getMessageBus = () => toolRegistry.messageBus;
|
||||
// Override toolRegistry property so AgentLoopContext reads the agent-specific registry.
|
||||
Object.defineProperty(agentConfig, 'toolRegistry', {
|
||||
get: () => toolRegistry,
|
||||
@@ -69,7 +69,7 @@ export async function scheduleAgentTools(
|
||||
|
||||
const scheduler = new Scheduler({
|
||||
context: agentConfig,
|
||||
messageBus: toolRegistry.getMessageBus(),
|
||||
messageBus: toolRegistry.messageBus,
|
||||
getPreferredEditor: getPreferredEditor ?? (() => undefined),
|
||||
schedulerId,
|
||||
subagent,
|
||||
|
||||
@@ -44,7 +44,7 @@ interface FrontmatterLocalAgentDefinition
|
||||
* Authentication configuration for remote agents in frontmatter format.
|
||||
*/
|
||||
interface FrontmatterAuthConfig {
|
||||
type: 'apiKey' | 'http' | 'oauth2';
|
||||
type: 'apiKey' | 'http' | 'google-credentials' | 'oauth2';
|
||||
// API Key
|
||||
key?: string;
|
||||
name?: string;
|
||||
@@ -54,10 +54,11 @@ interface FrontmatterAuthConfig {
|
||||
username?: string;
|
||||
password?: string;
|
||||
value?: string;
|
||||
// Google Credentials
|
||||
scopes?: string[];
|
||||
// OAuth2
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
scopes?: string[];
|
||||
authorization_url?: string;
|
||||
token_url?: string;
|
||||
}
|
||||
@@ -152,6 +153,15 @@ const httpAuthSchema = z.object({
|
||||
value: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Google Credentials auth schema.
|
||||
*/
|
||||
const googleCredentialsAuthSchema = z.object({
|
||||
...baseAuthFields,
|
||||
type: z.literal('google-credentials'),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* OAuth2 auth schema.
|
||||
* authorization_url and token_url can be discovered from the agent card if omitted.
|
||||
@@ -170,6 +180,7 @@ const authConfigSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
apiKeyAuthSchema,
|
||||
httpAuthSchema,
|
||||
googleCredentialsAuthSchema,
|
||||
oauth2AuthSchema,
|
||||
])
|
||||
.superRefine((data, ctx) => {
|
||||
@@ -369,6 +380,13 @@ function convertFrontmatterAuthToConfig(
|
||||
name: frontmatter.name,
|
||||
};
|
||||
|
||||
case 'google-credentials':
|
||||
return {
|
||||
...base,
|
||||
type: 'google-credentials',
|
||||
scopes: frontmatter.scopes,
|
||||
};
|
||||
|
||||
case 'http': {
|
||||
if (!frontmatter.scheme) {
|
||||
throw new Error(
|
||||
|
||||
@@ -12,12 +12,15 @@ import type {
|
||||
} from './types.js';
|
||||
import { ApiKeyAuthProvider } from './api-key-provider.js';
|
||||
import { HttpAuthProvider } from './http-provider.js';
|
||||
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
|
||||
|
||||
export interface CreateAuthProviderOptions {
|
||||
/** Required for OAuth/OIDC token storage. */
|
||||
agentName?: string;
|
||||
authConfig?: A2AAuthConfig;
|
||||
agentCard?: AgentCard;
|
||||
/** Required by some providers (like google-credentials) to determine token audience. */
|
||||
targetUrl?: string;
|
||||
/** URL to fetch the agent card from, used for OAuth2 URL discovery. */
|
||||
agentCardUrl?: string;
|
||||
}
|
||||
@@ -43,9 +46,14 @@ export class A2AAuthProviderFactory {
|
||||
}
|
||||
|
||||
switch (authConfig.type) {
|
||||
case 'google-credentials':
|
||||
// TODO: Implement
|
||||
throw new Error('google-credentials auth provider not yet implemented');
|
||||
case 'google-credentials': {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
authConfig,
|
||||
options.targetUrl,
|
||||
);
|
||||
await provider.initialize();
|
||||
return provider;
|
||||
}
|
||||
|
||||
case 'apiKey': {
|
||||
const provider = new ApiKeyAuthProvider(authConfig);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { GoogleCredentialsAuthProvider } from './google-credentials-provider.js';
|
||||
import type { GoogleCredentialsAuthConfig } from './types.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { OAuthUtils } from '../../mcp/oauth-utils.js';
|
||||
|
||||
// Mock the external dependencies
|
||||
vi.mock('google-auth-library', () => ({
|
||||
GoogleAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('GoogleCredentialsAuthProvider', () => {
|
||||
const mockConfig: GoogleCredentialsAuthConfig = {
|
||||
type: 'google-credentials',
|
||||
};
|
||||
|
||||
let mockGetClient: Mock;
|
||||
let mockGetAccessToken: Mock;
|
||||
let mockGetIdTokenClient: Mock;
|
||||
let mockFetchIdToken: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockGetAccessToken = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ token: 'mock-access-token' });
|
||||
mockGetClient = vi.fn().mockResolvedValue({
|
||||
getAccessToken: mockGetAccessToken,
|
||||
credentials: { expiry_date: Date.now() + 3600 * 1000 },
|
||||
});
|
||||
|
||||
mockFetchIdToken = vi.fn().mockResolvedValue('mock-id-token');
|
||||
mockGetIdTokenClient = vi.fn().mockResolvedValue({
|
||||
idTokenProvider: {
|
||||
fetchIdToken: mockFetchIdToken,
|
||||
},
|
||||
});
|
||||
|
||||
(GoogleAuth as unknown as Mock).mockImplementation(() => ({
|
||||
getClient: mockGetClient,
|
||||
getIdTokenClient: mockGetIdTokenClient,
|
||||
}));
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('throws if no targetUrl is provided', () => {
|
||||
expect(() => new GoogleCredentialsAuthProvider(mockConfig)).toThrow(
|
||||
/targetUrl must be provided/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if targetHost is not allowed', () => {
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(mockConfig, 'https://example.com'),
|
||||
).toThrow(/is not an allowed host/);
|
||||
});
|
||||
|
||||
it('initializes seamlessly with .googleapis.com', () => {
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com/v1/models',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('initializes seamlessly with .run.app', () => {
|
||||
expect(
|
||||
() =>
|
||||
new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://my-cloud-run-service.run.app',
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Fetching', () => {
|
||||
it('fetches an access token for googleapis.com endpoint', async () => {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com',
|
||||
);
|
||||
const headers = await provider.headers();
|
||||
|
||||
expect(headers).toEqual({ Authorization: 'Bearer mock-access-token' });
|
||||
expect(mockGetClient).toHaveBeenCalled();
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
expect(mockGetIdTokenClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetches an identity token for run.app endpoint', async () => {
|
||||
// Mock OAuthUtils.parseTokenExpiry to avoid Base64 decoding issues in tests
|
||||
vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue(
|
||||
Date.now() + 1000000,
|
||||
);
|
||||
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://my-service.run.app/some-path',
|
||||
);
|
||||
const headers = await provider.headers();
|
||||
|
||||
expect(headers).toEqual({ Authorization: 'Bearer mock-id-token' });
|
||||
expect(mockGetIdTokenClient).toHaveBeenCalledWith('my-service.run.app');
|
||||
expect(mockFetchIdToken).toHaveBeenCalledWith('my-service.run.app');
|
||||
expect(mockGetClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns cached access token on subsequent calls', async () => {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com',
|
||||
);
|
||||
|
||||
await provider.headers();
|
||||
await provider.headers();
|
||||
|
||||
// Should only call getClient/getAccessToken once due to caching
|
||||
expect(mockGetClient).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns cached id token on subsequent calls', async () => {
|
||||
vi.spyOn(OAuthUtils, 'parseTokenExpiry').mockReturnValue(
|
||||
Date.now() + 1000000,
|
||||
);
|
||||
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://my-service.run.app',
|
||||
);
|
||||
|
||||
await provider.headers();
|
||||
await provider.headers();
|
||||
|
||||
expect(mockGetIdTokenClient).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchIdToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-fetches access token on 401 (shouldRetryWithHeaders)', async () => {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com',
|
||||
);
|
||||
|
||||
// Prime the cache
|
||||
await provider.headers();
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
const req = {} as RequestInit;
|
||||
const res = { status: 401 } as Response;
|
||||
|
||||
const retryHeaders = await provider.shouldRetryWithHeaders(req, res);
|
||||
|
||||
expect(retryHeaders).toEqual({
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
});
|
||||
// Cache was cleared, so getAccessToken was called again
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-fetches token on 403', async () => {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com',
|
||||
);
|
||||
|
||||
const req = {} as RequestInit;
|
||||
const res = { status: 403 } as Response;
|
||||
|
||||
const retryHeaders = await provider.shouldRetryWithHeaders(req, res);
|
||||
|
||||
expect(retryHeaders).toEqual({
|
||||
Authorization: 'Bearer mock-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops retrying after MAX_AUTH_RETRIES', async () => {
|
||||
const provider = new GoogleCredentialsAuthProvider(
|
||||
mockConfig,
|
||||
'https://language.googleapis.com',
|
||||
);
|
||||
|
||||
const req = {} as RequestInit;
|
||||
const res = { status: 401 } as Response;
|
||||
|
||||
// First two retries should succeed (MAX_AUTH_RETRIES = 2)
|
||||
expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined();
|
||||
expect(await provider.shouldRetryWithHeaders(req, res)).toBeDefined();
|
||||
|
||||
// Third should return undefined (exhausted)
|
||||
expect(await provider.shouldRetryWithHeaders(req, res)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { HttpHeaders } from '@a2a-js/sdk/client';
|
||||
import { BaseA2AAuthProvider } from './base-provider.js';
|
||||
import type { GoogleCredentialsAuthConfig } from './types.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from '../../mcp/oauth-utils.js';
|
||||
|
||||
const CLOUD_RUN_HOST_REGEX = /^(.*\.)?run\.app$/;
|
||||
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, CLOUD_RUN_HOST_REGEX];
|
||||
|
||||
/**
|
||||
* Authentication provider for Google ADC (Application Default Credentials).
|
||||
* Automatically decides whether to use identity tokens or access tokens
|
||||
* based on the target endpoint URL.
|
||||
*/
|
||||
export class GoogleCredentialsAuthProvider extends BaseA2AAuthProvider {
|
||||
readonly type = 'google-credentials' as const;
|
||||
|
||||
private readonly auth: GoogleAuth;
|
||||
private readonly useIdToken: boolean = false;
|
||||
private readonly audience?: string;
|
||||
private cachedToken?: string;
|
||||
private tokenExpiryTime?: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: GoogleCredentialsAuthConfig,
|
||||
targetUrl?: string,
|
||||
) {
|
||||
super();
|
||||
|
||||
if (!targetUrl) {
|
||||
throw new Error(
|
||||
'targetUrl must be provided to GoogleCredentialsAuthProvider to determine token audience.',
|
||||
);
|
||||
}
|
||||
|
||||
const hostname = new URL(targetUrl).hostname;
|
||||
const isRunAppHost = CLOUD_RUN_HOST_REGEX.test(hostname);
|
||||
|
||||
if (isRunAppHost) {
|
||||
this.useIdToken = true;
|
||||
}
|
||||
this.audience = hostname;
|
||||
|
||||
if (
|
||||
!this.useIdToken &&
|
||||
!ALLOWED_HOSTS.some((pattern) => pattern.test(hostname))
|
||||
) {
|
||||
throw new Error(
|
||||
`Host "${hostname}" is not an allowed host for Google Credential provider.`,
|
||||
);
|
||||
}
|
||||
|
||||
// A2A spec requires scopes if configured, otherwise use default cloud-platform
|
||||
const scopes =
|
||||
this.config.scopes && this.config.scopes.length > 0
|
||||
? this.config.scopes
|
||||
: ['https://www.googleapis.com/auth/cloud-platform'];
|
||||
|
||||
this.auth = new GoogleAuth({
|
||||
scopes,
|
||||
});
|
||||
}
|
||||
|
||||
override async initialize(): Promise<void> {
|
||||
// We can pre-fetch or validate if necessary here,
|
||||
// but deferred fetching is usually better for auth tokens.
|
||||
}
|
||||
|
||||
async headers(): Promise<HttpHeaders> {
|
||||
// Check cache
|
||||
if (
|
||||
this.cachedToken &&
|
||||
this.tokenExpiryTime &&
|
||||
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
|
||||
) {
|
||||
return { Authorization: `Bearer ${this.cachedToken}` };
|
||||
}
|
||||
|
||||
// Clear expired cache
|
||||
this.cachedToken = undefined;
|
||||
this.tokenExpiryTime = undefined;
|
||||
|
||||
if (this.useIdToken) {
|
||||
try {
|
||||
const idClient = await this.auth.getIdTokenClient(this.audience!);
|
||||
const idToken = await idClient.idTokenProvider.fetchIdToken(
|
||||
this.audience!,
|
||||
);
|
||||
|
||||
const expiryTime = OAuthUtils.parseTokenExpiry(idToken);
|
||||
if (expiryTime) {
|
||||
this.tokenExpiryTime = expiryTime;
|
||||
this.cachedToken = idToken;
|
||||
}
|
||||
|
||||
return { Authorization: `Bearer ${idToken}` };
|
||||
} catch (e) {
|
||||
const errorMessage = `Failed to get ADC ID token: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`;
|
||||
debugLogger.error(errorMessage, e);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, access token
|
||||
try {
|
||||
const client = await this.auth.getClient();
|
||||
const token = await client.getAccessToken();
|
||||
|
||||
if (token.token) {
|
||||
this.cachedToken = token.token;
|
||||
// Use expiry_date from the underlying credentials if available.
|
||||
const creds = client.credentials;
|
||||
if (creds.expiry_date) {
|
||||
this.tokenExpiryTime = creds.expiry_date;
|
||||
}
|
||||
return { Authorization: `Bearer ${token.token}` };
|
||||
}
|
||||
throw new Error('Failed to retrieve ADC access token.');
|
||||
} catch (e) {
|
||||
const errorMessage = `Failed to get ADC access token: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`;
|
||||
debugLogger.error(errorMessage, e);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
override async shouldRetryWithHeaders(
|
||||
_req: RequestInit,
|
||||
res: Response,
|
||||
): Promise<HttpHeaders | undefined> {
|
||||
if (res.status !== 401 && res.status !== 403) {
|
||||
this.authRetryCount = 0;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) {
|
||||
return undefined;
|
||||
}
|
||||
this.authRetryCount++;
|
||||
|
||||
debugLogger.debug(
|
||||
'[GoogleCredentialsAuthProvider] Re-fetching token after auth failure',
|
||||
);
|
||||
|
||||
// Clear cache to force a re-fetch
|
||||
this.cachedToken = undefined;
|
||||
this.tokenExpiryTime = undefined;
|
||||
|
||||
return this.headers();
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export const BrowserAgentDefinition = (
|
||||
): LocalAgentDefinition<typeof BrowserTaskResultSchema> => {
|
||||
// Use Preview Flash model if the main model is any of the preview models.
|
||||
// If the main model is not a preview model, use the default flash model.
|
||||
const model = isPreviewModel(config.getModel())
|
||||
const model = isPreviewModel(config.getModel(), config)
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
: DEFAULT_GEMINI_FLASH_MODEL;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import type { AgentDefinition } from './types.js';
|
||||
import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { GetInternalDocsTool } from '../tools/get-internal-docs.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
const CliHelpReportSchema = z.object({
|
||||
answer: z
|
||||
@@ -24,7 +24,7 @@ const CliHelpReportSchema = z.object({
|
||||
* using its own documentation and runtime state.
|
||||
*/
|
||||
export const CliHelpAgent = (
|
||||
config: Config,
|
||||
context: AgentLoopContext,
|
||||
): AgentDefinition<typeof CliHelpReportSchema> => ({
|
||||
name: 'cli_help',
|
||||
kind: 'local',
|
||||
@@ -69,7 +69,7 @@ export const CliHelpAgent = (
|
||||
},
|
||||
|
||||
toolConfig: {
|
||||
tools: [new GetInternalDocsTool(config.getMessageBus())],
|
||||
tools: [new GetInternalDocsTool(context.messageBus)],
|
||||
},
|
||||
|
||||
promptConfig: {
|
||||
|
||||
@@ -22,9 +22,19 @@ describe('GeneralistAgent', () => {
|
||||
|
||||
it('should create a valid generalist agent definition', () => {
|
||||
const config = makeFakeConfig();
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
|
||||
const mockToolRegistry = {
|
||||
getAllToolNames: () => ['tool1', 'tool2', 'agent-tool'],
|
||||
} as unknown as ToolRegistry);
|
||||
} as unknown as ToolRegistry;
|
||||
vi.spyOn(config, 'getToolRegistry').mockReturnValue(mockToolRegistry);
|
||||
Object.defineProperty(config, 'toolRegistry', {
|
||||
get: () => mockToolRegistry,
|
||||
});
|
||||
Object.defineProperty(config, 'config', {
|
||||
get() {
|
||||
return this;
|
||||
},
|
||||
});
|
||||
|
||||
vi.spyOn(config, 'getAgentRegistry').mockReturnValue({
|
||||
getDirectoryContext: () => 'mock directory context',
|
||||
getAllAgentNames: () => ['agent-tool'],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
import { getCoreSystemPrompt } from '../core/prompts.js';
|
||||
import type { LocalAgentDefinition } from './types.js';
|
||||
|
||||
@@ -18,7 +18,7 @@ const GeneralistAgentSchema = z.object({
|
||||
* It uses the same core system prompt as the main agent but in a non-interactive mode.
|
||||
*/
|
||||
export const GeneralistAgent = (
|
||||
config: Config,
|
||||
context: AgentLoopContext,
|
||||
): LocalAgentDefinition<typeof GeneralistAgentSchema> => ({
|
||||
kind: 'local',
|
||||
name: 'generalist',
|
||||
@@ -46,7 +46,7 @@ export const GeneralistAgent = (
|
||||
model: 'inherit',
|
||||
},
|
||||
get toolConfig() {
|
||||
const tools = config.getToolRegistry().getAllToolNames();
|
||||
const tools = context.toolRegistry.getAllToolNames();
|
||||
return {
|
||||
tools,
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const GeneralistAgent = (
|
||||
get promptConfig() {
|
||||
return {
|
||||
systemPrompt: getCoreSystemPrompt(
|
||||
config,
|
||||
context.config,
|
||||
/*useMemory=*/ undefined,
|
||||
/*interactiveOverride=*/ false,
|
||||
),
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type PartListUnion,
|
||||
type Tool,
|
||||
type CallableTool,
|
||||
type FunctionDeclaration,
|
||||
} from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { MockTool } from '../test-utils/mock-tool.js';
|
||||
@@ -312,12 +313,9 @@ describe('LocalAgentExecutor', () => {
|
||||
get: () => 'test-prompt-id',
|
||||
configurable: true,
|
||||
});
|
||||
parentToolRegistry = new ToolRegistry(
|
||||
mockConfig,
|
||||
mockConfig.getMessageBus(),
|
||||
);
|
||||
parentToolRegistry = new ToolRegistry(mockConfig, mockConfig.messageBus);
|
||||
parentToolRegistry.registerTool(
|
||||
new LSTool(mockConfig, mockConfig.getMessageBus()),
|
||||
new LSTool(mockConfig, mockConfig.messageBus),
|
||||
);
|
||||
parentToolRegistry.registerTool(
|
||||
new MockTool({ name: READ_FILE_TOOL_NAME }),
|
||||
@@ -523,7 +521,7 @@ describe('LocalAgentExecutor', () => {
|
||||
toolName,
|
||||
'description',
|
||||
{},
|
||||
mockConfig.getMessageBus(),
|
||||
mockConfig.messageBus,
|
||||
);
|
||||
|
||||
// Mock getTool to return our real DiscoveredMCPTool instance
|
||||
@@ -560,6 +558,34 @@ describe('LocalAgentExecutor', () => {
|
||||
|
||||
getToolSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should not duplicate schemas when instantiated tools are provided in toolConfig', async () => {
|
||||
// Create an instantiated mock tool
|
||||
const instantiatedTool = new MockTool({ name: 'instantiated_tool' });
|
||||
|
||||
// Create an agent definition containing the instantiated tool
|
||||
const definition = createTestDefinition([instantiatedTool]);
|
||||
|
||||
// Create the executor
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
// Extract the prepared tools list using the private method
|
||||
const toolsList = (
|
||||
executor as unknown as { prepareToolsList: () => FunctionDeclaration[] }
|
||||
).prepareToolsList();
|
||||
|
||||
// Filter for the specific tool schema
|
||||
const foundSchemas = (
|
||||
toolsList as unknown as FunctionDeclaration[]
|
||||
).filter((t: FunctionDeclaration) => t.name === 'instantiated_tool');
|
||||
|
||||
// Assert that there is exactly ONE schema for this tool
|
||||
expect(foundSchemas).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run (Execution Loop and Logic)', () => {
|
||||
@@ -2466,4 +2492,337 @@ describe('LocalAgentExecutor', () => {
|
||||
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeclarativeTool instance tools (browser agent pattern)', () => {
|
||||
/**
|
||||
* The browser agent passes DeclarativeTool instances (not string names) in
|
||||
* toolConfig.tools. These tests ensure that prepareToolsList() and
|
||||
* create() handle that pattern correctly — in particular, that each tool
|
||||
* appears exactly once in the function declarations sent to the model.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper that creates a definition using MockTool *instances* in
|
||||
* toolConfig.tools — the same pattern the browser agent uses.
|
||||
*/
|
||||
const createInstanceToolDefinition = (
|
||||
instanceTools: MockTool[],
|
||||
outputConfigMode: 'default' | 'none' = 'default',
|
||||
): LocalAgentDefinition => {
|
||||
const outputConfig =
|
||||
outputConfigMode === 'default'
|
||||
? {
|
||||
outputName: 'finalResult',
|
||||
description: 'The final result.',
|
||||
schema: z.string(),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
name: 'BrowserLikeAgent',
|
||||
description: 'An agent using instance tools.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
goal: { type: 'string', description: 'goal' },
|
||||
},
|
||||
required: ['goal'],
|
||||
},
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-test-model',
|
||||
generateContentConfig: { temperature: 0, topP: 1 },
|
||||
},
|
||||
runConfig: { maxTimeMinutes: 5, maxTurns: 5 },
|
||||
promptConfig: { systemPrompt: 'Achieve: ${goal}.' },
|
||||
toolConfig: {
|
||||
// Cast required because the type expects AnyDeclarativeTool |
|
||||
// string | FunctionDeclaration; MockTool satisfies the first.
|
||||
tools: instanceTools as unknown as AnyDeclarativeTool[],
|
||||
},
|
||||
outputConfig,
|
||||
} as unknown as LocalAgentDefinition;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to extract the functionDeclarations sent to GeminiChat.
|
||||
*/
|
||||
const getSentFunctionDeclarations = () => {
|
||||
const chatCtorArgs = MockedGeminiChat.mock.calls[0];
|
||||
const toolsArg = chatCtorArgs[2] as Tool[];
|
||||
return toolsArg[0].functionDeclarations ?? [];
|
||||
};
|
||||
|
||||
it('should produce NO duplicate function declarations when tools are DeclarativeTool instances', async () => {
|
||||
const clickTool = new MockTool({ name: 'click' });
|
||||
const fillTool = new MockTool({ name: 'fill' });
|
||||
const snapshotTool = new MockTool({ name: 'take_snapshot' });
|
||||
|
||||
const definition = createInstanceToolDefinition([
|
||||
clickTool,
|
||||
fillTool,
|
||||
snapshotTool,
|
||||
]);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
await executor.run({ goal: 'Test' }, signal);
|
||||
|
||||
const declarations = getSentFunctionDeclarations();
|
||||
const names = declarations.map((d) => d.name);
|
||||
|
||||
// Each tool must appear exactly once
|
||||
expect(names.filter((n) => n === 'click')).toHaveLength(1);
|
||||
expect(names.filter((n) => n === 'fill')).toHaveLength(1);
|
||||
expect(names.filter((n) => n === 'take_snapshot')).toHaveLength(1);
|
||||
|
||||
// Total = 3 tools + complete_task
|
||||
expect(declarations).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should register DeclarativeTool instances in the isolated tool registry', async () => {
|
||||
const clickTool = new MockTool({ name: 'click' });
|
||||
const navTool = new MockTool({ name: 'navigate_page' });
|
||||
|
||||
const definition = createInstanceToolDefinition([clickTool, navTool]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
|
||||
const registry = executor['toolRegistry'];
|
||||
expect(registry.getTool('click')).toBeDefined();
|
||||
expect(registry.getTool('navigate_page')).toBeDefined();
|
||||
// Should NOT have tools that were not passed
|
||||
expect(registry.getTool(LS_TOOL_NAME)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle mixed string + DeclarativeTool instances without duplicates', async () => {
|
||||
const instanceTool = new MockTool({ name: 'fill' });
|
||||
|
||||
const definition: LocalAgentDefinition = {
|
||||
kind: 'local',
|
||||
name: 'MixedAgent',
|
||||
description: 'Uses both patterns.',
|
||||
inputConfig: {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { goal: { type: 'string', description: 'goal' } },
|
||||
},
|
||||
},
|
||||
modelConfig: {
|
||||
model: 'gemini-test-model',
|
||||
generateContentConfig: { temperature: 0, topP: 1 },
|
||||
},
|
||||
runConfig: { maxTimeMinutes: 5, maxTurns: 5 },
|
||||
promptConfig: { systemPrompt: 'Achieve: ${goal}.' },
|
||||
toolConfig: {
|
||||
tools: [
|
||||
LS_TOOL_NAME, // string reference
|
||||
instanceTool as unknown as AnyDeclarativeTool, // instance
|
||||
],
|
||||
},
|
||||
outputConfig: {
|
||||
outputName: 'finalResult',
|
||||
description: 'result',
|
||||
schema: z.string(),
|
||||
},
|
||||
} as unknown as LocalAgentDefinition;
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'ok' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
await executor.run({ goal: 'Mixed' }, signal);
|
||||
|
||||
const declarations = getSentFunctionDeclarations();
|
||||
const names = declarations.map((d) => d.name);
|
||||
|
||||
expect(names.filter((n) => n === LS_TOOL_NAME)).toHaveLength(1);
|
||||
expect(names.filter((n) => n === 'fill')).toHaveLength(1);
|
||||
expect(names.filter((n) => n === TASK_COMPLETE_TOOL_NAME)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
// Total = ls + fill + complete_task
|
||||
expect(declarations).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should correctly execute tools passed as DeclarativeTool instances', async () => {
|
||||
const executeFn = vi.fn().mockResolvedValue({
|
||||
llmContent: 'Clicked successfully.',
|
||||
returnDisplay: 'Clicked successfully.',
|
||||
});
|
||||
const clickTool = new MockTool({ name: 'click', execute: executeFn });
|
||||
|
||||
const definition = createInstanceToolDefinition([clickTool]);
|
||||
|
||||
// Turn 1: Model calls click
|
||||
mockModelResponse([
|
||||
{ name: 'click', args: { uid: '42' }, id: 'call-click' },
|
||||
]);
|
||||
mockScheduleAgentTools.mockResolvedValueOnce([
|
||||
{
|
||||
status: 'success',
|
||||
request: {
|
||||
callId: 'call-click',
|
||||
name: 'click',
|
||||
args: { uid: '42' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'test',
|
||||
},
|
||||
tool: {} as AnyDeclarativeTool,
|
||||
invocation: {} as AnyToolInvocation,
|
||||
response: {
|
||||
callId: 'call-click',
|
||||
resultDisplay: 'Clicked',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'click',
|
||||
response: { result: 'Clicked' },
|
||||
id: 'call-click',
|
||||
},
|
||||
},
|
||||
],
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Turn 2: Model completes
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'call-done',
|
||||
},
|
||||
]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
const output = await executor.run({ goal: 'Click test' }, signal);
|
||||
|
||||
// The scheduler should have received the click tool call
|
||||
expect(mockScheduleAgentTools).toHaveBeenCalled();
|
||||
const scheduledRequests = mockScheduleAgentTools.mock
|
||||
.calls[0][1] as ToolCallRequestInfo[];
|
||||
expect(scheduledRequests).toHaveLength(1);
|
||||
expect(scheduledRequests[0].name).toBe('click');
|
||||
|
||||
expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL);
|
||||
});
|
||||
|
||||
it('should always include complete_task even when all tools are instances', async () => {
|
||||
const definition = createInstanceToolDefinition(
|
||||
[new MockTool({ name: 'take_snapshot' })],
|
||||
'none',
|
||||
);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { result: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
await executor.run({ goal: 'Test' }, signal);
|
||||
|
||||
const declarations = getSentFunctionDeclarations();
|
||||
const names = declarations.map((d) => d.name);
|
||||
|
||||
expect(names).toContain(TASK_COMPLETE_TOOL_NAME);
|
||||
expect(names).toContain('take_snapshot');
|
||||
expect(declarations).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should produce unique declarations for many instance tools (browser agent scale)', async () => {
|
||||
// Simulates the full set of tools the browser agent typically registers
|
||||
const browserToolNames = [
|
||||
'click',
|
||||
'click_at',
|
||||
'fill',
|
||||
'fill_form',
|
||||
'hover',
|
||||
'drag',
|
||||
'press_key',
|
||||
'take_snapshot',
|
||||
'navigate_page',
|
||||
'new_page',
|
||||
'close_page',
|
||||
'select_page',
|
||||
'evaluate_script',
|
||||
'type_text',
|
||||
];
|
||||
const instanceTools = browserToolNames.map(
|
||||
(name) => new MockTool({ name }),
|
||||
);
|
||||
|
||||
const definition = createInstanceToolDefinition(instanceTools);
|
||||
|
||||
mockModelResponse([
|
||||
{
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
args: { finalResult: 'done' },
|
||||
id: 'c1',
|
||||
},
|
||||
]);
|
||||
|
||||
const executor = await LocalAgentExecutor.create(
|
||||
definition,
|
||||
mockConfig,
|
||||
onActivity,
|
||||
);
|
||||
await executor.run({ goal: 'Scale test' }, signal);
|
||||
|
||||
const declarations = getSentFunctionDeclarations();
|
||||
const names = declarations.map((d) => d.name);
|
||||
|
||||
// Every tool name must appear exactly once
|
||||
for (const toolName of browserToolNames) {
|
||||
const count = names.filter((n) => n === toolName).length;
|
||||
expect(count).toBe(1);
|
||||
}
|
||||
// Plus complete_task
|
||||
expect(declarations).toHaveLength(browserToolNames.length + 1);
|
||||
|
||||
// Verify the complete set of names has no duplicates
|
||||
const uniqueNames = new Set(names);
|
||||
expect(uniqueNames.size).toBe(names.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1209,17 +1209,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
|
||||
|
||||
if (toolConfig) {
|
||||
for (const toolRef of toolConfig.tools) {
|
||||
if (typeof toolRef === 'string') {
|
||||
// The names were already expanded and loaded into the registry during creation.
|
||||
} else if (typeof toolRef === 'object' && 'schema' in toolRef) {
|
||||
// Tool instance with an explicit schema property.
|
||||
toolsList.push(toolRef.schema);
|
||||
} else {
|
||||
if (typeof toolRef === 'object' && !('schema' in toolRef)) {
|
||||
// Raw `FunctionDeclaration` object.
|
||||
toolsList.push(toolRef);
|
||||
}
|
||||
}
|
||||
// Add schemas from tools that were explicitly registered by name or wildcard.
|
||||
// Add schemas from tools that were explicitly registered by name, wildcard, or instance.
|
||||
toolsList.push(...this.toolRegistry.getFunctionDeclarations());
|
||||
}
|
||||
|
||||
|
||||
@@ -593,6 +593,7 @@ describe('AgentRegistry', () => {
|
||||
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
|
||||
authConfig: mockAuth,
|
||||
agentName: 'RemoteAgentWithAuth',
|
||||
targetUrl: 'https://example.com/card',
|
||||
agentCardUrl: 'https://example.com/card',
|
||||
});
|
||||
expect(loadAgentSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -69,7 +69,7 @@ export class AgentRegistry {
|
||||
* Clears the current registry and re-scans for agents.
|
||||
*/
|
||||
async reload(): Promise<void> {
|
||||
A2AClientManager.getInstance().clearCache();
|
||||
A2AClientManager.getInstance(this.config).clearCache();
|
||||
await this.config.reloadAgents();
|
||||
this.agents.clear();
|
||||
this.allDefinitions.clear();
|
||||
@@ -414,12 +414,13 @@ export class AgentRegistry {
|
||||
|
||||
// Load the remote A2A agent card and register.
|
||||
try {
|
||||
const clientManager = A2AClientManager.getInstance();
|
||||
const clientManager = A2AClientManager.getInstance(this.config);
|
||||
let authHandler: AuthenticationHandler | undefined;
|
||||
if (definition.auth) {
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: definition.auth,
|
||||
agentName: definition.name,
|
||||
targetUrl: definition.agentCardUrl,
|
||||
agentCardUrl: remoteDef.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
|
||||
@@ -195,6 +195,7 @@ describe('RemoteAgentInvocation', () => {
|
||||
expect(A2AAuthProviderFactory.create).toHaveBeenCalledWith({
|
||||
authConfig: mockAuth,
|
||||
agentName: 'test-agent',
|
||||
targetUrl: 'http://test-agent/card',
|
||||
agentCardUrl: 'http://test-agent/card',
|
||||
});
|
||||
expect(mockClientManager.loadAgent).toHaveBeenCalledWith(
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type SendMessageResult,
|
||||
} from './a2a-client-manager.js';
|
||||
import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import type { AuthenticationHandler } from '@a2a-js/sdk/client';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { safeJsonToMarkdown } from '../utils/markdownUtils.js';
|
||||
@@ -30,39 +29,6 @@ import type { AnsiOutput } from '../utils/terminalSerializer.js';
|
||||
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
|
||||
import { A2AAgentError } from './a2a-errors.js';
|
||||
|
||||
/**
|
||||
* Authentication handler implementation using Google Application Default Credentials (ADC).
|
||||
*/
|
||||
export class ADCHandler implements AuthenticationHandler {
|
||||
private auth = new GoogleAuth({
|
||||
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
|
||||
});
|
||||
|
||||
async headers(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const client = await this.auth.getClient();
|
||||
const token = await client.getAccessToken();
|
||||
if (token.token) {
|
||||
return { Authorization: `Bearer ${token.token}` };
|
||||
}
|
||||
throw new Error('Failed to retrieve ADC access token.');
|
||||
} catch (e) {
|
||||
const errorMessage = `Failed to get ADC token: ${
|
||||
e instanceof Error ? e.message : String(e)
|
||||
}`;
|
||||
debugLogger.log('ERROR', errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async shouldRetryWithHeaders(
|
||||
_response: unknown,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
// For ADC, we usually just re-fetch the token if needed.
|
||||
return this.headers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool invocation that proxies to a remote A2A agent.
|
||||
*
|
||||
@@ -121,6 +87,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation<
|
||||
const provider = await A2AAuthProviderFactory.create({
|
||||
authConfig: this.definition.auth,
|
||||
agentName: this.definition.name,
|
||||
targetUrl: this.definition.agentCardUrl,
|
||||
agentCardUrl: this.definition.agentCardUrl,
|
||||
});
|
||||
if (!provider) {
|
||||
|
||||
@@ -103,9 +103,19 @@ describe('SubagentToolWrapper', () => {
|
||||
|
||||
expect(schema.name).toBe(mockDefinition.name);
|
||||
expect(schema.description).toBe(mockDefinition.description);
|
||||
expect(schema.parametersJsonSchema).toEqual(
|
||||
mockDefinition.inputConfig.inputSchema,
|
||||
);
|
||||
expect(schema.parametersJsonSchema).toEqual({
|
||||
...(mockDefinition.inputConfig.inputSchema as Record<string, unknown>),
|
||||
properties: {
|
||||
...((
|
||||
mockDefinition.inputConfig.inputSchema as Record<string, unknown>
|
||||
)['properties'] as Record<string, unknown>),
|
||||
wait_for_previous: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Set to true to wait for all previously requested tools in this turn to complete before starting. Set to false (or omit) to run in parallel. Use true when this tool depends on the output of previous tools.',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -43,12 +43,12 @@ export const DEFAULT_QUERY_STRING = 'Get Started!';
|
||||
/**
|
||||
* The default maximum number of conversational turns for an agent.
|
||||
*/
|
||||
export const DEFAULT_MAX_TURNS = 15;
|
||||
export const DEFAULT_MAX_TURNS = 30;
|
||||
|
||||
/**
|
||||
* The default maximum execution time for an agent in minutes.
|
||||
*/
|
||||
export const DEFAULT_MAX_TIME_MINUTES = 5;
|
||||
export const DEFAULT_MAX_TIME_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* Represents the validated input parameters passed to an agent upon invocation.
|
||||
@@ -223,12 +223,12 @@ export interface OutputConfig<T extends z.ZodTypeAny> {
|
||||
export interface RunConfig {
|
||||
/**
|
||||
* The maximum execution time for the agent in minutes.
|
||||
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (5).
|
||||
* If not specified, defaults to DEFAULT_MAX_TIME_MINUTES (10).
|
||||
*/
|
||||
maxTimeMinutes?: number;
|
||||
/**
|
||||
* The maximum number of conversational turns.
|
||||
* If not specified, defaults to DEFAULT_MAX_TURNS (15).
|
||||
* If not specified, defaults to DEFAULT_MAX_TURNS (30).
|
||||
*/
|
||||
maxTurns?: number;
|
||||
}
|
||||
|
||||
@@ -54,19 +54,21 @@ export function resolvePolicyChain(
|
||||
useCustomToolModel,
|
||||
hasAccessToPreview,
|
||||
);
|
||||
const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel);
|
||||
const isAutoPreferred = preferredModel
|
||||
? isAutoModel(preferredModel, config)
|
||||
: false;
|
||||
const isAutoConfigured = isAutoModel(configuredModel, config);
|
||||
|
||||
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
|
||||
chain = getFlashLitePolicyChain();
|
||||
} else if (
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
isAutoPreferred ||
|
||||
isAutoConfigured
|
||||
) {
|
||||
if (hasAccessToPreview) {
|
||||
const previewEnabled =
|
||||
isGemini3Model(resolvedModel) ||
|
||||
isGemini3Model(resolvedModel, config) ||
|
||||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
|
||||
chain = getModelPolicyChain({
|
||||
|
||||
@@ -700,7 +700,6 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- TODO: Migrate to safeFetch for SSRF protection
|
||||
const response = await fetch(
|
||||
'https://www.googleapis.com/oauth2/v2/userinfo',
|
||||
{
|
||||
|
||||
@@ -208,6 +208,7 @@ describe('CodeAssistServer', () => {
|
||||
traceId: 'test-trace-id',
|
||||
status: ActionStatus.ACTION_STATUS_NO_ERROR,
|
||||
initiationMethod: InitiationMethod.COMMAND,
|
||||
trajectoryId: 'test-session',
|
||||
streamingLatency: expect.objectContaining({
|
||||
totalLatency: expect.stringMatching(/\d+s/),
|
||||
firstMessageLatency: expect.stringMatching(/\d+s/),
|
||||
@@ -277,6 +278,7 @@ describe('CodeAssistServer', () => {
|
||||
conversationOffered: expect.objectContaining({
|
||||
traceId: 'stream-trace-id',
|
||||
initiationMethod: InitiationMethod.COMMAND,
|
||||
trajectoryId: 'test-session',
|
||||
}),
|
||||
timestamp: expect.stringMatching(
|
||||
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/,
|
||||
|
||||
@@ -153,6 +153,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
server.sessionId, // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.consumedCredits) {
|
||||
@@ -223,6 +224,7 @@ export class CodeAssistServer implements ContentGenerator {
|
||||
translatedResponse,
|
||||
streamingLatency,
|
||||
req.config?.abortSignal,
|
||||
this.sessionId, // Use sessionId as trajectoryId
|
||||
);
|
||||
|
||||
if (response.remainingCredits) {
|
||||
|
||||
@@ -92,6 +92,7 @@ describe('telemetry', () => {
|
||||
traceId,
|
||||
undefined,
|
||||
streamingLatency,
|
||||
'trajectory-id',
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -102,6 +103,7 @@ describe('telemetry', () => {
|
||||
streamingLatency,
|
||||
isAgentic: true,
|
||||
initiationMethod: InitiationMethod.COMMAND,
|
||||
trajectoryId: 'trajectory-id',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +126,7 @@ describe('telemetry', () => {
|
||||
'trace-id',
|
||||
undefined,
|
||||
{},
|
||||
'trajectory-id',
|
||||
);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
@@ -140,6 +143,7 @@ describe('telemetry', () => {
|
||||
'trace-id',
|
||||
signal,
|
||||
{},
|
||||
'trajectory-id',
|
||||
);
|
||||
|
||||
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_CANCELLED);
|
||||
@@ -155,6 +159,7 @@ describe('telemetry', () => {
|
||||
'trace-id',
|
||||
undefined,
|
||||
{},
|
||||
'trajectory-id',
|
||||
);
|
||||
|
||||
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
|
||||
@@ -177,6 +182,7 @@ describe('telemetry', () => {
|
||||
'trace-id',
|
||||
undefined,
|
||||
{},
|
||||
'trajectory-id',
|
||||
);
|
||||
|
||||
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_ERROR_UNKNOWN);
|
||||
@@ -194,6 +200,7 @@ describe('telemetry', () => {
|
||||
'trace-id',
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(result?.status).toBe(ActionStatus.ACTION_STATUS_EMPTY);
|
||||
@@ -214,7 +221,13 @@ describe('telemetry', () => {
|
||||
true,
|
||||
[{ name: 'replace', args: {} }],
|
||||
);
|
||||
const result = createConversationOffered(response, 'id', undefined, {});
|
||||
const result = createConversationOffered(
|
||||
response,
|
||||
'id',
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(result?.includedCode).toBe(true);
|
||||
});
|
||||
|
||||
@@ -231,7 +244,13 @@ describe('telemetry', () => {
|
||||
true,
|
||||
[{ name: 'replace', args: {} }],
|
||||
);
|
||||
const result = createConversationOffered(response, 'id', undefined, {});
|
||||
const result = createConversationOffered(
|
||||
response,
|
||||
'id',
|
||||
undefined,
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(result?.includedCode).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -260,6 +279,7 @@ describe('telemetry', () => {
|
||||
response,
|
||||
streamingLatency,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(serverMock.recordConversationOffered).toHaveBeenCalledWith(
|
||||
@@ -283,6 +303,7 @@ describe('telemetry', () => {
|
||||
response,
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(serverMock.recordConversationOffered).not.toHaveBeenCalled();
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function recordConversationOffered(
|
||||
response: GenerateContentResponse,
|
||||
streamingLatency: StreamingLatency,
|
||||
abortSignal: AbortSignal | undefined,
|
||||
trajectoryId: string | undefined,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (traceId) {
|
||||
@@ -44,6 +45,7 @@ export async function recordConversationOffered(
|
||||
traceId,
|
||||
abortSignal,
|
||||
streamingLatency,
|
||||
trajectoryId,
|
||||
);
|
||||
if (offered) {
|
||||
await server.recordConversationOffered(offered);
|
||||
@@ -87,6 +89,7 @@ export function createConversationOffered(
|
||||
traceId: string,
|
||||
signal: AbortSignal | undefined,
|
||||
streamingLatency: StreamingLatency,
|
||||
trajectoryId: string | undefined,
|
||||
): ConversationOffered | undefined {
|
||||
// Only send conversation offered events for responses that contain edit
|
||||
// function calls. Non-edit function calls don't represent file modifications.
|
||||
@@ -107,6 +110,7 @@ export function createConversationOffered(
|
||||
streamingLatency,
|
||||
isAgentic: true,
|
||||
initiationMethod: InitiationMethod.COMMAND,
|
||||
trajectoryId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -315,6 +315,7 @@ export interface ConversationOffered {
|
||||
streamingLatency?: StreamingLatency;
|
||||
isAgentic?: boolean;
|
||||
initiationMethod?: InitiationMethod;
|
||||
trajectoryId?: string;
|
||||
}
|
||||
|
||||
export interface StreamingLatency {
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
} from './models.js';
|
||||
import { Storage } from './storage.js';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
|
||||
vi.mock('fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('fs')>();
|
||||
@@ -641,8 +642,9 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
|
||||
const loopContext: AgentLoopContext = config;
|
||||
expect(
|
||||
config.getGeminiClient().stripThoughtsFromHistory,
|
||||
loopContext.geminiClient.stripThoughtsFromHistory,
|
||||
).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -660,8 +662,9 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
await config.refreshAuth(AuthType.USE_VERTEX_AI);
|
||||
|
||||
const loopContext: AgentLoopContext = config;
|
||||
expect(
|
||||
config.getGeminiClient().stripThoughtsFromHistory,
|
||||
loopContext.geminiClient.stripThoughtsFromHistory,
|
||||
).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -679,8 +682,9 @@ describe('Server Config (config.ts)', () => {
|
||||
|
||||
await config.refreshAuth(AuthType.USE_GEMINI);
|
||||
|
||||
const loopContext: AgentLoopContext = config;
|
||||
expect(
|
||||
config.getGeminiClient().stripThoughtsFromHistory,
|
||||
loopContext.geminiClient.stripThoughtsFromHistory,
|
||||
).not.toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -3059,7 +3063,8 @@ describe('Config JIT Initialization', () => {
|
||||
await config.initialize();
|
||||
|
||||
const skillManager = config.getSkillManager();
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
|
||||
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
|
||||
vi.spyOn(skillManager, 'setDisabledSkills');
|
||||
@@ -3095,7 +3100,8 @@ describe('Config JIT Initialization', () => {
|
||||
await config.initialize();
|
||||
|
||||
const skillManager = config.getSkillManager();
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = config;
|
||||
const toolRegistry = loopContext.toolRegistry;
|
||||
|
||||
vi.spyOn(skillManager, 'discoverSkills').mockResolvedValue(undefined);
|
||||
vi.spyOn(toolRegistry, 'registerTool');
|
||||
|
||||
@@ -601,6 +601,7 @@ export interface ConfigParameters {
|
||||
disableYoloMode?: boolean;
|
||||
rawOutput?: boolean;
|
||||
acceptRawOutputRisk?: boolean;
|
||||
dynamicModelConfiguration?: boolean;
|
||||
modelConfigServiceConfig?: ModelConfigServiceConfig;
|
||||
enableHooks?: boolean;
|
||||
enableHooksUI?: boolean;
|
||||
@@ -614,7 +615,6 @@ export interface ConfigParameters {
|
||||
disabledSkills?: string[];
|
||||
adminSkillsEnabled?: boolean;
|
||||
experimentalJitContext?: boolean;
|
||||
deferInitialMemoryLoad?: boolean;
|
||||
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
|
||||
disableLLMCorrection?: boolean;
|
||||
plan?: boolean;
|
||||
@@ -800,6 +800,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly disableYoloMode: boolean;
|
||||
private readonly rawOutput: boolean;
|
||||
private readonly acceptRawOutputRisk: boolean;
|
||||
private readonly dynamicModelConfiguration: boolean;
|
||||
private pendingIncludeDirectories: string[];
|
||||
private readonly enableHooks: boolean;
|
||||
private readonly enableHooksUI: boolean;
|
||||
@@ -833,7 +834,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly adminSkillsEnabled: boolean;
|
||||
|
||||
private readonly experimentalJitContext: boolean;
|
||||
private readonly deferInitialMemoryLoad: boolean;
|
||||
private readonly disableLLMCorrection: boolean;
|
||||
private readonly planEnabled: boolean;
|
||||
private readonly trackerEnabled: boolean;
|
||||
@@ -936,7 +936,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.adminSkillsEnabled = params.adminSkillsEnabled ?? true;
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.deferInitialMemoryLoad = params.deferInitialMemoryLoad ?? false;
|
||||
this.modelSteering = params.modelSteering ?? false;
|
||||
this.userHintService = new UserHintService(() =>
|
||||
this.isModelSteeringEnabled(),
|
||||
@@ -990,7 +989,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.truncateToolOutputThreshold =
|
||||
params.truncateToolOutputThreshold ??
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD;
|
||||
this.useWriteTodos = isPreviewModel(this.model)
|
||||
this.useWriteTodos = isPreviewModel(this.model, this)
|
||||
? false
|
||||
: (params.useWriteTodos ?? true);
|
||||
this.workspacePoliciesDir = params.workspacePoliciesDir;
|
||||
@@ -1039,7 +1038,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// Register Conseca if enabled
|
||||
if (this.enableConseca) {
|
||||
debugLogger.log('[SAFETY] Registering Conseca Safety Checker');
|
||||
ConsecaSafetyChecker.getInstance().setConfig(this);
|
||||
ConsecaSafetyChecker.getInstance().setContext(this);
|
||||
}
|
||||
|
||||
this._messageBus = new MessageBus(this.policyEngine, this.debugMode);
|
||||
@@ -1065,6 +1064,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.disableYoloMode = params.disableYoloMode ?? false;
|
||||
this.rawOutput = params.rawOutput ?? false;
|
||||
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
|
||||
this.dynamicModelConfiguration = params.dynamicModelConfiguration ?? false;
|
||||
|
||||
if (params.hooks) {
|
||||
this.hooks = params.hooks;
|
||||
@@ -1114,18 +1114,23 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
// remove this hack.
|
||||
let modelConfigServiceConfig = params.modelConfigServiceConfig;
|
||||
if (modelConfigServiceConfig) {
|
||||
if (!modelConfigServiceConfig.aliases) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
aliases: DEFAULT_MODEL_CONFIGS.aliases,
|
||||
};
|
||||
}
|
||||
if (!modelConfigServiceConfig.overrides) {
|
||||
modelConfigServiceConfig = {
|
||||
...modelConfigServiceConfig,
|
||||
overrides: DEFAULT_MODEL_CONFIGS.overrides,
|
||||
};
|
||||
}
|
||||
// Ensure user-defined model definitions augment, not replace, the defaults.
|
||||
const mergedModelDefinitions = {
|
||||
...DEFAULT_MODEL_CONFIGS.modelDefinitions,
|
||||
...modelConfigServiceConfig.modelDefinitions,
|
||||
};
|
||||
|
||||
modelConfigServiceConfig = {
|
||||
// Preserve other user settings like customAliases
|
||||
...modelConfigServiceConfig,
|
||||
// Apply defaults for aliases and overrides if they are not provided
|
||||
aliases:
|
||||
modelConfigServiceConfig.aliases ?? DEFAULT_MODEL_CONFIGS.aliases,
|
||||
overrides:
|
||||
modelConfigServiceConfig.overrides ?? DEFAULT_MODEL_CONFIGS.overrides,
|
||||
// Use the merged model definitions
|
||||
modelDefinitions: mergedModelDefinitions,
|
||||
};
|
||||
}
|
||||
|
||||
this.modelConfigService = new ModelConfigService(
|
||||
@@ -1177,23 +1182,6 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.getExtensionLoader().isLoaded()) {
|
||||
const extensionLoadHandle = startupProfiler.start('load_extensions');
|
||||
await this.getExtensionLoader().ensureLoaded();
|
||||
extensionLoadHandle?.end();
|
||||
} else {
|
||||
await this.getExtensionLoader().ensureLoaded();
|
||||
}
|
||||
|
||||
if (this.deferInitialMemoryLoad) {
|
||||
const memoryLoadHandle = startupProfiler.start('load_memory');
|
||||
const { refreshServerHierarchicalMemory } = await import(
|
||||
'../utils/memoryDiscovery.js'
|
||||
);
|
||||
await refreshServerHierarchicalMemory(this);
|
||||
memoryLoadHandle?.end();
|
||||
}
|
||||
|
||||
// Initialize centralized FileDiscoveryService
|
||||
const discoverToolsHandle = startupProfiler.start('discover_tools');
|
||||
this.getFileService();
|
||||
@@ -1245,8 +1233,8 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
// Re-register ActivateSkillTool to update its schema with the discovered enabled skill enums
|
||||
if (this.getSkillManager().getSkills().length > 0) {
|
||||
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
|
||||
this.getToolRegistry().registerTool(
|
||||
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
|
||||
this.toolRegistry.registerTool(
|
||||
new ActivateSkillTool(this, this.messageBus),
|
||||
);
|
||||
}
|
||||
@@ -1345,7 +1333,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
// Only reset when we have explicit "no access" (hasAccessToPreviewModel === false).
|
||||
// When null (quota not fetched) or true, we preserve the saved model.
|
||||
if (isPreviewModel(this.model) && this.hasAccessToPreviewModel === false) {
|
||||
if (
|
||||
isPreviewModel(this.model, this) &&
|
||||
this.hasAccessToPreviewModel === false
|
||||
) {
|
||||
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
}
|
||||
|
||||
@@ -1417,14 +1408,26 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
*/
|
||||
get toolRegistry(): ToolRegistry {
|
||||
return this._toolRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
*/
|
||||
get messageBus(): MessageBus {
|
||||
return this._messageBus;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Do not access directly on Config.
|
||||
* Use the injected AgentLoopContext instead.
|
||||
*/
|
||||
get geminiClient(): GeminiClient {
|
||||
return this._geminiClient;
|
||||
}
|
||||
@@ -1601,7 +1604,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
const isPreview =
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
isPreviewModel(this.getActiveModel());
|
||||
isPreviewModel(this.getActiveModel(), this);
|
||||
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
|
||||
const flashModel = isPreview
|
||||
? PREVIEW_GEMINI_FLASH_MODEL
|
||||
@@ -1799,8 +1802,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
const hasAccess =
|
||||
quota.buckets?.some((b) => b.modelId && isPreviewModel(b.modelId)) ??
|
||||
false;
|
||||
quota.buckets?.some(
|
||||
(b) => b.modelId && isPreviewModel(b.modelId, this),
|
||||
) ?? false;
|
||||
this.setHasAccessToPreviewModel(hasAccess);
|
||||
return quota;
|
||||
} catch (e) {
|
||||
@@ -2192,6 +2196,10 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
return this.acceptRawOutputRisk;
|
||||
}
|
||||
|
||||
getExperimentalDynamicModelConfiguration(): boolean {
|
||||
return this.dynamicModelConfiguration;
|
||||
}
|
||||
|
||||
getPendingIncludeDirectories(): string[] {
|
||||
return this.pendingIncludeDirectories;
|
||||
}
|
||||
@@ -2263,7 +2271,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
* Whenever the user memory (GEMINI.md files) is updated.
|
||||
*/
|
||||
updateSystemInstructionIfInitialized(): void {
|
||||
const geminiClient = this.getGeminiClient();
|
||||
const geminiClient = this.geminiClient;
|
||||
if (geminiClient?.isInitialized()) {
|
||||
geminiClient.updateSystemInstruction();
|
||||
}
|
||||
@@ -2729,16 +2737,16 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
// Re-register ActivateSkillTool to update its schema with the newly discovered skills
|
||||
if (this.getSkillManager().getSkills().length > 0) {
|
||||
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
|
||||
this.getToolRegistry().registerTool(
|
||||
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
|
||||
this.toolRegistry.registerTool(
|
||||
new ActivateSkillTool(this, this.messageBus),
|
||||
);
|
||||
} else {
|
||||
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
|
||||
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
|
||||
}
|
||||
} else {
|
||||
this.getSkillManager().clearSkills();
|
||||
this.getToolRegistry().unregisterTool(ActivateSkillTool.Name);
|
||||
this.toolRegistry.unregisterTool(ActivateSkillTool.Name);
|
||||
}
|
||||
|
||||
// Notify the client that system instructions might need updating
|
||||
@@ -3074,7 +3082,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
|
||||
for (const definition of definitions) {
|
||||
try {
|
||||
const tool = new SubagentTool(definition, this, this.getMessageBus());
|
||||
const tool = new SubagentTool(definition, this, this.messageBus);
|
||||
registry.registerTool(tool);
|
||||
} catch (e: unknown) {
|
||||
debugLogger.warn(
|
||||
@@ -3179,7 +3187,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.registerSubAgentTools(this._toolRegistry);
|
||||
}
|
||||
// Propagate updates to the active chat session
|
||||
const client = this.getGeminiClient();
|
||||
const client = this.geminiClient;
|
||||
if (client?.isInitialized()) {
|
||||
await client.setTools();
|
||||
client.updateSystemInstruction();
|
||||
|
||||
@@ -249,4 +249,94 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
modelDefinitions: {
|
||||
// Concrete Models
|
||||
'gemini-3.1-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3.1-pro-preview-customtools': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-pro-preview': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: true, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-3-flash-preview': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-3',
|
||||
isPreview: true,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: true },
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
tier: 'pro',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
tier: 'flash',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'gemini-2.5-flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
family: 'gemini-2.5',
|
||||
isPreview: false,
|
||||
dialogLocation: 'manual',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
// Aliases
|
||||
auto: {
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
pro: {
|
||||
tier: 'pro',
|
||||
isPreview: false,
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
flash: {
|
||||
tier: 'flash',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'flash-lite': {
|
||||
tier: 'flash-lite',
|
||||
isPreview: false,
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-3': {
|
||||
displayName: 'Auto (Gemini 3)',
|
||||
tier: 'auto',
|
||||
isPreview: true,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash',
|
||||
features: { thinking: true, multimodalToolUse: false },
|
||||
},
|
||||
'auto-gemini-2.5': {
|
||||
displayName: 'Auto (Gemini 2.5)',
|
||||
tier: 'auto',
|
||||
isPreview: false,
|
||||
dialogLocation: 'main',
|
||||
dialogDescription:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
features: { thinking: false, multimodalToolUse: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,6 +36,121 @@ import {
|
||||
VALID_GEMINI_MODELS,
|
||||
VALID_ALIASES,
|
||||
} from './models.js';
|
||||
import type { Config } from './config.js';
|
||||
import { ModelConfigService } from '../services/modelConfigService.js';
|
||||
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
|
||||
|
||||
const modelConfigService = new ModelConfigService(DEFAULT_MODEL_CONFIGS);
|
||||
|
||||
const dynamicConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => true,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
const legacyConfig = {
|
||||
getExperimentalDynamicModelConfiguration: () => false,
|
||||
modelConfigService,
|
||||
} as unknown as Config;
|
||||
|
||||
describe('Dynamic Configuration Parity', () => {
|
||||
const modelsToTest = [
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
GEMINI_MODEL_ALIAS_PRO,
|
||||
GEMINI_MODEL_ALIAS_FLASH,
|
||||
GEMINI_MODEL_ALIAS_FLASH_LITE,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
'custom-model',
|
||||
];
|
||||
|
||||
it('getDisplayString should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = getDisplayString(model, legacyConfig);
|
||||
const dynamic = getDisplayString(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isPreviewModel should match legacy behavior', () => {
|
||||
const allModels = [
|
||||
...modelsToTest,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
];
|
||||
for (const model of allModels) {
|
||||
const legacy = isPreviewModel(model, legacyConfig);
|
||||
const dynamic = isPreviewModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isProModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isProModel(model, legacyConfig);
|
||||
const dynamic = isProModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini3Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini3Model(model, legacyConfig);
|
||||
const dynamic = isGemini3Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isGemini2Model should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isGemini2Model(model, legacyConfig);
|
||||
const dynamic = isGemini2Model(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isCustomModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isCustomModel(model, legacyConfig);
|
||||
const dynamic = isCustomModel(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsModernFeatures should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsModernFeatures(model, legacyConfig);
|
||||
const dynamic = supportsModernFeatures(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isActiveModel should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isActiveModel(model, false, false, legacyConfig);
|
||||
const dynamic = isActiveModel(model, false, false, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('isValidModelOrAlias should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = isValidModelOrAlias(model, legacyConfig);
|
||||
const dynamic = isValidModelOrAlias(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
|
||||
it('supportsMultimodalFunctionResponse should match legacy behavior', () => {
|
||||
for (const model of modelsToTest) {
|
||||
const legacy = supportsMultimodalFunctionResponse(model, legacyConfig);
|
||||
const dynamic = supportsMultimodalFunctionResponse(model, dynamicConfig);
|
||||
expect(dynamic).toBe(legacy);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPreviewModel', () => {
|
||||
it('should return true for preview models', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ModelConfigService } from '../services/modelConfigService.js';
|
||||
|
||||
export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview';
|
||||
export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL =
|
||||
@@ -46,6 +48,15 @@ export const DEFAULT_GEMINI_EMBEDDING_MODEL = 'gemini-embedding-001';
|
||||
// Cap the thinking at 8192 to prevent run-away thinking loops.
|
||||
export const DEFAULT_THINKING_MODE = 8192;
|
||||
|
||||
/**
|
||||
* Represents the minimal configuration needed to resolve models dynamically.
|
||||
* This breaks circular dependencies by avoiding an import of the full Config class.
|
||||
*/
|
||||
export interface ModelResolutionContext {
|
||||
getExperimentalDynamicModelConfiguration?: () => boolean;
|
||||
modelConfigService: ModelConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the requested model alias (e.g., 'auto-gemini-3', 'pro', 'flash', 'flash-lite')
|
||||
* to a concrete model name.
|
||||
@@ -148,7 +159,17 @@ export function resolveClassifierModel(
|
||||
}
|
||||
return resolveModel(requestedModel, useGemini3_1, useCustomToolModel);
|
||||
}
|
||||
export function getDisplayString(model: string) {
|
||||
export function getDisplayString(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (definition?.displayName) {
|
||||
return definition.displayName;
|
||||
}
|
||||
}
|
||||
|
||||
switch (model) {
|
||||
case PREVIEW_GEMINI_MODEL_AUTO:
|
||||
return 'Auto (Gemini 3)';
|
||||
@@ -169,9 +190,19 @@ export function getDisplayString(model: string) {
|
||||
* Checks if the model is a preview model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a preview model.
|
||||
*/
|
||||
export function isPreviewModel(model: string): boolean {
|
||||
export function isPreviewModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.isPreview === true
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
model === PREVIEW_GEMINI_MODEL ||
|
||||
model === PREVIEW_GEMINI_3_1_MODEL ||
|
||||
@@ -186,9 +217,16 @@ export function isPreviewModel(model: string): boolean {
|
||||
* Checks if the model is a Pro model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Pro model.
|
||||
*/
|
||||
export function isProModel(model: string): boolean {
|
||||
export function isProModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'pro';
|
||||
}
|
||||
return model.toLowerCase().includes('pro');
|
||||
}
|
||||
|
||||
@@ -196,9 +234,22 @@ export function isProModel(model: string): boolean {
|
||||
* Checks if the model is a Gemini 3 model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini 3 model.
|
||||
*/
|
||||
export function isGemini3Model(model: string): boolean {
|
||||
export function isGemini3Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior resolves the model first.
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.family ===
|
||||
'gemini-3'
|
||||
);
|
||||
}
|
||||
|
||||
const resolved = resolveModel(model);
|
||||
return /^gemini-3(\.|-|$)/.test(resolved);
|
||||
}
|
||||
@@ -207,9 +258,20 @@ export function isGemini3Model(model: string): boolean {
|
||||
* Checks if the model is a Gemini 2.x model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is a Gemini-2.x model.
|
||||
*/
|
||||
export function isGemini2Model(model: string): boolean {
|
||||
export function isGemini2Model(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
// Legacy behavior does NOT resolve the model first for Gemini 2 check.
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(model)?.family ===
|
||||
'gemini-2.5'
|
||||
);
|
||||
}
|
||||
return /^gemini-2(\.|$)/.test(model);
|
||||
}
|
||||
|
||||
@@ -217,9 +279,20 @@ export function isGemini2Model(model: string): boolean {
|
||||
* Checks if the model is a "custom" model (not Gemini branded).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is not a Gemini branded model.
|
||||
*/
|
||||
export function isCustomModel(model: string): boolean {
|
||||
export function isCustomModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.tier ===
|
||||
'custom' || !resolved.startsWith('gemini-')
|
||||
);
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return !resolved.startsWith('gemini-');
|
||||
}
|
||||
@@ -229,20 +302,31 @@ export function isCustomModel(model: string): boolean {
|
||||
* This includes Gemini 3 models and any custom models.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports modern features like thoughts.
|
||||
*/
|
||||
export function supportsModernFeatures(model: string): boolean {
|
||||
if (isGemini3Model(model)) return true;
|
||||
return isCustomModel(model);
|
||||
export function supportsModernFeatures(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (isGemini3Model(model, config)) return true;
|
||||
return isCustomModel(model, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the model is an auto model.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is an auto model.
|
||||
*/
|
||||
export function isAutoModel(model: string): boolean {
|
||||
export function isAutoModel(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
return config.modelConfigService.getModelDefinition(model)?.tier === 'auto';
|
||||
}
|
||||
return (
|
||||
model === GEMINI_MODEL_ALIAS_AUTO ||
|
||||
model === PREVIEW_GEMINI_MODEL_AUTO ||
|
||||
@@ -255,9 +339,23 @@ export function isAutoModel(model: string): boolean {
|
||||
* This is supported in Gemini 3.
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model supports multimodal function responses.
|
||||
*/
|
||||
export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
export function supportsMultimodalFunctionResponse(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const resolved = resolveModel(model);
|
||||
return (
|
||||
config.modelConfigService.getModelDefinition(resolved)?.features
|
||||
?.multimodalToolUse === true
|
||||
);
|
||||
}
|
||||
return model.startsWith('gemini-3-');
|
||||
}
|
||||
|
||||
@@ -266,16 +364,32 @@ export function supportsMultimodalFunctionResponse(model: string): boolean {
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is active.
|
||||
*/
|
||||
export function isActiveModel(
|
||||
model: string,
|
||||
useGemini3_1: boolean = false,
|
||||
useCustomToolModel: boolean = false,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return false;
|
||||
}
|
||||
const definition = config.modelConfigService.getModelDefinition(model);
|
||||
if (!definition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy logic only considers explicit Gemini models as "active".
|
||||
if (definition.tier === 'custom' && !model.startsWith('gemini-')) {
|
||||
return false;
|
||||
}
|
||||
} else if (!VALID_GEMINI_MODELS.has(model)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (useGemini3_1) {
|
||||
if (model === PREVIEW_GEMINI_MODEL) {
|
||||
return false;
|
||||
@@ -297,9 +411,19 @@ export function isActiveModel(
|
||||
* Checks if the model name is valid (either a valid model or a valid alias).
|
||||
*
|
||||
* @param model The model name to check.
|
||||
* @param config Optional config object for dynamic model configuration.
|
||||
* @returns True if the model is valid.
|
||||
*/
|
||||
export function isValidModelOrAlias(model: string): boolean {
|
||||
export function isValidModelOrAlias(
|
||||
model: string,
|
||||
config?: ModelResolutionContext,
|
||||
): boolean {
|
||||
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
|
||||
if (config.modelConfigService.getModelDefinition(model)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a valid alias
|
||||
if (VALID_ALIASES.has(model)) {
|
||||
return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { Config } from './config.js';
|
||||
import { TRACKER_CREATE_TASK_TOOL_NAME } from '../tools/tool-names.js';
|
||||
import * as os from 'node:os';
|
||||
import type { AgentLoopContext } from './agent-loop-context.js';
|
||||
|
||||
describe('Config Tracker Feature Flag', () => {
|
||||
const baseParams = {
|
||||
@@ -21,7 +22,8 @@ describe('Config Tracker Feature Flag', () => {
|
||||
it('should not register tracker tools by default', async () => {
|
||||
const config = new Config(baseParams);
|
||||
await config.initialize();
|
||||
const registry = config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = config;
|
||||
const registry = loopContext.toolRegistry;
|
||||
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -31,7 +33,8 @@ describe('Config Tracker Feature Flag', () => {
|
||||
tracker: true,
|
||||
});
|
||||
await config.initialize();
|
||||
const registry = config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = config;
|
||||
const registry = loopContext.toolRegistry;
|
||||
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -41,7 +44,8 @@ describe('Config Tracker Feature Flag', () => {
|
||||
tracker: false,
|
||||
});
|
||||
await config.initialize();
|
||||
const registry = config.getToolRegistry();
|
||||
const loopContext: AgentLoopContext = config;
|
||||
const registry = loopContext.toolRegistry;
|
||||
expect(registry.getTool(TRACKER_CREATE_TASK_TOOL_NAME)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,7 +158,8 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -334,7 +335,8 @@ An approved plan is available for this task at \`/tmp/plans/feature-x.md\`.
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -617,7 +619,8 @@ Use the \`exit_plan_mode\` tool to present the plan and formally request approva
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -770,7 +773,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -909,7 +913,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
|
||||
@@ -1031,7 +1036,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim).
|
||||
@@ -1670,7 +1676,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -1823,7 +1830,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -1980,7 +1988,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2137,7 +2146,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2290,7 +2300,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2435,7 +2446,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2587,7 +2599,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2740,7 +2753,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -2904,7 +2918,8 @@ You are operating with a persistent file-based task tracking system located at \
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -3298,7 +3313,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -3451,7 +3467,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -3716,7 +3733,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
@@ -3869,7 +3887,8 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
|
||||
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
|
||||
|
||||
## Tool Usage
|
||||
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
|
||||
- **Parallelism & Sequencing:** Tools execute in parallel by default. Execute multiple independent tool calls in parallel when feasible (e.g., searching, reading files, independent shell commands, or editing *different* files). If a tool depends on the output or side-effects of a previous tool in the same turn (e.g., running a shell command that depends on the success of a previous command), you MUST set the \`wait_for_previous\` parameter to \`true\` on the dependent tool to ensure sequential execution.
|
||||
- **File Editing Collisions:** Do NOT make multiple calls to the \`replace\` tool for the SAME file in a single turn. To make multiple edits to the same file, you MUST perform them sequentially across multiple conversational turns to prevent race conditions and ensure the file state is accurate before each edit.
|
||||
- **Command Execution:** Use the \`run_shell_command\` tool for running shell commands, remembering the safety rule to explain modifying commands first.
|
||||
- **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. If unsure, ask the user.
|
||||
- **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input.
|
||||
|
||||
@@ -52,6 +52,7 @@ import * as policyCatalog from '../availability/policyCatalog.js';
|
||||
import { LlmRole, LoopType } from '../telemetry/types.js';
|
||||
import { partToString } from '../utils/partUtils.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { MessageBus } from '../confirmation-bus/message-bus.js';
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
@@ -216,6 +217,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||
getGlobalMemory: vi.fn().mockReturnValue(''),
|
||||
getEnvironmentMemory: vi.fn().mockReturnValue(''),
|
||||
isJitContextEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManager: vi.fn().mockReturnValue(undefined),
|
||||
getToolOutputMaskingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableLoopDetection: vi.fn().mockReturnValue(false),
|
||||
|
||||
@@ -284,7 +286,10 @@ describe('Gemini Client (client.ts)', () => {
|
||||
(
|
||||
mockConfig as unknown as { toolRegistry: typeof mockToolRegistry }
|
||||
).toolRegistry = mockToolRegistry;
|
||||
(mockConfig as unknown as { messageBus: undefined }).messageBus = undefined;
|
||||
(mockConfig as unknown as { messageBus: MessageBus }).messageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
} as unknown as MessageBus;
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).config =
|
||||
mockConfig;
|
||||
(mockConfig as unknown as { config: Config; promptId: string }).promptId =
|
||||
@@ -293,6 +298,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
client = new GeminiClient(mockConfig as unknown as AgentLoopContext);
|
||||
await client.initialize();
|
||||
vi.mocked(mockConfig.getGeminiClient).mockReturnValue(client);
|
||||
(mockConfig as unknown as { geminiClient: GeminiClient }).geminiClient =
|
||||
client;
|
||||
|
||||
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
|
||||
});
|
||||
@@ -368,6 +375,23 @@ describe('Gemini Client (client.ts)', () => {
|
||||
expect(newHistory.length).toBe(initialHistory.length);
|
||||
expect(JSON.stringify(newHistory)).not.toContain('some old message');
|
||||
});
|
||||
|
||||
it('should refresh ContextManager to reset JIT loaded paths', async () => {
|
||||
const mockRefresh = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(mockConfig.getContextManager).mockReturnValue({
|
||||
refresh: mockRefresh,
|
||||
} as unknown as ReturnType<typeof mockConfig.getContextManager>);
|
||||
|
||||
await client.resetChat();
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fail when ContextManager is undefined', async () => {
|
||||
vi.mocked(mockConfig.getContextManager).mockReturnValue(undefined);
|
||||
|
||||
await expect(client.resetChat()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('startChat', () => {
|
||||
|
||||
@@ -299,6 +299,9 @@ export class GeminiClient {
|
||||
async resetChat(): Promise<void> {
|
||||
this.chat = await this.startChat();
|
||||
this.updateTelemetryTokenCount();
|
||||
// Reset JIT context loaded paths so subdirectory context can be
|
||||
// re-discovered in the new session.
|
||||
await this.config.getContextManager()?.refresh();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@@ -866,7 +869,7 @@ export class GeminiClient {
|
||||
}
|
||||
|
||||
const hooksEnabled = this.config.getEnableHooks();
|
||||
const messageBus = this.config.getMessageBus();
|
||||
const messageBus = this.context.messageBus;
|
||||
|
||||
if (this.lastPromptId !== prompt_id) {
|
||||
this.loopDetector.reset(prompt_id, partListUnionToString(request));
|
||||
|
||||
@@ -318,6 +318,16 @@ function createMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
}) as unknown as PolicyEngine;
|
||||
}
|
||||
|
||||
Object.defineProperty(finalConfig, 'toolRegistry', {
|
||||
get: () => finalConfig.getToolRegistry?.() || defaultToolRegistry,
|
||||
});
|
||||
Object.defineProperty(finalConfig, 'messageBus', {
|
||||
get: () => finalConfig.getMessageBus?.(),
|
||||
});
|
||||
Object.defineProperty(finalConfig, 'geminiClient', {
|
||||
get: () => finalConfig.getGeminiClient?.(),
|
||||
});
|
||||
|
||||
return finalConfig;
|
||||
}
|
||||
|
||||
@@ -351,7 +361,7 @@ describe('CoreToolScheduler', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -431,7 +441,7 @@ describe('CoreToolScheduler', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -532,7 +542,7 @@ describe('CoreToolScheduler', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -629,7 +639,7 @@ describe('CoreToolScheduler', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -684,7 +694,7 @@ describe('CoreToolScheduler', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -750,7 +760,7 @@ describe('CoreToolScheduler with payload', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -898,7 +908,7 @@ describe('CoreToolScheduler edit cancellation', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -991,7 +1001,7 @@ describe('CoreToolScheduler YOLO mode', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1083,7 +1093,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1212,7 +1222,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1320,7 +1330,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
});
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1381,7 +1391,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1453,7 +1463,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
getAllTools: () => [],
|
||||
getToolsByServer: () => [],
|
||||
tools: new Map(),
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
mcpClientManager: undefined,
|
||||
getToolByName: () => testTool,
|
||||
getToolByDisplayName: () => testTool,
|
||||
@@ -1471,7 +1481,7 @@ describe('CoreToolScheduler request queueing', () => {
|
||||
> = [];
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate: (toolCalls) => {
|
||||
onToolCallsUpdate(toolCalls);
|
||||
@@ -1620,7 +1630,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1725,7 +1735,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1829,7 +1839,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
@@ -1894,7 +1904,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
|
||||
@@ -2005,7 +2015,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
|
||||
@@ -2069,7 +2079,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
.mockReturnValue(new HookSystem(mockConfig));
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
@@ -2138,7 +2148,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
@@ -2229,7 +2239,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
@@ -2283,7 +2293,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
});
|
||||
@@ -2344,7 +2354,7 @@ describe('CoreToolScheduler Sequential Execution', () => {
|
||||
mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const scheduler = new CoreToolScheduler({
|
||||
config: mockConfig,
|
||||
context: mockConfig,
|
||||
onAllToolCallsComplete,
|
||||
onToolCallsUpdate,
|
||||
getPreferredEditor: () => 'vscode',
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ToolConfirmationOutcome,
|
||||
} from '../tools/tools.js';
|
||||
import type { EditorType } from '../utils/editor.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { PolicyDecision } from '../policy/types.js';
|
||||
import { logToolCall } from '../telemetry/loggers.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
@@ -50,6 +49,7 @@ import { ToolExecutor } from '../scheduler/tool-executor.js';
|
||||
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
|
||||
import { getPolicyDenialError } from '../scheduler/policy.js';
|
||||
import { GeminiCliOperation } from '../telemetry/constants.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
export type {
|
||||
ToolCall,
|
||||
@@ -92,7 +92,7 @@ const createErrorResponse = (
|
||||
});
|
||||
|
||||
interface CoreToolSchedulerOptions {
|
||||
config: Config;
|
||||
context: AgentLoopContext;
|
||||
outputUpdateHandler?: OutputUpdateHandler;
|
||||
onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||
onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||
@@ -112,7 +112,7 @@ export class CoreToolScheduler {
|
||||
private onAllToolCallsComplete?: AllToolCallsCompleteHandler;
|
||||
private onToolCallsUpdate?: ToolCallsUpdateHandler;
|
||||
private getPreferredEditor: () => EditorType | undefined;
|
||||
private config: Config;
|
||||
private context: AgentLoopContext;
|
||||
private isFinalizingToolCalls = false;
|
||||
private isScheduling = false;
|
||||
private isCancelling = false;
|
||||
@@ -128,19 +128,19 @@ export class CoreToolScheduler {
|
||||
private toolModifier: ToolModificationHandler;
|
||||
|
||||
constructor(options: CoreToolSchedulerOptions) {
|
||||
this.config = options.config;
|
||||
this.context = options.context;
|
||||
this.outputUpdateHandler = options.outputUpdateHandler;
|
||||
this.onAllToolCallsComplete = options.onAllToolCallsComplete;
|
||||
this.onToolCallsUpdate = options.onToolCallsUpdate;
|
||||
this.getPreferredEditor = options.getPreferredEditor;
|
||||
this.toolExecutor = new ToolExecutor(this.config);
|
||||
this.toolExecutor = new ToolExecutor(this.context.config);
|
||||
this.toolModifier = new ToolModificationHandler();
|
||||
|
||||
// Subscribe to message bus for ASK_USER policy decisions
|
||||
// Use a static WeakMap to ensure we only subscribe ONCE per MessageBus instance
|
||||
// This prevents memory leaks when multiple CoreToolScheduler instances are created
|
||||
// (e.g., on every React render, or for each non-interactive tool call)
|
||||
const messageBus = this.config.getMessageBus();
|
||||
const messageBus = this.context.messageBus;
|
||||
|
||||
// Check if we've already subscribed a handler to this message bus
|
||||
if (!CoreToolScheduler.subscribedMessageBuses.has(messageBus)) {
|
||||
@@ -526,18 +526,16 @@ export class CoreToolScheduler {
|
||||
);
|
||||
}
|
||||
const requestsToProcess = Array.isArray(request) ? request : [request];
|
||||
const currentApprovalMode = this.config.getApprovalMode();
|
||||
const currentApprovalMode = this.context.config.getApprovalMode();
|
||||
this.completedToolCallsForBatch = [];
|
||||
|
||||
const newToolCalls: ToolCall[] = requestsToProcess.map(
|
||||
(reqInfo): ToolCall => {
|
||||
const toolInstance = this.config
|
||||
.getToolRegistry()
|
||||
.getTool(reqInfo.name);
|
||||
const toolInstance = this.context.toolRegistry.getTool(reqInfo.name);
|
||||
if (!toolInstance) {
|
||||
const suggestion = getToolSuggestion(
|
||||
reqInfo.name,
|
||||
this.config.getToolRegistry().getAllToolNames(),
|
||||
this.context.toolRegistry.getAllToolNames(),
|
||||
);
|
||||
const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`;
|
||||
return {
|
||||
@@ -647,13 +645,13 @@ export class CoreToolScheduler {
|
||||
: undefined;
|
||||
const toolAnnotations = toolCall.tool.toolAnnotations;
|
||||
|
||||
const { decision, rule } = await this.config
|
||||
const { decision, rule } = await this.context.config
|
||||
.getPolicyEngine()
|
||||
.check(toolCallForPolicy, serverName, toolAnnotations);
|
||||
|
||||
if (decision === PolicyDecision.DENY) {
|
||||
const { errorMessage, errorType } = getPolicyDenialError(
|
||||
this.config,
|
||||
this.context.config,
|
||||
rule,
|
||||
);
|
||||
this.setStatusInternal(
|
||||
@@ -694,7 +692,7 @@ export class CoreToolScheduler {
|
||||
signal,
|
||||
);
|
||||
} else {
|
||||
if (!this.config.isInteractive()) {
|
||||
if (!this.context.config.isInteractive()) {
|
||||
throw new Error(
|
||||
`Tool execution for "${
|
||||
toolCall.tool.displayName || toolCall.tool.name
|
||||
@@ -703,7 +701,7 @@ export class CoreToolScheduler {
|
||||
}
|
||||
|
||||
// Fire Notification hook before showing confirmation to user
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
await hookSystem.fireToolNotificationEvent(confirmationDetails);
|
||||
}
|
||||
@@ -988,7 +986,7 @@ export class CoreToolScheduler {
|
||||
// The active tool is finished. Move it to the completed batch.
|
||||
const completedCall = activeCall as CompletedToolCall;
|
||||
this.completedToolCallsForBatch.push(completedCall);
|
||||
logToolCall(this.config, new ToolCallEvent(completedCall));
|
||||
logToolCall(this.context.config, new ToolCallEvent(completedCall));
|
||||
|
||||
// Clear the active tool slot. This is crucial for the sequential processing.
|
||||
this.toolCalls = [];
|
||||
|
||||
@@ -137,6 +137,10 @@ describe('GeminiChat', () => {
|
||||
let currentActiveModel = 'gemini-pro';
|
||||
|
||||
mockConfig = {
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
promptId: 'test-session-id',
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
getRetryErrorType,
|
||||
} from '../utils/retry.js';
|
||||
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import {
|
||||
resolveModel,
|
||||
isGemini2Model,
|
||||
@@ -59,6 +58,7 @@ import {
|
||||
createAvailabilityContextProvider,
|
||||
} from '../availability/policyHelpers.js';
|
||||
import { coreEvents } from '../utils/events.js';
|
||||
import type { AgentLoopContext } from '../config/agent-loop-context.js';
|
||||
|
||||
export enum StreamEventType {
|
||||
/** A regular content chunk from the API. */
|
||||
@@ -251,7 +251,7 @@ export class GeminiChat {
|
||||
private lastPromptTokenCount: number;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly context: AgentLoopContext,
|
||||
private systemInstruction: string = '',
|
||||
private tools: Tool[] = [],
|
||||
private history: Content[] = [],
|
||||
@@ -260,7 +260,7 @@ export class GeminiChat {
|
||||
kind: 'main' | 'subagent' = 'main',
|
||||
) {
|
||||
validateHistory(history);
|
||||
this.chatRecordingService = new ChatRecordingService(config);
|
||||
this.chatRecordingService = new ChatRecordingService(context);
|
||||
this.chatRecordingService.initialize(resumedSessionData, kind);
|
||||
this.lastPromptTokenCount = estimateTokenCountSync(
|
||||
this.history.flatMap((c) => c.parts || []),
|
||||
@@ -315,7 +315,7 @@ export class GeminiChat {
|
||||
|
||||
const userContent = createUserContent(message);
|
||||
const { model } =
|
||||
this.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
this.context.config.modelConfigService.getResolvedConfig(modelConfigKey);
|
||||
|
||||
// Record user input - capture complete message with all parts (text, files, images, etc.)
|
||||
// but skip recording function responses (tool call results) as they should be stored in tool call records
|
||||
@@ -350,7 +350,7 @@ export class GeminiChat {
|
||||
this: GeminiChat,
|
||||
): AsyncGenerator<StreamEvent, void, void> {
|
||||
try {
|
||||
const maxAttempts = this.config.getMaxAttempts();
|
||||
const maxAttempts = this.context.config.getMaxAttempts();
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
let isConnectionPhase = true;
|
||||
@@ -412,7 +412,7 @@ export class GeminiChat {
|
||||
// like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC or ApiError)
|
||||
const isRetryable = isRetryableError(
|
||||
error,
|
||||
this.config.getRetryFetchErrors(),
|
||||
this.context.config.getRetryFetchErrors(),
|
||||
);
|
||||
|
||||
const isContentError = error instanceof InvalidStreamError;
|
||||
@@ -421,7 +421,7 @@ export class GeminiChat {
|
||||
: getRetryErrorType(error);
|
||||
|
||||
if (
|
||||
(isContentError && isGemini2Model(model)) ||
|
||||
(isContentError && isGemini2Model(model, this.context.config)) ||
|
||||
(isRetryable && !signal.aborted)
|
||||
) {
|
||||
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
|
||||
@@ -437,12 +437,12 @@ export class GeminiChat {
|
||||
|
||||
if (isContentError) {
|
||||
logContentRetry(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new ContentRetryEvent(attempt, errorType, delayMs, model),
|
||||
);
|
||||
} else {
|
||||
logNetworkRetryAttempt(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new NetworkRetryAttemptEvent(
|
||||
attempt + 1,
|
||||
maxAttempts,
|
||||
@@ -472,7 +472,7 @@ export class GeminiChat {
|
||||
}
|
||||
|
||||
logContentRetryFailure(
|
||||
this.config,
|
||||
this.context.config,
|
||||
new ContentRetryFailureEvent(attempt + 1, errorType, model),
|
||||
);
|
||||
|
||||
@@ -502,7 +502,7 @@ export class GeminiChat {
|
||||
model: availabilityFinalModel,
|
||||
config: newAvailabilityConfig,
|
||||
maxAttempts: availabilityMaxAttempts,
|
||||
} = applyModelSelection(this.config, modelConfigKey);
|
||||
} = applyModelSelection(this.context.config, modelConfigKey);
|
||||
|
||||
let lastModelToUse = availabilityFinalModel;
|
||||
let currentGenerateContentConfig: GenerateContentConfig =
|
||||
@@ -511,26 +511,30 @@ export class GeminiChat {
|
||||
let lastContentsToUse: Content[] = [...requestContents];
|
||||
|
||||
const getAvailabilityContext = createAvailabilityContextProvider(
|
||||
this.config,
|
||||
this.context.config,
|
||||
() => lastModelToUse,
|
||||
);
|
||||
// Track initial active model to detect fallback changes
|
||||
const initialActiveModel = this.config.getActiveModel();
|
||||
const initialActiveModel = this.context.config.getActiveModel();
|
||||
|
||||
const apiCall = async () => {
|
||||
const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false;
|
||||
const useGemini3_1 =
|
||||
(await this.context.config.getGemini31Launched?.()) ?? false;
|
||||
// Default to the last used model (which respects arguments/availability selection)
|
||||
let modelToUse = resolveModel(lastModelToUse, useGemini3_1);
|
||||
|
||||
// If the active model has changed (e.g. due to a fallback updating the config),
|
||||
// we switch to the new active model.
|
||||
if (this.config.getActiveModel() !== initialActiveModel) {
|
||||
modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1);
|
||||
if (this.context.config.getActiveModel() !== initialActiveModel) {
|
||||
modelToUse = resolveModel(
|
||||
this.context.config.getActiveModel(),
|
||||
useGemini3_1,
|
||||
);
|
||||
}
|
||||
|
||||
if (modelToUse !== lastModelToUse) {
|
||||
const { generateContentConfig: newConfig } =
|
||||
this.config.modelConfigService.getResolvedConfig({
|
||||
this.context.config.modelConfigService.getResolvedConfig({
|
||||
...modelConfigKey,
|
||||
model: modelToUse,
|
||||
});
|
||||
@@ -547,11 +551,14 @@ export class GeminiChat {
|
||||
abortSignal,
|
||||
};
|
||||
|
||||
let contentsToUse: Content[] = supportsModernFeatures(modelToUse)
|
||||
let contentsToUse: Content[] = supportsModernFeatures(
|
||||
modelToUse,
|
||||
this.context.config,
|
||||
)
|
||||
? [...contentsForPreviewModel]
|
||||
: [...requestContents];
|
||||
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (hookSystem) {
|
||||
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
|
||||
model: modelToUse,
|
||||
@@ -619,7 +626,7 @@ export class GeminiChat {
|
||||
lastConfig = config;
|
||||
lastContentsToUse = contentsToUse;
|
||||
|
||||
return this.config.getContentGenerator().generateContentStream(
|
||||
return this.context.config.getContentGenerator().generateContentStream(
|
||||
{
|
||||
model: modelToUse,
|
||||
contents: contentsToUse,
|
||||
@@ -633,12 +640,12 @@ export class GeminiChat {
|
||||
const onPersistent429Callback = async (
|
||||
authType?: string,
|
||||
error?: unknown,
|
||||
) => handleFallback(this.config, lastModelToUse, authType, error);
|
||||
) => handleFallback(this.context.config, lastModelToUse, authType, error);
|
||||
|
||||
const onValidationRequiredCallback = async (
|
||||
validationError: ValidationRequiredError,
|
||||
) => {
|
||||
const handler = this.config.getValidationHandler();
|
||||
const handler = this.context.config.getValidationHandler();
|
||||
if (typeof handler !== 'function') {
|
||||
// No handler registered, re-throw to show default error message
|
||||
throw validationError;
|
||||
@@ -653,15 +660,17 @@ export class GeminiChat {
|
||||
const streamResponse = await retryWithBackoff(apiCall, {
|
||||
onPersistent429: onPersistent429Callback,
|
||||
onValidationRequired: onValidationRequiredCallback,
|
||||
authType: this.config.getContentGeneratorConfig()?.authType,
|
||||
retryFetchErrors: this.config.getRetryFetchErrors(),
|
||||
authType: this.context.config.getContentGeneratorConfig()?.authType,
|
||||
retryFetchErrors: this.context.config.getRetryFetchErrors(),
|
||||
signal: abortSignal,
|
||||
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
|
||||
getAvailabilityContext,
|
||||
onRetry: (attempt, error, delayMs) => {
|
||||
coreEvents.emitRetryAttempt({
|
||||
attempt,
|
||||
maxAttempts: availabilityMaxAttempts ?? this.config.getMaxAttempts(),
|
||||
maxAttempts:
|
||||
availabilityMaxAttempts ?? this.context.config.getMaxAttempts(),
|
||||
delayMs,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
model: lastModelToUse,
|
||||
@@ -814,7 +823,7 @@ export class GeminiChat {
|
||||
isSchemaDepthError(error.message) ||
|
||||
isInvalidArgumentError(error.message)
|
||||
) {
|
||||
const tools = this.config.getToolRegistry().getAllTools();
|
||||
const tools = this.context.toolRegistry.getAllTools();
|
||||
const cyclicSchemaTools: string[] = [];
|
||||
for (const tool of tools) {
|
||||
if (
|
||||
@@ -881,7 +890,7 @@ export class GeminiChat {
|
||||
}
|
||||
}
|
||||
|
||||
const hookSystem = this.config.getHookSystem();
|
||||
const hookSystem = this.context.config.getHookSystem();
|
||||
if (originalRequest && chunk && hookSystem) {
|
||||
const hookResult = await hookSystem.fireAfterModelEvent(
|
||||
originalRequest,
|
||||
|
||||
@@ -79,7 +79,20 @@ describe('GeminiChat Network Retries', () => {
|
||||
// Default mock implementation: execute the function immediately
|
||||
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
|
||||
|
||||
const mockToolRegistry = { getTool: vi.fn() };
|
||||
const testMessageBus = { publish: vi.fn(), subscribe: vi.fn() };
|
||||
|
||||
mockConfig = {
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
get messageBus() {
|
||||
return testMessageBus;
|
||||
},
|
||||
promptId: 'test-session-id',
|
||||
getSessionId: () => 'test-session-id',
|
||||
getTelemetryLogPromptsEnabled: () => true,
|
||||
getUsageStatisticsEnabled: () => true,
|
||||
|
||||
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { AgentDefinition } from '../agents/types.js';
|
||||
import * as toolNames from '../tools/tool-names.js';
|
||||
import type { ToolRegistry } from '../tools/tool-registry.js';
|
||||
|
||||
vi.mock('node:fs');
|
||||
vi.mock('../utils/gitUtils', () => ({
|
||||
@@ -22,6 +23,17 @@ describe('Core System Prompt Substitution', () => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', 'true');
|
||||
mockConfig = {
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
toolRegistry: {
|
||||
getAllToolNames: vi
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
toolNames.WRITE_FILE_TOOL_NAME,
|
||||
toolNames.READ_FILE_TOOL_NAME,
|
||||
]),
|
||||
},
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllToolNames: vi
|
||||
.fn()
|
||||
@@ -131,7 +143,10 @@ describe('Core System Prompt Substitution', () => {
|
||||
});
|
||||
|
||||
it('should not substitute disabled tool names', () => {
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
|
||||
vi.mocked(
|
||||
(mockConfig as unknown as { toolRegistry: ToolRegistry }).toolRegistry
|
||||
.getAllToolNames,
|
||||
).mockReturnValue([]);
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.readFileSync).mockReturnValue('Use ${write_file_ToolName}.');
|
||||
|
||||
|
||||
@@ -82,11 +82,12 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
vi.stubEnv('SANDBOX', undefined);
|
||||
vi.stubEnv('GEMINI_SYSTEM_MD', undefined);
|
||||
vi.stubEnv('GEMINI_WRITE_SYSTEM_MD', undefined);
|
||||
const mockRegistry = {
|
||||
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
mockConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllToolNames: vi.fn().mockReturnValue(['grep_search', 'glob']),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
@@ -114,6 +115,12 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
get toolRegistry() {
|
||||
return mockRegistry;
|
||||
},
|
||||
} as unknown as Config;
|
||||
});
|
||||
|
||||
@@ -374,7 +381,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
|
||||
it('should redact grep and glob from the system prompt when they are disabled', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([]);
|
||||
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([]);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
|
||||
expect(prompt).not.toContain('`grep_search`');
|
||||
@@ -390,10 +397,11 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
])(
|
||||
'should handle CodebaseInvestigator with tools=%s',
|
||||
(toolNames, expectCodebaseInvestigator) => {
|
||||
const mockToolRegistry = {
|
||||
getAllToolNames: vi.fn().mockReturnValue(toolNames),
|
||||
};
|
||||
const testConfig = {
|
||||
getToolRegistry: vi.fn().mockReturnValue({
|
||||
getAllToolNames: vi.fn().mockReturnValue(toolNames),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'),
|
||||
@@ -413,6 +421,12 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
}),
|
||||
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
|
||||
isTrackerEnabled: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
} as unknown as Config;
|
||||
|
||||
const prompt = getCoreSystemPrompt(testConfig);
|
||||
@@ -468,7 +482,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
|
||||
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
|
||||
planModeTools,
|
||||
);
|
||||
};
|
||||
@@ -522,7 +536,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
);
|
||||
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllTools).mockReturnValue(
|
||||
vi.mocked(mockConfig.toolRegistry.getAllTools).mockReturnValue(
|
||||
subsetTools,
|
||||
);
|
||||
|
||||
@@ -667,7 +681,7 @@ describe('Core System Prompt (prompts.ts)', () => {
|
||||
|
||||
it('should include planning phase suggestion when enter_plan_mode tool is enabled', () => {
|
||||
vi.mocked(mockConfig.getActiveModel).mockReturnValue(PREVIEW_GEMINI_MODEL);
|
||||
vi.mocked(mockConfig.getToolRegistry().getAllToolNames).mockReturnValue([
|
||||
vi.mocked(mockConfig.toolRegistry.getAllToolNames).mockReturnValue([
|
||||
'enter_plan_mode',
|
||||
]);
|
||||
const prompt = getCoreSystemPrompt(mockConfig);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user