Compare commits

...

29 Commits

Author SHA1 Message Date
Your Name 0cf502e72c address reviewer feedback 2026-04-30 22:18:41 +00:00
Your Name 602d6858f9 fix stale state in /rewind 2026-04-30 20:51:22 +00:00
Sri Pasumarthi 0ccc5ce58f refactor(acp): delegate prompt turn processing logic to GeminiClient (#26222) 2026-04-29 23:58:16 +00:00
Christian Gunderman 1834ad0298 fix(bot): productivity and backlog optimizations (#26236) 2026-04-29 23:18:22 +00:00
Stephen Eckels a2d10b7b99 Allow non-https proxy urls to support container environments (#26234)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-04-29 23:12:03 +00:00
Samee Zahid 8cec567064 docs(core): add automated gemma setup guide (#26233)
Co-authored-by: Samee Zahid <sameez@google.com>
2026-04-29 23:08:54 +00:00
gemini-cli[bot] fa1a7c10bd # Fix: Inconsistent Case-Sensitivity in GrepTool (#26235)
Co-authored-by: gemini-cli[bot] <gemini-cli[bot]@users.noreply.github.com>
2026-04-29 22:59:58 +00:00
Abhijit Balaji 49988fc05c fix(agent): prevent exit_plan_mode from being called via shell (#26230) 2026-04-29 22:22:21 +00:00
Martin d6ce310901 fix: correct API key validation logic in handleApiKeySubmit (#25453)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-04-29 22:06:14 +00:00
lp-peg 2194da2b02 Respect logPrompts flag for logging sensitive fields (#26153)
Co-authored-by: David Pierce <davidapierce@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-04-29 21:43:34 +00:00
Christian Gunderman dce13019b9 ci(github-actions): switch to github app token and fix bot self-trigger (#26223) 2026-04-29 20:45:16 +00:00
Adam Weidman 88626f37e3 fix(cli): handle InvalidStream event gracefully without throwing (#26218) 2026-04-29 20:27:53 +00:00
Adam Weidman 3aedbbc067 fix(core): distinguish fallback chains and fix maxAttempts for auto vs explicit model selection (#26163) 2026-04-29 20:23:37 +00:00
Adib234 99235fc59d fix(core): reduce default API timeout to 60s and enable retries for undici timeouts (#26191) 2026-04-29 20:05:45 +00:00
AK 25f422d0e4 test(evals): add EvalMetadata JSDoc annotations to older tests (#26147) 2026-04-29 19:11:51 +00:00
Christian Gunderman 6dec6720de Add the ability to @ mention the gemini robot. (#26207)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-29 18:46:33 +00:00
Adib234 3bc56d0ef5 test(core): add regression test for issue for ToolConfirmationResponse (#26194) 2026-04-29 17:51:09 +00:00
Abdul Tawab 011c0f9bc0 feat(cli): add --delete flag to /exit command for session deletion (#19332)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-04-29 17:20:57 +00:00
Adam Weidman 2cf0c75a04 fix(core): add explicit empty log guard in A2A pushMessage (#26198) 2026-04-29 17:06:18 +00:00
Adam Weidman 7ab932c8bf test: fix failures due to antigravity environment leakage (#26162) 2026-04-29 14:51:43 +00:00
Sri Pasumarthi c2e5b28e94 refactor(acp): modularize monolithic acpClient into specialized files (#26143) 2026-04-29 14:51:01 +00:00
Sam Roberts c7d5fcff95 Update documentation workflows with workspace trust (#26150) 2026-04-29 01:00:57 +00:00
Coco Sheng 6d99113936 fix(core): disconnect extension-backed MCP clients in stopExtension (#26136) 2026-04-28 22:46:17 +00:00
Abhi fbd8aaad57 fix(core): add missing oauth fields support in subagent parsing (#26141) 2026-04-28 21:57:30 +00:00
ifitisit 9e7c924f7b docs(cli): point plan-mode session retention to actual /settings labels (#25978)
Co-authored-by: Spencer <spencertang@google.com>
2026-04-28 21:27:42 +00:00
Anas Khalid 4edd7c745c fix(cli): handle DECKPAM keypad Enter sequences in terminal (#26092)
Co-authored-by: Gitanaskhan26 <Gitanaskhan26@users.noreply.github.com>
Co-authored-by: Spencer <spencertang@google.com>
2026-04-28 21:17:31 +00:00
Coco Sheng 12a77da45c fix(cli): pass node arguments via NODE_OPTIONS during relaunch to support SEA (#26130) 2026-04-28 21:15:23 +00:00
gemini-cli-robot 8cfebb9e31 chore(release): bump version to 0.42.0-nightly.20260428.g59b2dea0e (#26142) 2026-04-28 13:28:48 -07:00
Adib234 f8603e990b fix(cli): prevent automatic updates from switching to less stable channels (#26132) 2026-04-28 18:03:08 +00:00
118 changed files with 5897 additions and 3866 deletions
+2
View File
@@ -28,6 +28,8 @@ jobs:
- name: 'Run Docs Audit with Gemini'
id: 'run_gemini'
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31'
env:
GEMINI_CLI_TRUST_WORKSPACE: true
with:
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
prompt: |
+110 -33
View File
@@ -3,8 +3,22 @@ name: '🧠 Gemini CLI Bot: Brain'
on:
schedule:
- cron: '0 0 * * *' # Every 24 hours
issue_comment:
types: ['created']
workflow_dispatch:
inputs:
run_interactive:
description: 'Run interactive flow (requires issue_number)'
type: 'boolean'
default: false
issue_number:
description: 'Issue/PR number to simulate context from'
type: 'string'
required: false
comment_id:
description: 'Specific comment ID to simulate'
type: 'string'
required: false
clear_memory:
description: 'Clear memory (drops learnings from previous runs)'
type: 'boolean'
@@ -15,14 +29,21 @@ on:
default: false
concurrency:
group: '${{ github.workflow }}-${{ github.ref }}'
group: '${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.event.inputs.issue_number || github.ref }}'
cancel-in-progress: true
jobs:
reasoning:
name: 'Brain (Reasoning Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
if: |
github.repository == 'google-gemini/gemini-cli' && (
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive != 'true') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_interactive == 'true') ||
(github.event_name == 'issue_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association)) ||
(github.event_name == 'pull_request_review_comment' && github.event.comment.user.login != 'gemini-cli[bot]' && contains(github.event.comment.body, '@gemini-cli') && contains(fromJSON('["COLLABORATOR", "MEMBER", "OWNER"]'), github.event.comment.author_association))
)
# The reasoning phase is strictly readonly.
permissions:
contents: 'read'
@@ -82,13 +103,40 @@ jobs:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GEMINI_MODEL: 'gemini-3-flash-preview'
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
run: 'node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/metrics.md)"'
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
TRIGGER_COMMENT_ID: '${{ github.event.comment.id || github.event.inputs.comment_id }}'
run: |
PROMPT_PATH="tools/gemini-cli-bot/brain/metrics.md"
if [ "${{ github.event_name }}" = "issue_comment" ] || [ "${{ github.event.inputs.run_interactive }}" = "true" ]; then
PROMPT_PATH="tools/gemini-cli-bot/brain/interactive.md"
export ENABLE_PRS="true"
fi
touch trigger_context.md
if [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
echo "<untrusted_context>" > trigger_context.md
echo "# Interactive Trigger Context" >> trigger_context.md
echo "You were invoked by a user in issue/PR #$TRIGGER_ISSUE_NUMBER." >> trigger_context.md
if [ -n "$TRIGGER_COMMENT_ID" ]; then
echo "## User Comment" >> trigger_context.md
gh api "repos/${{ github.repository }}/issues/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md 2>/dev/null || gh api "repos/${{ github.repository }}/pulls/comments/$TRIGGER_COMMENT_ID" -q '.body' >> trigger_context.md
echo "" >> trigger_context.md
fi
echo "## Issue/PR Context" >> trigger_context.md
gh issue view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md 2>/dev/null || gh pr view "$TRIGGER_ISSUE_NUMBER" >> trigger_context.md
echo "</untrusted_context>" >> trigger_context.md
fi
cat trigger_context.md "$PROMPT_PATH" tools/gemini-cli-bot/brain/common.md > combined_prompt.md
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat combined_prompt.md)"
- name: 'Run Critique Phase'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
# This token is strictly readonly as enforced by the job-level permissions.
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GEMINI_MODEL: 'gemini-3-flash-preview'
run: |
@@ -98,24 +146,23 @@ jobs:
else
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
# PIPESTATUS[0] captures the exit code of the node command before the pipe
if [ "${PIPESTATUS[0]}" -ne 0 ] || grep -q "\[REJECTED\]" critique_output.log; then
echo "Critique failed or rejected changes. Skipping PR creation."
echo "[REJECTED]" > critique_result.txt
else
if [ "${PIPESTATUS[0]}" -eq 0 ] && grep -q "\[APPROVED\]" critique_output.log && ! grep -q "\[REJECTED\]" critique_output.log; then
echo "[APPROVED]" > critique_result.txt
else
echo "Critique failed, rejected, or did not explicitly approve changes. Skipping PR creation."
echo "[REJECTED]" > critique_result.txt
fi
fi
- name: 'Generate Patch'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
run: |
touch bot-changes.patch
touch pr-description.md
if [ -f critique_result.txt ] && grep -q "\[REJECTED\]" critique_result.txt; then
echo "Critique rejected. Skipping patch generation."
else
if [ -f critique_result.txt ] && grep -q "\[APPROVED\]" critique_result.txt && ! grep -q "\[REJECTED\]" critique_result.txt; then
git diff --staged > bot-changes.patch
else
echo "Critique did not approve. Skipping patch generation."
fi
- name: 'Archive Brain Data'
@@ -130,6 +177,7 @@ jobs:
branch-name.txt
pr-comment.md
pr-number.txt
issue-comment.md
retention-days: 90
publish:
@@ -143,6 +191,19 @@ jobs:
pull-requests: 'write'
actions: 'write'
steps:
- name: 'Generate GitHub App Token 🔑'
id: 'generate_token'
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
with:
app-id: '${{ secrets.APP_ID }}'
private-key: '${{ secrets.PRIVATE_KEY }}'
owner: '${{ github.repository_owner }}'
repositories: '${{ github.event.repository.name }}'
permission-contents: 'write'
permission-pull-requests: 'write'
permission-issues: 'write'
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
with:
@@ -157,13 +218,14 @@ jobs:
path: '${{ runner.temp }}/brain-data/'
- name: 'Create or Update PR'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
if: "${{ github.event.inputs.enable_prs == 'true' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event.inputs.run_interactive == 'true' }}"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
FALLBACK_PAT: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
run: |
if [ -s "${{ runner.temp }}/brain-data/bot-changes.patch" ]; then
git config user.name "gemini-cli-robot"
git config user.email "gemini-cli-robot@google.com"
git config user.name "gemini-cli[bot]"
git config user.email "gemini-cli[bot]@users.noreply.github.com"
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}"
@@ -171,7 +233,6 @@ jobs:
BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
fi
# SECURITY: Only allow pushing to branches starting with 'bot/'
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
exit 1
@@ -187,35 +248,51 @@ jobs:
git commit -m "🤖 Gemini Bot Productivity Optimizations"
fi
# Use force to update existing PR branches
git push origin "$BRANCH_NAME" --force
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
fi
# Create PR if it doesn't exist
if ! git push origin "$BRANCH_NAME" --force; then
echo "Push failed. Retrying with FALLBACK_PAT..."
export GH_TOKEN="$FALLBACK_PAT"
git remote set-url origin "https://x-access-token:${FALLBACK_PAT}@github.com/${{ github.repository }}.git"
git push origin "$BRANCH_NAME" --force
fi
if ! gh pr view "$BRANCH_NAME" > /dev/null 2>&1; then
gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$BRANCH_NAME" --base main || \
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$BRANCH_NAME" --base main
else
PR_STATE=$(gh pr view "$BRANCH_NAME" --json state --jq .state)
if [ "$PR_STATE" = "CLOSED" ]; then
NEW_BRANCH_NAME="${BRANCH_NAME}-retry-${{ github.run_id }}"
git checkout -b "$NEW_BRANCH_NAME"
git push origin "$NEW_BRANCH_NAME" --force
gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$NEW_BRANCH_NAME" --base main || \
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$NEW_BRANCH_NAME" --base main
fi
fi
fi
- name: 'Post PR Comment'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
- name: 'Post PR/Issue Comment'
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
GH_TOKEN: '${{ steps.generate_token.outputs.token }}'
TRIGGER_ISSUE_NUMBER: '${{ github.event.issue.number || github.event.inputs.issue_number }}'
run: |
if [ -s "${{ runner.temp }}/brain-data/issue-comment.md" ] && [ -n "$TRIGGER_ISSUE_NUMBER" ]; then
echo "Posting comment to triggering issue #$TRIGGER_ISSUE_NUMBER"
# Use REST API (gh api) instead of GraphQL (gh issue comment) to ensure robot identity
# while avoiding potential GraphQL-specific authorization hurdles with PATs.
gh api "repos/${{ github.repository }}/issues/$TRIGGER_ISSUE_NUMBER/comments" -F body=@"${{ runner.temp }}/brain-data/issue-comment.md"
fi
if [ -s "${{ runner.temp }}/brain-data/pr-comment.md" ] && [ -f "${{ runner.temp }}/brain-data/pr-number.txt" ]; then
PR_NUM=$(cat "${{ runner.temp }}/brain-data/pr-number.txt")
# SECURITY: Only allow commenting on PRs authored by the bot
PR_AUTHOR=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
if [ "$PR_AUTHOR" != "gemini-cli-robot" ]; then
echo "Error: PR #$PR_NUM is authored by '$PR_AUTHOR', not 'gemini-cli-robot'. Safety abort."
exit 1
fi
# Using GitHub App, so author check is no longer valid against gemini-cli-robot
# Skipping author validation here to let the app post.
gh pr comment "$PR_NUM" -F "${{ runner.temp }}/brain-data/pr-comment.md"
# Use REST API (gh api) for consistency and robot identity
gh api "repos/${{ github.repository }}/issues/$PR_NUM/comments" -F body=@"${{ runner.temp }}/brain-data/pr-comment.md"
fi
+2
View File
@@ -70,6 +70,8 @@ jobs:
- name: 'Generate Changelog with Gemini'
if: "steps.validate_version.outputs.CONTINUE == 'true'"
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
env:
GEMINI_CLI_TRUST_WORKSPACE: true
with:
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
prompt: |
+3 -3
View File
@@ -34,11 +34,11 @@ Gemini CLI will use a locally-running **Gemma** model to make routing decisions
reduce costs associated with hosted model usage while offering similar routing
decision latency and quality.
In order to use this feature, the local Gemma model **must** be served behind a
Gemini API and accessible via HTTP at an endpoint configured in `settings.json`.
The easiest way to set this up is using the automated `gemini gemma setup`
command.
For more details on how to configure local model routing, see
[Local Model Routing](../core/local-model-routing.md).
[`gemini gemma` — Local Model Routing Setup](../core/gemma-setup.md).
### Model selection precedence
+2 -1
View File
@@ -470,7 +470,8 @@ associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
(search for **Enable Session Cleanup** or **Keep chat history**) or in your
`settings.json` file. See
[session retention](../cli/session-management.md#session-retention) for more
details.
+13
View File
@@ -61,6 +61,19 @@ gemini --list-sessions
gemini --delete-session 1
```
### Scenario: Delete session on exit
If you're doing a one-off task and don't want to leave any session history
behind, use the `--delete` flag when exiting:
```
/exit --delete
```
This removes the current session's conversation history and tool output files
before exiting. It's useful for privacy-sensitive tasks or quick one-off
interactions.
## How to rewind time (Undo mistakes)
Gemini CLI's **Rewind** feature is like `Ctrl+Z` for your workflow.
+83
View File
@@ -0,0 +1,83 @@
# `gemini gemma` — Automated Local Model Routing Setup
Local model routing uses a local Gemma 3 1B model running on your machine to
classify and route user requests. It routes simple requests (like file reads) to
Gemini Flash and complex requests (like architecture discussions) to Gemini Pro.
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development.
## What is this?
This feature saves cloud API costs by using local inference for task
classification instead of a cloud-based classifier. It adds a few milliseconds
of local latency but can significantly reduce the overall token usage for hosted
models.
## Quick start
```bash
# One command does everything: downloads runtime, pulls model, configures settings, starts server
gemini gemma setup
```
You'll be prompted to accept the Gemma Terms of Use. The model is ~1 GB.
After setup, **just use the CLI normally** — routing happens automatically on
every request.
## Commands
| Command | What it does |
| --------------------- | -------------------------------------------------------------- |
| `gemini gemma setup` | Full install (binary + model + settings + server start) |
| `gemini gemma status` | Health check — shows what's installed and running |
| `gemini gemma start` | Start the LiteRT server (auto-starts on CLI launch by default) |
| `gemini gemma stop` | Stop the LiteRT server |
| `gemini gemma logs` | Tail the server logs to see routing requests live |
| `/gemma` | In-session status check (type it inside the CLI) |
## Verifying it works
1. Run `gemini gemma status` — all checks should show green
2. Open two terminals:
- Terminal 1: `gemini gemma logs` (watch for incoming requests)
- Terminal 2: use the CLI normally
3. You should see classification requests appear in the logs as you interact
with the CLI
4. The `/gemma` slash command inside a session shows a quick status panel
## Setup flags
```bash
gemini gemma setup --port 8080 # custom port
gemini gemma setup --no-start # don't start server after install
gemini gemma setup --force # re-download everything
gemini gemma setup --skip-model # binary only, skip the 1GB model download
```
## How it works under the hood
- Local Gemma classifies each request as "simple" or "complex" (~100ms)
- Simple → Flash, Complex → Pro
- If the local server is down, the CLI silently falls back to the cloud
classifier — no errors, no disruption
## Disabling
Set `enabled: false` in settings or just run `gemini gemma stop` to turn off the
server:
```json
{ "experimental": { "gemmaModelRouter": { "enabled": false } } }
```
## Advanced setup
If you are in an environment where the `gemini gemma setup` command cannot
automatically download binaries (for example, behind a strict corporate
firewall), you can perform the setup manually.
For more information, see the
[Manual Local Model Routing Setup guide](./local-model-routing.md).
+3 -2
View File
@@ -15,8 +15,9 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
modular GEMINI.md import feature using @file.md syntax.
- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for
fine-grained control over tool execution.
- **[Local Model Routing (experimental)](./local-model-routing.md):** Learn how
to enable use of a local Gemma model for model routing decisions.
- **[Local Model Routing (experimental)](./gemma-setup.md):** Learn how to
enable use of a local Gemma model for model routing decisions using the
automated setup command.
## Role of the core
+14 -7
View File
@@ -1,22 +1,29 @@
# Local Model Routing (experimental)
# Manual Local Model Routing Setup (experimental)
Gemini CLI supports using a local model for
[routing decisions](../cli/model-routing.md). When configured, Gemini CLI will
use a locally-running **Gemma** model to make routing decisions (instead of
sending routing decisions to a hosted model).
<!-- prettier-ignore -->
> [!NOTE]
> This is an experimental feature currently under active development.
<!-- prettier-ignore -->
> [!IMPORTANT]
> **Recommended:** We now provide a fully automated setup command. We recommend
> using the [`gemini gemma` Setup Guide](./gemma-setup.md) instead of following
> these manual steps.
This feature can help reduce costs associated with hosted model usage while
offering similar routing decision latency and quality.
> **Note: Local model routing is currently an experimental feature.**
## Setup
## Manual Setup
Using a Gemma model for routing decisions requires that an implementation of a
Gemma model be running locally on your machine, served behind an HTTP endpoint
and accessed via the Gemini API.
To serve the Gemma model, follow these steps:
and accessed via the Gemini API. If you cannot use the `gemini gemma setup`
command, follow these manual steps:
### Download the LiteRT-LM runtime
+5
View File
@@ -323,6 +323,11 @@ Slash commands provide meta-level control over the CLI itself.
### `/quit` (or `/exit`)
- **Description:** Exit Gemini CLI.
- **Flags:**
- **`--delete`** _(optional)_: Exit and permanently delete the current
session's history and temporary files (chat recording, tool outputs). Useful
for privacy or one-off tasks where you don't want to leave any traces.
- **Usage:** `/quit --delete` or `/exit --delete`
### `/restore`
+76 -4
View File
@@ -1191,7 +1191,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1199,18 +1199,54 @@ their corresponding top-level category object in your `settings.json` file.
{
"model": "gemini-3-flash-preview",
"isLastResort": true,
"maxAttempts": 10,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"auto-preview": [
{
"model": "gemini-3-pro-preview",
"maxAttempts": 3,
"actions": {
"terminal": "prompt",
"transient": "silent",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-3-flash-preview",
"isLastResort": true,
"maxAttempts": 10,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"default": [
@@ -1232,18 +1268,54 @@ their corresponding top-level category object in your `settings.json` file.
{
"model": "gemini-2.5-flash",
"isLastResort": true,
"maxAttempts": 10,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"auto-default": [
{
"model": "gemini-2.5-pro",
"maxAttempts": 3,
"actions": {
"terminal": "prompt",
"transient": "silent",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"not_found": "terminal",
"unknown": "terminal"
}
},
{
"model": "gemini-2.5-flash",
"isLastResort": true,
"maxAttempts": 10,
"actions": {
"terminal": "prompt",
"transient": "prompt",
"not_found": "prompt",
"unknown": "prompt"
},
"stateTransitions": {
"terminal": "terminal",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
}
],
"lite": [
@@ -1257,7 +1329,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1272,7 +1344,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1288,7 +1360,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
+50
View File
@@ -420,4 +420,54 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('USUALLY_PASSES', {
suiteName: 'plan_mode',
suiteType: 'behavioral',
name: 'should invoke exit_plan_mode as a tool instead of a shell command',
approvalMode: ApprovalMode.PLAN,
params: {
settings: {
general: {
plan: { enabled: true },
},
},
},
files: {
'plans/my-plan.md': '# My Plan\n\n1. Step one',
},
prompt:
'I agree with the plan in plans/my-plan.md. Please exit plan mode and then run `echo "Starting implementation"`',
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
// Check if exit_plan_mode was called as a tool
const exitPlanToolCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
// Check if exit_plan_mode was called via shell
const shellCalls = toolLogs.filter(
(log) => log.toolRequest.name === 'run_shell_command',
);
const exitPlanViaShell = shellCalls.find((log) => {
try {
const args = JSON.parse(log.toolRequest.args);
return args.command.includes('exit_plan_mode');
} catch {
return false;
}
});
expect(
exitPlanViaShell,
'Should NOT call exit_plan_mode via run_shell_command',
).toBeUndefined();
expect(
exitPlanToolCall,
'Should call exit_plan_mode tool directly',
).toBeDefined();
},
});
});
+9 -9
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"workspaces": [
"packages/*"
],
@@ -18077,7 +18077,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -18206,7 +18206,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -18354,7 +18354,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -18665,7 +18665,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -18680,7 +18680,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18711,7 +18711,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18743,7 +18743,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"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.41.0-nightly.20260423.gaa05b4583"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
},
"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.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+8 -11
View File
@@ -9,6 +9,11 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import v8 from 'node:v8';
import {
RELAUNCH_EXIT_CODE,
getSpawnConfig,
getScriptArgs,
} from './src/utils/processUtils.js';
// --- Global Entry Point ---
@@ -74,18 +79,10 @@ async function run() {
// --- Lightweight Parent Process / Daemon ---
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
const nodeArgs: string[] = [...process.execArgv];
const scriptArgs = process.argv.slice(2);
const scriptArgs = getScriptArgs();
const memoryArgs = await getMemoryNodeArgs();
nodeArgs.push(...memoryArgs);
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
const script = process.argv[1];
nodeArgs.push(script);
nodeArgs.push(...scriptArgs);
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
const RELAUNCH_EXIT_CODE = 199;
let latestAdminSettings: unknown = undefined;
// Prevent the parent process from exiting prematurely on signals.
@@ -97,7 +94,7 @@ async function run() {
const runner = () => {
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
const child = spawn(process.execPath, spawnArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
+81
View File
@@ -0,0 +1,81 @@
# Agent Client Protocol (ACP) Implementation
This directory contains the implementation of the Agent Client Protocol (ACP)
for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
## Directory Structure
Following Phase 1 of the modularization refactor, the ACP client is organized
into the following specialized modules, all sharing the `acp` prefix for
consistency:
- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
up the Web streams for standard input/output and creates the
`AgentSideConnection` using line-delimited JSON (ndjson).
- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
class. This is the main entry point for incoming JSON-RPC messages. It
implements the protocol methods and delegates session-specific work to the
manager and individual sessions.
- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
state. It handles session creation (`newSession`), loading (`loadSession`),
and configuration, isolating session state from the RPC routing.
- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
It handles prompt execution, `@path` file resolution, tool execution, command
interception, and streaming updates back to the client.
- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
across the modules.
- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
ACP-compliant error codes.
- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
the file system restricted by the workspace boundaries and permissions.
## Development Instructions
### Running Tests
Tests are co-located with the source files:
- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
handler delegation.
- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
resolution.
- `acpResume.test.ts`: Integration tests for loading/resuming sessions.
To run specific tests, use Vitest with the workspace filter:
```bash
# General pattern
npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
# Example
npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
```
Note: You may need to ensure your environment has Node available. If running in
a restricted environment, try sourcing NVM first:
```bash
source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
```
### Adding New Features
- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
and register it in the `AgentSideConnection` setup if necessary.
- **Session State**: If a feature requires storing state across turns within a
session, add it to the `Session` class in `acpSession.ts`.
- **Protocol Helpers**: Add any new mapping or serialization logic to
`acpUtils.ts`.
### Coding Conventions
- **Imports**: Use specific imports and do not import across package boundaries
using relative paths.
- **License Headers**: All new files must include the Apache-2.0 license header.
- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
untrusted input from the protocol.
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './commandHandler.js';
import { CommandHandler } from './acpCommandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -13,7 +13,7 @@ import {
afterEach,
type Mocked,
} from 'vitest';
import { AcpFileSystemService } from './fileSystemService.js';
import { AcpFileSystemService } from './acpFileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
import os from 'node:os';
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+7 -8
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './acpClient.js';
import { GeminiAgent } from './acpRpcDispatcher.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -28,6 +28,7 @@ import {
} from '../utils/sessionUtils.js';
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { waitFor } from '../test-utils/async.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
@@ -106,6 +107,9 @@ describe('GeminiAgent Session Resume', () => {
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
toolRegistry: {
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
},
get config() {
return this;
},
@@ -170,11 +174,6 @@ describe('GeminiAgent Session Resume', () => {
],
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockConfig as any).toolRegistry = {
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
};
(SessionSelector as unknown as Mock).mockImplementation(() => ({
resolveSession: vi.fn().mockResolvedValue({
sessionData,
@@ -240,7 +239,7 @@ describe('GeminiAgent Session Resume', () => {
}),
);
await vi.waitFor(() => {
await waitFor(() => {
// User message
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
@@ -0,0 +1,338 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent } from './acpRpcDispatcher.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
type Config,
type MessageBus,
type Storage,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings, SettingScope } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
describe('GeminiAgent - RPC Dispatcher', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let agent: GeminiAgent;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
startChat: vi.fn().mockResolvedValue({}),
}),
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus,
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
} as unknown as Storage,
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: 'login_with_google' } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
}));
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
});
it('should initialize correctly', async () => {
const response = await agent.initialize({
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
protocolVersion: 1,
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(4);
const gatewayAuth = response.authMethods?.find(
(m) => m.id === AuthType.GATEWAY,
);
expect(gatewayAuth?._meta).toEqual({
gateway: {
protocol: 'google',
restartRequired: 'false',
},
});
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
expect(geminiAuth?._meta).toEqual({
'api-key': {
provider: 'google',
},
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
it('should authenticate correctly', async () => {
await agent.authenticate({
methodId: AuthType.LOGIN_WITH_GOOGLE,
});
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should authenticate correctly with api-key in _meta', async () => {
await agent.authenticate({
methodId: AuthType.USE_GEMINI,
_meta: {
'api-key': 'test-api-key',
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.GATEWAY,
undefined,
'https://example.com',
{ Authorization: 'Bearer token' },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.GATEWAY,
);
});
it('should throw acp.RequestError when gateway payload is malformed', async () => {
await expect(
agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 123,
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest),
).rejects.toThrow(/Malformed gateway payload/);
});
it('should cancel a session', async () => {
const mockSession = {
cancelPendingPrompt: vi.fn(),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
await agent.cancel({ sessionId: 'test-session-id' });
expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
});
it('should throw error when cancelling non-existent session', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
'Session not found',
);
});
it('should delegate prompt to session', async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.prompt({
sessionId: 'test-session-id',
prompt: [],
});
expect(mockSession.prompt).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should delegate setMode to session', async () => {
const mockSession = {
setMode: vi.fn().mockReturnValue({}),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.setSessionMode({
sessionId: 'test-session-id',
modeId: 'plan',
});
expect(mockSession.setMode).toHaveBeenCalledWith('plan');
expect(result).toEqual({});
});
it('should throw error when setting mode on non-existent session', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(
agent.setSessionMode({
sessionId: 'unknown',
modeId: 'plan',
}),
).rejects.toThrow('Session not found: unknown');
});
it('should delegate setModel to session (unstable)', async () => {
const mockSession = {
setModel: vi.fn().mockReturnValue({}),
};
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(mockSession),
};
const result = await agent.unstable_setSessionModel({
sessionId: 'test-session-id',
modelId: 'gemini-2.0-pro-exp',
});
expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
expect(result).toEqual({});
});
it('should throw error when setting model on non-existent session (unstable)', async () => {
(
agent as unknown as { sessionManager: { getSession: Mock } }
).sessionManager = {
getSession: vi.fn().mockReturnValue(undefined),
};
await expect(
agent.unstable_setSessionModel({
sessionId: 'unknown',
modelId: 'gemini-2.0-pro-exp',
}),
).rejects.toThrow('Session not found: unknown');
});
});
+232
View File
@@ -0,0 +1,232 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type AgentLoopContext,
AuthType,
clearCachedCredentialFile,
getVersion,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
import { SettingScope, type LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
import { hasMeta } from './acpUtils.js';
export class GeminiAgent {
private apiKey: string | undefined;
private baseUrl: string | undefined;
private customHeaders: Record<string, string> | undefined;
private sessionManager: AcpSessionManager;
constructor(
private context: AgentLoopContext,
private settings: LoadedSettings,
argv: CliArgs,
connection: acp.AgentSideConnection,
) {
this.sessionManager = new AcpSessionManager(settings, argv, connection);
}
async initialize(
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
if (args.clientCapabilities) {
this.sessionManager.setClientCapabilities(args.clientCapabilities);
}
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: 'Log in with your Google account',
},
{
id: AuthType.USE_GEMINI,
name: 'Gemini API key',
description: 'Use an API key with Gemini Developer API',
_meta: {
'api-key': {
provider: 'google',
},
},
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
{
id: AuthType.GATEWAY,
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
];
await this.context.config.initialize();
const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentInfo: {
name: 'gemini-cli',
title: 'Gemini CLI',
version,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
mcpCapabilities: {
http: true,
sse: true,
},
},
};
}
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
const { methodId } = req;
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
// Only clear credentials when switching to a different auth method
if (selectedAuthType && selectedAuthType !== method) {
await clearCachedCredentialFile();
}
// Check for api-key in _meta
const meta = hasMeta(req) ? req._meta : undefined;
const apiKey =
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
if (apiKey) {
this.apiKey = apiKey;
}
// Extract gateway details if present
const gatewaySchema = z.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
});
let baseUrl: string | undefined;
let headers: Record<string, string> | undefined;
if (meta?.['gateway']) {
const result = gatewaySchema.safeParse(meta['gateway']);
if (result.success) {
baseUrl = result.data.baseUrl;
headers = result.data.headers;
} else {
throw new acp.RequestError(
-32602,
`Malformed gateway payload: ${result.error.message}`,
);
}
}
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.context.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
headers,
);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
this.settings.setValue(
SettingScope.User,
'security.auth.selectedType',
method,
);
}
private getAuthDetails(): AuthDetails {
return {
apiKey: this.apiKey,
baseUrl: this.baseUrl,
customHeaders: this.customHeaders,
};
}
async newSession(
params: acp.NewSessionRequest,
): Promise<acp.NewSessionResponse> {
return this.sessionManager.newSession(params, this.getAuthDetails());
}
async loadSession(
params: acp.LoadSessionRequest,
): Promise<acp.LoadSessionResponse> {
return this.sessionManager.loadSession(params, this.getAuthDetails());
}
async cancel(params: acp.CancelNotification): Promise<void> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
await session.cancelPendingPrompt();
}
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.prompt(params);
}
async setSessionMode(
params: acp.SetSessionModeRequest,
): Promise<acp.SetSessionModeResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.setMode(params.modeId);
}
async unstable_setSessionModel(
params: acp.SetSessionModelRequest,
): Promise<acp.SetSessionModelResponse> {
const session = this.sessionManager.getSession(params.sessionId);
if (!session) {
throw new acp.RequestError(
-32602,
`Session not found: ${params.sessionId}`,
);
}
return session.setModel(params.modelId);
}
}
+567
View File
@@ -0,0 +1,567 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
type Mocked,
type MockInstance,
} from 'vitest';
import { Session } from './acpSession.js';
import type * as acp from '@agentclientprotocol/sdk';
import {
ReadManyFilesTool,
type GeminiChat,
type Config,
type MessageBus,
type GitService,
InvalidStreamError,
GeminiEventType,
type ServerGeminiStreamEvent,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { type Part, FinishReason } from '@google/genai';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { CommandHandler } from './acpCommandHandler.js';
vi.mock('node:fs/promises');
vi.mock('node:path', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:path')>();
return {
...actual,
resolve: vi.fn(),
};
});
vi.mock(
'@google/gemini-cli-core',
async (
importOriginal: () => Promise<typeof import('@google/gemini-cli-core')>,
) => {
const actual = await importOriginal();
return {
...actual,
updatePolicy: vi.fn(),
ReadManyFilesTool: vi.fn(),
logToolCall: vi.fn(),
processSingleFileContent: vi.fn(),
};
},
);
async function* createMockStream(
items: readonly ServerGeminiStreamEvent[],
): AsyncGenerator<ServerGeminiStreamEvent> {
for (const item of items) {
yield item;
}
yield {
type: GeminiEventType.Finished,
value: {
reason: FinishReason.STOP,
usageMetadata: {
promptTokenCount: 5,
candidatesTokenCount: 10,
},
},
};
}
describe('Session', () => {
let mockChat: Mocked<GeminiChat>;
let mockConfig: Mocked<Config>;
let mockConnection: Mocked<acp.AgentSideConnection>;
let session: Session;
let mockToolRegistry: { getTool: Mock };
let mockTool: { kind: string; build: Mock };
let mockMessageBus: Mocked<MessageBus>;
let mockSendMessageStream: MockInstance<
(
request: Part[],
signal: AbortSignal,
promptId: string,
) => AsyncGenerator<ServerGeminiStreamEvent>
>;
beforeEach(() => {
mockChat = {
sendMessageStream: vi.fn(),
addHistory: vi.fn(),
recordCompletedToolCalls: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
} as unknown as Mocked<GeminiChat>;
mockTool = {
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
}),
};
mockToolRegistry = {
getTool: vi.fn().mockReturnValue(mockTool),
};
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as Mocked<MessageBus>;
mockSendMessageStream = vi.fn();
mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModelRouterService: vi.fn().mockReturnValue({
route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
}),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
getFileFilteringOptions: vi.fn().mockReturnValue({}),
getFileSystemService: vi.fn().mockReturnValue({}),
getTargetDir: vi.fn().mockReturnValue('/tmp'),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
geminiClient: {
sendMessageStream: mockSendMessageStream,
getChat: vi.fn().mockReturnValue(mockChat),
},
get config() {
return this;
},
get toolRegistry() {
return mockToolRegistry;
},
} as unknown as Mocked<Config>;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
merged: {
security: { enablePermanentToolApproval: true },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings);
(ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
name: 'read_many_files',
kind: 'read',
build: vi.fn().mockReturnValue({
getDescription: () => 'Read files',
toolLocations: () => [],
execute: vi.fn().mockResolvedValue({
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
}),
}),
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should send available commands', async () => {
await session.sendAvailableCommands();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
});
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
type: GeminiEventType.Content,
value: 'Hi',
},
]);
mockSendMessageStream.mockReturnValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'test' }],
});
expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce();
});
it('should handle prompt with text response', async () => {
const stream = createMockStream([
{
type: GeminiEventType.Content,
value: 'Hello',
},
]);
mockSendMessageStream.mockReturnValue(stream);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockSendMessageStream).toHaveBeenCalled();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'session-1',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Hello' },
},
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should pass current session information directly onto geminiClient.sendMessageStream', async () => {
const stream = createMockStream([
{
type: GeminiEventType.Content,
value: 'Hello',
},
]);
mockSendMessageStream.mockReturnValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockSendMessageStream).toHaveBeenCalledWith(
expect.arrayContaining([{ text: 'Hi' }]),
expect.any(AbortSignal),
expect.any(String),
);
});
it('should handle prompt with empty response (InvalidStreamError)', async () => {
const error = new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT');
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
yield* [];
throw error;
}
return errorGen();
});
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
const error = new InvalidStreamError(
'No finish reason',
'NO_FINISH_REASON',
);
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
yield* [];
throw error;
}
return errorGen();
});
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toMatchObject({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
);
});
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'call-1',
name: 'test_tool',
args: { foo: 'bar' },
isClientInitiated: false,
prompt_id: 'prompt-1',
},
},
]);
const stream2 = createMockStream([
{
type: GeminiEventType.Content,
value: 'Result',
},
]);
mockSendMessageStream
.mockReturnValueOnce(stream1)
.mockReturnValueOnce(stream2);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool');
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle tool call permission request', async () => {
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: 'proceed_once',
},
});
const stream1 = createMockStream([
{
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'call-1',
name: 'test_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
},
},
]);
const stream2 = createMockStream([
{
type: GeminiEventType.Content,
value: '',
},
]);
mockSendMessageStream
.mockReturnValueOnce(stream1)
.mockReturnValueOnce(stream2);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalled();
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
});
it('should handle @path resolution', async () => {
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
(fs.stat as unknown as Mock).mockResolvedValue({
isDirectory: () => false,
});
const stream = createMockStream([
{
type: GeminiEventType.Content,
value: '',
},
]);
mockSendMessageStream.mockReturnValue(stream);
await session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: 'Read' },
{
type: 'resource_link',
uri: 'file://file.txt',
mimeType: 'text/plain',
name: 'file.txt',
},
],
});
expect(path.resolve).toHaveBeenCalled();
expect(fs.stat).toHaveBeenCalled();
expect(mockSendMessageStream).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('Content from @file.txt'),
}),
]),
expect.any(AbortSignal),
expect.any(String),
);
});
it('should handle rate limit error', async () => {
const error = new Error('Rate limit');
const customError = error as { status?: number; message?: string };
customError.status = 429;
mockSendMessageStream.mockImplementation(() => {
async function* errorGen(): AsyncGenerator<
ServerGeminiStreamEvent,
void,
unknown
> {
yield* [];
throw customError;
}
return errorGen();
});
await expect(
session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
}),
).rejects.toMatchObject({
code: 429,
message: 'Rate limit exceeded. Try again later.',
});
});
it('should handle missing tool', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const stream1 = createMockStream([
{
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'call-1',
name: 'unknown_tool',
args: {},
isClientInitiated: false,
prompt_id: 'prompt-1',
},
},
]);
const stream2 = createMockStream([
{
type: GeminiEventType.Content,
value: '',
},
]);
mockSendMessageStream
.mockReturnValueOnce(stream1)
.mockReturnValueOnce(stream2);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockSendMessageStream).toHaveBeenCalledTimes(2);
});
it('should handle GeminiEventType.LoopDetected', async () => {
const stream = createMockStream([
{
type: GeminiEventType.LoopDetected,
},
]);
mockSendMessageStream.mockReturnValue(stream);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Trigger Loop Simulation' }],
});
expect(result.stopReason).toBe('max_turn_requests');
});
it('should handle GeminiEventType.ContextWindowWillOverflow', async () => {
const stream = createMockStream([
{
type: GeminiEventType.ContextWindowWillOverflow,
value: { estimatedRequestTokenCount: 1000, remainingTokenCount: 200 },
},
]);
mockSendMessageStream.mockReturnValue(stream);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Trigger Overflow Simulation' }],
});
expect(result.stopReason).toBe('max_tokens');
});
it('should handle GeminiEventType.MaxSessionTurns', async () => {
const stream = createMockStream([
{
type: GeminiEventType.MaxSessionTurns,
},
]);
mockSendMessageStream.mockReturnValue(stream);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Trigger Safety Limits' }],
});
expect(result.stopReason).toBe('max_turn_requests');
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
type Mocked,
} from 'vitest';
import { AcpSessionManager } from './acpSessionManager.js';
import type * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
type Config,
type MessageBus,
type Storage,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(),
}));
vi.mock('../config/settings.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(),
};
});
const startAutoMemoryIfEnabledMock = vi.fn();
vi.mock('../utils/autoMemory.js', () => ({
startAutoMemoryIfEnabled: (config: Config) =>
startAutoMemoryIfEnabledMock(config),
}));
describe('AcpSessionManager', () => {
let mockConfig: Mocked<Config>;
let mockSettings: Mocked<LoadedSettings>;
let mockArgv: CliArgs;
let mockConnection: Mocked<acp.AgentSideConnection>;
let manager: AcpSessionManager;
beforeEach(() => {
mockConfig = {
refreshAuth: vi.fn(),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
getFileSystemService: vi.fn(),
setFileSystemService: vi.fn(),
getContentGeneratorConfig: vi.fn(),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getGeminiClient: vi.fn().mockReturnValue({
startChat: vi.fn().mockResolvedValue({}),
}),
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus,
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
} as unknown as Storage,
get config() {
return this;
},
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
security: { auth: { selectedType: 'login_with_google' } },
mcpServers: {},
},
setValue: vi.fn(),
} as unknown as Mocked<LoadedSettings>;
mockArgv = {} as unknown as CliArgs;
mockConnection = {
sessionUpdate: vi.fn(),
requestPermission: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
}));
manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
vi.mock('node:crypto', () => ({
randomUUID: () => 'test-session-id',
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.sessionId).toBe('test-session-id');
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
// Verify deferred call (sendAvailableCommands)
await vi.runAllTimersAsync();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.modes).toEqual({
availableModes: [
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
{
id: 'autoEdit',
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
],
currentModeId: 'default',
});
});
it('should include preview models when user has access', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-3',
name: expect.stringContaining('Auto'),
}),
]),
);
});
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'gemini-3.1-flash-lite-preview',
name: 'gemini-3.1-flash-lite-preview',
}),
]),
);
});
it('should return modes with plan mode when plan is enabled', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
const response = await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(response.modes).toEqual({
availableModes: [
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
{
id: 'autoEdit',
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
],
currentModeId: 'plan',
});
});
it('should fail session creation if Gemini API key is missing', async () => {
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: { auth: { selectedType: AuthType.USE_GEMINI } },
mcpServers: {},
},
setValue: vi.fn(),
}));
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: undefined,
});
await expect(
manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
),
).rejects.toMatchObject({
message: 'Gemini API key is missing or not configured.',
});
});
it('should create a new session with mcp servers', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
const mcpServers = [
{
name: 'test-server',
command: 'node',
args: ['server.js'],
env: [{ name: 'KEY', value: 'VALUE' }],
},
];
await manager.newSession(
{
cwd: '/tmp',
mcpServers,
},
{},
);
expect(loadCliConfig).toHaveBeenCalledWith(
expect.objectContaining({
mcpServers: expect.objectContaining({
'test-server': expect.objectContaining({
command: 'node',
args: ['server.js'],
env: { KEY: 'VALUE' },
}),
}),
}),
'test-session-id',
mockArgv,
{ cwd: '/tmp' },
);
});
it('should handle authentication failure gracefully', async () => {
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
await expect(
manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
),
).rejects.toMatchObject({
message: 'Auth failed',
});
});
it('should initialize file system service if client supports it', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
manager.setClientCapabilities({
fs: { readTextFile: true, writeTextFile: true },
});
await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(mockConfig.setFileSystemService).toHaveBeenCalled();
});
it('should start auto memory for new ACP sessions', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
await manager.newSession(
{
cwd: '/tmp',
mcpServers: [],
},
{},
);
expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
});
});
+322
View File
@@ -0,0 +1,322 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
AuthType,
MCPServerConfig,
debugLogger,
startupProfiler,
convertSessionToClientHistory,
createPolicyUpdater,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { randomUUID } from 'node:crypto';
import { loadSettings, type LoadedSettings } from '../config/settings.js';
import { SessionSelector } from '../utils/sessionUtils.js';
import { Session } from './acpSession.js';
import { AcpFileSystemService } from './acpFileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
export interface AuthDetails {
apiKey?: string;
baseUrl?: string;
customHeaders?: Record<string, string>;
}
export class AcpSessionManager {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
constructor(
private settings: LoadedSettings,
private argv: CliArgs,
private connection: acp.AgentSideConnection,
) {}
setClientCapabilities(capabilities: acp.ClientCapabilities) {
this.clientCapabilities = capabilities;
}
getSession(sessionId: string): Session | undefined {
return this.sessions.get(sessionId);
}
async newSession(
{ cwd, mcpServers }: acp.NewSessionRequest,
authDetails: AuthDetails,
): Promise<acp.NewSessionResponse> {
const sessionId = randomUUID();
const loadedSettings = loadSettings(cwd);
const config = await this.newSessionConfig(
sessionId,
cwd,
mcpServers,
loadedSettings,
);
const authType =
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(
authType,
authDetails.apiKey,
authDetails.baseUrl,
authDetails.customHeaders,
);
isAuthenticated = true;
// Extra validation for Gemini API key
const contentGeneratorConfig = config.getContentGeneratorConfig();
if (
authType === AuthType.USE_GEMINI &&
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
) {
isAuthenticated = false;
authErrorMessage = 'Gemini API key is missing or not configured.';
}
} catch (e) {
isAuthenticated = false;
authErrorMessage = getAcpErrorMessage(e);
debugLogger.error(
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
);
}
if (!isAuthenticated) {
throw new acp.RequestError(
-32000,
authErrorMessage || 'Authentication required.',
);
}
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
loadedSettings,
);
const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
async loadSession(
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
authDetails: AuthDetails,
): Promise<acp.LoadSessionResponse> {
const config = await this.initializeSessionConfig(
sessionId,
cwd,
mcpServers,
authDetails,
);
const sessionSelector = new SessionSelector(config.storage);
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
await geminiClient.resumeChat(clientHistory, {
conversation: sessionData,
filePath: sessionPath,
});
const session = new Session(
sessionId,
geminiClient.getChat(),
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
// Stream history back to client
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
this.settings,
);
const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
private async initializeSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
authDetails: AuthDetails,
): Promise<Config> {
const selectedAuthType = this.settings.merged.security.auth.selectedType;
if (!selectedAuthType) {
throw acp.RequestError.authRequired();
}
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(
selectedAuthType,
authDetails.apiKey,
authDetails.baseUrl,
authDetails.customHeaders,
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
}
// 3. Set the ACP FileSystemService (if supported) before config initialization
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
// 4. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
startAutoMemoryIfEnabled(config);
return config;
}
async newSessionConfig(
sessionId: string,
cwd: string,
mcpServers: acp.McpServer[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const currentSettings = loadedSettings || this.settings;
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
for (const server of mcpServers) {
if (
'type' in server &&
(server.type === 'sse' || server.type === 'http')
) {
// HTTP or SSE MCP server
const headers = Object.fromEntries(
server.headers.map(({ name, value }) => [name, value]),
);
mergedMcpServers[server.name] = new MCPServerConfig(
undefined, // command
undefined, // args
undefined, // env
undefined, // cwd
server.type === 'sse' ? server.url : undefined, // url (sse)
server.type === 'http' ? server.url : undefined, // httpUrl
headers,
);
} else if ('command' in server) {
// Stdio MCP server
const env: Record<string, string> = {};
for (const { name: envName, value } of server.env) {
env[envName] = value;
}
mergedMcpServers[server.name] = new MCPServerConfig(
server.command,
server.args,
env,
cwd,
);
}
}
const settings = {
...currentSettings.merged,
mcpServers: mergedMcpServers,
};
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
createPolicyUpdater(
config.getPolicyEngine(),
config.messageBus,
config.storage,
);
return config;
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
import { runExitCleanup } from '../utils/cleanup.js';
import * as acp from '@agentclientprotocol/sdk';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import type { CliArgs } from '../config/config.js';
import { GeminiAgent } from './acpRpcDispatcher.js';
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(stdout, stdin);
const connection = new acp.AgentSideConnection(
(connection) => new GeminiAgent(config, settings, argv, connection),
stream,
);
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
// We must explicitly await the connection close to flush telemetry.
// Use finally() to ensure cleanup runs even on stream errors.
await connection.closed.finally(runExitCleanup);
}
+373
View File
@@ -0,0 +1,373 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Config,
type ToolResult,
type ToolCallConfirmationDetails,
Kind,
ApprovalMode,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
getDisplayString,
AuthType,
ToolConfirmationOutcome,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
import type { LoadedSettings } from '../config/settings.js';
export function hasMeta(
obj: unknown,
): obj is { _meta?: Record<string, unknown> } {
return typeof obj === 'object' && obj !== null && '_meta' in obj;
}
export const RequestPermissionResponseSchema = z.object({
outcome: z.discriminatedUnion('outcome', [
z.object({ outcome: z.literal('cancelled') }),
z.object({
outcome: z.literal('selected'),
optionId: z.string(),
}),
]),
});
export function toToolCallContent(
toolResult: ToolResult,
): acp.ToolCallContent | null {
if (toolResult.error?.message) {
throw new Error(toolResult.error.message);
}
if (toolResult.returnDisplay) {
if (typeof toolResult.returnDisplay === 'string') {
return {
type: 'content',
content: { type: 'text', text: toolResult.returnDisplay },
};
} else {
if ('fileName' in toolResult.returnDisplay) {
return {
type: 'diff',
path:
toolResult.returnDisplay.filePath ??
toolResult.returnDisplay.fileName,
oldText: toolResult.returnDisplay.originalContent,
newText: toolResult.returnDisplay.newContent,
_meta: {
kind: !toolResult.returnDisplay.originalContent
? 'add'
: toolResult.returnDisplay.newContent === ''
? 'delete'
: 'modify',
},
};
}
return null;
}
} else {
return null;
}
}
const basicPermissionOptions = [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Reject',
kind: 'reject_once',
},
] as const;
export function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
config: Config,
enablePermanentToolApproval: boolean = false,
): acp.PermissionOption[] {
const disableAlwaysAllow = config.getDisableAlwaysAllow();
const options: acp.PermissionOption[] = [];
if (!disableAlwaysAllow) {
switch (confirmation.type) {
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for this file in all future sessions',
kind: 'allow_always',
});
}
break;
case 'exec':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow this command for all future sessions',
kind: 'allow_always',
});
}
break;
case 'mcp':
options.push(
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: 'Allow all server tools for this session',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: 'Allow tool for this session',
kind: 'allow_always',
},
);
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow tool for all future sessions',
kind: 'allow_always',
});
}
break;
case 'info':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow for this session',
kind: 'allow_always',
});
if (enablePermanentToolApproval) {
options.push({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for all future sessions',
kind: 'allow_always',
});
}
break;
case 'ask_user':
case 'exit_plan_mode':
// askuser and exit_plan_mode don't need "always allow" options
break;
default:
// No "always allow" options for other types
break;
}
}
options.push(...basicPermissionOptions);
// Exhaustive check
switch (confirmation.type) {
case 'edit':
case 'exec':
case 'mcp':
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
throw new Error(`Unexpected: ${unreachable}`);
}
}
return options;
}
export function toAcpToolKind(kind: Kind): acp.ToolKind {
switch (kind) {
case Kind.Read:
case Kind.Edit:
case Kind.Execute:
case Kind.Search:
case Kind.Delete:
case Kind.Move:
case Kind.Think:
case Kind.Fetch:
case Kind.SwitchMode:
case Kind.Other:
return kind as acp.ToolKind;
case Kind.Agent:
return 'think';
case Kind.Plan:
case Kind.Communicate:
default:
return 'other';
}
}
export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
const modes: acp.SessionMode[] = [
{
id: ApprovalMode.DEFAULT,
name: 'Default',
description: 'Prompts for approval',
},
{
id: ApprovalMode.AUTO_EDIT,
name: 'Auto Edit',
description: 'Auto-approves edit tools',
},
{
id: ApprovalMode.YOLO,
name: 'YOLO',
description: 'Auto-approves all tools',
},
];
if (isPlanEnabled) {
modes.push({
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
});
}
return modes;
}
export function buildAvailableModels(
config: Config,
settings: LoadedSettings,
): {
availableModels: Array<{
modelId: string;
name: string;
description?: string;
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
// --- DYNAMIC PATH ---
if (
config.getExperimentalDynamicModelConfiguration?.() === true &&
config.getModelConfigService
) {
const options = config.getModelConfigService().getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
});
return {
availableModels: options,
currentModelId: preferredModel,
};
}
// --- LEGACY PATH ---
const mainOptions = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
},
];
if (shouldShowPreviewModels) {
mainOptions.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
});
}
const manualOptions = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
const previewOptions = [
{
value: previewProValue,
title: getDisplayString(previewProModel),
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
},
];
if (useGemini31FlashLite) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
});
}
manualOptions.unshift(...previewOptions);
}
const scaleOptions = (
options: Array<{ value: string; title: string; description?: string }>,
) =>
options.map((o) => ({
modelId: o.value,
name: o.title,
description: o.description,
}));
return {
availableModels: [
...scaleOptions(mainOptions),
...scaleOptions(manualOptions),
],
currentModelId: preferredModel,
};
}
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+10 -13
View File
@@ -157,10 +157,15 @@ describe('RestoreCommand', () => {
describe('ListCheckpointsCommand', () => {
let context: CommandContext;
let listCommand: ListCheckpointsCommand;
let mockReaddir: Mock<(path: string) => Promise<string[]>>;
beforeEach(() => {
vi.resetAllMocks();
listCommand = new ListCheckpointsCommand();
mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
(path: string) => Promise<string[]>
>;
context = {
agentContext: {
config: {
@@ -186,10 +191,7 @@ describe('ListCheckpointsCommand', () => {
});
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
'not-a-checkpoint.txt',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
const response = await listCommand.execute(context);
@@ -198,7 +200,7 @@ describe('ListCheckpointsCommand', () => {
it('ignores error when mkdir fails', async () => {
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
vi.mocked(fs.readdir).mockResolvedValue([]);
mockReaddir.mockResolvedValue([]);
const response = await listCommand.execute(context);
@@ -207,11 +209,7 @@ describe('ListCheckpointsCommand', () => {
});
it('formats checkpoint summary output from checkpoint metadata', async () => {
vi.mocked(fs.readdir).mockResolvedValue([
'cp1.json',
'cp2.json',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
vi.mocked(getCheckpointInfoList).mockReturnValue([
{ messageId: 'id1', checkpoint: 'cp1' },
{ messageId: 'id2', checkpoint: 'cp2' },
@@ -226,8 +224,7 @@ describe('ListCheckpointsCommand', () => {
});
it('handles empty checkpoint info list', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue(['some.json'] as any);
mockReaddir.mockResolvedValue(['some.json']);
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
const response = await listCommand.execute(context);
@@ -236,7 +233,7 @@ describe('ListCheckpointsCommand', () => {
});
it('returns generic unexpected error message on failures', async () => {
vi.mocked(fs.readdir).mockRejectedValue(new Error('Readdir fail'));
mockReaddir.mockRejectedValue(new Error('Readdir fail'));
const response = await listCommand.execute(context);
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+4 -4
View File
@@ -3988,7 +3988,7 @@ describe('loadCliConfig acpMode and clientName', () => {
expect(config.getClientName()).toBe('acp-vscode');
});
it('should set acpMode to true but leave clientName undefined for generic terminals', async () => {
it('should set acpMode to true and set clientName to acp for generic terminals', async () => {
process.argv = ['node', 'script.js', '--acp'];
vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); // Generic terminal
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
@@ -4000,10 +4000,10 @@ describe('loadCliConfig acpMode and clientName', () => {
argv,
);
expect(config.getAcpMode()).toBe(true);
expect(config.getClientName()).toBeUndefined();
expect(config.getClientName()).toBe('acp');
});
it('should set acpMode to false and clientName to undefined by default', async () => {
it('should set acpMode to false and clientName to tui by default', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
@@ -4012,6 +4012,6 @@ describe('loadCliConfig acpMode and clientName', () => {
argv,
);
expect(config.getAcpMode()).toBe(false);
expect(config.getClientName()).toBeUndefined();
expect(config.getClientName()).toBe('tui');
});
});
+6
View File
@@ -931,7 +931,13 @@ export async function loadCliConfig(
(ide.name !== 'vscode' || process.env['TERM_PROGRAM'] === 'vscode')
) {
clientName = `acp-${ide.name}`;
} else {
clientName = 'acp';
}
} else if (argv.isCommand) {
clientName = 'cli-command';
} else {
clientName = 'tui';
}
// TODO(joshualitt): Clean this up alongside removal of the legacy config.
@@ -42,10 +42,12 @@ describe('ExtensionManager agents loading', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-'));
mockHomedir.mockReturnValue(tempDir);
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
// Create the extensions directory that ExtensionManager expects
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
@@ -48,11 +48,13 @@ describe('ExtensionManager hydration', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
vi.spyOn(coreEvents, 'emitFeedback');
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
mockHomedir.mockReturnValue(tempDir);
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
// Create the extensions directory that ExtensionManager expects
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
+1 -1
View File
@@ -76,7 +76,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runAcpClient } from './acp/acpClient.js';
import { runAcpClient } from './acp/acpStdioTransport.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { appEvents, AppEvent } from './utils/events.js';
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
+1 -1
View File
@@ -157,7 +157,7 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
};
});
vi.mock('./acp/acpClient.js', () => ({
vi.mock('./acp/acpStdioTransport.js', () => ({
runAcpClient: vi.fn().mockResolvedValue(undefined),
}));
+89 -8
View File
@@ -703,7 +703,7 @@ describe('runNonInteractive', () => {
createStreamFromEvents(events),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -793,7 +793,7 @@ describe('runNonInteractive', () => {
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -836,7 +836,7 @@ describe('runNonInteractive', () => {
createStreamFromEvents(events),
);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1530,7 +1530,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1692,7 +1692,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1867,7 +1867,7 @@ describe('runNonInteractive', () => {
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -1931,7 +1931,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
@@ -2037,6 +2037,87 @@ describe('runNonInteractive', () => {
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
expect(getWrittenOutput()).toBe('Final answer\n');
});
it('should handle InvalidStream event gracefully in TEXT mode', async () => {
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
expect(processStderrSpy).toHaveBeenCalledWith(
'[ERROR] Invalid stream: The model returned an empty response or malformed tool call.\n',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.STREAM_JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"type":"error"');
expect(output).toContain('"severity":"error"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
it('should handle InvalidStream event gracefully in JSON mode', async () => {
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue(
OutputFormat.JSON,
);
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.InvalidStream },
];
mockGeminiClient.sendMessageStream.mockReturnValue(
createStreamFromEvents(events),
);
await runNonInteractive({
config: mockConfig,
settings: mockSettings,
input: 'test invalid stream',
prompt_id: 'prompt-id-invalid',
});
const output = getWrittenOutput();
expect(output).toContain('"error": {');
expect(output).toContain('"type": "INVALID_STREAM"');
expect(output).toContain(
'Invalid stream: The model returned an empty response or malformed tool call.',
);
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
});
});
describe('Output Sanitization', () => {
@@ -2218,7 +2299,7 @@ describe('runNonInteractive', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
OutputFormat.STREAM_JSON,
);
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue(
MOCK_SESSION_METRICS,
);
+25 -2
View File
@@ -295,6 +295,7 @@ export async function runNonInteractive(
let currentMessages: Content[] = [{ role: 'user', parts: query }];
let turnCount = 0;
let invalidStreamError: string | undefined;
while (true) {
turnCount++;
if (
@@ -395,6 +396,21 @@ export async function runNonInteractive(
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[WARNING] ${blockMessage}\n`);
}
} else if (event.type === GeminiEventType.InvalidStream) {
invalidStreamError =
'Invalid stream: The model returned an empty response or malformed tool call.';
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'error',
message: invalidStreamError,
});
} else if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[ERROR] ${invalidStreamError}\n`);
}
toolCallRequests.length = 0;
break;
}
}
@@ -508,14 +524,21 @@ export async function runNonInteractive(
streamFormatter.emitEvent({
type: JsonStreamEventType.RESULT,
timestamp: new Date().toISOString(),
status: 'success',
status: invalidStreamError ? 'error' : 'success',
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
});
} else if (config.getOutputFormat() === OutputFormat.JSON) {
const formatter = new JsonFormatter();
const stats = uiTelemetryService.getMetrics();
textOutput.write(
formatter.format(config.getSessionId(), responseText, stats),
formatter.format(
config.getSessionId(),
responseText,
stats,
invalidStreamError
? { type: 'INVALID_STREAM', message: invalidStreamError }
: undefined,
),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
+2 -4
View File
@@ -875,10 +875,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (apiKey: string) => {
try {
onAuthError(null);
if (!apiKey.trim() && apiKey.length > 1) {
onAuthError(
'API key cannot be empty string with length greater than 1.',
);
if (!apiKey.trim()) {
onAuthError('API key cannot be empty or whitespace only.');
return;
}
@@ -33,11 +33,12 @@ describe('quitCommand', () => {
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, 'quit');
const result = quitCommand.action(mockContext, '');
expect(formatDuration).toHaveBeenCalledWith(3600000); // 1 hour in ms
expect(result).toEqual({
type: 'quit',
deleteSession: false,
messages: [
{
type: 'user',
@@ -52,4 +53,54 @@ describe('quitCommand', () => {
],
});
});
it('sets deleteSession to true when --delete flag is provided', () => {
const mockContext = createMockCommandContext({
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
},
},
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, '--delete');
expect(result).toEqual({
type: 'quit',
deleteSession: true,
messages: [
{
type: 'user',
text: '/quit',
id: expect.any(Number),
},
{
type: 'quit',
duration: '1h 0m 0s',
id: expect.any(Number),
},
],
});
});
it('does not set deleteSession for unrecognized args', () => {
const mockContext = createMockCommandContext({
session: {
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00Z'),
},
},
});
if (!quitCommand.action) throw new Error('Action is not defined');
const result = quitCommand.action(mockContext, 'some-random-arg');
expect(result).toEqual(
expect.objectContaining({
type: 'quit',
deleteSession: false,
}),
);
});
});
+4 -1
View File
@@ -13,13 +13,16 @@ export const quitCommand: SlashCommand = {
description: 'Exit the cli',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context) => {
action: (context, args) => {
const now = Date.now();
const { sessionStartTime } = context.session.stats;
const wallDuration = now - sessionStartTime.getTime();
const deleteSession = args.trim() === '--delete';
return {
type: 'quit',
deleteSession,
messages: [
{
type: 'user',
+2
View File
@@ -108,6 +108,8 @@ export interface CommandContext {
export interface QuitActionReturn {
type: 'quit';
messages: HistoryItem[];
/** When true, the current session's history and temporary files will be deleted on exit. */
deleteSession?: boolean;
}
/**
@@ -41,6 +41,7 @@ const KEY_INFO_MAP: Record<
string,
{ name: string; shift?: boolean; ctrl?: boolean }
> = {
OM: { name: 'enter' },
'[200~': { name: 'paste-start' },
'[201~': { name: 'paste-end' },
'[[A': { name: 'f1' },
@@ -577,7 +577,7 @@ describe('useSlashCommandProcessor', () => {
it('should handle "load_history" action', async () => {
const mockClient = {
setHistory: vi.fn(),
resumeChat: vi.fn().mockResolvedValue(undefined),
stripThoughtsFromHistory: vi.fn(),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
@@ -646,6 +646,108 @@ describe('useSlashCommandProcessor', () => {
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should delete the current session when quit action has deleteSession flag', async () => {
const mockDeleteCurrentSessionAsync = vi
.fn()
.mockResolvedValue(undefined);
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
deleteSession: true,
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit --delete');
});
expect(mockDeleteCurrentSessionAsync).toHaveBeenCalled();
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should not delete session when quit action does not have deleteSession flag', async () => {
const mockDeleteCurrentSessionAsync = vi
.fn()
.mockResolvedValue(undefined);
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: mockDeleteCurrentSessionAsync,
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
expect(mockDeleteCurrentSessionAsync).not.toHaveBeenCalled();
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should still quit even if session deletion fails', async () => {
const mockClient = {
getChatRecordingService: vi.fn().mockReturnValue({
deleteCurrentSessionAsync: vi
.fn()
.mockRejectedValue(new Error('Deletion failed')),
}),
} as unknown as GeminiClient;
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(mockClient);
const quitAction = vi.fn().mockResolvedValue({
type: 'quit',
deleteSession: true,
messages: ['bye'],
});
const command = createTestCommand({
name: 'exit',
action: quitAction,
});
const result = await setupProcessorHook({
builtinCommands: [command],
});
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/exit --delete');
});
// Should still quit even though deletion threw
expect(mockSetQuittingMessages).toHaveBeenCalledWith(['bye']);
});
it('should handle "submit_prompt" action returned from a file-based command', async () => {
const fileCommand = createTestCommand(
{
@@ -549,7 +549,9 @@ export const useSlashCommandProcessor = (
}
}
case 'load_history': {
config?.getGeminiClient()?.setHistory(result.clientHistory);
await config
?.getGeminiClient()
?.resumeChat(result.clientHistory);
fullCommandContext.ui.clear();
result.history.forEach((item, index) => {
fullCommandContext.ui.addItem(item, index);
@@ -557,6 +559,18 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
}
case 'quit':
if (result.deleteSession) {
try {
const chatRecordingService = config
?.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
await chatRecordingService.deleteCurrentSessionAsync();
}
} catch {
// Don't let deletion errors prevent exit.
}
}
actions.quit(result.messages);
return { type: 'handled' };
@@ -15,6 +15,25 @@ const debugLogger = vi.hoisted(() => ({
vi.mock('@google/gemini-cli-core', () => ({
getPackageJson,
debugLogger,
ReleaseChannel: {
NIGHTLY: 'nightly',
PREVIEW: 'preview',
STABLE: 'stable',
},
getChannelFromVersion: (version: string) => {
if (!version || version.includes('nightly')) {
return 'nightly';
}
if (version.includes('preview')) {
return 'preview';
}
return 'stable';
},
RELEASE_CHANNEL_STABILITY: {
nightly: 0,
preview: 1,
stable: 2,
},
}));
const latestVersion = vi.hoisted(() => vi.fn());
@@ -152,4 +171,68 @@ describe('checkForUpdates', () => {
expect(result?.update.latest).toBe('1.2.3-nightly.2');
});
});
describe('channel stability', () => {
it('should NOT offer nightly update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a nightly that is semver-greater
latestVersion.mockResolvedValue('1.1.0-nightly.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should NOT offer preview update to a stable user even if tagged as latest', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
// latest points to a preview that is semver-greater
latestVersion.mockResolvedValue('1.1.0-preview.1');
const result = await checkForUpdates(mockSettings);
expect(result).toBeNull();
});
it('should offer stable update to a stable user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a nightly user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-nightly.1',
});
latestVersion.mockImplementation(async (name, options) => {
if (options?.version === 'nightly') {
return '1.0.0-nightly.1'; // No nightly update
}
return '1.1.0'; // Stable update available
});
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
it('should offer stable update to a preview user', async () => {
getPackageJson.mockResolvedValue({
name: 'test-package',
version: '1.0.0-preview.1',
});
latestVersion.mockResolvedValue('1.1.0');
const result = await checkForUpdates(mockSettings);
expect(result?.update.latest).toBe('1.1.0');
});
});
});
+21 -2
View File
@@ -6,7 +6,12 @@
import latestVersion from 'latest-version';
import semver from 'semver';
import { getPackageJson, debugLogger } from '@google/gemini-cli-core';
import {
getPackageJson,
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -65,6 +70,7 @@ export async function checkForUpdates(
}
const { name, version: currentVersion } = packageJson;
const currentChannel = getChannelFromVersion(currentVersion);
const isNightly = currentVersion.includes('nightly');
if (isNightly) {
@@ -90,8 +96,21 @@ export async function checkForUpdates(
}
} else {
const latestUpdate = await latestVersion(name);
if (!latestUpdate) {
return null;
}
if (latestUpdate && semver.gt(latestUpdate, currentVersion)) {
const targetChannel = getChannelFromVersion(latestUpdate);
// Only offer updates that are as stable or more stable than the current version
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return null;
}
if (semver.gt(latestUpdate, currentVersion)) {
const message = `Gemini CLI update available! ${currentVersion}${latestUpdate}`;
const type = semver.diff(latestUpdate, currentVersion) || undefined;
return {
@@ -334,7 +334,8 @@ describe('handleAutoUpdate', () => {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
latest: '2.0.0-nightly',
current: '1.0.0-nightly.0',
latest: '2.0.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
@@ -356,6 +357,26 @@ describe('handleAutoUpdate', () => {
);
});
it('should NOT update if target is less stable than current (defense-in-depth)', async () => {
mockUpdateInfo = {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
current: '1.0.0',
latest: '1.1.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should emit "update-success" when the update process succeeds', async () => {
await new Promise<void>((resolve) => {
mockGetInstallationInfo.mockReturnValue({
+24 -1
View File
@@ -11,7 +11,11 @@ import { updateEventEmitter } from './updateEventEmitter.js';
import { MessageType, type HistoryItem } from '../ui/types.js';
import { spawnWrapper } from './spawnWrapper.js';
import type { spawn } from 'node:child_process';
import { debugLogger } from '@google/gemini-cli-core';
import {
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
let _updateInProgress = false;
@@ -122,6 +126,25 @@ export function handleAutoUpdate(
return;
}
const currentVersion = info.update.current;
if (!currentVersion) {
debugLogger.warn(
'Update check: current version is missing. Skipping automatic update for safety.',
);
return;
}
const currentChannel = getChannelFromVersion(currentVersion);
const targetChannel = getChannelFromVersion(info.update.latest);
// Defense-in-depth: prevent updates to a less stable channel
if (
RELEASE_CHANNEL_STABILITY[targetChannel] <
RELEASE_CHANNEL_STABILITY[currentChannel]
) {
return;
}
const isNightly = info.update.latest.includes('nightly');
const updateCommand = installationInfo.updateCommand.replace(
+158
View File
@@ -9,6 +9,11 @@ import {
RELAUNCH_EXIT_CODE,
relaunchApp,
_resetRelaunchStateForTesting,
isStandardSea,
getScriptArgs,
isSeaEnvironment,
getSpawnConfig,
type ProcessWithSea,
} from './processUtils.js';
import * as cleanup from './cleanup.js';
import * as handleAutoUpdate from './handleAutoUpdate.js';
@@ -36,3 +41,156 @@ describe('processUtils', () => {
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
});
});
describe('SEA handling utilities', () => {
const originalArgv = process.argv;
const originalExecArgv = process.execArgv;
const originalExecPath = process.execPath;
const originalIsSea = (process as ProcessWithSea).isSea;
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv('NODE_OPTIONS', '');
process.argv = [...originalArgv];
process.execArgv = [...originalExecArgv];
process.execPath = '/fake/exec/path';
delete (process as ProcessWithSea).isSea;
});
afterEach(() => {
vi.unstubAllEnvs();
process.argv = originalArgv;
process.execArgv = originalExecArgv;
process.execPath = originalExecPath;
if (originalIsSea) {
(process as ProcessWithSea).isSea = originalIsSea;
} else {
delete (process as ProcessWithSea).isSea;
}
});
describe('isStandardSea', () => {
it('returns false if argv[0] === argv[1]', () => {
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command'];
vi.stubEnv('IS_BINARY', 'true');
expect(isStandardSea()).toBe(false);
});
it('returns true if IS_BINARY is true and argv[0] !== argv[1]', () => {
process.argv = ['/bin/gemini', 'my-command'];
vi.stubEnv('IS_BINARY', 'true');
expect(isStandardSea()).toBe(true);
});
it('returns true if process.isSea() is true and argv[0] !== argv[1]', () => {
process.argv = ['/bin/gemini', 'my-command'];
(process as ProcessWithSea).isSea = () => true;
expect(isStandardSea()).toBe(true);
});
it('returns false in standard node environment', () => {
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
expect(isStandardSea()).toBe(false);
});
});
describe('getScriptArgs', () => {
it('slices from index 1 if isStandardSea is true', () => {
process.argv = ['/bin/gemini', 'my-command', '--flag'];
vi.stubEnv('IS_BINARY', 'true');
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
});
it('slices from index 2 if isStandardSea is false (relaunch SEA or standard node)', () => {
// Relaunch SEA
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command', '--flag'];
vi.stubEnv('IS_BINARY', 'true');
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
// Standard node
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
vi.stubEnv('IS_BINARY', '');
expect(getScriptArgs()).toEqual(['my-command']);
});
});
describe('isSeaEnvironment', () => {
it('returns true if IS_BINARY is true', () => {
vi.stubEnv('IS_BINARY', 'true');
expect(isSeaEnvironment()).toBe(true);
});
it('returns true if process.isSea() is true', () => {
(process as ProcessWithSea).isSea = () => true;
expect(isSeaEnvironment()).toBe(true);
});
it('returns true if argv[0] === argv[1]', () => {
process.argv = ['/bin/gemini', '/bin/gemini'];
expect(isSeaEnvironment()).toBe(true);
});
it('returns false otherwise', () => {
process.argv = ['/bin/node', '/path/to/script.js'];
expect(isSeaEnvironment()).toBe(false);
});
});
describe('getSpawnConfig', () => {
it('handles standard node mode', () => {
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
process.execArgv = ['--inspect'];
process.execPath = '/bin/node';
const config = getSpawnConfig(
['--max-old-space-size=8192'],
['my-command'],
);
expect(config.spawnArgs).toEqual([
'--inspect',
'--max-old-space-size=8192',
'/path/to/script.js',
'my-command',
]);
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
expect(config.env['NODE_OPTIONS']).toBeFalsy();
});
it('handles SEA binary mode with new nodeArgs', () => {
vi.stubEnv('IS_BINARY', 'true');
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
process.argv = ['/bin/gemini', 'my-command'];
process.execArgv = ['--inspect']; // Should not be duplicated in NODE_OPTIONS
process.execPath = '/bin/gemini';
const config = getSpawnConfig(
['--max-old-space-size=8192'],
['my-command'],
);
expect(config.spawnArgs).toEqual([
'/bin/gemini', // explicitly uses execPath as placeholder
'my-command',
]);
expect(config.env['NODE_OPTIONS']).toBe(
'--existing-flag --max-old-space-size=8192',
);
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
});
it('throws error for complex nodeArgs in SEA mode', () => {
vi.stubEnv('IS_BINARY', 'true');
expect(() => {
getSpawnConfig(['--title "My App"'], []);
}).toThrow(
'Unsupported node argument for SEA relaunch: --title "My App". Complex escaping is not supported.',
);
expect(() => {
getSpawnConfig(['--title=A\\B'], []);
}).toThrow();
});
});
});
+98
View File
@@ -29,3 +29,101 @@ export async function relaunchApp(): Promise<void> {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}
export interface ProcessWithSea extends NodeJS.Process {
isSea?: () => boolean;
}
/**
* Determines whether the current process is a "standard" SEA (Single Executable Application)
* where the user arguments start at index 1 instead of index 2.
* A relaunched SEA child will have process.argv[0] === process.argv[1] (because we inject execPath),
* so it will return false here and correctly slice from index 2.
*/
export function isStandardSea(): boolean {
return (
process.argv[0] !== process.argv[1] &&
(process.env['IS_BINARY'] === 'true' ||
(process as ProcessWithSea).isSea?.() === true)
);
}
/**
* Extracts the user-provided script arguments from process.argv,
* accounting for the differences in SEA execution modes.
*/
export function getScriptArgs(): string[] {
return process.argv.slice(isStandardSea() ? 1 : 2);
}
/**
* Determines if the current process is running in any SEA environment
* (either the initial launch or a relaunched child).
*/
export function isSeaEnvironment(): boolean {
return (
process.env['IS_BINARY'] === 'true' ||
(process as ProcessWithSea).isSea?.() === true ||
process.argv[0] === process.argv[1]
);
}
/**
* Constructs the arguments and environment for spawning a child process during relaunch.
* Handles differences between standard Node and SEA binary modes.
*/
export function getSpawnConfig(
nodeArgs: string[],
scriptArgs: string[],
): {
spawnArgs: string[];
env: NodeJS.ProcessEnv;
} {
const isBinary = isSeaEnvironment();
const newEnv: NodeJS.ProcessEnv = {
...process.env,
GEMINI_CLI_NO_RELAUNCH: 'true',
};
const finalSpawnArgs: string[] = [];
if (isBinary) {
// In SEA mode, Node flags must be passed via NODE_OPTIONS, as the binary
// passes all CLI arguments directly to the application.
// We only need to append the *new* nodeArgs (e.g., memory flags).
// Existing execArgv are inherited via the environment or baked into the binary.
if (nodeArgs.length > 0) {
for (const arg of nodeArgs) {
if (/[\s"'\\]/.test(arg)) {
throw new Error(
`Unsupported node argument for SEA relaunch: ${arg}. Complex escaping is not supported.`,
);
}
}
const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
// nodeArgs in our codebase are simple flags like --max-old-space-size=X
// that do not contain spaces and do not require complex escaping.
newEnv['NODE_OPTIONS'] =
`${existingNodeOptions} ${nodeArgs.join(' ')}`.trim();
}
// Binary is its own entry point. To maintain the [node, script, ...args]
// structure expected by the application (which uses slice(2)),
// we must provide a placeholder for the script path.
// We explicitly use process.execPath to break the cycle and prevent
// compounding argument duplication on subsequent relaunches.
finalSpawnArgs.push(process.execPath, ...scriptArgs);
} else {
// Standard Node mode: pass all flags via command line.
finalSpawnArgs.push(
...process.execArgv,
...nodeArgs,
process.argv[1],
...scriptArgs,
);
}
return {
spawnArgs: finalSpawnArgs,
env: newEnv,
};
}
+110 -98
View File
@@ -59,6 +59,7 @@ describe('relaunchOnExitCode', () => {
});
afterEach(() => {
vi.unstubAllEnvs();
processExitSpy.mockRestore();
stdinResumeSpy.mockRestore();
});
@@ -116,7 +117,6 @@ describe('relaunchAppInChildProcess', () => {
let stdinResumeSpy: MockInstance;
// Store original values to restore later
const originalEnv = { ...process.env };
const originalExecArgv = [...process.execArgv];
const originalArgv = [...process.argv];
const originalExecPath = process.execPath;
@@ -125,8 +125,9 @@ describe('relaunchAppInChildProcess', () => {
vi.clearAllMocks();
mocks.writeToStderr.mockClear();
process.env = { ...originalEnv };
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
vi.stubEnv('IS_BINARY', '');
vi.stubEnv('NODE_OPTIONS', '');
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
@@ -144,7 +145,7 @@ describe('relaunchAppInChildProcess', () => {
});
afterEach(() => {
process.env = { ...originalEnv };
vi.unstubAllEnvs();
process.execArgv = [...originalExecArgv];
process.argv = [...originalArgv];
process.execPath = originalExecPath;
@@ -156,7 +157,7 @@ describe('relaunchAppInChildProcess', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
it('should return early without spawning a child process', async () => {
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', 'true');
await relaunchAppInChildProcess(['--test'], ['--verbose']);
@@ -167,132 +168,141 @@ describe('relaunchAppInChildProcess', () => {
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
beforeEach(() => {
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
});
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
// Test the argument construction logic directly by extracting it into a testable function
// This tests the same logic that's used in relaunchAppInChildProcess
// Setup test data to verify argument ordering
const mockExecArgv = ['--inspect=9229', '--trace-warnings'];
const mockArgv = [
it('should construct correct spawn arguments and use command line for node arguments in standard Node mode', async () => {
process.execArgv = ['--inspect=9229', '--trace-warnings'];
process.argv = [
'/usr/bin/node',
'/path/to/cli.js',
'command',
'--flag=value',
'--verbose',
];
const additionalNodeArgs = [
'--max-old-space-size=4096',
'--experimental-modules',
];
const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug'];
// Extract the argument construction logic from relaunchAppInChildProcess
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
[
'--inspect=9229',
'--trace-warnings',
'--max-old-space-size=4096',
'--experimental-modules',
'/path/to/cli.js',
'--model',
'gemini-1.5-pro',
'--debug',
'command',
'--flag=value',
'--verbose',
],
expect.objectContaining({
env: expect.objectContaining({
GEMINI_CLI_NO_RELAUNCH: 'true',
}),
}),
);
const lastCall = mockedSpawn.mock.calls[0] as unknown as [
string,
string[],
{ env: NodeJS.ProcessEnv },
];
const env = lastCall[2].env;
expect(env['NODE_OPTIONS']).toBeFalsy();
});
// Verify the argument construction follows the expected pattern:
// [...process.execArgv, ...additionalNodeArgs, script, ...additionalScriptArgs, ...scriptArgs]
const expectedArgs = [
// Original node execution arguments
'--inspect=9229',
'--trace-warnings',
// Additional node arguments passed to function
'--max-old-space-size=4096',
'--experimental-modules',
// The script path
'/path/to/cli.js',
// Additional script arguments passed to function
'--model',
'gemini-1.5-pro',
'--debug',
// Original script arguments (everything after the script in process.argv)
it('should handle SEA binary mode (IS_BINARY=true) correctly using NODE_OPTIONS', async () => {
vi.stubEnv('IS_BINARY', 'true');
// execArgv should be inherited, not duplicated in NODE_OPTIONS
process.execArgv = ['--inspect=9229'];
process.argv = [
'/usr/bin/gemini',
'/usr/bin/gemini',
'command',
'--flag=value',
'--verbose',
];
expect(nodeArgs).toEqual(expectedArgs);
});
it('should handle empty additional arguments correctly', () => {
// Test edge cases with empty arrays
const mockExecArgv = ['--trace-warnings'];
const mockArgv = ['/usr/bin/node', '/app/cli.js', 'start'];
const additionalNodeArgs: string[] = [];
const additionalNodeArgs = ['--max-old-space-size=8192'];
const additionalScriptArgs: string[] = [];
// Extract the argument construction logic
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
const expectedArgs = ['--trace-warnings', '/app/cli.js', 'start'];
expect(nodeArgs).toEqual(expectedArgs);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/node', 'command', '--verbose'],
expect.objectContaining({
env: expect.objectContaining({
GEMINI_CLI_NO_RELAUNCH: 'true',
NODE_OPTIONS: '--max-old-space-size=8192',
}),
}),
);
});
it('should handle complex argument patterns', () => {
// Test with various argument types including flags with values, boolean flags, etc.
const mockExecArgv = ['--max-old-space-size=8192'];
const mockArgv = [
'/usr/bin/node',
'/cli.js',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
const additionalNodeArgs = ['--inspect-brk=9230'];
const additionalScriptArgs = ['--model=gpt-4', '--temperature=0.7'];
it('should append new nodeArgs to NODE_OPTIONS in SEA mode without escaping', async () => {
vi.stubEnv('IS_BINARY', 'true');
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
process.execArgv = ['--inspect']; // inherited from env/binary, should not be duplicated
process.argv = ['/usr/bin/gemini', '/usr/bin/gemini', 'command'];
const script = mockArgv[1];
const scriptArgs = mockArgv.slice(2);
// In our use case, these are simple flags like --max-old-space-size=X
const additionalNodeArgs = ['--max-old-space-size=8192'];
const additionalScriptArgs: string[] = [];
const nodeArgs = [
...mockExecArgv,
...additionalNodeArgs,
script,
...additionalScriptArgs,
...scriptArgs,
];
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
const expectedArgs = [
'--max-old-space-size=8192',
'--inspect-brk=9230',
'/cli.js',
'--model=gpt-4',
'--temperature=0.7',
'--config=/path/to/config.json',
'--verbose',
'subcommand',
'--output',
'file.txt',
];
await expect(
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
).rejects.toThrow('PROCESS_EXIT_CALLED');
expect(nodeArgs).toEqual(expectedArgs);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['/usr/bin/node', 'command'],
expect.objectContaining({
env: expect.objectContaining({
NODE_OPTIONS: '--existing-flag --max-old-space-size=8192',
}),
}),
);
});
// Note: Additional integration tests for spawn behavior are complex due to module mocking
// limitations with ES modules. The core logic is tested in relaunchOnExitCode tests.
it('should handle empty additional arguments correctly in Node mode', async () => {
process.execArgv = ['--trace-warnings'];
process.argv = ['/usr/bin/node', '/app/cli.js', 'start'];
const mockChild = createMockChildProcess(0, true);
mockedSpawn.mockReturnValue(mockChild);
await expect(relaunchAppInChildProcess([], [])).rejects.toThrow(
'PROCESS_EXIT_CALLED',
);
expect(mockedSpawn).toHaveBeenCalledWith(
process.execPath,
['--trace-warnings', '/app/cli.js', 'start'],
expect.anything(),
);
});
it('should handle null exit code from child process', async () => {
process.argv = ['/usr/bin/node', '/app/cli.js'];
@@ -342,6 +352,8 @@ function createMockChildProcess(
disconnect: vi.fn(),
unref: vi.fn(),
ref: vi.fn(),
on: mockChild.on.bind(mockChild),
emit: mockChild.emit.bind(mockChild),
});
if (autoClose) {
+9 -13
View File
@@ -5,7 +5,11 @@
*/
import { spawn } from 'node:child_process';
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
import {
RELAUNCH_EXIT_CODE,
getSpawnConfig,
getScriptArgs,
} from './processUtils.js';
import {
writeToStderr,
type AdminControlsSettings,
@@ -43,24 +47,16 @@ export async function relaunchAppInChildProcess(
let latestAdminSettings = remoteAdminSettings;
const runner = () => {
// process.argv is [node, script, ...args]
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
const script = process.argv[1];
const scriptArgs = process.argv.slice(2);
const nodeArgs = [
...process.execArgv,
...additionalNodeArgs,
script,
const scriptArgs = getScriptArgs();
const { spawnArgs, env: newEnv } = getSpawnConfig(additionalNodeArgs, [
...additionalScriptArgs,
...scriptArgs,
];
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
]);
// The parent process should not be reading from stdin while the child is running.
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
const child = spawn(process.execPath, spawnArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
env: newEnv,
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.41.0-nightly.20260423.gaa05b4583",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+22
View File
@@ -585,5 +585,27 @@ describe('a2aUtils', () => {
status: 'completed',
});
});
it('should correctly push the first message when messageLog is empty (Issue #24894)', () => {
const reassembler = new A2AResultReassembler();
const message: Message = {
kind: 'message',
role: 'agent',
messageId: 'm1',
parts: [{ kind: 'text', text: 'First message' }],
};
reassembler.update({
kind: 'status-update',
contextId: 'ctx1',
status: {
state: 'working',
message,
},
} as unknown as SendMessageResult);
expect(reassembler.toString()).toBe('First message');
});
});
});
+1 -1
View File
@@ -126,7 +126,7 @@ export class A2AResultReassembler {
if (!message) return;
if (message.role === 'user') return; // Skip user messages reflected by server
const text = extractPartsText(message.parts, '');
if (text && this.messageLog[this.messageLog.length - 1] !== text) {
if (text && this.messageLog.at(-1) !== text) {
this.messageLog.push(text);
}
}
@@ -529,6 +529,59 @@ Body`);
});
});
it('should convert mcp_servers with auth block in local agent (oauth with full fields)', () => {
const markdown = {
kind: 'local' as const,
name: 'oauth-test-agent',
description: 'An agent to test OAuth MCP with full fields',
mcp_servers: {
'test-server': {
url: 'https://api.example.com/mcp',
type: 'http' as const,
auth: {
type: 'oauth' as const,
client_id: 'my-client-id',
client_secret: 'my-client-secret',
scopes: ['read', 'write'],
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirect_uri: 'http://localhost:8080/callback',
token_param_name: 'access_token',
registration_url: 'https://auth.example.com/register',
},
timeout: 30000,
},
},
system_prompt: 'You are a test agent.',
};
const result = markdownToAgentDefinition(
markdown,
) as LocalAgentDefinition;
expect(result.kind).toBe('local');
expect(result.mcpServers).toBeDefined();
expect(result.mcpServers!['test-server']).toMatchObject({
url: 'https://api.example.com/mcp',
type: 'http',
oauth: {
enabled: true,
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
scopes: ['read', 'write'],
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirectUri: 'http://localhost:8080/callback',
tokenParamName: 'access_token',
registrationUrl: 'https://auth.example.com/register',
},
timeout: 30000,
});
});
it('should pass through unknown model names (e.g. auto)', () => {
const markdown = {
kind: 'local' as const,
@@ -886,6 +939,12 @@ auth:
- profile
authorization_url: https://auth.example.com/authorize
token_url: https://auth.example.com/token
issuer: https://auth.example.com
audiences:
- audience1
redirect_uri: http://localhost:8080/callback
token_param_name: access_token
registration_url: https://auth.example.com/register
---
`);
const result = await parseAgentMarkdown(filePath);
@@ -900,6 +959,11 @@ auth:
scopes: ['openid', 'profile'],
authorization_url: 'https://auth.example.com/authorize',
token_url: 'https://auth.example.com/token',
issuer: 'https://auth.example.com',
audiences: ['audience1'],
redirect_uri: 'http://localhost:8080/callback',
token_param_name: 'access_token',
registration_url: 'https://auth.example.com/register',
},
});
});
+20
View File
@@ -79,6 +79,11 @@ const mcpServerSchema = z.object({
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
issuer: z.string().url().optional(),
audiences: z.array(z.string()).optional(),
redirect_uri: z.string().url().optional(),
token_param_name: z.string().optional(),
registration_url: z.string().url().optional(),
}),
])
.optional(),
@@ -148,6 +153,11 @@ const oauth2AuthSchema = z.object({
scopes: z.array(z.string()).optional(),
authorization_url: z.string().url().optional(),
token_url: z.string().url().optional(),
issuer: z.string().url().optional(),
audiences: z.array(z.string()).optional(),
redirect_uri: z.string().url().optional(),
token_param_name: z.string().optional(),
registration_url: z.string().url().optional(),
});
const authConfigSchema = z
@@ -459,6 +469,11 @@ function convertFrontmatterAuthToConfig(
scopes: frontmatter.scopes,
authorization_url: frontmatter.authorization_url,
token_url: frontmatter.token_url,
issuer: frontmatter.issuer,
audiences: frontmatter.audiences,
redirect_uri: frontmatter.redirect_uri,
token_param_name: frontmatter.token_param_name,
registration_url: frontmatter.registration_url,
};
default: {
@@ -552,6 +567,11 @@ export function markdownToAgentDefinition(
scopes: config.auth.scopes,
authorizationUrl: config.auth.authorization_url,
tokenUrl: config.auth.token_url,
issuer: config.auth.issuer,
audiences: config.auth.audiences,
redirectUri: config.auth.redirect_uri,
tokenParamName: config.auth.token_param_name,
registrationUrl: config.auth.registration_url,
};
}
}
@@ -77,6 +77,11 @@ export interface OAuth2AuthConfig extends BaseAuthConfig {
authorization_url?: string;
/** Override or provide the token endpoint URL. Discovered from agent card if omitted. */
token_url?: string;
issuer?: string;
audiences?: string[];
redirect_uri?: string;
token_param_name?: string;
registration_url?: string;
}
/** Client config corresponding to OpenIdConnectSecurityScheme. */
@@ -0,0 +1,414 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BaseLlmClient } from '../core/baseLlmClient.js';
import { FakeContentGenerator } from '../core/fakeContentGenerator.js';
import { Config } from '../config/config.js';
import { RetryableQuotaError } from '../utils/googleQuotaErrors.js';
import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../config/models.js';
import fs from 'node:fs';
import { AuthType } from '../core/contentGenerator.js';
import type { FallbackIntent } from '../fallback/types.js';
import { LlmRole } from '../telemetry/types.js';
import type { GenerateContentResponse } from '@google/genai';
vi.mock('node:fs');
describe('Auto Routing Fallback Integration', () => {
let config: Config;
let fakeGenerator: FakeContentGenerator;
let client: BaseLlmClient;
beforeEach(() => {
vi.useFakeTimers();
// Mock fs to avoid real file system access
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as fs.Stats);
// Provide a valid dummy sandbox policy for any readFileSync calls for TOML files
vi.mocked(fs.readFileSync).mockImplementation((path) => {
if (typeof path === 'string' && path.endsWith('.toml')) {
return `
[modes.plan]
network = false
readonly = true
approvedTools = []
[modes.default]
network = false
readonly = false
approvedTools = []
[modes.accepting_edits]
network = false
readonly = false
approvedTools = []
`;
}
return ''; // Fallback for other files
});
fakeGenerator = new FakeContentGenerator([]);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should fallback to Flash after 3 tries and try 10 times for Flash in auto mode', async () => {
// Instantiate real Config in auto mode
config = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: PREVIEW_GEMINI_MODEL_AUTO, // Trigger auto mode
});
// Force interactive mode to enable fallback handler in BaseLlmClient
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
client = new BaseLlmClient(
fakeGenerator,
config,
AuthType.LOGIN_WITH_GOOGLE,
);
let attemptsPro = 0;
let attemptsFlash = 0;
const mockGoogleApiError = {
code: 429,
message: 'Quota exceeded',
details: [],
};
// Spy on generateContent to simulate failures
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
attemptsPro++;
throw new RetryableQuotaError(
'Quota exceeded for Pro',
mockGoogleApiError,
0,
);
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
attemptsFlash++;
throw new RetryableQuotaError(
'Quota exceeded for Flash',
mockGoogleApiError,
0,
);
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
// Set a fallback handler that approves the switch (simulating user or auto approval)
config.setFallbackModelHandler(
async (failed, _fallback, _error): Promise<FallbackIntent | null> => {
if (failed === PREVIEW_GEMINI_FLASH_MODEL) {
return 'stop'; // Stop retrying after Flash fails
}
return 'retry_always'; // Trigger fallback to Flash
},
);
// Call generateContent
const promise = client.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt',
role: LlmRole.UTILITY_TOOL,
});
await Promise.all([
expect(promise).rejects.toThrow('Quota exceeded for Flash'),
vi.runAllTimersAsync(),
]);
// Verify attempts
expect(attemptsPro).toBe(3);
expect(attemptsFlash).toBe(10);
});
it('should try 10 times and prompt user in non-auto mode', async () => {
// Instantiate real Config in non-auto mode
const configNonAuto = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: PREVIEW_GEMINI_MODEL, // Non-auto mode
});
// Force interactive mode to enable fallback handler in BaseLlmClient
vi.spyOn(configNonAuto, 'isInteractive').mockReturnValue(true);
const clientNonAuto = new BaseLlmClient(
fakeGenerator,
configNonAuto,
AuthType.LOGIN_WITH_GOOGLE,
);
let attemptsPro = 0;
const mockGoogleApiError = {
code: 429,
message: 'Quota exceeded',
details: [],
};
// Spy on generateContent to simulate failures
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
attemptsPro++;
throw new RetryableQuotaError(
'Quota exceeded for Pro',
mockGoogleApiError,
0,
);
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
// Set a fallback handler that returns 'stop' (simulating user stopping or failing to handle)
const handler = vi.fn(
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
'stop',
);
configNonAuto.setFallbackModelHandler(handler);
const promise = clientNonAuto.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt',
role: LlmRole.UTILITY_TOOL,
maxAttempts: 10,
});
await Promise.all([
expect(promise).rejects.toThrow('Quota exceeded for Pro'),
vi.runAllTimersAsync(),
]);
// Verify attempts (should default to 10)
expect(attemptsPro).toBe(10);
// Verify handler was called once after 10 attempts to prompt user
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
expect.any(RetryableQuotaError),
);
});
it('should fallback to Flash after 3 tries in experimental dynamic mode', async () => {
// Instantiate real Config in auto mode
const configDynamic = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: PREVIEW_GEMINI_MODEL_AUTO, // Trigger auto mode
});
// Force interactive mode to enable fallback handler in BaseLlmClient
vi.spyOn(configDynamic, 'isInteractive').mockReturnValue(true);
// Enable experimental dynamic model configuration
vi.spyOn(
configDynamic,
'getExperimentalDynamicModelConfiguration',
).mockReturnValue(true);
const clientDynamic = new BaseLlmClient(
fakeGenerator,
configDynamic,
AuthType.LOGIN_WITH_GOOGLE,
);
let attemptsPro = 0;
let attemptsFlash = 0;
const mockGoogleApiError = {
code: 429,
message: 'Quota exceeded',
details: [],
};
// Spy on generateContent to simulate failures
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
attemptsPro++;
throw new RetryableQuotaError(
'Quota exceeded for Pro',
mockGoogleApiError,
0,
);
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
attemptsFlash++;
throw new RetryableQuotaError(
'Quota exceeded for Flash',
mockGoogleApiError,
0,
);
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
// Set a fallback handler that approves the switch
configDynamic.setFallbackModelHandler(
async (failed, _fallback, _error): Promise<FallbackIntent | null> => {
if (failed === PREVIEW_GEMINI_FLASH_MODEL) {
return 'stop';
}
return 'retry_always';
},
);
const promise = clientDynamic.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt',
role: LlmRole.UTILITY_TOOL,
});
await Promise.all([
expect(promise).rejects.toThrow('Quota exceeded for Flash'),
vi.runAllTimersAsync(),
]);
// Verify attempts
expect(attemptsPro).toBe(3);
expect(attemptsFlash).toBe(10);
});
it('should retry Pro on next turn after successful fallback to Flash', async () => {
// Instantiate real Config in auto mode
config = new Config({
sessionId: 'test-session',
targetDir: '/test',
debugMode: false,
cwd: '/test',
model: PREVIEW_GEMINI_MODEL_AUTO, // Trigger auto mode
});
// Force interactive mode to enable fallback handler in BaseLlmClient
vi.spyOn(config, 'isInteractive').mockReturnValue(true);
client = new BaseLlmClient(
fakeGenerator,
config,
AuthType.LOGIN_WITH_GOOGLE,
);
let attemptsPro = 0;
let attemptsFlash = 0;
const mockGoogleApiError = {
code: 429,
message: 'Quota exceeded',
details: [],
};
// Turn 1: Pro fails, Flash succeeds
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
attemptsPro++;
throw new RetryableQuotaError(
'Quota exceeded for Pro',
mockGoogleApiError,
0,
);
} else if (params.model === PREVIEW_GEMINI_FLASH_MODEL) {
attemptsFlash++;
return {
candidates: [
{
content: { role: 'model', parts: [{ text: 'Flash success' }] },
},
],
} as unknown as GenerateContentResponse;
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
config.setFallbackModelHandler(
async (_failed, _fallback, _error): Promise<FallbackIntent | null> =>
'retry_always', // Approve switch to Flash
);
// Call generateContent for Turn 1
const promise1 = client.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true },
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt-1',
role: LlmRole.UTILITY_TOOL,
});
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'Flash success',
);
expect(attemptsPro).toBe(3);
expect(attemptsFlash).toBe(1);
// Simulate start of next turn
config.getModelAvailabilityService().resetTurn();
// Turn 2: Pro should be attempted again!
// Let's make it succeed this time to verify it works!
vi.spyOn(fakeGenerator, 'generateContent').mockImplementation(
async (params) => {
if (params.model === PREVIEW_GEMINI_MODEL) {
return {
candidates: [
{ content: { role: 'model', parts: [{ text: 'Pro success' }] } },
],
} as unknown as GenerateContentResponse;
}
throw new Error(`Unexpected model: ${params.model}`);
},
);
const promise2 = client.generateContent({
modelConfigKey: { model: PREVIEW_GEMINI_MODEL, isChatModel: true }, // Request Pro again
contents: [{ role: 'user', parts: [{ text: 'hello again' }] }],
abortSignal: new AbortController().signal,
promptId: 'test-prompt-2',
role: LlmRole.UTILITY_TOOL,
});
const result2 = await promise2;
expect(result2.candidates?.[0]?.content?.parts?.[0]?.text).toBe(
'Pro success',
);
});
});
@@ -77,4 +77,38 @@ describe('Fallback Integration', () => {
// 5. Expect it to fallback to Flash (because Gemini 3 uses PREVIEW_CHAIN)
expect(result.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should fallback to Flash after failures and restore Pro on next turn', () => {
const requestedModel = PREVIEW_GEMINI_MODEL;
// 1. Initial call should return Pro with 3 attempts
const result1 = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
expect(result1.model).toBe(PREVIEW_GEMINI_MODEL);
expect(result1.maxAttempts).toBe(3);
// 2. Simulate failure and transition to sticky_retry with consumed=true
availabilityService.markRetryOncePerTurn(PREVIEW_GEMINI_MODEL, 3);
availabilityService.consumeStickyAttempt(PREVIEW_GEMINI_MODEL);
// 3. Next call in same turn should fallback to Flash
const result2 = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
expect(result2.model).toBe(PREVIEW_GEMINI_FLASH_MODEL);
// 4. Reset turn (start of new interaction)
availabilityService.resetTurn();
// 5. Next call should restore Pro with 3 attempts
const result3 = applyModelSelection(config, {
model: requestedModel,
isChatModel: true,
});
expect(result3.model).toBe(PREVIEW_GEMINI_MODEL);
expect(result3.maxAttempts).toBe(3);
});
});
@@ -34,6 +34,12 @@ describe('ModelAvailabilityService', () => {
expect(service.snapshot(model)).toEqual({ available: true });
});
it('tracks retry with custom attempts', () => {
service.markRetryOncePerTurn(model, 3);
const selection = service.selectFirstAvailable([model]);
expect(selection.attempts).toBe(3);
});
it('tracks terminal failures', () => {
service.markTerminal(model, 'quota');
expect(service.snapshot(model)).toEqual({
@@ -22,6 +22,7 @@ type HealthState =
status: 'sticky_retry';
reason: TurnUnavailabilityReason;
consumed: boolean;
attempts: number;
};
export interface ModelAvailabilitySnapshot {
@@ -52,7 +53,7 @@ export class ModelAvailabilityService {
this.clearState(model);
}
markRetryOncePerTurn(model: ModelId) {
markRetryOncePerTurn(model: ModelId, attempts: number = 1) {
const currentState = this.health.get(model);
// Do not override a terminal failure with a transient one.
if (currentState?.status === 'terminal') {
@@ -70,6 +71,7 @@ export class ModelAvailabilityService {
status: 'sticky_retry',
reason: 'retry_once_per_turn',
consumed,
attempts,
});
}
@@ -106,7 +108,8 @@ export class ModelAvailabilityService {
if (snapshot.available) {
const state = this.health.get(model);
// A sticky model is being attempted, so note that.
const attempts = state?.status === 'sticky_retry' ? 1 : undefined;
const attempts =
state?.status === 'sticky_retry' ? state.attempts : undefined;
return { selectedModel: model, skipped, attempts };
} else {
skipped.push({ model, reason: snapshot.reason ?? 'unknown' });
@@ -46,6 +46,7 @@ export interface ModelPolicy {
actions: ModelPolicyActionMap;
stateTransitions: ModelPolicyStateMap;
isLastResort?: boolean;
maxAttempts?: number;
}
/**
@@ -53,8 +53,11 @@ describe('policyCatalog', () => {
expect(chain).toHaveLength(2);
});
it('marks preview transients as sticky retries', () => {
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
it('marks preview transients as sticky retries when auto-selected', () => {
const [previewPolicy] = getModelPolicyChain({
previewEnabled: true,
isAutoSelection: true,
});
expect(previewPolicy.model).toBe(PREVIEW_GEMINI_MODEL);
expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
});
@@ -28,6 +28,7 @@ type PolicyConfig = Omit<ModelPolicy, 'actions' | 'stateTransitions'> & {
export interface ModelPolicyOptions {
previewEnabled: boolean;
isAutoSelection?: boolean;
userTier?: UserTierId;
useGemini31?: boolean;
useGemini31FlashLite?: boolean;
@@ -50,15 +51,19 @@ export const SILENT_ACTIONS: ModelPolicyActionMap = {
const DEFAULT_STATE: ModelPolicyStateMap = {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
};
const DEFAULT_CHAIN: ModelPolicyChain = [
definePolicy({ model: DEFAULT_GEMINI_MODEL }),
definePolicy({ model: DEFAULT_GEMINI_FLASH_MODEL, isLastResort: true }),
];
const AUTO_ROUTING_OVERRIDES = {
maxAttempts: 3,
actions: { ...DEFAULT_ACTIONS, transient: 'silent' } as ModelPolicyActionMap,
stateTransitions: {
...DEFAULT_STATE,
transient: 'sticky_retry',
} as ModelPolicyStateMap,
};
const FLASH_LITE_CHAIN: ModelPolicyChain = [
definePolicy({
@@ -82,20 +87,45 @@ const FLASH_LITE_CHAIN: ModelPolicyChain = [
export function getModelPolicyChain(
options: ModelPolicyOptions,
): ModelPolicyChain {
const isAuto = options.isAutoSelection ?? false;
if (options.previewEnabled) {
const previewModel = resolveModel(
const proModel = resolveModel(
PREVIEW_GEMINI_MODEL,
options.useGemini31,
options.useGemini31FlashLite,
options.useCustomToolModel,
);
return [
definePolicy({ model: previewModel }),
definePolicy({ model: PREVIEW_GEMINI_FLASH_MODEL, isLastResort: true }),
definePolicy({
model: proModel,
...(isAuto
? {
maxAttempts: 3,
actions: { ...DEFAULT_ACTIONS, transient: 'silent' },
stateTransitions: { ...DEFAULT_STATE, transient: 'sticky_retry' },
}
: {}),
}),
definePolicy({
model: PREVIEW_GEMINI_FLASH_MODEL,
isLastResort: true,
maxAttempts: 10,
}),
];
}
return cloneChain(DEFAULT_CHAIN);
return [
definePolicy({
model: DEFAULT_GEMINI_MODEL,
...(isAuto ? AUTO_ROUTING_OVERRIDES : {}),
}),
definePolicy({
model: DEFAULT_GEMINI_FLASH_MODEL,
isLastResort: true,
maxAttempts: 10,
}),
];
}
export function createSingleModelChain(model: string): ModelPolicyChain {
@@ -137,6 +167,7 @@ function definePolicy(config: PolicyConfig): ModelPolicy {
return {
model: config.model,
isLastResort: config.isLastResort,
maxAttempts: config.maxAttempts,
actions: { ...DEFAULT_ACTIONS, ...(config.actions ?? {}) },
stateTransitions: {
...DEFAULT_STATE,
@@ -9,8 +9,10 @@ import {
resolvePolicyChain,
buildFallbackPolicyContext,
applyModelSelection,
applyAvailabilityTransition,
} from './policyHelpers.js';
import { createDefaultPolicy, SILENT_ACTIONS } from './policyCatalog.js';
import type { RetryAvailabilityContext } from './modelPolicy.js';
import type { Config } from '../config/config.js';
import {
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -35,6 +37,7 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config => {
return useGemini31 && authType === AuthType.USE_GEMINI;
},
getContentGeneratorConfig: () => ({ authType: undefined }),
getMaxAttemptsPerTurn: () => 3,
...overrides,
} as unknown as Config;
return config;
@@ -201,6 +204,7 @@ describe('policyHelpers', () => {
hasAccess: false,
},
{ name: 'Concrete Model (2.5 Pro)', model: 'gemini-2.5-pro' },
{ name: 'Explicit Gemini 3', model: 'gemini-3-pro-preview' },
{ name: 'Custom Model', model: 'my-custom-model' },
{
name: 'Wrap Around',
@@ -438,4 +442,51 @@ describe('policyHelpers', () => {
expect(result.maxAttempts).toBe(1);
});
});
describe('applyAvailabilityTransition', () => {
it('marks terminal on terminal transition', () => {
const mockService = { markTerminal: vi.fn() };
const context = {
service: mockService,
policy: {
model: 'test-model',
stateTransitions: { transient: 'terminal' },
},
};
const getContext = () => context as unknown as RetryAvailabilityContext;
applyAvailabilityTransition(getContext, 'transient');
expect(mockService.markTerminal).toHaveBeenCalledWith(
'test-model',
'capacity',
);
});
it('marks sticky and consumes on sticky_retry transition', () => {
const mockService = {
markRetryOncePerTurn: vi.fn(),
consumeStickyAttempt: vi.fn(),
};
const context = {
service: mockService,
policy: {
model: 'test-model',
stateTransitions: { transient: 'sticky_retry' },
maxAttempts: 3,
},
};
const getContext = () => context as unknown as RetryAvailabilityContext;
applyAvailabilityTransition(getContext, 'transient');
expect(mockService.markRetryOncePerTurn).toHaveBeenCalledWith(
'test-model',
3,
);
expect(mockService.consumeStickyAttempt).toHaveBeenCalledWith(
'test-model',
);
});
});
});
@@ -77,12 +77,12 @@ export function resolvePolicyChain(
chain = config.modelConfigService.resolveChain('lite', context);
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoModel(preferredModel ?? '', config) ||
isAutoModel(configuredModel, config)
isAutoPreferred ||
isAutoConfigured
) {
// 1. Try to find a chain specifically for the current configured alias
if (
isAutoModel(configuredModel, config) &&
isAutoConfigured &&
config.modelConfigService.getModelChain(configuredModel)
) {
chain = config.modelConfigService.resolveChain(
@@ -92,13 +92,18 @@ export function resolvePolicyChain(
}
// 2. Fallback to family-based auto-routing
if (!chain) {
const isAutoSelection = isAutoPreferred || isAutoConfigured;
const previewEnabled =
hasAccessToPreview &&
(isGemini3Model(resolvedModel, config) ||
preferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO);
const autoPrefix = isAutoSelection ? 'auto-' : '';
const chainKey = previewEnabled ? 'preview' : 'default';
chain = config.modelConfigService.resolveChain(chainKey, context);
chain = config.modelConfigService.resolveChain(
`${autoPrefix}${chainKey}`,
context,
);
}
}
if (!chain) {
@@ -116,6 +121,7 @@ export function resolvePolicyChain(
isAutoPreferred ||
isAutoConfigured
) {
const isAutoSelection = isAutoPreferred || isAutoConfigured;
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
@@ -123,6 +129,7 @@ export function resolvePolicyChain(
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
previewEnabled,
isAutoSelection,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
@@ -133,6 +140,7 @@ export function resolvePolicyChain(
// to the stable Gemini 2.5 chain.
chain = getModelPolicyChain({
previewEnabled: false,
isAutoSelection,
userTier: config.getUserTier(),
useGemini31,
useGemini31FlashLite,
@@ -144,7 +152,6 @@ export function resolvePolicyChain(
}
chain = applyDynamicSlicing(chain, resolvedModel, wrapsAround);
}
// Apply Unified Silent Injection for Plan Mode with defensive checks
if (config?.getApprovalMode?.() === ApprovalMode.PLAN) {
return chain.map((policy) => ({
@@ -295,10 +302,13 @@ export function applyModelSelection(
config.getModelAvailabilityService().consumeStickyAttempt(finalModel);
}
const chain = resolvePolicyChain(config, finalModel);
const policy = chain.find((p) => p.model === finalModel);
return {
model: finalModel,
config: generateContentConfig,
maxAttempts: selection.attempts,
maxAttempts: selection.attempts ?? policy?.maxAttempts,
};
}
@@ -318,6 +328,10 @@ export function applyAvailabilityTransition(
failureKind === 'terminal' ? 'quota' : 'capacity',
);
} else if (transition === 'sticky_retry') {
context.service.markRetryOncePerTurn(context.policy.model);
context.service.markRetryOncePerTurn(
context.policy.model,
context.policy.maxAttempts,
);
context.service.consumeStickyAttempt(context.policy.model);
}
}
@@ -557,7 +557,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
@@ -565,12 +565,31 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
{
model: 'gemini-3-flash-preview',
isLastResort: true,
maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
},
],
'auto-preview': [
{
model: 'gemini-3-pro-preview',
maxAttempts: 3,
actions: {
terminal: 'prompt',
transient: 'silent',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
@@ -578,6 +597,23 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
unknown: 'terminal',
},
},
{
model: 'gemini-3-flash-preview',
isLastResort: true,
maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
},
],
default: [
{
@@ -598,12 +634,31 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
{
model: 'gemini-2.5-flash',
isLastResort: true,
maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
},
],
'auto-default': [
{
model: 'gemini-2.5-pro',
maxAttempts: 3,
actions: {
terminal: 'prompt',
transient: 'silent',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
@@ -611,6 +666,23 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
unknown: 'terminal',
},
},
{
model: 'gemini-2.5-flash',
isLastResort: true,
maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
not_found: 'prompt',
unknown: 'prompt',
},
stateTransitions: {
terminal: 'terminal',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
},
],
lite: [
{
@@ -623,7 +695,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
@@ -638,7 +710,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
@@ -654,7 +726,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
stateTransitions: {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
},
@@ -196,7 +196,9 @@ describe('ChatCompressionService', () => {
} as unknown as Config;
vi.mocked(getInitialChatHistory).mockImplementation(
async (_config, extraHistory) => extraHistory || [],
async (_config, extraHistory?: readonly Content[]) => [
...(extraHistory || []),
],
);
});
@@ -118,7 +118,7 @@ The following tools are available in Plan Mode:
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use the built-in \`exit_plan_mode\` tool to request approval. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.**
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow
@@ -143,7 +143,7 @@ Write the implementation plan to \`../plans/\`. The plan's structure adapts to t
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.** When called, this tool will present the plan and formally request approval.
# Operational Guidelines
@@ -298,7 +298,7 @@ The following tools are available in Plan Mode:
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use the built-in \`exit_plan_mode\` tool to request approval. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.**
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow
@@ -323,7 +323,7 @@ Write the implementation plan to \`../plans/\`. The plan's structure adapts to t
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.** When called, this tool will present the plan and formally request approval.
## Approved Plan
An approved plan is available for this task at \`../plans/feature-x.md\`.
@@ -599,7 +599,7 @@ The following tools are available in Plan Mode:
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use the built-in \`exit_plan_mode\` tool to request approval. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.**
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow
@@ -624,7 +624,7 @@ Write the implementation plan to \`plans/\`. The plan's structure adapts to the
- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.
### 4. Review & Approval
ONLY use the \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and formally request approval.
ONLY use the built-in \`exit_plan_mode\` tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. **CRITICAL: NEVER attempt to call this tool via \`run_shell_command\`.** When called, this tool will present the plan and formally request approval.
# Operational Guidelines
+30 -25
View File
@@ -43,36 +43,41 @@ vi.mock('../utils/errors.js', async (importOriginal) => {
};
});
vi.mock('../utils/retry.js', () => ({
retryWithBackoff: vi.fn(async (fn, options) => {
// Default implementation - just call the function
const result = await fn();
vi.mock('../utils/retry.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/retry.js')>();
return {
...actual,
retryWithBackoff: vi.fn(async (fn, options) => {
// Default implementation - just call the function
const result = await fn();
// If shouldRetryOnContent is provided, test it but don't actually retry
// (unless we want to simulate retry exhaustion for testing)
if (options?.shouldRetryOnContent) {
const shouldRetry = options.shouldRetryOnContent(result);
if (shouldRetry) {
// Check if we need to simulate retry exhaustion (for error testing)
const responseText = result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (
!responseText ||
responseText.trim() === '' ||
responseText.includes('{"color": "blue"')
) {
throw new Error('Retry attempts exhausted for invalid content');
// If shouldRetryOnContent is provided, test it but don't actually retry
// (unless we want to simulate retry exhaustion for testing)
if (options?.shouldRetryOnContent) {
const shouldRetry = options.shouldRetryOnContent(result);
if (shouldRetry) {
// Check if we need to simulate retry exhaustion (for error testing)
const responseText =
result?.candidates?.[0]?.content?.parts?.[0]?.text;
if (
!responseText ||
responseText.trim() === '' ||
responseText.includes('{"color": "blue"')
) {
throw new Error('Retry attempts exhausted for invalid content');
}
}
}
}
const context = options?.getAvailabilityContext?.();
if (context) {
context.service.markHealthy(context.policy.model);
}
const context = options?.getAvailabilityContext?.();
if (context) {
context.service.markHealthy(context.policy.model);
}
return result;
}),
}));
return result;
}),
};
});
const mockGenerateContent = vi.fn();
const mockEmbedContent = vi.fn();
+3 -1
View File
@@ -339,7 +339,9 @@ export class BaseLlmClient {
retryFetchErrors: this.config.getRetryFetchErrors(),
onRetry: (attempt, error, delayMs) => {
const actualMaxAttempts =
availabilityMaxAttempts ?? maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
getAvailabilityContext()?.policy.maxAttempts ??
maxAttempts ??
DEFAULT_MAX_ATTEMPTS;
const modelName = getDisplayString(currentModel);
const errorType = getRetryErrorType(error);
+2 -2
View File
@@ -324,7 +324,7 @@ export class GeminiClient {
}
async resumeChat(
history: Content[],
history: readonly Content[],
resumedSessionData?: ResumedSessionData,
): Promise<void> {
this.chat = await this.startChat(history, resumedSessionData);
@@ -365,7 +365,7 @@ export class GeminiClient {
}
async startChat(
extraHistory?: Content[],
extraHistory?: readonly Content[],
resumedSessionData?: ResumedSessionData,
): Promise<GeminiChat> {
this.forceFullIdeContext = true;
@@ -39,6 +39,7 @@ describe('createContentGenerator', () => {
beforeEach(() => {
resetVersionCache();
vi.clearAllMocks();
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
});
afterEach(() => {
@@ -850,19 +851,6 @@ describe('createContentGenerator', () => {
),
).rejects.toThrow('Invalid custom base URL: not-a-url');
});
it('should reject non-https remote custom baseUrl values', async () => {
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
baseUrl: 'http://example.com',
},
mockConfig,
),
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
});
});
describe('createContentGeneratorConfig', () => {
+1 -7
View File
@@ -110,22 +110,16 @@ export interface VertexAiRoutingConfig {
sharedRequestType?: VertexAiSharedRequestType;
}
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
const VERTEX_AI_REQUEST_TYPE_HEADER = 'X-Vertex-AI-LLM-Request-Type';
const VERTEX_AI_SHARED_REQUEST_TYPE_HEADER =
'X-Vertex-AI-LLM-Shared-Request-Type';
function validateBaseUrl(baseUrl: string): void {
let url: URL;
try {
url = new URL(baseUrl);
new URL(baseUrl);
} catch {
throw new Error(`Invalid custom base URL: ${baseUrl}`);
}
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
}
}
export async function createContentGeneratorConfig(
+9 -1
View File
@@ -273,6 +273,14 @@ export class GeminiChat {
kind: 'main' | 'subagent' = 'main',
) {
await this.chatRecordingService.initialize(resumedSessionData, kind);
// If we have history but didn't resume a session record, sync it to the recording service.
// This handles initial history passed to startChat.
if (!resumedSessionData && this.agentHistory.get().length > 0) {
this.chatRecordingService.updateMessagesFromHistory(
this.agentHistory.get(),
);
}
}
setSystemInstruction(sysInstr: string) {
@@ -775,7 +783,7 @@ export class GeminiChat {
this.lastPromptTokenCount = estimateTokenCountSync(
this.agentHistory.flatMap((c) => c.parts || []),
);
this.chatRecordingService.updateMessagesFromHistory(history);
this.chatRecordingService.updateMessagesFromHistory(history, true);
}
stripThoughtsFromHistory(): void {
+1 -1
View File
@@ -477,7 +477,7 @@ ${options.planModeToolsList}
- Save the implementation plan to the designated plans directory
### Phase 4: Review & Approval
- Present the plan and request approval for the finalized plan using the \`${EXIT_PLAN_MODE_TOOL_NAME}\` tool
- Present the plan and request approval for the finalized plan using the built-in \`${EXIT_PLAN_MODE_TOOL_NAME}\` tool. **CRITICAL: NEVER attempt to call this tool via \`${SHELL_TOOL_NAME}\`.**
- If plan is approved, you can begin implementation
- If plan is rejected, address the feedback and iterate on the plan
+2 -2
View File
@@ -604,7 +604,7 @@ ${options.planModeToolsList}
- **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan.
- **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below.
5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} to request approval.
6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use the built-in ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to request approval. **CRITICAL: NEVER attempt to call this tool via ${formatToolName(SHELL_TOOL_NAME)}.**
7. **Presenting Plan:** When seeking informal agreement on a plan, or any time the user asks to see the plan, you MUST output the full content of the plan in the chat response. This overrides the "Minimal Output" guideline.
## Planning Workflow
@@ -628,7 +628,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.${options.interactive ? '\n- **Alignment Check:** After drafting the plan, you MUST present it to the user in the chat (adhering to Rule 7 for presenting plans) to ensure alignment on the specific details. Ask for feedback or confirmation, and proceed to Step 4 (Review & Approval) once the user agrees with the detailed plan.' : ''}
### 4. Review & Approval
ONLY use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. When called, this tool will present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
ONLY use the built-in ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan for formal approval AFTER you have reached an informal agreement with the user in the chat regarding the proposed strategy. **CRITICAL: NEVER attempt to call this tool via ${formatToolName(SHELL_TOOL_NAME)}.** When called, this tool will present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
${renderApprovedPlanSection(options.approvedPlanPath)}`.trim();
}
@@ -357,6 +357,45 @@ describe('confirmation.ts', () => {
expect(mockState.updateArgs).toHaveBeenCalled();
});
it('should pass payload to onConfirm callback', async () => {
const details = {
type: 'ask_user' as const,
questions: [],
title: 'Title',
onConfirm: vi.fn(),
};
invocationMock.shouldConfirmExecute.mockResolvedValue(details);
const listenerPromise = waitForListener(
MessageBusType.TOOL_CONFIRMATION_RESPONSE,
);
const promise = resolveConfirmation(toolCall, signal, {
config: mockConfig,
messageBus: mockMessageBus,
state: mockState,
modifier: mockModifier,
getPreferredEditor,
schedulerId: ROOT_SCHEDULER_ID,
});
await listenerPromise;
const payload = { answers: { '0': 'user choice' } };
emitResponse({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: '123e4567-e89b-12d3-a456-426614174000',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload,
});
await promise;
expect(details.onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
payload,
);
});
it('should resolve immediately if IDE confirmation resolves first', async () => {
const idePromise = Promise.resolve({
status: 'accepted' as const,
@@ -735,6 +735,62 @@ describe('ChatRecordingService', () => {
});
});
describe('deleteCurrentSessionAsync', () => {
it('should asynchronously delete the current session file and tool outputs', async () => {
await chatRecordingService.initialize();
// Record a message to trigger the file write (writeConversation skips
// writing when there are no messages).
chatRecordingService.recordMessage({
type: 'user',
content: 'test',
model: 'gemini-pro',
});
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
// Create a tool output directory matching the session ID used by
// deleteSessionArtifactsAsync (this.sessionId = mockConfig.promptId).
const toolOutputDir = path.join(
testTempDir,
'tool-outputs',
'session-test-session-id',
);
fs.mkdirSync(toolOutputDir, { recursive: true });
fs.writeFileSync(path.join(toolOutputDir, 'output.txt'), 'data');
expect(fs.existsSync(conversationFile!)).toBe(true);
expect(fs.existsSync(toolOutputDir)).toBe(true);
await chatRecordingService.deleteCurrentSessionAsync();
expect(fs.existsSync(conversationFile!)).toBe(false);
expect(fs.existsSync(toolOutputDir)).toBe(false);
});
it('should not throw if the session was never initialized', async () => {
// conversationFile is null when not initialized
await expect(
chatRecordingService.deleteCurrentSessionAsync(),
).resolves.not.toThrow();
});
it('should not throw if session file does not exist on disk', async () => {
// initialize() writes an initial metadata record synchronously, so
// delete the file manually to simulate the "missing on disk" scenario.
await chatRecordingService.initialize();
const conversationFile = chatRecordingService.getConversationFilePath();
expect(conversationFile).not.toBeNull();
if (conversationFile && fs.existsSync(conversationFile)) {
fs.unlinkSync(conversationFile);
}
expect(fs.existsSync(conversationFile!)).toBe(false);
await expect(
chatRecordingService.deleteCurrentSessionAsync(),
).resolves.not.toThrow();
});
});
describe('recordDirectories', () => {
beforeEach(async () => {
await chatRecordingService.initialize();
@@ -1181,6 +1237,105 @@ describe('ChatRecordingService', () => {
// No tool calls matched, so writeFileSync should NOT have been called
expect(appendFileSyncSpy).not.toHaveBeenCalled();
});
it('should repopulate cachedConversation.messages when updating from history if cache is empty (regression)', async () => {
// This simulates the state after /chat resume where history is loaded into GeminiChat
// but ChatRecordingService's cache is still empty.
const history: Content[] = [
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'Hi there!' }],
},
{
role: 'user',
parts: [{ text: 'How are you?' }],
},
];
// Initially empty (except for metadata)
expect(chatRecordingService.getConversation()?.messages).toHaveLength(0);
chatRecordingService.updateMessagesFromHistory(history);
const messages = chatRecordingService.getConversation()?.messages;
// CURRENTLY FAILS: it only updates tool results, doesn't reconstruct messages.
expect(messages).toHaveLength(3);
expect(messages![0].content).toEqual([{ text: 'Hello' }]);
expect(messages![1].content).toEqual([{ text: 'Hi there!' }]);
expect(messages![2].content).toEqual([{ text: 'How are you?' }]);
});
it('should force reconstruction when reconstruct flag is true, even if cache is not empty', async () => {
// 1. Initial state with some messages
chatRecordingService.recordMessage({
type: 'user',
content: 'Old user message',
model: 'gemini-pro',
});
expect(chatRecordingService.getConversation()?.messages).toHaveLength(1);
// 2. New history to replace the old one
const newHistory: Content[] = [
{
role: 'user',
parts: [{ text: 'New user message' }],
},
];
// 3. Update with reconstruct = true
chatRecordingService.updateMessagesFromHistory(newHistory, true);
const messages = chatRecordingService.getConversation()?.messages;
expect(messages).toHaveLength(1);
expect(messages![0].content).toEqual([{ text: 'New user message' }]);
expect(messages![0].type).toBe('user');
});
it('should correctly reconstruct sibling parts (text/media) in tool response turns', async () => {
const callId = 'tool-call-1';
const history: Content[] = [
{
role: 'model',
parts: [
{ functionCall: { id: callId, name: 'list_files', args: {} } },
],
},
{
role: 'user',
parts: [
{ text: 'Sibling text' },
{
functionResponse: {
id: callId,
name: 'list_files',
response: { files: [] },
},
},
{ inlineData: { data: 'base64data', mimeType: 'image/png' } },
],
},
];
chatRecordingService.updateMessagesFromHistory(history, true);
const messages = chatRecordingService.getConversation()?.messages;
expect(messages).toHaveLength(1);
const geminiMsg = messages![0] as MessageRecord & { type: 'gemini' };
expect(geminiMsg.toolCalls).toHaveLength(1);
const result = geminiMsg.toolCalls![0].result as Part[];
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ text: 'Sibling text' });
expect(result[1].functionResponse?.id).toBe(callId);
expect(result[2]).toEqual({
inlineData: { data: 'base64data', mimeType: 'image/png' },
});
});
});
describe('ENOENT (missing directory) handling', () => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type ThoughtSummary } from '../utils/thoughtUtils.js';
import { type ThoughtSummary, parseThought } from '../utils/thoughtUtils.js';
import { getProjectHash } from '../utils/paths.js';
import path from 'node:path';
import * as fs from 'node:fs';
@@ -23,6 +23,7 @@ import type {
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import {
SESSION_FILE_PREFIX,
@@ -792,6 +793,32 @@ export class ChatRecordingService {
}
}
/**
* Asynchronously deletes the current session's chat file and tool outputs.
* This encapsulates the session ID logic and uses non-blocking I/O to avoid
* blocking the event loop on exit.
*/
async deleteCurrentSessionAsync(): Promise<void> {
if (!this.conversationFile) {
return;
}
try {
const tempDir = this.context.config.storage.getProjectTempDir();
// Delete the conversation file directly using the tracked path.
await fs.promises.unlink(this.conversationFile).catch(() => {
// File may not exist; ignore.
});
// Delegate tool-output and log cleanup to the shared utility.
await deleteSessionArtifactsAsync(this.sessionId, tempDir);
} catch (error) {
debugLogger.error('Error deleting current session.', error);
throw error;
}
}
/**
* Rewinds the conversation to the state just before the specified message ID.
* All messages from (and including) the specified ID onwards are removed.
@@ -818,10 +845,127 @@ export class ChatRecordingService {
return this.cachedConversation;
}
updateMessagesFromHistory(history: readonly Content[]): void {
private reconstructMessagesFromHistory(
history: readonly Content[],
): MessageRecord[] {
const messages: MessageRecord[] = [];
let i = 0;
while (i < history.length) {
const content = history[i];
const parts = content.parts || [];
if (content.role === 'user') {
// Simple user message
messages.push({
id: randomUUID(),
timestamp: new Date().toISOString(),
type: 'user',
content: parts,
});
i++;
} else if (content.role === 'model') {
const geminiMsg: MessageRecord & { type: 'gemini' } = {
id: randomUUID(),
timestamp: new Date().toISOString(),
type: 'gemini',
content: parts.filter(
(p) => !p.functionCall && !p.thought && !p.functionResponse,
),
toolCalls: [],
thoughts: [],
};
// Add thoughts
for (const part of parts) {
if (part.thought) {
const thoughtObj = parseThought(part.text || '');
geminiMsg.thoughts!.push({
...thoughtObj,
timestamp: new Date().toISOString(),
});
}
}
// Add tool calls
for (const part of parts) {
if (part.functionCall) {
geminiMsg.toolCalls!.push({
id: part.functionCall.id || `reconstructed-${randomUUID()}`,
name: part.functionCall.name || 'unknown_tool',
args: part.functionCall.args || {},
status: CoreToolCallStatus.Success, // Assume success for reconstructed history
timestamp: new Date().toISOString(),
});
}
}
// Look ahead for responses
if (
geminiMsg.toolCalls!.length > 0 &&
i + 1 < history.length &&
history[i + 1].role === 'user'
) {
const nextTurn = history[i + 1];
const nextParts = nextTurn.parts || [];
const callIds = nextParts
.map((p) => p.functionResponse?.id)
.filter((id): id is string => !!id);
if (callIds.length > 0) {
const respMap = new Map<string, Part[]>();
let currentCallId = callIds[0];
for (const p of nextParts) {
if (p.functionResponse?.id) {
currentCallId = p.functionResponse.id;
}
if (!respMap.has(currentCallId)) {
respMap.set(currentCallId, []);
}
respMap.get(currentCallId)!.push(p);
}
for (const tc of geminiMsg.toolCalls!) {
const respParts = respMap.get(tc.id);
if (respParts) {
tc.result = respParts;
}
}
// Consume the response turn
i++;
}
}
messages.push(geminiMsg);
i++;
} else {
i++; // Skip unknown roles
}
}
return messages;
}
updateMessagesFromHistory(
history: readonly Content[],
reconstruct = false,
): void {
if (!this.conversationFile || !this.cachedConversation) return;
try {
// If the cache is empty (e.g. after /resume load_history), or reconstruction is forced,
// reconstruct from history.
if (
(this.cachedConversation.messages.length === 0 && history.length > 0) ||
reconstruct
) {
this.updateMetadata({
messages: this.reconstructMessagesFromHistory(history),
lastUpdated: new Date().toISOString(),
});
return;
}
const partsMap = new Map<string, Part[]>();
for (const content of history) {
if (content.role === 'user' && content.parts) {
@@ -19,6 +19,7 @@ import {
import type { Config } from '../config/config.js';
import * as sdk from './sdk.js';
import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
vi.mock('@opentelemetry/api-logs');
vi.mock('./sdk.js');
@@ -144,4 +145,174 @@ describe('conseca-logger', () => {
expect(mockLogger.emit).not.toHaveBeenCalled();
});
it('should omit user_prompt/trusted_content/policy from OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
);
logConsecaPolicyGeneration(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['trusted_content']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['event.name']).toBe(EVENT_CONSECA_POLICY_GENERATION);
});
it('should omit user_prompt/trusted_content/policy from Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaPolicyGenerationEvent(
'sensitive prompt',
'sensitive content',
'sensitive policy',
'some error',
);
logConsecaPolicyGeneration(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include user_prompt/trusted_content/policy in OTEL when logPrompts is enabled', () => {
const event = new ConsecaPolicyGenerationEvent(
'visible prompt',
'visible content',
'visible policy',
);
logConsecaPolicyGeneration(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['trusted_content']).toBe('visible content');
expect(attrs['policy']).toBe('visible policy');
});
it('should omit sensitive fields from verdict OTEL when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
);
logConsecaVerdict(configNoPrompts, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBeUndefined();
expect(attrs['policy']).toBeUndefined();
expect(attrs['tool_call']).toBeUndefined();
expect(attrs['verdict_rationale']).toBeUndefined();
// verdict (the allow/deny result) is not sensitive and should be present
expect(attrs['verdict']).toBe('allow');
});
it('should omit sensitive fields from verdict Clearcut when logPrompts is disabled', () => {
const configNoPrompts = {
getTelemetryEnabled: vi.fn().mockReturnValue(true),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryTracesEnabled: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(true),
getExperiments: vi.fn().mockReturnValue({ experimentIds: [] }),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'oauth' }),
} as unknown as Config;
const event = new ConsecaVerdictEvent(
'sensitive prompt',
'sensitive policy',
'sensitive tool call',
'allow',
'sensitive rationale',
'some error',
);
logConsecaVerdict(configNoPrompts, event);
expect(mockClearcutLogger.createLogEvent).toHaveBeenCalledWith(
expect.anything(),
[
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: '"allow"',
},
{
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
value: 'some error',
},
],
);
});
it('should include sensitive fields in verdict OTEL when logPrompts is enabled', () => {
const event = new ConsecaVerdictEvent(
'visible prompt',
'visible policy',
'visible tool call',
'deny',
'visible rationale',
);
logConsecaVerdict(mockConfig, event);
const attrs = mockLogger.emit.mock.calls[0][0].attributes as Record<
string,
unknown
>;
expect(attrs['user_prompt']).toBe('visible prompt');
expect(attrs['policy']).toBe('visible policy');
expect(attrs['tool_call']).toBe('visible tool call');
expect(attrs['verdict_rationale']).toBe('visible rationale');
expect(attrs['verdict']).toBe('deny');
});
});
+41 -31
View File
@@ -11,6 +11,7 @@ import { isTelemetrySdkInitialized } from './sdk.js';
import {
ClearcutLogger,
EventNames,
type EventValue,
} from './clearcut-logger/clearcut-logger.js';
import { EventMetadataKey } from './clearcut-logger/event-metadata-key.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
@@ -27,20 +28,24 @@ export function logConsecaPolicyGeneration(
debugLogger.debug('Conseca Policy Generation Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
];
const data: EventValue[] = [];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_TRUSTED_CONTENT,
value: safeJsonStringify(event.trusted_content),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
);
}
if (event.error) {
data.push({
@@ -71,29 +76,34 @@ export function logConsecaVerdict(
debugLogger.debug('Conseca Verdict Event:', event);
const clearcutLogger = ClearcutLogger.getInstance(config);
if (clearcutLogger) {
const data = [
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RESULT,
value: safeJsonStringify(event.verdict),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
];
if (config.getTelemetryLogPromptsEnabled()) {
data.push(
{
gemini_cli_key: EventMetadataKey.CONSECA_USER_PROMPT,
value: safeJsonStringify(event.user_prompt),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_GENERATED_POLICY,
value: safeJsonStringify(event.policy),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME,
value: safeJsonStringify(event.tool_call),
},
{
gemini_cli_key: EventMetadataKey.CONSECA_VERDICT_RATIONALE,
value: event.verdict_rationale,
},
);
}
if (event.error) {
data.push({
gemini_cli_key: EventMetadataKey.CONSECA_ERROR,
+168 -15
View File
@@ -642,6 +642,54 @@ describe('loggers', () => {
}),
});
});
it('should not include response_text when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-noprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be hidden',
);
logApiResponse(mockConfigNoPrompts, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBeUndefined();
});
it('should include response_text when logPrompts is enabled', () => {
const event = new ApiResponseEvent(
'test-model',
100,
{ prompt_id: 'prompt-id-withprompts', contents: [] },
{ candidates: [] },
AuthType.LOGIN_WITH_GOOGLE,
{},
'this response should be visible',
);
logApiResponse(mockConfig, event);
const firstEmitCall = mockLogger.emit.mock.calls[0][0];
expect(firstEmitCall.attributes['response_text']).toBe(
'this response should be visible',
);
});
});
describe('logApiError', () => {
@@ -1076,6 +1124,10 @@ describe('loggers', () => {
expect(attributes['gen_ai.provider.name']).toBe('gcp.vertex_ai');
// Ensure prompt messages are NOT included
expect(attributes['gen_ai.input.messages']).toBeUndefined();
// Ensure request_text is also NOT included in the first (toLogRecord) log
const firstLogCall = mockLogger.emit.mock.calls[0][0];
expect(firstLogCall.attributes['request_text']).toBeUndefined();
});
it('should correctly derive model from prompt details if available in semantic log', () => {
@@ -1373,16 +1425,20 @@ describe('loggers', () => {
error_type: undefined,
mcp_server_name: undefined,
extension_id: undefined,
metadata: {
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
metadata: JSON.stringify(
{
model_added_lines: 1,
model_removed_lines: 2,
model_added_chars: 3,
model_removed_chars: 4,
user_added_lines: 5,
user_removed_lines: 6,
user_added_chars: 7,
user_removed_chars: 8,
},
null,
2,
),
content_length: 13,
},
});
@@ -1455,12 +1511,16 @@ describe('loggers', () => {
body: 'Tool call: ask_user. Decision: accept. Success: true. Duration: 100ms.',
attributes: expect.objectContaining({
function_name: 'ask_user',
metadata: expect.objectContaining({
ask_user: {
question_types: ['choice'],
dismissed: false,
metadata: JSON.stringify(
{
ask_user: {
question_types: ['choice'],
dismissed: false,
},
},
}),
null,
2,
),
}),
});
});
@@ -1867,6 +1927,99 @@ describe('loggers', () => {
});
});
describe('logToolCall — logPrompts flag', () => {
it('should omit function_args when logPrompts is disabled', () => {
const mockConfigNoPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => false,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo sensitive' },
callId: 'call-1',
isClientInitiated: false,
prompt_id: 'prompt-noprompts',
},
response: {
callId: 'call-1',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigNoPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBeUndefined();
expect(emitted.attributes['function_name']).toBe('run_bash');
});
it('should include function_args when logPrompts is enabled', () => {
const mockConfigWithPrompts = {
getSessionId: () => 'test-session-id',
getTargetDir: () => 'target-dir',
getUsageStatisticsEnabled: () => true,
getTelemetryEnabled: () => true,
getTelemetryLogPromptsEnabled: () => true,
getTelemetryTracesEnabled: () => false,
isInteractive: () => false,
getExperiments: () => undefined,
getExperimentsAsync: async () => undefined,
getContentGeneratorConfig: () => undefined,
} as unknown as Config;
const call: CompletedToolCall = {
status: CoreToolCallStatus.Success,
request: {
name: 'run_bash',
args: { command: 'echo visible' },
callId: 'call-2',
isClientInitiated: false,
prompt_id: 'prompt-withprompts',
},
response: {
callId: 'call-2',
responseParts: [],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: undefined,
},
tool: undefined as unknown as AnyDeclarativeTool,
invocation: {} as AnyToolInvocation,
durationMs: 50,
};
const event = new ToolCallEvent(call);
logToolCall(mockConfigWithPrompts, event);
const emitted = mockLogger.emit.mock.calls[0][0] as {
attributes: Record<string, unknown>;
};
expect(emitted.attributes['function_args']).toBe(
JSON.stringify({ command: 'echo visible' }, null, 2),
);
});
});
describe('logMalformedJsonResponse', () => {
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logMalformedJsonResponseEvent');
+57 -11
View File
@@ -231,6 +231,17 @@ export class UserPromptEvent implements BaseTelemetryEvent {
}
export const EVENT_TOOL_CALL = 'gemini_cli.tool_call';
const TOOL_CALL_METADATA_SAFE_KEYS = [
'model_added_lines',
'model_removed_lines',
'model_added_chars',
'model_removed_chars',
'user_added_lines',
'user_removed_lines',
'user_added_chars',
'user_removed_chars',
] as const;
export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': 'tool_call';
'event.timestamp': string;
@@ -355,7 +366,6 @@ export class ToolCallEvent implements BaseTelemetryEvent {
'event.name': EVENT_TOOL_CALL,
'event.timestamp': this['event.timestamp'],
function_name: this.function_name,
function_args: safeJsonStringify(this.function_args, 2),
duration_ms: this.duration_ms,
success: this.success,
decision: this.decision,
@@ -367,8 +377,22 @@ export class ToolCallEvent implements BaseTelemetryEvent {
extension_id: this.extension_id,
start_time: this.start_time,
end_time: this.end_time,
metadata: this.metadata,
};
if (config.getTelemetryLogPromptsEnabled() && this.function_args) {
attributes['function_args'] = safeJsonStringify(this.function_args, 2);
}
if (this.metadata) {
const metadata = config.getTelemetryLogPromptsEnabled()
? this.metadata
: Object.fromEntries(
Object.entries(this.metadata).filter(([k]) =>
(TOOL_CALL_METADATA_SAFE_KEYS as readonly string[]).includes(k),
),
);
if (Object.keys(metadata).length > 0) {
attributes['metadata'] = safeJsonStringify(metadata, 2);
}
}
if (this.error) {
attributes[CoreToolCallStatus.Error] = this.error;
@@ -423,8 +447,10 @@ export class ApiRequestEvent implements BaseTelemetryEvent {
'event.timestamp': this['event.timestamp'],
model: this.model,
prompt_id: this.prompt.prompt_id,
request_text: this.request_text,
};
if (config.getTelemetryLogPromptsEnabled() && this.request_text) {
attributes['request_text'] = this.request_text;
}
if (this.role) {
attributes['role'] = this.role;
}
@@ -692,7 +718,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent {
if (this.role) {
attributes['role'] = this.role;
}
if (this.response_text) {
if (config.getTelemetryLogPromptsEnabled() && this.response_text) {
attributes['response_text'] = this.response_text;
}
if (this.status_code) {
@@ -954,11 +980,20 @@ export class ConsecaPolicyGenerationEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_POLICY_GENERATION,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
trusted_content: this.trusted_content,
policy: this.policy,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.trusted_content) {
attributes['trusted_content'] = this.trusted_content;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
}
if (this.error) {
attributes['error'] = this.error;
}
@@ -1005,13 +1040,24 @@ export class ConsecaVerdictEvent implements BaseTelemetryEvent {
...getCommonAttributes(config),
'event.name': EVENT_CONSECA_VERDICT,
'event.timestamp': this['event.timestamp'],
user_prompt: this.user_prompt,
policy: this.policy,
tool_call: this.tool_call,
verdict: this.verdict,
verdict_rationale: this.verdict_rationale,
};
if (config.getTelemetryLogPromptsEnabled()) {
if (this.user_prompt) {
attributes['user_prompt'] = this.user_prompt;
}
if (this.policy) {
attributes['policy'] = this.policy;
}
if (this.tool_call) {
attributes['tool_call'] = this.tool_call;
}
if (this.verdict_rationale) {
attributes['verdict_rationale'] = this.verdict_rationale;
}
}
if (this.error) {
attributes['error'] = this.error;
}
+28
View File
@@ -314,6 +314,34 @@ describe('GrepTool', () => {
);
}, 30000);
it('should pass -i flag to system grep for case-insensitivity', async () => {
vi.mocked(execStreaming).mockImplementationOnce(() =>
createLineGenerator(['fileA.txt:1:hello world']),
);
const params: GrepToolParams = { pattern: 'HELLO' };
const invocation = grepTool.build(params) as unknown as {
isCommandAvailable: (command: string) => Promise<boolean>;
execute: (options: ExecuteOptions) => Promise<ToolResult>;
};
// Force system grep strategy by mocking isCommandAvailable and ensuring git grep is not used
invocation.isCommandAvailable = vi.fn(async (command: string) => {
if (command === 'git') return false;
if (command === 'grep') return true;
return false;
});
await invocation.execute({ abortSignal });
expect(execStreaming).toHaveBeenCalledWith(
'grep',
expect.arrayContaining(['-i']),
expect.objectContaining({
cwd: expect.any(String),
}),
);
});
it('should throw an error if params are invalid', async () => {
const params = { dir_path: '.' } as unknown as GrepToolParams; // Invalid: pattern missing
expect(() => grepTool.build(params)).toThrow(
+1 -1
View File
@@ -465,7 +465,7 @@ class GrepToolInvocation extends BaseToolInvocation<
const grepAvailable = await this.isCommandAvailable('grep');
if (grepAvailable) {
strategyUsed = 'system grep';
const grepArgs = ['-r', '-n', '-H', '-E', '-I'];
const grepArgs = ['-r', '-n', '-H', '-E', '-I', '-i'];
// Extract directory names from exclusion patterns for grep --exclude-dir
const globExcludes = this.fileExclusions.getGlobExcludes();
const commonExcludes = globExcludes
@@ -17,6 +17,7 @@ import { McpClientManager } from './mcp-client-manager.js';
import { McpClient, MCPDiscoveryState, MCPServerStatus } from './mcp-client.js';
import type { ToolRegistry } from './tool-registry.js';
import type { Config, GeminiCLIExtension } from '../config/config.js';
import { MCPServerConfig } from '../config/config.js';
import type { PromptRegistry } from '../prompts/prompt-registry.js';
import type { ResourceRegistry } from '../resources/resource-registry.js';
@@ -726,6 +727,40 @@ describe('McpClientManager', () => {
extensionName: 'test-extension',
});
});
it('should disconnect extension-backed MCP clients when stopping extension (#24050)', async () => {
const manager = setupManager(new McpClientManager('0.0.1', mockConfig));
const extension: GeminiCLIExtension = {
id: 'test-ext-id',
name: 'test-extension',
isActive: true,
version: '1.0.0',
path: '/fake/path',
contextFiles: [],
mcpServers: {
'test-server': new MCPServerConfig('node', ['script.js']),
},
};
await manager.startExtension(extension);
// Wait for discovery to complete
// eslint-disable-next-line @typescript-eslint/no-explicit-any
while ((manager as any).discoveryPromise) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (manager as any).discoveryPromise;
}
// Verify it was connected
expect(mockedMcpClient.connect).toHaveBeenCalled();
// Stop the extension
await manager.stopExtension(extension);
// Verify disconnect was called on the client
expect(mockedMcpClient.disconnect).toHaveBeenCalled();
expect(manager.getClient('test-server')).toBeUndefined();
});
});
describe('diagnostic reporting', () => {

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