Compare commits

..

25 Commits

Author SHA1 Message Date
Christian Gunderman a94cf529d6 Merge remote-tracking branch 'origin/main' into gundermanc/promote2 2026-03-13 14:21:32 -07:00
christine betts 24adacdbc2 Move keychain fallback to keychain service (#22332) 2026-03-13 20:57:08 +00:00
gemini-cli-robot aa23da67af chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 (#22251) 2026-03-13 20:33:16 +00:00
cynthialong0-0 bfbd3c40a7 feat(agent): add allowed domain restrictions for browser agent (#21775) 2026-03-13 19:41:40 +00:00
anj-s dd8d4c98b3 feat(tracker): return TodoList display for tracker tools (#22060) 2026-03-13 18:18:33 +00:00
Gaurav d368997ca3 test: add Object.create context regression test and tool confirmation integration test (#22356) 2026-03-13 17:49:33 +00:00
Abhi bbd80c9393 docs: overhaul subagents documentation and add /agents command (#22345) 2026-03-13 17:26:13 +00:00
Abhi 3b601b3d90 refactor(ui): extract SessionBrowser static ui components (#22348) 2026-03-13 17:25:13 +00:00
Adam Weidman b4bcd1a015 docs(core): add authentication guide for remote subagents (#22178) 2026-03-13 16:48:21 +00:00
Alexander Farber aa000d7d30 fix(core): show descriptive error messages when saving settings fails (#18095)
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
2026-03-13 16:19:56 +00:00
Christian Gunderman 6c397faee1 chore: fix merge conflict in evals/ask_user.eval.ts 2026-03-13 09:08:26 -07:00
Tommaso Sciortino 2a7e602356 refactor(cli): consolidate getErrorMessage utility to core (#22190) 2026-03-13 15:40:29 +00:00
matt korwel 8d0b2d7f1b feat(skills): improve async-pr-review workflow and logging (#21790) 2026-03-13 15:18:07 +00:00
Ankit c156bac5f7 fix(settings): prevent j/k navigation keys from intercepting edit buffer input (#21865) 2026-03-13 14:55:36 +00:00
Adib234 263b8cd3b3 fix(plan): Fix AskUser evals (#22074) 2026-03-13 13:30:19 +00:00
Abhi 2d05396dd2 refactor(ui): extract pure session browser utilities (#22256) 2026-03-13 06:22:52 +00:00
Sandy Tao 7b4a822b0e feat(core): instrument file system tools for JIT context discovery (#22082) 2026-03-13 03:44:42 +00:00
Bryan Morgan d44615ac2f feat(core): increase sub-agent turn and time limits (#22196) 2026-03-13 02:39:49 +00:00
joshualitt de656f01d7 feat(core): Fully migrate packages/core to AgentLoopContext. (#22115) 2026-03-13 01:56:31 +00:00
Abhi 1d2585dba6 fix(core): explicitly pass messageBus to policy engine for MCP tool saves (#22255) 2026-03-13 01:31:13 +00:00
Christian Gunderman 41678570dc Promote stable tests. 2026-03-12 17:43:41 -07:00
Christian Gunderman 97bc3f28c5 build(ci): fix false positive evals trigger on merge commits (#22237) 2026-03-13 00:38:54 +00:00
gemini-cli-robot 3038fdce2e Changelog for v0.33.1 (#22235)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-13 00:12:06 +00:00
ruomeng bb060d7a98 docs(plan): document hooks with plan mode (#22197) 2026-03-12 23:35:56 +00:00
Qiaochu Hu 9a73aa4072 docs: clarify that tools.core is an allowlist for ALL built-in tools (#18813)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: hobostay <hobostay@users.noreply.github.com>
2026-03-12 22:40:05 +00:00
172 changed files with 3881 additions and 1508 deletions
+45
View File
@@ -0,0 +1,45 @@
---
name: async-pr-review
description: Trigger this skill when the user wants to start an asynchronous PR review, run background checks on a PR, or check the status of a previously started async PR review.
---
# Async PR Review
This skill provides a set of tools to asynchronously review a Pull Request. It will create a background job to run the project's preflight checks, execute Gemini-powered test plans, and perform a comprehensive code review using custom prompts.
This skill is designed to showcase an advanced "Agentic Asynchronous Pattern":
1. **Native Background Shells vs Headless Inference**: While Gemini CLI can natively spawn and detach background shell commands (using the `run_shell_command` tool with `is_background: true`), a standard bash background job cannot perform LLM inference. To conduct AI-driven code reviews and test generation in the background, the shell script *must* invoke the `gemini` executable headlessly using `-p`. This offloads the AI tasks to independent worker agents.
2. **Dynamic Git Scoping**: The review scripts avoid hardcoded paths. They use `git rev-parse --show-toplevel` to automatically resolve the root of the user's current project.
3. **Ephemeral Worktrees**: Instead of checking out branches in the user's main workspace, the skill provisions temporary git worktrees in `.gemini/tmp/async-reviews/pr-<number>`. This prevents git lock conflicts and namespace pollution.
4. **Agentic Evaluation (`check-async-review.sh`)**: The check script outputs clean JSON/text statuses for the main agent to parse. The interactive agent itself synthesizes the final assessment dynamically from the generated log files.
## Workflow
1. **Determine Action**: Establish whether the user wants to start a new async review or check the status of an existing one.
* If the user says "start an async review for PR #123" or similar, proceed to **Start Review**.
* If the user says "check the status of my async review for PR #123" or similar, proceed to **Check Status**.
### Start Review
If the user wants to start a new async PR review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `async-review.sh` script, passing the PR number as the first argument. Be sure to run it with the `is_background` flag set to true to ensure it immediately detaches.
```bash
.gemini/skills/async-pr-review/scripts/async-review.sh <PR_NUMBER>
```
3. Inform the user that the tasks have started successfully and they can check the status later.
### Check Status
If the user wants to check the status or view the final assessment of a previously started async review:
1. Ask the user for the PR number if they haven't provided it.
2. Execute the `check-async-review.sh` script, passing the PR number as the first argument:
```bash
.gemini/skills/async-pr-review/scripts/check-async-review.sh <PR_NUMBER>
```
3. **Evaluate Output**: Read the output from the script.
* If the output contains `STATUS: IN_PROGRESS`, tell the user which tasks are still running.
* If the output contains `STATUS: COMPLETE`, use your file reading tools (`read_file`) to retrieve the contents of `final-assessment.md`, `review.md`, `pr-diff.diff`, `npm-test.log`, and `test-execution.log` files from the `LOG_DIR` specified in the output.
* **Final Assessment**: Read those files, synthesize their results, and give the user a concise recommendation on whether the PR builds successfully, passes tests, and if you recommend they approve it based on the review.
+148
View File
@@ -0,0 +1,148 @@
# --- CORE TOOLS ---
[[rule]]
toolName = "read_file"
decision = "allow"
priority = 100
[[rule]]
toolName = "write_file"
decision = "allow"
priority = 100
[[rule]]
toolName = "grep_search"
decision = "allow"
priority = 100
[[rule]]
toolName = "glob"
decision = "allow"
priority = 100
[[rule]]
toolName = "list_directory"
decision = "allow"
priority = 100
[[rule]]
toolName = "codebase_investigator"
decision = "allow"
priority = 100
# --- SHELL COMMANDS ---
# Git (Safe/Read-only)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"git blame",
"git show",
"git grep",
"git show-ref",
"git ls-tree",
"git ls-remote",
"git reflog",
"git remote -v",
"git diff",
"git rev-list",
"git rev-parse",
"git merge-base",
"git cherry",
"git fetch",
"git status",
"git st",
"git branch",
"git br",
"git log",
"git --version"
]
decision = "allow"
priority = 100
# GitHub CLI (Read-only)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"gh workflow list",
"gh auth status",
"gh checkout view",
"gh run view",
"gh run job view",
"gh run list",
"gh run --help",
"gh issue view",
"gh issue list",
"gh label list",
"gh pr diff",
"gh pr check",
"gh pr checks",
"gh pr view",
"gh pr list",
"gh pr status",
"gh repo view",
"gh job view",
"gh api",
"gh log"
]
decision = "allow"
priority = 100
# Node.js/NPM (Generic Tests, Checks, and Build)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"npm run start",
"npm install",
"npm run",
"npm test",
"npm ci",
"npm list",
"npm --version"
]
decision = "allow"
priority = 100
# Core Utilities (Safe)
[[rule]]
toolName = "run_shell_command"
commandPrefix = [
"sleep",
"env",
"break",
"xargs",
"base64",
"uniq",
"sort",
"echo",
"which",
"ls",
"find",
"tail",
"head",
"cat",
"cd",
"grep",
"ps",
"pwd",
"wc",
"file",
"stat",
"diff",
"lsof",
"date",
"whoami",
"uname",
"du",
"cut",
"true",
"false",
"readlink",
"awk",
"jq",
"rg",
"less",
"more",
"tree"
]
decision = "allow"
priority = 100
+241
View File
@@ -0,0 +1,241 @@
#!/bin/bash
notify() {
local title="$1"
local message="$2"
local pr="$3"
# Terminal escape sequence
printf "\e]9;%s | PR #%s | %s\a" "$title" "$pr" "$message"
# Native macOS notification
if [[ "$(uname)" == "Darwin" ]]; then
osascript -e "display notification \"$message\" with title \"$title\" subtitle \"PR #$pr\""
fi
}
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
# Use the repository's local .gemini/tmp directory for ephemeral worktrees and logs
pr_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number"
target_dir="$pr_dir/worktree"
log_dir="$pr_dir/logs"
cd "$base_dir" || exit 1
mkdir -p "$log_dir"
rm -f "$log_dir/setup.exit" "$log_dir/final-assessment.exit" "$log_dir/final-assessment.md"
echo "🧹 Cleaning up previous worktree if it exists..." | tee -a "$log_dir/setup.log"
git worktree remove -f "$target_dir" >> "$log_dir/setup.log" 2>&1 || true
git branch -D "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1 || true
git worktree prune >> "$log_dir/setup.log" 2>&1 || true
echo "📡 Fetching PR #$pr_number..." | tee -a "$log_dir/setup.log"
if ! git fetch origin -f "pull/$pr_number/head:gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Fetch failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Fetch failed." "$pr_number"
exit 1
fi
if [[ ! -d "$target_dir" ]]; then
echo "🧹 Pruning missing worktrees..." | tee -a "$log_dir/setup.log"
git worktree prune >> "$log_dir/setup.log" 2>&1
echo "🌿 Creating worktree in $target_dir..." | tee -a "$log_dir/setup.log"
if ! git worktree add "$target_dir" "gemini-async-pr-$pr_number" >> "$log_dir/setup.log" 2>&1; then
echo 1 > "$log_dir/setup.exit"
echo "❌ Worktree creation failed. Check $log_dir/setup.log"
notify "Async Review Failed" "Worktree creation failed." "$pr_number"
exit 1
fi
else
echo "🌿 Worktree already exists." | tee -a "$log_dir/setup.log"
fi
echo 0 > "$log_dir/setup.exit"
cd "$target_dir" || exit 1
echo "🚀 Launching background tasks. Logs saving to: $log_dir"
echo " ↳ [1/5] Grabbing PR diff..."
rm -f "$log_dir/pr-diff.exit"
{ gh pr diff "$pr_number" > "$log_dir/pr-diff.diff" 2>&1; echo $? > "$log_dir/pr-diff.exit"; } &
echo " ↳ [2/5] Starting build and lint..."
rm -f "$log_dir/build-and-lint.exit"
{ { npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck; } > "$log_dir/build-and-lint.log" 2>&1; echo $? > "$log_dir/build-and-lint.exit"; } &
# Dynamically resolve gemini binary (fallback to your nightly path)
GEMINI_CMD=$(which gemini || echo "$HOME/.gcli/nightly/node_modules/.bin/gemini")
POLICY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/policy.toml"
echo " ↳ [3/5] Starting Gemini code review..."
rm -f "$log_dir/review.exit"
{ "$GEMINI_CMD" --policy "$POLICY_PATH" -p "/review-frontend $pr_number" > "$log_dir/review.md" 2>&1; echo $? > "$log_dir/review.exit"; } &
echo " ↳ [4/5] Starting automated tests (waiting for build and lint)..."
rm -f "$log_dir/npm-test.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
gh pr checks "$pr_number" > "$log_dir/ci-checks.log" 2>&1
ci_status=$?
if [ "$ci_status" -eq 0 ]; then
echo "CI checks passed. Skipping local npm tests." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
elif [ "$ci_status" -eq 8 ]; then
echo "CI checks are still pending. Skipping local npm tests to avoid duplicate work. Please check GitHub for final results." > "$log_dir/npm-test.log"
echo 0 > "$log_dir/npm-test.exit"
else
echo "CI checks failed. Failing checks:" > "$log_dir/npm-test.log"
gh pr checks "$pr_number" --json name,bucket -q '.[] | select(.bucket=="fail") | .name' >> "$log_dir/npm-test.log" 2>&1
echo "Attempting to extract failing test files from CI logs..." >> "$log_dir/npm-test.log"
pr_branch=$(gh pr view "$pr_number" --json headRefName -q '.headRefName' 2>/dev/null)
run_id=$(gh run list --branch "$pr_branch" --workflow ci.yml --json databaseId -q '.[0].databaseId' 2>/dev/null)
failed_files=""
if [[ -n "$run_id" ]]; then
failed_files=$(gh run view "$run_id" --log-failed 2>/dev/null | grep -o -E '(packages/[a-zA-Z0-9_-]+|integration-tests|evals)/[a-zA-Z0-9_/-]+\.test\.ts(x)?' | sort | uniq)
fi
if [[ -n "$failed_files" ]]; then
echo "Found failing test files from CI:" >> "$log_dir/npm-test.log"
for f in $failed_files; do echo " - $f" >> "$log_dir/npm-test.log"; done
echo "Running ONLY failing tests locally..." >> "$log_dir/npm-test.log"
exit_code=0
for file in $failed_files; do
if [[ "$file" == packages/* ]]; then
ws_dir=$(echo "$file" | cut -d'/' -f1,2)
else
ws_dir=$(echo "$file" | cut -d'/' -f1)
fi
rel_file=${file#$ws_dir/}
echo "--- Running $rel_file in workspace $ws_dir ---" >> "$log_dir/npm-test.log"
if ! npm run test:ci -w "$ws_dir" -- "$rel_file" >> "$log_dir/npm-test.log" 2>&1; then
exit_code=1
fi
done
echo $exit_code > "$log_dir/npm-test.exit"
else
echo "Could not extract specific failing files. Skipping full local test suite as it takes too long. Please check CI logs manually." >> "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
fi
else
echo "Skipped due to build-and-lint failure" > "$log_dir/npm-test.log"
echo 1 > "$log_dir/npm-test.exit"
fi
} &
echo " ↳ [5/5] Starting Gemini test execution (waiting for build and lint)..."
rm -f "$log_dir/test-execution.exit"
{
while [ ! -f "$log_dir/build-and-lint.exit" ]; do sleep 1; done
if [ "$(cat "$log_dir/build-and-lint.exit")" == "0" ]; then
"$GEMINI_CMD" --policy "$POLICY_PATH" -p "Analyze the diff for PR $pr_number using 'gh pr diff $pr_number'. Instead of running the project's automated test suite (like 'npm test'), physically exercise the newly changed code in the terminal (e.g., by writing a temporary script to call the new functions, or testing the CLI command directly). Verify the feature's behavior works as expected. IMPORTANT: Do NOT modify any source code to fix errors. Just exercise the code and log the results, reporting any failures clearly. Do not ask for user confirmation." > "$log_dir/test-execution.log" 2>&1; echo $? > "$log_dir/test-execution.exit"
else
echo "Skipped due to build-and-lint failure" > "$log_dir/test-execution.log"
echo 1 > "$log_dir/test-execution.exit"
fi
} &
echo "✅ All tasks dispatched!"
echo "You can monitor progress with: tail -f $log_dir/*.log"
echo "Read your review later at: $log_dir/review.md"
# Polling loop to wait for all background tasks to finish
tasks=("pr-diff" "build-and-lint" "review" "npm-test" "test-execution")
log_files=("pr-diff.diff" "build-and-lint.log" "review.md" "npm-test.log" "test-execution.log")
declare -A task_done
for t in "${tasks[@]}"; do task_done[$t]=0; done
all_done=0
while [[ $all_done -eq 0 ]]; do
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
all_done=1
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
if [[ -f "$log_dir/$t.exit" ]]; then
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
fi
task_done[$t]=1
else
echo "$t: RUNNING"
all_done=0
fi
done
echo ""
echo "=================================================="
echo "📝 Live Logs (Last 5 lines of running tasks)"
echo "=================================================="
for i in "${!tasks[@]}"; do
t="${tasks[$i]}"
log_file="${log_files[$i]}"
if [[ ${task_done[$t]} -eq 0 ]]; then
if [[ -f "$log_dir/$log_file" ]]; then
echo ""
echo "--- $t ---"
tail -n 5 "$log_dir/$log_file"
fi
fi
done
if [[ $all_done -eq 0 ]]; then
sleep 3
fi
done
clear
echo "=================================================="
echo "🚀 Async PR Review Status for PR #$pr_number"
echo "=================================================="
echo ""
for t in "${tasks[@]}"; do
exit_code=$(cat "$log_dir/$t.exit")
if [[ "$exit_code" == "0" ]]; then
echo "$t: SUCCESS"
else
echo "$t: FAILED (exit code $exit_code)"
fi
done
echo ""
echo "⏳ Tasks complete! Synthesizing final assessment..."
if ! "$GEMINI_CMD" --policy "$POLICY_PATH" -p "Read the review at $log_dir/review.md, the automated test logs at $log_dir/npm-test.log, and the manual test execution logs at $log_dir/test-execution.log. Summarize the results, state whether the build and tests passed based on $log_dir/build-and-lint.exit and $log_dir/npm-test.exit, and give a final recommendation for PR $pr_number." > "$log_dir/final-assessment.md" 2>&1; then
echo $? > "$log_dir/final-assessment.exit"
echo "❌ Final assessment synthesis failed!"
echo "Check $log_dir/final-assessment.md for details."
notify "Async Review Failed" "Final assessment synthesis failed." "$pr_number"
exit 1
fi
echo 0 > "$log_dir/final-assessment.exit"
echo "✅ Final assessment complete! Check $log_dir/final-assessment.md"
notify "Async Review Complete" "Review and test execution finished successfully." "$pr_number"
@@ -0,0 +1,65 @@
#!/bin/bash
pr_number=$1
if [[ -z "$pr_number" ]]; then
echo "Usage: check-async-review <pr_number>"
exit 1
fi
base_dir=$(git rev-parse --show-toplevel 2>/dev/null)
if [[ -z "$base_dir" ]]; then
echo "❌ Must be run from within a git repository."
exit 1
fi
log_dir="$base_dir/.gemini/tmp/async-reviews/pr-$pr_number/logs"
if [[ ! -d "$log_dir" ]]; then
echo "STATUS: NOT_FOUND"
echo "❌ No logs found for PR #$pr_number in $log_dir"
exit 0
fi
tasks=(
"setup|setup.log"
"pr-diff|pr-diff.diff"
"build-and-lint|build-and-lint.log"
"review|review.md"
"npm-test|npm-test.log"
"test-execution|test-execution.log"
"final-assessment|final-assessment.md"
)
all_done=true
echo "STATUS: CHECKING"
for task_info in "${tasks[@]}"; do
IFS="|" read -r task_name log_file <<< "$task_info"
file_path="$log_dir/$log_file"
exit_file="$log_dir/$task_name.exit"
if [[ -f "$exit_file" ]]; then
exit_code=$(cat "$exit_file")
if [[ "$exit_code" == "0" ]]; then
echo "$task_name: SUCCESS"
else
echo "$task_name: FAILED (exit code $exit_code)"
echo " Last lines of $file_path:"
tail -n 3 "$file_path" | sed 's/^/ /'
fi
elif [[ -f "$file_path" ]]; then
echo "$task_name: RUNNING"
all_done=false
else
echo " $task_name: NOT STARTED"
all_done=false
fi
done
if $all_done; then
echo "STATUS: COMPLETE"
echo "LOG_DIR: $log_dir"
else
echo "STATUS: IN_PROGRESS"
fi
+6 -3
View File
@@ -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
+71 -10
View File
@@ -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
+282
View File
@@ -25,6 +25,20 @@ To use remote subagents, you must explicitly enable them in your
}
```
## Proxy support
Gemini CLI routes traffic to remote agents through an HTTP/HTTPS proxy if one is
configured. It uses the `general.proxy` setting in your `settings.json` file or
standard environment variables (`HTTP_PROXY`, `HTTPS_PROXY`).
```json
{
"general": {
"proxy": "http://my-proxy:8080"
}
}
```
## Defining remote subagents
Remote subagents are defined as Markdown files (`.md`) with YAML frontmatter.
@@ -40,6 +54,7 @@ You can place them in:
| `kind` | string | Yes | Must be `remote`. |
| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). |
| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. |
| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). |
### Single-subagent example
@@ -70,6 +85,273 @@ Markdown file.
> **Note:** Mixed local and remote agents, or multiple local agents, are not
> supported in a single file; the list format is currently remote-only.
## Authentication
Many remote agents require authentication. Gemini CLI supports several
authentication methods aligned with the
[A2A security specification](https://a2a-protocol.org/latest/specification/#451-securityscheme).
Add an `auth` block to your agent's frontmatter to configure credentials.
### Supported auth types
Gemini CLI supports the following authentication types:
| Type | Description |
| :------------------- | :--------------------------------------------------------------------------------------------- |
| `apiKey` | Send a static API key as an HTTP header. |
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
### Dynamic values
For `apiKey` and `http` auth types, secret values (`key`, `token`, `username`,
`password`, `value`) support dynamic resolution:
| Format | Description | Example |
| :---------- | :-------------------------------------------------- | :------------------------- |
| `$ENV_VAR` | Read from an environment variable. | `$MY_API_KEY` |
| `!command` | Execute a shell command and use the trimmed output. | `!gcloud auth print-token` |
| literal | Use the string as-is. | `sk-abc123` |
| `$$` / `!!` | Escape prefix. `$$FOO` becomes the literal `$FOO`. | `$$NOT_AN_ENV_VAR` |
> **Security tip:** Prefer `$ENV_VAR` or `!command` over embedding secrets
> directly in agent files, especially for project-level agents checked into
> version control.
### API key (`apiKey`)
Sends an API key as an HTTP header on every request.
| Field | Type | Required | Description |
| :----- | :----- | :------- | :---------------------------------------------------- |
| `type` | string | Yes | Must be `apiKey`. |
| `key` | string | Yes | The API key value. Supports dynamic values. |
| `name` | string | No | Header name to send the key in. Default: `X-API-Key`. |
```yaml
---
kind: remote
name: my-agent
agent_card_url: https://example.com/agent-card
auth:
type: apiKey
key: $MY_API_KEY
---
```
### HTTP authentication (`http`)
Supports Bearer tokens, Basic auth, and arbitrary IANA-registered HTTP
authentication schemes.
#### Bearer token
Use the following fields to configure a Bearer token:
| Field | Type | Required | Description |
| :------- | :----- | :------- | :----------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | Must be `Bearer`. |
| `token` | string | Yes | The bearer token. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Bearer
token: $MY_BEARER_TOKEN
```
#### Basic authentication
Use the following fields to configure Basic authentication:
| Field | Type | Required | Description |
| :--------- | :----- | :------- | :------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | Must be `Basic`. |
| `username` | string | Yes | The username. Supports dynamic values. |
| `password` | string | Yes | The password. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Basic
username: $MY_USERNAME
password: $MY_PASSWORD
```
#### Raw scheme
For any other IANA-registered scheme (for example, Digest, HOBA), provide the
raw authorization value.
| Field | Type | Required | Description |
| :------- | :----- | :------- | :---------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `http`. |
| `scheme` | string | Yes | The scheme name (for example, `Digest`). |
| `value` | string | Yes | Raw value sent as `Authorization: <scheme> <value>`. Supports dynamic values. |
```yaml
auth:
type: http
scheme: Digest
value: $MY_DIGEST_VALUE
```
### Google Application Default Credentials (`google-credentials`)
Uses
[Google Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/application-default-credentials)
to authenticate with Google Cloud services and Cloud Run endpoints. This is the
recommended auth method for agents hosted on Google Cloud infrastructure.
| Field | Type | Required | Description |
| :------- | :------- | :------- | :-------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `google-credentials`. |
| `scopes` | string[] | No | OAuth scopes. Defaults to `https://www.googleapis.com/auth/cloud-platform`. |
```yaml
---
kind: remote
name: my-gcp-agent
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
auth:
type: google-credentials
---
```
#### How token selection works
The provider automatically selects the correct token type based on the agent's
host:
| Host pattern | Token type | Use case |
| :----------------- | :----------------- | :------------------------------------------ |
| `*.googleapis.com` | **Access token** | Google APIs (Agent Engine, Vertex AI, etc.) |
| `*.run.app` | **Identity token** | Cloud Run services |
- **Access tokens** authorize API calls to Google services. They are scoped
(default: `cloud-platform`) and fetched via `GoogleAuth.getClient()`.
- **Identity tokens** prove the caller's identity to a service that validates
the token's audience. The audience is set to the target host. These are
fetched via `GoogleAuth.getIdTokenClient()`.
Both token types are cached and automatically refreshed before expiry.
#### Setup
`google-credentials` relies on ADC, which means your environment must have
credentials configured. Common setups:
- **Local development:** Run `gcloud auth application-default login` to
authenticate with your Google account.
- **CI / Cloud environments:** Use a service account. Set the
`GOOGLE_APPLICATION_CREDENTIALS` environment variable to the path of your
service account key file, or use workload identity on GKE / Cloud Run.
#### Allowed hosts
For security, `google-credentials` only sends tokens to known Google-owned
hosts:
- `*.googleapis.com`
- `*.run.app`
Requests to any other host will be rejected with an error. If your agent is
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
or `oauth2`).
#### Examples
The following examples demonstrate how to configure Google Application Default
Credentials.
**Cloud Run agent:**
```yaml
---
kind: remote
name: cloud-run-agent
agent_card_url: https://my-agent-xyz.run.app/.well-known/agent.json
auth:
type: google-credentials
---
```
**Google API with custom scopes:**
```yaml
---
kind: remote
name: vertex-agent
agent_card_url: https://us-central1-aiplatform.googleapis.com/.well-known/agent.json
auth:
type: google-credentials
scopes:
- https://www.googleapis.com/auth/cloud-platform
- https://www.googleapis.com/auth/compute
---
```
### OAuth 2.0 (`oauth2`)
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
for subsequent requests.
| Field | Type | Required | Description |
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `oauth2`. |
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
| `authorization_url` | string | No | Authorization endpoint. Discovered from the agent card if omitted. |
| `token_url` | string | No | Token endpoint. Discovered from the agent card if omitted. |
```yaml
---
kind: remote
name: oauth-agent
agent_card_url: https://example.com/.well-known/agent.json
auth:
type: oauth2
client_id: my-client-id.apps.example.com
---
```
If the agent card advertises an `oauth2` security scheme with
`authorizationCode` flow, the `authorization_url`, `token_url`, and `scopes` are
automatically discovered. You only need to provide `client_id` (and
`client_secret` if required).
Tokens are persisted to disk and refreshed automatically when they expire.
### Auth validation
When Gemini CLI loads a remote agent, it validates your auth configuration
against the agent card's declared `securitySchemes`. If the agent requires
authentication that you haven't configured, you'll see an error describing
what's needed.
`google-credentials` is treated as compatible with `http` Bearer security
schemes, since it produces Bearer tokens.
### Auth retry behavior
All auth providers automatically retry on `401` and `403` responses by
re-fetching credentials (up to 2 retries). This handles cases like expired
tokens or rotated credentials. For `apiKey` with `!command` values, the command
is re-executed on retry to fetch a fresh key.
### Agent card fetching and auth
When connecting to a remote agent, Gemini CLI first fetches the agent card
**without** authentication. If the card endpoint returns a `401` or `403`, it
retries the fetch **with** the configured auth headers. This lets agents have
publicly accessible cards while protecting their task endpoints, or to protect
both behind auth.
## Managing Subagents
Users can manage subagents using the following commands within the Gemini CLI:
+135 -19
View File
@@ -38,6 +38,34 @@ main agent calls the tool, it delegates the task to the subagent. Once the
subagent completes its task, it reports back to the main agent with its
findings.
## How to use subagents
You can use subagents through automatic delegation or by explicitly forcing them
in your prompt.
### Automatic delegation
Gemini CLI's main agent is instructed to use specialized subagents when a task
matches their expertise. For example, if you ask "How does the auth system
work?", the main agent may decide to call the `codebase_investigator` subagent
to perform the research.
### Forcing a subagent (@ syntax)
You can explicitly direct a task to a specific subagent by using the `@` symbol
followed by the subagent's name at the beginning of your prompt. This is useful
when you want to bypass the main agent's decision-making and go straight to a
specialist.
**Example:**
```bash
@codebase_investigator Map out the relationship between the AgentRegistry and the LocalAgentExecutor.
```
When you use the `@` syntax, the CLI injects a system note that nudges the
primary model to use that specific subagent tool immediately.
## Built-in subagents
Gemini CLI comes with the following built-in subagents:
@@ -49,15 +77,17 @@ Gemini CLI comes with the following built-in subagents:
dependencies.
- **When to use:** "How does the authentication system work?", "Map out the
dependencies of the `AgentRegistry` class."
- **Configuration:** Enabled by default. You can configure it in
`settings.json`. Example (forcing a specific model):
- **Configuration:** Enabled by default. You can override its settings in
`settings.json` under `agents.overrides`. Example (forcing a specific model
and increasing turns):
```json
{
"experimental": {
"codebaseInvestigatorSettings": {
"enabled": true,
"maxNumTurns": 20,
"model": "gemini-2.5-pro"
"agents": {
"overrides": {
"codebase_investigator": {
"modelConfig": { "model": "gemini-3-flash-preview" },
"runConfig": { "maxTurns": 50 }
}
}
}
}
@@ -233,7 +263,7 @@ kind: local
tools:
- read_file
- grep_search
model: gemini-2.5-pro
model: gemini-3-flash-preview
temperature: 0.2
max_turns: 10
---
@@ -254,16 +284,102 @@ it yourself; just report it.
### Configuration schema
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. If omitted, it may have access to a default set. |
| `model` | string | No | Specific model to use (e.g., `gemini-2.5-pro`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `15`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `5`. |
| Field | Type | Required | Description |
| :------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name` | string | Yes | Unique identifier (slug) used as the tool name for the agent. Only lowercase letters, numbers, hyphens, and underscores. |
| `description` | string | Yes | Short description of what the agent does. This is visible to the main agent to help it decide when to call this subagent. |
| `kind` | string | No | `local` (default) or `remote`. |
| `tools` | array | No | List of tool names this agent can use. Supports wildcards: `*` (all tools), `mcp_*` (all MCP tools), `mcp_server_*` (all tools from a server). **If omitted, it inherits all tools from the parent session.** |
| `model` | string | No | Specific model to use (e.g., `gemini-3-preview`). Defaults to `inherit` (uses the main session model). |
| `temperature` | number | No | Model temperature (0.0 - 2.0). Defaults to `1`. |
| `max_turns` | number | No | Maximum number of conversation turns allowed for this agent before it must return. Defaults to `30`. |
| `timeout_mins` | number | No | Maximum execution time in minutes. Defaults to `10`. |
### Tool wildcards
When defining `tools` for a subagent, you can use wildcards to quickly grant
access to groups of tools:
- `*`: Grant access to all available built-in and discovered tools.
- `mcp_*`: Grant access to all tools from all connected MCP servers.
- `mcp_my-server_*`: Grant access to all tools from a specific MCP server named
`my-server`.
### Isolation and recursion protection
Each subagent runs in its own isolated context loop. This means:
- **Independent history:** The subagent's conversation history does not bloat
the main agent's context.
- **Isolated tools:** The subagent only has access to the tools you explicitly
grant it.
- **Recursion protection:** To prevent infinite loops and excessive token usage,
subagents **cannot** call other subagents. If a subagent is granted the `*`
tool wildcard, it will still be unable to see or invoke other agents.
## Managing subagents
You can manage subagents interactively using the `/agents` command or
persistently via `settings.json`.
### Interactive management (/agents)
If you are in an interactive CLI session, you can use the `/agents` command to
manage subagents without editing configuration files manually. This is the
recommended way to quickly enable, disable, or re-configure agents on the fly.
For a full list of sub-commands and usage, see the
[`/agents` command reference](../reference/commands.md#agents).
### Persistent configuration (settings.json)
While the `/agents` command and agent definition files provide a starting point,
you can use `settings.json` for global, persistent overrides. This is useful for
enforcing specific models or execution limits across all sessions.
#### `agents.overrides`
Use this to enable or disable specific agents or override their run
configurations.
```json
{
"agents": {
"overrides": {
"security-auditor": {
"enabled": false,
"runConfig": {
"maxTurns": 20,
"maxTimeMinutes": 10
}
}
}
}
}
```
#### `modelConfigs.overrides`
You can target specific subagents with custom model settings (like system
instruction prefixes or specific safety settings) using the `overrideScope`
field.
```json
{
"modelConfigs": {
"overrides": [
{
"match": { "overrideScope": "security-auditor" },
"modelConfig": {
"generateContentConfig": {
"temperature": 0.1
}
}
}
]
}
}
```
### Optimizing your subagent
@@ -298,7 +414,7 @@ Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
> **Note: Remote subagents are currently an experimental feature.**
See the [Remote Subagents documentation](remote-agents) for detailed
configuration and usage instructions.
configuration, authentication, and usage instructions.
## Extension subagents
+25
View File
@@ -14,6 +14,31 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Show version info. Share this information when filing issues.
### `/agents`
- **Description:** Manage local and remote subagents.
- **Note:** This command is experimental and requires
`experimental.enableAgents: true` in your `settings.json`.
- **Sub-commands:**
- **`list`**:
- **Description:** Lists all discovered agents, including built-in, local,
and remote agents.
- **Usage:** `/agents list`
- **`reload`** (alias: `refresh`):
- **Description:** Rescans agent directories (`~/.gemini/agents` and
`.gemini/agents`) and reloads the registry.
- **Usage:** `/agents reload`
- **`enable`**:
- **Description:** Enables a specific subagent.
- **Usage:** `/agents enable <agent-name>`
- **`disable`**:
- **Description:** Disables a specific subagent.
- **Usage:** `/agents disable <agent-name>`
- **`config`**:
- **Description:** Opens a configuration dialog for the specified agent to
adjust its model, temperature, or execution limits.
- **Usage:** `/agents config <agent-name>`
### `/auth`
- **Description:** Open a dialog that lets you change the authentication method.
+11
View File
@@ -706,6 +706,17 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`agents.browser.allowedDomains`** (array):
- **Description:** A list of allowed domains for the browser agent (e.g.,
["github.com", "*.google.com"]).
- **Default:**
```json
["github.com", "*.google.com", "localhost"]
```
- **Requires restart:** Yes
- **`agents.browser.disableUserInput`** (boolean):
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
+3 -1
View File
@@ -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
+8
View File
@@ -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.
+1 -1
View File
@@ -111,7 +111,7 @@ describe('Answer vs. ask eval', () => {
* Ensures that when the user asks a question about style, the agent does NOT
* automatically modify the file.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not edit files when asked about style',
prompt: 'Is app.ts following good style?',
files: FILES,
+72 -33
View File
@@ -5,31 +5,62 @@
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import { appEvalTest, AppEvalCase } from './app-test-helper.js';
import { EvalPolicy } from './test-helper.js';
function askUserEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
return appEvalTest(policy, {
...evalCase,
configOverrides: {
...evalCase.configOverrides,
general: {
...evalCase.configOverrides?.general,
approvalMode: 'default',
enableAutoUpdate: false,
enableAutoUpdateNotification: false,
},
},
files: {
...evalCase.files,
},
});
}
describe('ask_user', () => {
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to present multiple choice options',
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
setup: async (rig) => {
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(
confirmation,
'Expected a pending confirmation for ask_user tool',
).toBeDefined();
},
});
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
files: {
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
},
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
setup: async (rig) => {
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(
confirmation,
'Expected a pending confirmation for ask_user tool',
).toBeDefined();
},
});
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
files: {
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
@@ -39,28 +70,37 @@ describe('ask_user', () => {
}),
'README.md': '# Gemini CLI',
},
prompt: `Refactor the entire core package to be better.`,
prompt: `I want to completely rewrite the core package to support the upcoming V2 architecture, but I haven't decided what that looks like yet. We need to figure out the requirements first. Can you ask me some questions to help nail down the design?`,
setup: async (rig) => {
rig.setBreakpoint(['enter_plan_mode', 'ask_user']);
},
assert: async (rig) => {
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
true,
);
// It might call enter_plan_mode first.
let confirmation = await rig.waitForPendingConfirmation([
'enter_plan_mode',
'ask_user',
]);
expect(confirmation, 'Expected a tool call confirmation').toBeDefined();
if (confirmation?.name === 'enter_plan_mode') {
rig.acceptConfirmation('enter_plan_mode');
confirmation = await rig.waitForPendingConfirmation('ask_user');
}
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
expect(
wasAskUserCalled,
'Expected ask_user tool to be called to clarify the significant rework',
).toBe(true);
confirmation?.toolName,
'Expected ask_user to be called to clarify the significant rework',
).toBe('ask_user');
},
});
// --- Regression Tests for Recent Fixes ---
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
// Regression test for issue #20177: Ensure the agent does not use \`ask_user\` to
// confirm shell commands. Fixed via prompt refinements and tool definition
// updates to clarify that shell command confirmation is handled by the UI.
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
evalTest('USUALLY_PASSES', {
askUserEvalTest('USUALLY_PASSES', {
name: 'Agent does NOT use AskUser to confirm shell commands',
files: {
'package.json': JSON.stringify({
@@ -68,25 +108,24 @@ describe('ask_user', () => {
}),
},
prompt: `Run 'npm run build' in the current directory.`,
setup: async (rig) => {
rig.setBreakpoint(['run_shell_command', 'ask_user']);
},
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const wasShellCalled = toolLogs.some(
(log) => log.toolRequest.name === 'run_shell_command',
);
const wasAskUserCalled = toolLogs.some(
(log) => log.toolRequest.name === 'ask_user',
);
const confirmation = await rig.waitForPendingConfirmation([
'run_shell_command',
'ask_user',
]);
expect(
wasShellCalled,
'Expected run_shell_command tool to be called',
).toBe(true);
confirmation,
'Expected a pending confirmation for a tool',
).toBeDefined();
expect(
wasAskUserCalled,
confirmation?.toolName,
'ask_user should not be called to confirm shell commands',
).toBe(false);
).toBe('run_shell_command');
},
});
});
+1 -1
View File
@@ -11,7 +11,7 @@ import { assertModelHasOutput } from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
+3 -3
View File
@@ -14,7 +14,7 @@ import {
describe('save_memory', () => {
const TEST_PREFIX = 'Save memory test: ';
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingFavoriteColor,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -79,7 +79,7 @@ describe('save_memory', () => {
const ignoringTemporaryInformation =
'Agent ignores temporary conversation details';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -104,7 +104,7 @@ describe('save_memory', () => {
});
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -0,0 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"write_file","args":{"file_path":"test.txt","content":"hello"}}},{"text":"I've successfully written \"hello\" to test.txt. The file has been created with the specified content."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
+29
View File
@@ -203,4 +203,33 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
// Should successfully complete all operations
assertModelHasOutput(result);
});
it('should handle tool confirmation for write_file without crashing', async () => {
rig.setup('tool-confirmation', {
fakeResponsesPath: join(
__dirname,
'browser-agent.confirmation.responses',
),
settings: {
agents: {
browser_agent: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const run = await rig.runInteractive({ approvalMode: 'default' });
await run.type('Write hello to test.txt');
await run.type('\r');
await run.expectText('Allow', 15000);
await run.type('y');
await run.type('\r');
await run.expectText('successfully written', 15000);
});
});
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"workspaces": [
"packages/*"
],
@@ -16890,7 +16890,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17005,7 +17005,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17177,7 +17177,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17439,7 +17439,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17454,7 +17454,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17471,7 +17471,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17488,7 +17488,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -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);
+14 -7
View File
@@ -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 },
+3 -1
View File
@@ -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();
-1
View File
@@ -110,7 +110,6 @@ export async function loadConfig(
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? false,
};
const fileService = new FileDiscoveryService(workspaceDir, {
@@ -48,9 +48,6 @@ export interface Settings {
enableRecursiveFileSearch?: boolean;
customIgnoreFilePaths?: string[];
};
experimental?: {
enableAgents?: boolean;
};
}
export interface SettingsError {
+21 -5
View File
@@ -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';
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260311.657f19c1f"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+5 -2
View File
@@ -4,13 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions, type Config } from '@google/gemini-cli-core';
import {
listExtensions,
type Config,
getErrorMessage,
} from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
@@ -22,7 +22,7 @@ import {
SettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getErrorMessage } from '@google/gemini-cli-core';
// Mock dependencies
const emitConsoleLog = vi.hoisted(() => vi.fn());
@@ -44,12 +44,12 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn(),
};
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
@@ -6,8 +6,7 @@
import { type CommandModule } from 'yargs';
import { loadSettings, SettingScope } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
@@ -11,8 +11,8 @@ import {
debugLogger,
FolderTrustDiscoveryService,
getRealPath,
getErrorMessage,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
promptForConsentNonInteractive,
@@ -13,26 +13,24 @@ import {
afterEach,
type Mock,
} from 'vitest';
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
import { type Argv } from 'yargs';
import { handleLink, linkCommand } from './link.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{ stripAnsi: true },
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: true });
return { ...mocked, getErrorMessage: vi.fn() };
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
+1 -1
View File
@@ -8,10 +8,10 @@ import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import {
debugLogger,
getErrorMessage,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
requestConsentNonInteractive,
@@ -5,27 +5,23 @@
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { coreEvents } from '@google/gemini-cli-core';
import { coreEvents, getErrorMessage } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const { mockCoreDebugLogger } = await import(
'../../test-utils/mockDebugLogger.js'
);
return mockCoreDebugLogger(
await importOriginal<typeof import('@google/gemini-cli-core')>(),
{
stripAnsi: false,
},
);
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mocked = mockCoreDebugLogger(actual, { stripAnsi: false });
return { ...mocked, getErrorMessage: vi.fn() };
});
vi.mock('../../config/extension-manager.js');
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
+1 -2
View File
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
@@ -18,7 +18,7 @@ import { type Argv } from 'yargs';
import { handleUninstall, uninstallCommand } from './uninstall.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getErrorMessage } from '@google/gemini-cli-core';
// NOTE: This file uses vi.hoisted() mocks to enable testing of sequential
// mock behaviors (mockResolvedValueOnce/mockRejectedValueOnce chaining).
@@ -66,11 +66,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitConsoleLog,
},
debugLogger,
getErrorMessage: vi.fn(),
};
});
vi.mock('../../config/settings.js');
vi.mock('../../utils/errors.js');
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: vi.fn(),
}));
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
@@ -12,9 +12,12 @@ import {
updateExtension,
} from '../../config/extensions/update.js';
import { checkForExtensionUpdate } from '../../config/extensions/github.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import { coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
coreEvents,
debugLogger,
getErrorMessage,
} from '@google/gemini-cli-core';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import { loadSettings } from '../../config/settings.js';
@@ -5,11 +5,10 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import semver from 'semver';
import { getErrorMessage } from '../../utils/errors.js';
import type { ExtensionConfig } from '../../config/extension.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
@@ -28,6 +28,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
import { handleInstall, installCommand } from './install.js';
+5 -2
View File
@@ -5,8 +5,11 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
debugLogger,
type SkillDefinition,
getErrorMessage,
} from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { installSkill } from '../../utils/skillUtils.js';
import chalk from 'chalk';
@@ -24,6 +24,9 @@ const { debugLogger } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
vi.mock('../../config/extensions/consent.js', () => ({
+1 -2
View File
@@ -5,10 +5,9 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { getErrorMessage } from '../../utils/errors.js';
import { exitCli } from '../utils.js';
import {
requestConsentNonInteractive,
@@ -21,6 +21,9 @@ const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => {
vi.mock('@google/gemini-cli-core', () => ({
debugLogger,
getErrorMessage: vi.fn((e: unknown) =>
e instanceof Error ? e.message : String(e),
),
}));
import { handleUninstall, uninstallCommand } from './uninstall.js';
@@ -5,8 +5,7 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import { uninstallSkill } from '../../utils/skillUtils.js';
import chalk from 'chalk';
+1 -1
View File
@@ -5,9 +5,9 @@
*/
import { simpleGit } from 'simple-git';
import { getErrorMessage } from '../../utils/errors.js';
import {
debugLogger,
getErrorMessage,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
+5 -2
View File
@@ -11,9 +11,12 @@ import {
} from '../../ui/state/extensions.js';
import { loadInstallMetadata } from '../extension.js';
import { checkForExtensionUpdate } from './github.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import {
debugLogger,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import { getErrorMessage } from '../../utils/errors.js';
import { copyExtension, type ExtensionManager } from '../extension-manager.js';
import { ExtensionStorage } from './storage.js';
+1 -1
View File
@@ -2594,7 +2594,7 @@ describe('Settings Loading and Merging', () => {
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
'error',
'There was an error saving your latest settings changes.',
'Failed to save settings: Write failed',
error,
);
});
+5 -2
View File
@@ -14,6 +14,7 @@ import {
FatalConfigError,
GEMINI_DIR,
getErrorMessage,
getFsErrorMessage,
Storage,
coreEvents,
homedir,
@@ -1072,9 +1073,10 @@ export function saveSettings(settingsFile: SettingsFile): void {
settingsToSave as Record<string, unknown>,
);
} catch (error) {
const detailedErrorMessage = getFsErrorMessage(error);
coreEvents.emitFeedback(
'error',
'There was an error saving your latest settings changes.',
`Failed to save settings: ${detailedErrorMessage}`,
error,
);
}
@@ -1087,9 +1089,10 @@ export function saveModelChange(
try {
loadedSettings.setValue(SettingScope.User, 'model.name', model);
} catch (error) {
const detailedErrorMessage = getFsErrorMessage(error);
coreEvents.emitFeedback(
'error',
'There was an error saving your preferred model.',
`Failed to save preferred model: ${detailedErrorMessage}`,
error,
);
}
+13
View File
@@ -1117,6 +1117,19 @@ const SETTINGS_SCHEMA = {
description: 'Model override for the visual agent.',
showInDialog: false,
},
allowedDomains: {
type: 'array',
label: 'Allowed Domains',
category: 'Advanced',
requiresRestart: true,
default: ['github.com', '*.google.com', 'localhost'] as string[],
description: oneLine`
A list of allowed domains for the browser agent
(e.g., ["github.com", "*.google.com"]).
`,
showInDialog: false,
items: { type: 'string' },
},
disableUserInput: {
type: 'boolean',
label: 'Disable User Input',
+7 -1
View File
@@ -487,7 +487,7 @@ export class AppRig {
}
async waitForPendingConfirmation(
toolNameOrDisplayName?: string | RegExp,
toolNameOrDisplayName?: string | RegExp | string[],
timeout = 30000,
): Promise<PendingConfirmation> {
const matches = (p: PendingConfirmation) => {
@@ -498,6 +498,12 @@ export class AppRig {
p.toolDisplayName === toolNameOrDisplayName
);
}
if (Array.isArray(toolNameOrDisplayName)) {
return (
toolNameOrDisplayName.includes(p.toolName) ||
toolNameOrDisplayName.includes(p.toolDisplayName || '')
);
}
return (
toolNameOrDisplayName.test(p.toolName) ||
toolNameOrDisplayName.test(p.toolDisplayName || '')
@@ -7,10 +7,10 @@
import {
debugLogger,
listExtensions,
getErrorMessage,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import type { ExtensionUpdateInfo } from '../../config/extension.js';
import { getErrorMessage } from '../../utils/errors.js';
import {
emptyIcon,
MessageType,
@@ -16,9 +16,8 @@ import {
MessageType,
} from '../types.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { getAdminErrorMessage, getErrorMessage } from '@google/gemini-cli-core';
import {
linkSkill,
renderSkillActionFeedback,
@@ -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';
@@ -117,157 +116,11 @@ const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
</>
);
/**
* Loading state component displayed while sessions are being loaded.
*/
const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
/**
* Error state component displayed when session loading fails.
*/
const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
/**
* Empty state component displayed when no sessions are found.
*/
const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</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,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Empty state component displayed when no sessions are found.
*/
export const SessionBrowserEmpty = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>No auto-saved conversations found.</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,24 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Error state component displayed when session loading fails.
*/
export const SessionBrowserError = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.AccentRed}>Error: {state.error}</Text>
<Text color={Colors.Gray}>Press q to exit</Text>
</Box>
);
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
/**
* Loading state component displayed while sessions are being loaded.
*/
export const SessionBrowserLoading = (): React.JSX.Element => (
<Box flexDirection="column" paddingX={1}>
<Text color={Colors.Gray}>Loading sessions</Text>
</Box>
);
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { SessionBrowserLoading } from './SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowserEmpty.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser UI States', () => {
it('SessionBrowserLoading renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserLoading />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserError renders correctly', async () => {
const mockState = { error: 'Test error message' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionBrowserError state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionBrowserEmpty renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<SessionBrowserEmpty />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser UI States > SessionBrowserEmpty renders correctly 1`] = `
" No auto-saved conversations found.
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserError renders correctly 1`] = `
" Error: Test error message
Press q to exit
"
`;
exports[`SessionBrowser UI States > SessionBrowserLoading renders correctly 1`] = `
" Loading sessions…
"
`;
@@ -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;
});
};
@@ -18,7 +18,7 @@ export const TodoTray: React.FC = () => {
const uiState = useUIState();
const todos: TodoList | null = useMemo(() => {
// Find the most recent todo list written by the WriteTodosTool
// Find the most recent todo list written by tools that output a TodoList (e.g., WriteTodosTool or Tracker tools)
for (let i = uiState.history.length - 1; i >= 0; i--) {
const entry = uiState.history[i];
if (entry.type !== 'tool_group') {
@@ -760,6 +760,48 @@ describe('BaseSettingsDialog', () => {
});
unmount();
});
it('should allow j and k characters to be typed in string edit fields without triggering navigation', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Type 'j' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('j');
});
await waitUntilReady();
// Type 'k' - should appear in field, NOT trigger navigation
await act(async () => {
stdin.write('k');
});
await waitUntilReady();
// Commit with Enter
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// j and k should be typed into the field
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalledWith(
'string-setting',
'test-valuejk', // entered value + j and k
expect.objectContaining({ type: 'string' }),
);
});
unmount();
});
});
describe('custom key handling', () => {
@@ -325,13 +325,18 @@ export function BaseSettingsDialog({
return;
}
// Up/Down in edit mode - commit and navigate
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
// Up/Down in edit mode - commit and navigate.
// Only trigger on non-insertable keys (arrow keys) so that typing
// j/k characters into the edit buffer is not intercepted.
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key) && !key.insertable) {
commitEdit();
moveUp();
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
if (
keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key) &&
!key.insertable
) {
commitEdit();
moveDown();
return;
@@ -7,9 +7,9 @@
import {
debugLogger,
checkExhaustive,
getErrorMessage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
ExtensionUpdateState,
extensionUpdatesReducer,
-20
View File
@@ -21,7 +21,6 @@ import {
coreEvents,
} from '@google/gemini-cli-core';
import {
getErrorMessage,
handleError,
handleToolError,
handleCancellationError,
@@ -152,25 +151,6 @@ describe('errors', () => {
processExitSpy.mockRestore();
});
describe('getErrorMessage', () => {
it('should return error message for Error instances', () => {
const error = new Error('Test error message');
expect(getErrorMessage(error)).toBe('Test error message');
});
it('should convert non-Error values to strings', () => {
expect(getErrorMessage('string error')).toBe('string error');
expect(getErrorMessage(123)).toBe('123');
expect(getErrorMessage(null)).toBe('null');
expect(getErrorMessage(undefined)).toBe('undefined');
});
it('should handle objects', () => {
const obj = { message: 'test' };
expect(getErrorMessage(obj)).toBe('[object Object]');
});
});
describe('handleError', () => {
describe('in text mode', () => {
beforeEach(() => {
+1 -7
View File
@@ -18,16 +18,10 @@ import {
isFatalToolError,
debugLogger,
coreEvents,
getErrorMessage,
} from '@google/gemini-cli-core';
import { runSyncCleanup } from './cleanup.js';
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
interface ErrorWithCode extends Error {
exitCode?: number;
code?: string | number;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260311.657f19c1f",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -190,17 +190,11 @@ describe('A2AClientManager', () => {
expect(ClientFactoryOptions.createFrom).toHaveBeenCalled();
});
it('should return the cached card if an agent with the same name is already loaded (idempotent)', async () => {
const card1 = await manager.loadAgent(
'TestAgent',
'http://test.agent/card',
);
const card2 = await manager.loadAgent(
'TestAgent',
'http://test.agent/card',
);
expect(card1).toBe(card2);
expect(vi.mocked(DefaultAgentCardResolver)).toHaveBeenCalledTimes(1);
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://test.agent/card'),
).rejects.toThrow("Agent with name 'TestAgent' is already loaded.");
});
it('should use native fetch by default', async () => {
@@ -109,9 +109,8 @@ export class A2AClientManager {
agentCardUrl: string,
authHandler?: AuthenticationHandler,
): Promise<AgentCard> {
const existingCard = this.agentCards.get(name);
if (existingCard) {
return existingCard;
if (this.clients.has(name) && this.agentCards.has(name)) {
throw new Error(`Agent with name '${name}' is already loaded.`);
}
// Authenticated fetch for API calls (transports).
@@ -63,19 +63,6 @@ describe('AcknowledgedAgentsService', () => {
);
});
it('should return true for acknowledged agent via isAcknowledgedSync', async () => {
const service = new AcknowledgedAgentsService();
await service.acknowledge('/project', 'AgentA', 'hash1');
expect(service.isAcknowledgedSync('/project', 'AgentA', 'hash1')).toBe(
true,
);
expect(service.isAcknowledgedSync('/project', 'AgentA', 'hash2')).toBe(
false,
);
});
it('should load acknowledged agents from disk', async () => {
const ackPath = Storage.getAcknowledgedAgentsPath();
const data = {
@@ -66,18 +66,6 @@ export class AcknowledgedAgentsService {
hash: string,
): Promise<boolean> {
await this.load();
return this.isAcknowledgedSync(projectPath, agentName, hash);
}
/**
* Synchronous check for acknowledgment.
* Note: Assumes load() has already been called and awaited (e.g. during registry init).
*/
isAcknowledgedSync(
projectPath: string,
agentName: string,
hash: string,
): boolean {
const projectAgents = this.acknowledgedAgents[projectPath];
if (!projectAgents) return false;
return projectAgents[agentName] === hash;
@@ -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,
+2 -2
View File
@@ -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,
@@ -53,9 +53,22 @@ When you need to identify elements by visual attributes not in the AX tree (e.g.
* Extracted from prototype (computer_use_subagent_cdt branch).
*
* @param visionEnabled Whether visual tools (analyze_screenshot, click_at) are available.
* @param allowedDomains Optional list of allowed domains to restrict navigation.
*/
export function buildBrowserSystemPrompt(visionEnabled: boolean): string {
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.
export function buildBrowserSystemPrompt(
visionEnabled: boolean,
allowedDomains?: string[],
): string {
const allowedDomainsInstruction =
allowedDomains && allowedDomains.length > 0
? `\n\nSECURITY DOMAIN RESTRICTION - CRITICAL:\nYou are strictly limited to the following allowed domains (and their subdomains if specified with '*.'):\n${allowedDomains
.map((d) => `- ${d}`)
.join(
'\n',
)}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.`
: '';
return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction}
IMPORTANT: You will receive an accessibility tree snapshot showing elements with uid values (e.g., uid=87_4 button "Login").
Use these uid values directly with your tools:
@@ -166,7 +179,10 @@ export const BrowserAgentDefinition = (
</task>
First, use new_page to open the relevant URL. Then call take_snapshot to see the page and proceed with your task.`,
systemPrompt: buildBrowserSystemPrompt(visionEnabled),
systemPrompt: buildBrowserSystemPrompt(
visionEnabled,
config.getBrowserAgentConfig().customConfig.allowedDomains,
),
},
};
};
@@ -239,6 +239,25 @@ describe('browserAgentFactory', () => {
expect(toolNames).toContain('analyze_screenshot');
});
it('should include domain restrictions in system prompt when configured', async () => {
const configWithDomains = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['restricted.com'],
},
},
});
const { definition } = await createBrowserAgentDefinition(
configWithDomains,
mockMessageBus,
);
const systemPrompt = definition.promptConfig?.systemPrompt ?? '';
expect(systemPrompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
expect(systemPrompt).toContain('- restricted.com');
});
it('should include all MCP navigation tools (new_page, navigate_page) in definition', async () => {
mockBrowserManager.getDiscoveredTools.mockResolvedValue([
{ name: 'take_snapshot', description: 'Take snapshot' },
@@ -323,4 +342,22 @@ describe('buildBrowserSystemPrompt', () => {
expect(prompt).toContain('complete_task');
}
});
it('should include allowed domains restriction when provided', () => {
const prompt = buildBrowserSystemPrompt(false, [
'github.com',
'*.google.com',
]);
expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
expect(prompt).toContain('- github.com');
expect(prompt).toContain('- *.google.com');
});
it('should exclude allowed domains restriction when not provided or empty', () => {
let prompt = buildBrowserSystemPrompt(false);
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
prompt = buildBrowserSystemPrompt(false, []);
expect(prompt).not.toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:');
});
});
@@ -143,6 +143,75 @@ describe('BrowserManager', () => {
isError: false,
});
});
it('should block navigate_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
expect(Client).not.toHaveBeenCalled();
});
it('should allow navigate_page to allowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://google.com/search',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should allow navigate_page to subdomain when wildcard is used', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['*.google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('navigate_page', {
url: 'https://mail.google.com',
});
expect(result.isError).toBe(false);
expect((result.content || [])[0]?.text).toBe('Tool result');
});
it('should block new_page to disallowed domain', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
const result = await manager.callTool('new_page', {
url: 'https://evil.com',
});
expect(result.isError).toBe(true);
expect((result.content || [])[0]?.text).toContain('not permitted');
});
});
describe('MCP connection', () => {
@@ -172,6 +241,40 @@ describe('BrowserManager', () => {
expect(args[userDataDirIndex + 1]).toMatch(/cli-browser-profile$/);
});
it('should pass --host-rules when allowedDomains is configured', async () => {
const restrictedConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['google.com', '*.openai.com'],
},
},
});
const manager = new BrowserManager(restrictedConfig);
await manager.ensureConnection();
const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0]
?.args as string[];
expect(args).toContain(
'--chromeArg="--host-rules=MAP * 127.0.0.1, EXCLUDE google.com, EXCLUDE *.openai.com, EXCLUDE 127.0.0.1"',
);
});
it('should throw error when invalid domain is configured in allowedDomains', async () => {
const invalidConfig = makeFakeConfig({
agents: {
browser: {
allowedDomains: ['invalid domain!'],
},
},
});
const manager = new BrowserManager(invalidConfig);
await expect(manager.ensureConnection()).rejects.toThrow(
'Invalid domain in allowedDomains: invalid domain!',
);
});
it('should pass headless flag when configured', async () => {
const headlessConfig = makeFakeConfig({
agents: {
@@ -147,6 +147,19 @@ export class BrowserManager {
throw signal.reason ?? new Error('Operation cancelled');
}
const errorMessage = this.checkNavigationRestrictions(toolName, args);
if (errorMessage) {
return {
content: [
{
type: 'text',
text: errorMessage,
},
],
isError: true,
};
}
const client = await this.getRawMcpClient();
const callPromise = client.callTool(
{ name: toolName, arguments: args },
@@ -342,6 +355,23 @@ export class BrowserManager {
mcpArgs.push('--userDataDir', defaultProfilePath);
}
if (
browserConfig.customConfig.allowedDomains &&
browserConfig.customConfig.allowedDomains.length > 0
) {
const exclusionRules = browserConfig.customConfig.allowedDomains
.map((domain) => {
if (!/^(\*\.)?([a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$/.test(domain)) {
throw new Error(`Invalid domain in allowedDomains: ${domain}`);
}
return `EXCLUDE ${domain}`;
})
.join(', ');
mcpArgs.push(
`--chromeArg="--host-rules=MAP * 127.0.0.1, ${exclusionRules}, EXCLUDE 127.0.0.1"`,
);
}
debugLogger.log(
`Launching chrome-devtools-mcp (${sessionMode} mode) with args: ${mcpArgs.join(' ')}`,
);
@@ -502,6 +532,63 @@ export class BrowserManager {
);
}
/**
* Check navigation restrictions based on tools and the args sent
* along with them.
*
* @returns error message if failed, undefined if passed.
*/
private checkNavigationRestrictions(
toolName: string,
args: Record<string, unknown>,
): string | undefined {
const pageNavigationTools = ['navigate_page', 'new_page'];
if (!pageNavigationTools.includes(toolName)) {
return undefined;
}
const allowedDomains =
this.config.getBrowserAgentConfig().customConfig.allowedDomains;
if (!allowedDomains || allowedDomains.length === 0) {
return undefined;
}
const url = args['url'];
if (!url) {
return undefined;
}
if (typeof url !== 'string') {
return `Invalid URL: URL must be a string.`;
}
try {
const parsedUrl = new URL(url);
const urlHostname = parsedUrl.hostname.replace(/\.$/, '');
for (const domainPattern of allowedDomains) {
if (domainPattern.startsWith('*.')) {
const baseDomain = domainPattern.substring(2);
if (
urlHostname === baseDomain ||
urlHostname.endsWith(`.${baseDomain}`)
) {
return undefined;
}
} else {
if (urlHostname === domainPattern) {
return undefined;
}
}
}
} catch {
return `Invalid URL: Malformed URL string.`;
}
// If none matched, then deny
return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`;
}
/**
* Registers a fallback notification handler on the MCP client to
* automatically re-inject the input blocker after any server-side
+3 -3
View File
@@ -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'],
+4 -4
View File
@@ -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,
),
@@ -313,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 }),
@@ -524,7 +521,7 @@ describe('LocalAgentExecutor', () => {
toolName,
'description',
{},
mockConfig.getMessageBus(),
mockConfig.messageBus,
);
// Mock getTool to return our real DiscoveredMCPTool instance
+1 -32
View File
@@ -29,7 +29,7 @@ import { SimpleExtensionLoader } from '../utils/extensionLoader.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import { ThinkingLevel } from '@google/genai';
import type { AcknowledgedAgentsService } from './acknowledgedAgents.js';
import { PolicyDecision, ApprovalMode } from '../policy/types.js';
import { PolicyDecision } from '../policy/types.js';
import { A2AAuthProviderFactory } from './auth-provider/factory.js';
import type { A2AAuthProvider } from './auth-provider/types.js';
@@ -1171,37 +1171,6 @@ describe('AgentRegistry', () => {
}),
);
});
it('should register remote agents with ALLOW decision in YOLO mode', async () => {
const remoteAgent: AgentDefinition = {
kind: 'remote',
name: 'YoloAgent',
description: 'A remote agent in YOLO mode',
agentCardUrl: 'https://example.com/card',
inputConfig: { inputSchema: { type: 'object' } },
};
vi.mocked(A2AClientManager.getInstance).mockReturnValue({
loadAgent: vi.fn().mockResolvedValue({ name: 'YoloAgent' }),
} as unknown as A2AClientManager);
const policyEngine = mockConfig.getPolicyEngine();
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
ApprovalMode.YOLO,
);
const addRuleSpy = vi.spyOn(policyEngine, 'addRule');
await registry.testRegisterAgent(remoteAgent);
// In YOLO mode, even remote agents should be registered with ALLOW.
expect(addRuleSpy).toHaveBeenLastCalledWith(
expect.objectContaining({
toolName: 'YoloAgent',
decision: PolicyDecision.ALLOW,
source: 'AgentRegistry (Dynamic)',
}),
);
});
});
describe('reload', () => {
+5 -21
View File
@@ -23,11 +23,7 @@ import {
type ModelConfig,
ModelConfigService,
} from '../services/modelConfigService.js';
import {
PolicyDecision,
PRIORITY_SUBAGENT_TOOL,
ApprovalMode,
} from '../policy/types.js';
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js';
/**
@@ -180,8 +176,9 @@ export class AgentRegistry {
agent.metadata.hash,
);
agentsToRegister.push(agent);
if (!isAcknowledged) {
if (isAcknowledged) {
agentsToRegister.push(agent);
} else {
unacknowledgedAgents.push(agent);
}
}
@@ -343,23 +340,10 @@ export class AgentRegistry {
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
// Add the new dynamic policy
const isYolo = this.config.getApprovalMode() === ApprovalMode.YOLO;
const isAcknowledged =
definition.kind === 'local' &&
(!definition.metadata?.hash ||
(this.config.getProjectRoot() &&
this.config
.getAcknowledgedAgentsService()
?.isAcknowledgedSync?.(
this.config.getProjectRoot(),
definition.name,
definition.metadata.hash,
)));
policyEngine.addRule({
toolName: definition.name,
decision:
isAcknowledged || isYolo
definition.kind === 'local'
? PolicyDecision.ALLOW
: PolicyDecision.ASK_USER,
priority: PRIORITY_SUBAGENT_TOOL,
@@ -12,7 +12,6 @@ import { coreEvents } from '../utils/events.js';
import * as tomlLoader from './agentLoader.js';
import { type Config } from '../config/config.js';
import { AcknowledgedAgentsService } from './acknowledgedAgents.js';
import { PolicyDecision } from '../policy/types.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -104,22 +103,13 @@ describe('AgentRegistry Acknowledgement', () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('should register unacknowledged project agents and emit event', async () => {
it('should not register unacknowledged project agents and emit event', async () => {
const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered');
await registry.initialize();
// Now unacknowledged agents ARE registered (but with ASK_USER policy)
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
expect(registry.getDefinition('ProjectAgent')).toBeUndefined();
expect(emitSpy).toHaveBeenCalledWith([MOCK_AGENT_WITH_HASH]);
// Verify policy
const policyEngine = config.getPolicyEngine();
expect(
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
).toMatchObject({
decision: PolicyDecision.ASK_USER,
});
});
it('should register acknowledged project agents', async () => {
@@ -144,14 +134,6 @@ describe('AgentRegistry Acknowledgement', () => {
expect(registry.getDefinition('ProjectAgent')).toBeDefined();
expect(emitSpy).not.toHaveBeenCalled();
// Verify policy is ALLOW for acknowledged agent
const policyEngine = config.getPolicyEngine();
expect(
await policyEngine?.check({ name: 'ProjectAgent', args: {} }, undefined),
).toMatchObject({
decision: PolicyDecision.ALLOW,
});
});
it('should register agents without hash (legacy/safe?)', async () => {
@@ -10,7 +10,7 @@ import {
type ToolInvocation,
type ToolResult,
} from '../tools/tools.js';
import type { Config } from '../config/config.js';
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import type { AgentDefinition, AgentInputs } from './types.js';
import { LocalSubagentInvocation } from './local-invocation.js';
@@ -54,10 +54,6 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
);
}
private get config(): Config {
return this.context.config;
}
/**
* Creates an invocation instance for executing the subagent.
*
@@ -89,7 +85,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool<
// Special handling for browser agent - needs async MCP setup
if (definition.name === BROWSER_AGENT_NAME) {
return new BrowserAgentInvocation(
this.config,
this.context,
params,
effectiveMessageBus,
_toolName,
+4 -4
View File
@@ -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;
}
+11 -5
View File
@@ -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');
+25 -10
View File
@@ -316,6 +316,8 @@ export interface BrowserAgentCustomConfig {
profilePath?: string;
/** Model override for the visual agent. */
visualModel?: string;
/** List of allowed domains for the browser agent (e.g., ["github.com", "*.google.com"]). */
allowedDomains?: string[];
/** Disable user input on the browser window during automation. Default: true in non-headless mode */
disableUserInput?: boolean;
}
@@ -1036,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);
@@ -1225,8 +1227,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),
);
}
@@ -1397,14 +1399,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;
}
@@ -2243,7 +2257,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();
}
@@ -2709,16 +2723,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
@@ -2890,6 +2904,7 @@ export class Config implements McpContext, AgentLoopContext {
headless: customConfig.headless ?? false,
profilePath: customConfig.profilePath,
visualModel: customConfig.visualModel,
allowedDomains: customConfig.allowedDomains,
disableUserInput: customConfig.disableUserInput,
},
};
@@ -3054,7 +3069,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(
@@ -3159,7 +3174,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();
@@ -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();
});
});
+25 -1
View File
@@ -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', () => {
+4 -1
View File
@@ -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',
+14 -16
View File
@@ -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,
+32 -26
View File
@@ -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;
@@ -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,
});
@@ -551,7 +555,7 @@ export class GeminiChat {
? [...contentsForPreviewModel]
: [...requestContents];
const hookSystem = this.config.getHookSystem();
const hookSystem = this.context.config.getHookSystem();
if (hookSystem) {
const beforeModelResult = await hookSystem.fireBeforeModelEvent({
model: modelToUse,
@@ -619,7 +623,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 +637,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 +657,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 +820,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 +887,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}.');

Some files were not shown because too many files have changed in this diff Show More