diff --git a/.gemini/settings.json b/.gemini/settings.json
index 4ad7bc3ed6..e7ff785b7c 100644
--- a/.gemini/settings.json
+++ b/.gemini/settings.json
@@ -3,7 +3,10 @@
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true,
- "gemma": true
+ "gemma": true,
+ "memoryManager": true,
+ "topicUpdateNarration": true,
+ "voiceMode": true
},
"general": {
"devtools": true
diff --git a/.github/workflows/build-unsigned-mac-binaries.yml b/.github/workflows/build-unsigned-mac-binaries.yml
new file mode 100644
index 0000000000..b91d47fa94
--- /dev/null
+++ b/.github/workflows/build-unsigned-mac-binaries.yml
@@ -0,0 +1,56 @@
+name: 'Build Unsigned Mac Binaries'
+
+on:
+ workflow_dispatch:
+
+permissions:
+ contents: 'read'
+
+defaults:
+ run:
+ shell: 'bash'
+
+jobs:
+ build-mac:
+ name: 'Build Unsigned (${{ matrix.arch }})'
+ runs-on: 'macos-latest'
+ strategy:
+ fail-fast: false
+ matrix:
+ arch: ['x64', 'arm64']
+
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4
+
+ - name: 'Set up Node.js'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
+ with:
+ node-version-file: '.nvmrc'
+ architecture: '${{ matrix.arch }}'
+ cache: 'npm'
+
+ - name: 'Install dependencies'
+ run: 'npm ci'
+
+ - name: 'Build Binary'
+ env:
+ SKIP_SIGNING: 'true'
+ run: 'npm run build:binary'
+
+ - name: 'Verify Output Exists'
+ run: |
+ if [ -f "dist/darwin-${{ matrix.arch }}/gemini" ]; then
+ echo "Binary found at dist/darwin-${{ matrix.arch }}/gemini"
+ else
+ echo "Error: Binary not found in dist/darwin-${{ matrix.arch }}/"
+ ls -R dist/
+ exit 1
+ fi
+
+ - name: 'Upload Artifact'
+ uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
+ with:
+ name: 'gemini-darwin-${{ matrix.arch }}-unsigned'
+ path: 'dist/darwin-${{ matrix.arch }}/'
+ retention-days: 5
diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml
index bd276a3853..4a5de8bf7c 100644
--- a/.github/workflows/chained_e2e.yml
+++ b/.github/workflows/chained_e2e.yml
@@ -337,6 +337,7 @@ jobs:
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
+ GEMINI_CLI_TRUST_WORKSPACE: true
GEMINI_MODEL: 'gemini-3-pro-preview'
# Only run always passes behavioral tests.
EVAL_SUITE_TYPE: 'behavioral'
diff --git a/.github/workflows/docs-audit.yml b/.github/workflows/docs-audit.yml
index 4e3121e2cf..4a2da6aa37 100644
--- a/.github/workflows/docs-audit.yml
+++ b/.github/workflows/docs-audit.yml
@@ -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: |
diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml
index fbb770ac84..1fe61971fe 100644
--- a/.github/workflows/evals-nightly.yml
+++ b/.github/workflows/evals-nightly.yml
@@ -66,6 +66,7 @@ jobs:
continue-on-error: true
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
+ GEMINI_CLI_TRUST_WORKSPACE: true
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: 'true'
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
diff --git a/.github/workflows/gemini-cli-bot-brain.yml b/.github/workflows/gemini-cli-bot-brain.yml
new file mode 100644
index 0000000000..ef972f1bd1
--- /dev/null
+++ b/.github/workflows/gemini-cli-bot-brain.yml
@@ -0,0 +1,312 @@
+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'
+ default: false
+ enable_prs:
+ description: 'Enable PRs (automatically promote changes to PRs)'
+ type: 'boolean'
+ default: false
+
+concurrency:
+ 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' && (
+ 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'
+ issues: 'read'
+ pull-requests: 'read'
+ actions: 'read'
+ env:
+ GEMINI_CLI_TRUST_WORKSPACE: 'true'
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: 'Setup Node.js'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: 'Install dependencies'
+ run: 'npm ci'
+
+ - name: 'Build Gemini CLI'
+ run: 'npm run bundle'
+
+ - name: 'Download Previous State'
+ env:
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ run: |
+ if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then
+ echo "Memory clear requested. Skipping previous state download."
+ exit 0
+ fi
+
+ # Find the last successful run of this workflow
+ LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
+
+ if [ -n "$LAST_RUN_ID" ]; then
+ echo "Found previous successful run: $LAST_RUN_ID"
+
+ # Download brain memory to a temp dir so we can selectively restore only persistent state
+ mkdir -p .temp_brain_data
+ gh run download "$LAST_RUN_ID" -n brain-data -D .temp_brain_data || echo "brain-data not found"
+
+ # Restore only persistent memory files
+ cp .temp_brain_data/tools/gemini-cli-bot/lessons-learned.md tools/gemini-cli-bot/lessons-learned.md 2>/dev/null || true
+ mkdir -p tools/gemini-cli-bot/history/
+ cp .temp_brain_data/tools/gemini-cli-bot/history/*.csv tools/gemini-cli-bot/history/ 2>/dev/null || true
+ rm -rf .temp_brain_data
+ else
+ echo "No previous successful run found."
+ fi
+
+ - name: 'Collect Current Metrics'
+ env:
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
+
+ - name: 'Run Brain Phases'
+ env:
+ GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ GEMINI_MODEL: 'gemini-3-flash-preview'
+ ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
+ 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 "" > 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 "" >> 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)"
+
+ if [ -n "$TRIGGER_ISSUE_NUMBER" ] && [ ! -s "issue-comment.md" ] && [ ! -s "pr-comment.md" ]; then
+ echo "Agent failed to respond. Generating fallback error message."
+ echo "โ ๏ธ **Gemini CLI Bot failed to generate a response.**" > "issue-comment.md"
+ echo "" >> "issue-comment.md"
+ echo "I encountered an error or failed to generate a complete response to your request. You can check the [GitHub Actions Run Log](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for more details on what went wrong." >> "issue-comment.md"
+ fi
+
+ - name: 'Run Critique Phase'
+ 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 }}'
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ GEMINI_MODEL: 'gemini-3-flash-preview'
+ run: |
+ if git diff --staged --quiet; then
+ echo "No changes staged. Skipping critique."
+ echo "[APPROVED]" > critique_result.txt
+ 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
+
+ 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' || 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 "\[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'
+ uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
+ with:
+ name: 'brain-data'
+ path: |
+ tools/gemini-cli-bot/lessons-learned.md
+ tools/gemini-cli-bot/history/*.csv
+ bot-changes.patch
+ pr-description.md
+ branch-name.txt
+ pr-comment.md
+ pr-number.txt
+ issue-comment.md
+ retention-days: 90
+
+ publish:
+ name: 'Publish Artifacts (Archive Layer)'
+ needs: 'reasoning'
+ runs-on: 'ubuntu-latest'
+ if: "github.repository == 'google-gemini/gemini-cli'"
+ # The publish phase is for archiving artifacts and optionally creating PRs.
+ permissions:
+ contents: 'write'
+ 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:
+ ref: 'main'
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: 'Download Brain Data'
+ uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
+ with:
+ name: 'brain-data'
+ path: '${{ runner.temp }}/brain-data/'
+
+ - name: 'Create or Update PR'
+ 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: '${{ 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[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 }}"
+ if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then
+ BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
+ fi
+
+ if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
+ echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
+ exit 1
+ fi
+
+ git checkout -b "$BRANCH_NAME"
+ git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
+ git add .
+
+ if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
+ git commit -F "${{ runner.temp }}/brain-data/pr-description.md"
+ else
+ git commit -m "๐ค Gemini Bot Productivity Optimizations"
+ fi
+
+ 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
+
+ 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/Issue Comment'
+ env:
+ 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")
+
+ # Using GitHub App, so author check is no longer valid against gemini-cli-robot
+ # Skipping author validation here to let the app post.
+
+ # 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
diff --git a/.github/workflows/gemini-cli-bot-pulse.yml b/.github/workflows/gemini-cli-bot-pulse.yml
new file mode 100644
index 0000000000..b929444837
--- /dev/null
+++ b/.github/workflows/gemini-cli-bot-pulse.yml
@@ -0,0 +1,48 @@
+name: '๐ Gemini CLI Bot: Pulse'
+
+on:
+ schedule:
+ - cron: '*/30 * * * *' # Every 30 minutes
+ workflow_dispatch:
+
+concurrency:
+ group: '${{ github.workflow }}-${{ github.ref }}'
+ cancel-in-progress: true
+
+permissions:
+ contents: 'write'
+ issues: 'write'
+ pull-requests: 'write'
+
+jobs:
+ pulse:
+ name: 'Pulse (Reflex Layer)'
+ runs-on: 'ubuntu-latest'
+ if: "github.repository == 'google-gemini/gemini-cli'"
+ steps:
+ - name: 'Checkout'
+ uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
+ with:
+ fetch-depth: 0
+
+ - name: 'Setup Node.js'
+ uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: 'Install dependencies'
+ run: 'npm ci'
+
+ - name: 'Run Reflex Processes'
+ env:
+ GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
+ run: |
+ if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts)" ]; then
+ for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do
+ echo "Running reflex script: $script"
+ npx tsx "$script"
+ done
+ else
+ echo "No reflex scripts found."
+ fi
diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml
index a5a2f90db8..bf0b4f42f2 100644
--- a/.github/workflows/release-notes.yml
+++ b/.github/workflows/release-notes.yml
@@ -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: |
diff --git a/Dockerfile b/Dockerfile
index 25d27d46c6..44ba343902 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -40,8 +40,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
USER node
# install gemini-cli and clean up
-COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
-COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
+COPY --chown=node:node packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
+COPY --chown=node:node packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
RUN npm install -g /tmp/gemini-core.tgz \
&& npm install -g /tmp/gemini-cli.tgz \
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
diff --git a/README.md b/README.md
index 10458b2126..885b9d7429 100644
--- a/README.md
+++ b/README.md
@@ -371,6 +371,8 @@ for planned features and priorities.
## ๐ Resources
+- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
+ Learn the basics.
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
notable updates.
diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md
index 45b48ab53d..abb845121c 100644
--- a/docs/changelogs/index.md
+++ b/docs/changelogs/index.md
@@ -18,6 +18,24 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
+## Announcements: v0.39.0 - 2026-04-23
+
+- **Skill Management:** Added a new `/memory` inbox command for reviewing and
+ patching skills extracted during sessions
+ ([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by
+ @SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
+ by @SandyTao520).
+- **Improved Transparency:** Plan Mode now requires confirmation for skill
+ activation and allows plan inspection
+ ([#24946](https://github.com/google-gemini/gemini-cli/pull/24946),
+ [#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by
+ @ruomengz).
+- **Architecture & Reliability:** Introduced a decoupled `ContextManager`
+ architecture and resolved several critical memory leaks and PTY exhaustion
+ issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by
+ @joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
+ by @spencer426).
+
## Announcements: v0.38.0 - 2026-04-14
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md
index 37207cea8a..365325e626 100644
--- a/docs/changelogs/latest.md
+++ b/docs/changelogs/latest.md
@@ -1,6 +1,6 @@
-# Latest stable release: v0.38.2
+# Latest stable release: v0.39.0
-Released: April 17, 2026
+Released: April 23, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,264 +11,252 @@ npm install -g @google/gemini-cli
## Highlights
-- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
- to provide better session structure and narrative continuity in long-running
- tasks.
-- **Context Compression Service:** Implemented a dedicated service for advanced
- context management, efficiently distilling conversation history to preserve
- focus and tokens.
-- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
- solve rendering flicker, along with selective topic expansion and improved
- tool confirmation layouts.
-- **Context-Aware Policy Approvals:** Users can now grant persistent,
- context-aware approvals for tools, significantly reducing manual confirmation
- overhead for trusted workflows.
-- **Background Process Monitoring:** New tools for monitoring and inspecting
- background shell processes, providing better visibility into asynchronous
- tasks.
+- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
+ and patch skills extracted during agent sessions, streamlining the continuous
+ learning workflow.
+- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
+ requiring user confirmation for skill activation and allowing users to view
+ the full content of generated plans.
+- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
+ enabling agents to provide richer, more structured visual feedback during
+ execution.
+- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
+ `Sidecar` architecture to improve state management and session resilience.
+- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
+ text in responses, ensuring full visibility into the agent's reasoning
+ process.
## What's Changed
-- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
- v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
- [#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
-- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
- v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
- [#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
-- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
- [#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
-- Update README.md for links. by @g-samroberts in
- [#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
-- fix(core): ensure complete_task tool calls are recorded in chat history by
- @abhipatel12 in
- [#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
-- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
- @Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
-- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
- [#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
-- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
- by @adamfweidman in
- [#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
-- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
- [#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
-- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
- in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
-- fix(cli): ensure agent stops when all declinable tools are cancelled by
- @NTaylorMullen in
- [#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
-- fix(core): enhance sandbox usability and fix build error by @galz10 in
- [#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
-- Terminal Serializer Optimization by @jacob314 in
- [#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
-- Auto configure memory. by @jacob314 in
- [#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
-- Unused error variables in catch block are not allowed by @alisa-alisa in
- [#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
-- feat(core): add background memory service for skill extraction by @SandyTao520
- in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
-- feat: implement high-signal PR regression check for evaluations by
- @alisa-alisa in
- [#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
-- Fix shell output display by @jacob314 in
- [#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
-- fix(ui): resolve unwanted vertical spacing around various tool output
- treatments by @jwhelangoog in
- [#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
-- revert(cli): bring back input box and footer visibility in copy mode by
- @sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
-- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
- @sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
-- feat(cli): support default values for environment variables by @ruomengz in
- [#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
-- Implement background process monitoring and inspection tools by @cocosheng-g
- in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
-- docs(browser-agent): update stale browser agent documentation by @gsquared94
- in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
-- fix: enable browser_agent in integration tests and add localhost fixture tests
- by @gsquared94 in
- [#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
-- fix(browser): handle computer-use model detection for analyze_screenshot by
- @gsquared94 in
- [#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
-- feat(core): Land ContextCompressionService by @joshualitt in
- [#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
-- feat(core): scope subagent workspace directories via AsyncLocalStorage by
+- refactor(plan): simplify policy priorities and consolidate read-only rules by
+ @ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
+- feat(test-utils): add memory usage integration test harness by @sripasg in
+ [#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
+- feat(memory): add /memory inbox command for reviewing extracted skills by
@SandyTao520 in
- [#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
-- Update ink version to 6.6.7 by @jacob314 in
- [#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
-- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
- in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
-- Fix crash when vim editor is not found in PATH on Windows by
- @Nagajyothi-tammisetti in
- [#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
-- fix(core): move project memory dir under tmp directory by @SandyTao520 in
- [#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
-- Enable 'Other' option for yesno question type by @ruomengz in
- [#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
-- fix(cli): clear stale retry/loading state after cancellation (#21096) by
- @Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
-- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
- [#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
-- feat(core): implement context-aware persistent policy approvals by @jerop in
- [#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
-- docs: move agent disabling instructions and update remote agent status by
- @jackwotherspoon in
- [#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
-- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
- [#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
-- fix(core): unsafe type assertions in Core File System #19712 by
- @aniketsaurav18 in
- [#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
-- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
- in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
-- Changelog for v0.36.0 by @gemini-cli-robot in
- [#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
-- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
- [#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
-- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
- [#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
-- fix(ui): fixed table styling by @devr0306 in
- [#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
-- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
- [#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
-- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
- [#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
-- docs: clarify release coordination by @scidomino in
- [#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
-- fix(core): remove broken PowerShell translation and fix native \_\_write in
- Windows sandbox by @scidomino in
- [#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
-- Add instructions for how to start react in prod and force react to prod mode
- by @jacob314 in
- [#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
-- feat(cli): minimalist sandbox status labels by @galz10 in
- [#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
-- Feat/browser agent metrics by @kunal-10-cloud in
- [#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
-- test: fix Windows CI execution and resolve exposed platform failures by
- @ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
-- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
- [#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
-- show color by @jacob314 in
- [#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
-- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
- [#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
-- fix(core): inject skill system instructions into subagent prompts if activated
- by @abhipatel12 in
- [#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
-- fix(core): improve windows sandbox reliability and fix integration tests by
- @ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
-- fix(core): ensure sandbox approvals are correctly persisted and matched for
- proactive expansions by @galz10 in
- [#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
-- feat(cli) Scrollbar for input prompt by @jacob314 in
- [#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
-- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
- in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
-- Fix restoration of topic headers. by @gundermanc in
- [#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
-- feat(core): discourage update topic tool for simple tasks by @Samee24 in
- [#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
-- fix(core): ensure global temp directory is always in sandbox allowed paths by
- @galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
-- fix(core): detect uninitialized lines by @jacob314 in
- [#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
-- docs: update sandboxing documentation and toolSandboxing settings by @galz10
- in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
-- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
- [#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
-- feat(acp): add support for `/about` command by @sripasg in
- [#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
-- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
- [#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
-- split context by @jacob314 in
- [#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
-- fix(cli): remove -S from shebang to fix Windows and BSD execution by
- @scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
-- Fix issue where topic headers can be posted back to back by @gundermanc in
- [#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
-- fix(core): handle partial llm_request in BeforeModel hook override by
- @krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
-- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
- [#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
-- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
- [#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
-- fix(browser): remove premature browser cleanup after subagent invocation by
- @gsquared94 in
- [#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
-- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
- @Abhijit-2592 in
- [#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
-- relax tool sandboxing overrides for plan mode to match defaults. by
- @DavidAPierce in
- [#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
-- fix(cli): respect global environment variable allowlist by @scidomino in
- [#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
-- fix(cli): ensure skills list outputs to stdout in non-interactive environments
+ [#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
+- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
+ @gemini-cli-robot in
+ [#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
+- fix(core): ensure robust sandbox cleanup in all process execution paths by
+ @ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
+- chore: update ink version to 6.6.8 by @jacob314 in
+ [#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
+- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
+ [#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
+- chore: ignore conductor directory by @JayadityaGit in
+ [#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
+- Changelog for v0.37.0 by @gemini-cli-robot in
+ [#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
+- feat(plan): require user confirmation for activate_skill in Plan Mode by
+ @ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
+- feat(test-utils): add CPU performance integration test harness by @sripasg in
+ [#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
+- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
+ @dogukanozen in
+ [#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
+- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
+ [#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
+- fix(core): resolve windows symlink bypass and stabilize sandbox integration
+ tests by @ehedlund in
+ [#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
+- fix(cli): restore file path display in edit and write tool confirmations by
+ @jwhelangoog in
+ [#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
+- feat(core): refine shell tool description display logic by @jwhelangoog in
+ [#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
+- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
+ in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
+- Update ink version to 6.6.9 by @jacob314 in
+ [#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
+- Generalize evals infra to support more types of evals, organization and
+ queuing of named suites by @gundermanc in
+ [#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
+- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
+ [#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
+- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
+ implementation by @ehedlund in
+ [#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
+- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
+ [#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
+- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
+ [#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
+- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
+ in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
+- Support ctrl+shift+g by @jacob314 in
+ [#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
+- feat(core): refactor subagent tool to unified invoke_subagent tool by
+ @abhipatel12 in
+ [#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
+- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
+ error by @mrpmohiburrahman in
+ [#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
+- fix: respect hideContextPercentage when FooterConfigDialog is closed without
+ changes by @chernistry in
+ [#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
+- fix(cli): suppress unhandled AbortError logs during request cancellation by
+ @euxaristia in
+ [#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
+- Automated documentation audit by @g-samroberts in
+ [#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
+- feat(cli): implement useAgentStream hook by @mbleigh in
+ [#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
+- refactor(plan) Clean default plan toml by @ruomengz in
+ [#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
+- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
+ [#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
+- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
+ [#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
+- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
+ @abhipatel12 in
+ [#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
+- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
+ [#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
+- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
+ [#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
+- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
+ @spencer426 in
+ [#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
+- fix(sandbox): centralize async git worktree resolution and enforce read-only
+ security by @ehedlund in
+ [#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
+- feat(test): add high-volume shell test and refine perf harness by @sripasg in
+ [#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
+- fix(core): silently handle EPERM when listing dir structure by @scidomino in
+ [#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
+- Changelog for v0.37.1 by @gemini-cli-robot in
+ [#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
+- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
+ @kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
+- Automated documentation audit results by @g-samroberts in
+ [#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
+- debugging(ui): add optional debugRainbow setting by @jacob314 in
+ [#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
+- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
by @spencer426 in
- [#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
-- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
- [#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
-- fix(policy): allow complete_task in plan mode by @abhipatel12 in
- [#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
-- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
- [#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
-- feat(cli): support selective topic expansion and click-to-expand by
- @Abhijit-2592 in
- [#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
-- temporarily disable sandbox integration test on windows by @ehedlund in
- [#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
-- Remove flakey test by @scidomino in
- [#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
-- Alisa/approve button by @alisa-alisa in
- [#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
-- feat(hooks): display hook system messages in UI by @mbleigh in
- [#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
-- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
- in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
-- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
- in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
-- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
- [#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
-- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
- [#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
-- feat(acp): add /help command by @sripasg in
- [#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
-- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
- [#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
-- Improve sandbox error matching and caching by @DavidAPierce in
- [#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
-- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
- [#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
-- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
- [#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
-- Revert "fix(ui): improve narration suppression and reduce flicker (#2โฆ by
+ [#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
+- docs(cli): updates f12 description to be more precise by @JayadityaGit in
+ [#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
+- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
+ [#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
+- fix(core): remove buffer slice to prevent OOM on large output streams by
+ @spencer426 in
+ [#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
+- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
+ [#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
+- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
+ in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
+- refactor(core): consolidate execute() arguments into ExecuteOptions by
+ @mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
+- feat(core): add Strategic Re-evaluation guidance to system prompt by
+ @aishaneeshah in
+ [#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
+- fix(core): preserve shell execution config fields on update by
+ @jasonmatthewsuhari in
+ [#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
+- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
+ [#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
+- fix(cli): pass session id to interactive shell executions by
+ @jasonmatthewsuhari in
+ [#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
+- fix(cli): resolve text sanitization data loss due to C1 control characters by
+ @euxaristia in
+ [#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
+- feat(core): add large memory regression test by @cynthialong0-0 in
+ [#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
+- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
+ @spencer426 in
+ [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
+- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
+ [#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
+- perf(sandbox): optimize Windows sandbox initialization via native ACL
+ application by @ehedlund in
+ [#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
+- chore: switch from keytar to @github/keytar by @cocosheng-g in
+ [#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
+- fix: improve audio MIME normalization and validation in file reads by
+ @junaiddshaukat in
+ [#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
+- docs: Update docs-audit to include changes in PR body by @g-samroberts in
+ [#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
+- docs: correct documentation for enforced authentication type by @cocosheng-g
+ in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
+- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
+ in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
+- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
+ [#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
+- fix(core): fix quota footer for non-auto models and improve display by
+ @jackwotherspoon in
+ [#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
+- docs(contributing): clarify self-assignment policy for issues by @jmr in
+ [#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
+- feat(core): add skill patching support with /memory inbox integration by
+ @SandyTao520 in
+ [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
+- Stop suppressing thoughts and text in model response by @gundermanc in
+ [#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
+- fix(release): prefix git hash in nightly versions to prevent semver
+ normalization by @SandyTao520 in
+ [#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
+- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
+ in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
+- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
+ [#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
+- feat(ui): added enhancements to scroll momentum by @devr0306 in
+ [#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
+- fix(core): replace custom binary detection with isbinaryfile to correctly
+ handle UTF-8 (U+FFFD) by @Anjaligarhwal in
+ [#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
+- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
+ @mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
+- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
+ [#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
+- feat: support auth block in MCP servers config in agents by @TanmayVartak in
+ [#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
+- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
+ [#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
+- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
+ [#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
+- fix: correct redirect count increment in fetchJson by @KevinZhao in
+ [#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
+- fix(core): prevent secondary crash in ModelRouterService finally block by
@gundermanc in
- [#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
-- refactor(cli): remove duplication in interactive shell awaiting input hint by
- @JayadityaGit in
- [#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
-- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
- [#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
-- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
- [#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
-- fix(cli): always show shell command description or actual command by @jacob314
- in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
-- Added flag for ept size and increased default size by @devr0306 in
- [#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
-- fix(core): dispose Scheduler to prevent McpProgress listener leak by
- @Anjaligarhwal in
- [#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
-- fix(cli): switch default back to terminalBuffer=false and fix regressions
- introduced for that mode by @jacob314 in
- [#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
-- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
- [#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
-- fix: isolate concurrent browser agent instances by @gsquared94 in
- [#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
-- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
- [#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
+ [#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
+- feat(core): introduce decoupled ContextManager and Sidecar architecture by
+ @joshualitt in
+ [#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
+- docs(core): update generalist agent documentation by @abhipatel12 in
+ [#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
+- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
+ in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
+- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
+ in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
+- test(core): improve sandbox integration test coverage and fix OS-specific
+ failures by @ehedlund in
+ [#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
+- fix(core): use debug level for keychain fallback logging by @ehedlund in
+ [#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
+- feat(test): add a performance test in asian language by @cynthialong0-0 in
+ [#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
+- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
+ answers by @Adib234 in
+ [#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
+- fix(core): detect kmscon terminal as supporting true color by @claygeo in
+ [#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
+- ci: add agent session drift check workflow by @adamfweidman in
+ [#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
+- use macos-latest-large runner where applicable. by @scidomino in
+ [#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
+- Changelog for v0.37.2 by @gemini-cli-robot in
+ [#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
+- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
+ version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
+ @gemini-cli-robot in
+ [#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
+- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
+ version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
+ @gemini-cli-robot in
+ [#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
+https://github.com/google-gemini/gemini-cli/compare/v0.38.2...v0.39.0
diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md
index 737b0917b4..7be2a5ff6f 100644
--- a/docs/changelogs/preview.md
+++ b/docs/changelogs/preview.md
@@ -1,6 +1,6 @@
-# Preview release: v0.39.0-preview.0
+# Preview release: v0.41.0-preview.0
-Released: April 14, 2026
+Released: April 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,245 +13,109 @@ npm install -g @google/gemini-cli@preview
## Highlights
-- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
- a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
- turn limits for codebase investigator.
-- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
- reviewing extracted skills and added skill patching support, enhancing agent
- learning and persistence.
-- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
- performance integration test harnesses and generalized evaluation
- infrastructure for better suite organization.
-- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
- macOS, enforced read-only security for async git worktree resolution, and
- optimized Windows sandbox initialization.
-- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
- `debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
- issues for a smoother terminal experience.
+- **Real-Time Voice Mode:** Implemented a new real-time voice mode supporting
+ both cloud and local backends for a more interactive experience.
+- **Enhanced Security & Trust:** Enforced workspace trust in headless mode and
+ secured `.env` file loading to improve system integrity.
+- **Expanded Model Support:** Added experimental support for Gemma 4 models in
+ both core and CLI packages.
+- **Improved Core Infrastructure:** Wired up new `ContextManager` and
+ `AgentChatHistory` for better state management, and optimized boot performance
+ by fetching experiments and quota asynchronously.
+- **New Developer Tools & UX:** Added support for output redirection for CLI
+ commands, manual session UUIDs via command-line arguments, and persistent
+ auto-memory scratchpad for skill extraction.
## What's Changed
-- refactor(plan): simplify policy priorities and consolidate read-only rules by
- @ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
-- feat(test-utils): add memory usage integration test harness by @sripasg in
- [#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
-- feat(memory): add /memory inbox command for reviewing extracted skills by
- @SandyTao520 in
- [#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
-- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
+- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
@gemini-cli-robot in
- [#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
-- fix(core): ensure robust sandbox cleanup in all process execution paths by
- @ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
-- chore: update ink version to 6.6.8 by @jacob314 in
- [#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
-- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
- [#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
-- chore: ignore conductor directory by @JayadityaGit in
- [#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
-- Changelog for v0.37.0 by @gemini-cli-robot in
- [#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
-- feat(plan): require user confirmation for activate_skill in Plan Mode by
- @ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
-- feat(test-utils): add CPU performance integration test harness by @sripasg in
- [#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
-- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
- @dogukanozen in
- [#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
-- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
- [#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
-- fix(core): resolve windows symlink bypass and stabilize sandbox integration
- tests by @ehedlund in
- [#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
-- fix(cli): restore file path display in edit and write tool confirmations by
- @jwhelangoog in
- [#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
-- feat(core): refine shell tool description display logic by @jwhelangoog in
- [#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
-- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
- in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
-- Update ink version to 6.6.9 by @jacob314 in
- [#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
-- Generalize evals infra to support more types of evals, organization and
- queuing of named suites by @gundermanc in
- [#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
-- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
- [#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
-- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
- implementation by @ehedlund in
- [#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
-- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
- [#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
-- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
- [#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
-- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
- in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
-- Support ctrl+shift+g by @jacob314 in
- [#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
-- feat(core): refactor subagent tool to unified invoke_subagent tool by
- @abhipatel12 in
- [#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
-- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
- error by @mrpmohiburrahman in
- [#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
-- fix: respect hideContextPercentage when FooterConfigDialog is closed without
- changes by @chernistry in
- [#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
-- fix(cli): suppress unhandled AbortError logs during request cancellation by
- @euxaristia in
- [#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
-- Automated documentation audit by @g-samroberts in
- [#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
-- feat(cli): implement useAgentStream hook by @mbleigh in
- [#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
-- refactor(plan) Clean default plan toml by @ruomengz in
- [#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
-- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
- [#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
-- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
- [#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
-- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
- @abhipatel12 in
- [#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
-- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
- [#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
-- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
- [#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
-- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
+ [#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
+- fix(core): only show `list` suggestion if the partial input is empty by
+ @cynthialong0-0 in
+ [#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
+- feat(cli): secure .env loading and enforce workspace trust in headless mode by
+ @ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
+- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
+ [#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
+- update package-lock.json by @ehedlund in
+ [#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
+- feat(core): enhance shell command validation and add core tools allowlist by
+ @galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
+- fix(ui): corrected background color check in user message components by
+ @devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
+- perf(core): fix slow boot by fetching experiments and quota asynchronously by
@spencer426 in
- [#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
-- fix(sandbox): centralize async git worktree resolution and enforce read-only
- security by @ehedlund in
- [#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
-- feat(test): add high-volume shell test and refine perf harness by @sripasg in
- [#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
-- fix(core): silently handle EPERM when listing dir structure by @scidomino in
- [#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
-- Changelog for v0.37.1 by @gemini-cli-robot in
- [#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
-- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
- @kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
-- Automated documentation audit results by @g-samroberts in
- [#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
-- debugging(ui): add optional debugRainbow setting by @jacob314 in
- [#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
-- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
- by @spencer426 in
- [#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
-- docs(cli): updates f12 description to be more precise by @JayadityaGit in
- [#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
-- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
- [#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
-- fix(core): remove buffer slice to prevent OOM on large output streams by
- @spencer426 in
- [#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
-- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
- [#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
-- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
- in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
-- refactor(core): consolidate execute() arguments into ExecuteOptions by
- @mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
-- feat(core): add Strategic Re-evaluation guidance to system prompt by
- @aishaneeshah in
- [#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
-- fix(core): preserve shell execution config fields on update by
- @jasonmatthewsuhari in
- [#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
-- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
- [#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
-- fix(cli): pass session id to interactive shell executions by
- @jasonmatthewsuhari in
- [#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
-- fix(cli): resolve text sanitization data loss due to C1 control characters by
- @euxaristia in
- [#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
-- feat(core): add large memory regression test by @cynthialong0-0 in
- [#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
-- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
- @spencer426 in
- [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
-- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
- [#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
-- perf(sandbox): optimize Windows sandbox initialization via native ACL
- application by @ehedlund in
- [#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
-- chore: switch from keytar to @github/keytar by @cocosheng-g in
- [#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
-- fix: improve audio MIME normalization and validation in file reads by
- @junaiddshaukat in
- [#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
-- docs: Update docs-audit to include changes in PR body by @g-samroberts in
- [#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
-- docs: correct documentation for enforced authentication type by @cocosheng-g
- in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
-- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
- in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
-- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
- [#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
-- fix(core): fix quota footer for non-auto models and improve display by
- @jackwotherspoon in
- [#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
-- docs(contributing): clarify self-assignment policy for issues by @jmr in
- [#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
-- feat(core): add skill patching support with /memory inbox integration by
+ [#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
+- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
+ in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
+- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
+ in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
+- docs: add Gemini CLI course link to README by @JayadityaGit in
+ [#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
+- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
+ [#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
+- fix(cli): allow output redirection for cli commands by @spencer426 in
+ [#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
+- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
+ rules by @ehedlund in
+ [#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
+- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
+ in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
+- feat(voice): implement real-time voice mode with cloud and local backends by
+ @Abhijit-2592 in
+ [#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
+- Changelog for v0.39.0 by @gemini-cli-robot in
+ [#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
+- feat(memory): persist auto-memory scratchpad for skill extraction by
@SandyTao520 in
- [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
-- Stop suppressing thoughts and text in model response by @gundermanc in
- [#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
-- fix(release): prefix git hash in nightly versions to prevent semver
- normalization by @SandyTao520 in
- [#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
-- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
- in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
-- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
- [#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
-- feat(ui): added enhancements to scroll momentum by @devr0306 in
- [#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
-- fix(core): replace custom binary detection with isbinaryfile to correctly
- handle UTF-8 (U+FFFD) by @Anjaligarhwal in
- [#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
-- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
- @mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
-- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
- [#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
-- feat: support auth block in MCP servers config in agents by @TanmayVartak in
- [#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
-- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
- [#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
-- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
- [#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
-- fix: correct redirect count increment in fetchJson by @KevinZhao in
- [#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
-- fix(core): prevent secondary crash in ModelRouterService finally block by
- @gundermanc in
- [#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
-- feat(core): introduce decoupled ContextManager and Sidecar architecture by
- @joshualitt in
- [#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
-- docs(core): update generalist agent documentation by @abhipatel12 in
- [#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
-- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
- in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
-- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
- in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
-- test(core): improve sandbox integration test coverage and fix OS-specific
- failures by @ehedlund in
- [#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
-- fix(core): use debug level for keychain fallback logging by @ehedlund in
- [#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
-- feat(test): add a performance test in asian language by @cynthialong0-0 in
- [#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
-- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
- answers by @Adib234 in
- [#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
-- fix(core): detect kmscon terminal as supporting true color by @claygeo in
- [#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
-- ci: add agent session drift check workflow by @adamfweidman in
- [#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
-- use macos-latest-large runner where applicable. by @scidomino in
- [#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
-- Changelog for v0.37.2 by @gemini-cli-robot in
- [#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
+ [#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
+- fix(cli): add missing response key to custom theme text schema by @gaurav0107
+ in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
+- fix(cli): provide manual update command when automatic update fails by
+ @cocosheng-g in
+ [#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
+- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
+ [#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
+- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
+ [#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
+- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
+ in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
+- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
+ in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
+- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
+ [#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
+- fix(cli): make MCP ping optional in list command and use configured timeout by
+ @cocosheng-g in
+ [#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
+- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
+ [#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
+- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
+ [#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
+- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
+ [#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
+- (docs) update sandboxing documentation by @g-samroberts in
+ [#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
+- fix(core): enforce parallel task tracker updates by @anj-s in
+ [#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
+- Update policy so transient errors are not marked terminal by @DavidAPierce in
+ [#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
+- Implement bot that performs time-series metric analysis and suggests repo
+ management improvements by @gundermanc in
+ [#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
+- fix(core): handle non-string model flags in resolution by @Adib234 in
+ [#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
+- fix(ux): added error message for ENOTDIR by @devr0306 in
+ [#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
+- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
+ [#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
+- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
+ in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
+- feat(cli): support boolean and number casting for env vars in settings.json by
+ @cocosheng-g in
+ [#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
+- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
+ [#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
**Full Changelog**:
-https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
+https://github.com/google-gemini/gemini-cli/compare/v0.40.0-preview.5...v0.41.0-preview.0
diff --git a/docs/cli/model-routing.md b/docs/cli/model-routing.md
index c9ec073a64..a29dd98a9b 100644
--- a/docs/cli/model-routing.md
+++ b/docs/cli/model-routing.md
@@ -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
diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md
index 4e205164a0..995fade4c4 100644
--- a/docs/cli/plan-mode.md
+++ b/docs/cli/plan-mode.md
@@ -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.
diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md
index 3574e9d231..0eea995cbc 100644
--- a/docs/cli/sandbox.md
+++ b/docs/cli/sandbox.md
@@ -31,6 +31,53 @@ The benefits of sandboxing include:
- **Safety**: Reduce risk when working with untrusted code or experimental
commands.
+## Quickstart
+
+You can enable sandboxing using a command flag, environment variable, or
+configuration file.
+
+### Using the command flag
+
+```bash
+gemini -s -p "analyze the code structure"
+```
+
+### Using an environment variable
+
+**macOS/Linux**
+
+```bash
+export GEMINI_SANDBOX=true
+gemini -p "run the test suite"
+```
+
+**Windows (PowerShell)**
+
+```powershell
+$env:GEMINI_SANDBOX="true"
+gemini -p "run the test suite"
+```
+
+### Configuring via settings.json
+
+```json
+{
+ "tools": {
+ "sandbox": "docker"
+ }
+}
+```
+
+## Configuration
+
+Enable sandboxing using one of the following methods (in order of precedence):
+
+1. **Command flag**: `-s` or `--sandbox`
+2. **Environment variable**:
+ `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
+3. **Settings file**: `"sandbox": true` in the `tools` object of your
+ `settings.json` file (for example, `{"tools": {"sandbox": true}}`).
+
## Sandboxing methods
Your ideal method of sandboxing may differ depending on your platform and your
@@ -43,12 +90,92 @@ Lightweight, built-in sandboxing using `sandbox-exec`.
**Default profile**: `permissive-open` - restricts writes outside project
directory but allows most other operations.
+Built-in profiles (set via `SEATBELT_PROFILE` env var):
+
+- `permissive-open` (default): Write restrictions, network allowed
+- `permissive-proxied`: Write restrictions, network via proxy
+- `restrictive-open`: Strict restrictions, network allowed
+- `restrictive-proxied`: Strict restrictions, network via proxy
+- `strict-open`: Read and write restrictions, network allowed
+- `strict-proxied`: Read and write restrictions, network via proxy
+
### 2. Container-based (Docker/Podman)
-Cross-platform sandboxing with complete process isolation.
+Cross-platform sandboxing with complete process isolation using container
+technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image.
-**Note**: Requires building the sandbox image locally or using a published image
-from your organization's registry.
+**Prerequisites:**
+
+- Docker or Podman must be installed and running on your system.
+
+**How it works (Workspace directory):**
+
+Inside the sandbox container, your current working directory is mounted at the
+**exact same absolute path** as it is on your host machine. For example, if you
+run the CLI from `/Users/you/project` on your host machine, the sandbox will
+mount your local project folder and operate within `/Users/you/project` inside
+the container. This allows the AI to seamlessly read and modify your project
+files while remaining isolated from the rest of your system.
+
+**Quick setup:**
+
+To enable Docker sandboxing, run Gemini CLI with the sandbox flag and specify
+Docker as the provider:
+
+```bash
+# Using the environment variable (Recommended)
+export GEMINI_SANDBOX=docker
+gemini -p "build the project"
+
+# Or configure it permanently in your settings.json
+# {"tools": {"sandbox": "docker"}}
+```
+
+**Customizing the Sandbox Image:**
+
+If your project requires specific dependencies, you can specify a custom image
+name or have Gemini CLI build one for you automatically. You can use any Docker
+or Podman image as your sandbox, provided it has standard shell utilities (like
+`bash`) available.
+
+**Option A: Using an existing custom image (e.g., Artifact Registry)**
+
+To configure a custom image that is hosted on a registry (or built locally),
+update your `settings.json` to use an object for the sandbox configuration, or
+set the `GEMINI_SANDBOX_IMAGE` environment variable.
+
+_Example: Configuring via `settings.json`_
+
+```json
+{
+ "tools": {
+ "sandbox": {
+ "command": "docker",
+ "image": "us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
+ }
+ }
+}
+```
+
+_Example: Configuring via environment variable_
+
+```bash
+export GEMINI_SANDBOX_IMAGE="us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
+```
+
+**Option B: Building a local custom image automatically**
+
+If you prefer to define your environment as code, you can provide a Dockerfile
+and Gemini CLI will build the image automatically.
+
+1. Create a `.gemini/sandbox.Dockerfile` in your project root.
+2. Ensure you have the `gh` CLI installed and authenticated (if you are using
+ the default `ghcr.io/google/gemini-cli` image as a base).
+3. Run your command with the `BUILD_SANDBOX` environment variable set:
+
+```bash
+BUILD_SANDBOX=1 GEMINI_SANDBOX=docker gemini -p "run my custom build"
+```
### 3. Windows Native Sandbox (Windows only)
@@ -188,59 +315,49 @@ This mechanism ensures you don't have to manually re-run commands with more
permissive sandbox settings, while still maintaining control over what the AI
can access.
-## Quickstart
+### Including files outside the workspace
+
+By default, the sandbox only has access to the current project workspace. If you
+need the sandbox to have permission to operate on certain files or directories
+from the local file system outside of the project workspace, you can mount them
+using the `SANDBOX_MOUNTS` environment variable.
+
+Provide a comma-separated list of mount definitions in the format
+`from:to:opts`. If `to` is omitted, it defaults to the same path as `from`. If
+`opts` is omitted, it defaults to `ro` (read-only). Note that the `from` path
+must be an absolute path.
+
+**Example**:
```bash
-# Enable sandboxing with command flag
-gemini -s -p "analyze the code structure"
+export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro"
```
-**Use environment variable**
+## Running inside a Docker container
-**macOS/Linux**
+If you are running Gemini CLI itself from within an official or custom Docker
+container and want to enable sandboxing, you must share the host's Docker socket
+and ensure your workspace paths align.
+
+1. **Mount the Docker socket**: Map `/var/run/docker.sock` so the CLI can spawn
+ sibling sandbox containers via the host's Docker daemon.
+2. **Align workspace paths**: The path to your workspace inside the container
+ must exactly match the absolute path on the host. Because the sandbox
+ container is spawned by the host's Docker daemon, it resolves volume mounts
+ against the host file system.
+
+**Example**:
```bash
-export GEMINI_SANDBOX=true
-gemini -p "run the test suite"
+docker run -it \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v /absolute/path/on/host/project:/absolute/path/on/host/project \
+ -w /absolute/path/on/host/project \
+ -e GEMINI_SANDBOX=docker \
+ ghcr.io/google/gemini-cli:latest
```
-**Windows (PowerShell)**
-
-```powershell
-$env:GEMINI_SANDBOX="true"
-gemini -p "run the test suite"
-```
-
-**Configure in settings.json**
-
-```json
-{
- "tools": {
- "sandbox": "docker"
- }
-}
-```
-
-## Configuration
-
-### Enable sandboxing (in order of precedence)
-
-1. **Command flag**: `-s` or `--sandbox`
-2. **Environment variable**:
- `GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
-3. **Settings file**: `"sandbox": true` in the `tools` object of your
- `settings.json` file (for example, `{"tools": {"sandbox": true}}`).
-
-### macOS Seatbelt profiles
-
-Built-in profiles (set via `SEATBELT_PROFILE` env var):
-
-- `permissive-open` (default): Write restrictions, network allowed
-- `permissive-proxied`: Write restrictions, network via proxy
-- `restrictive-open`: Strict restrictions, network allowed
-- `restrictive-proxied`: Strict restrictions, network via proxy
-- `strict-open`: Read and write restrictions, network allowed
-- `strict-proxied`: Read and write restrictions, network via proxy
+## Advanced settings
### Custom sandbox flags
@@ -279,7 +396,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
$env:SANDBOX_FLAGS="--flag1 --flag2=value"
```
-## Linux UID/GID handling
+### Linux UID/GID handling
The sandbox automatically handles user permissions on Linux. Override these
permissions with:
diff --git a/docs/cli/settings.md b/docs/cli/settings.md
index 10bfee644f..834750fdf9 100644
--- a/docs/cli/settings.md
+++ b/docs/cli/settings.md
@@ -161,20 +161,25 @@ they appear in the UI.
### Experimental
-| UI Label | Setting | Description | Default |
-| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
-| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
-| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
-| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
-| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
-| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
-| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
-| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
-| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit โ settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
-| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
-| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
-| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
+| UI Label | Setting | Description | Default |
+| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
+| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
+| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
+| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
+| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
+| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
+| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
+| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
+| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
+| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
+| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
+| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
+| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
+| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
+| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit โ settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
+| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
+| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
+| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
### Skills
diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md
index 3a0a6fae86..e71da13921 100644
--- a/docs/cli/tutorials/session-management.md
+++ b/docs/cli/tutorials/session-management.md
@@ -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.
diff --git a/docs/cli/tutorials/skills-getting-started.md b/docs/cli/tutorials/skills-getting-started.md
index ee59641d21..1b4f8de3a8 100644
--- a/docs/cli/tutorials/skills-getting-started.md
+++ b/docs/cli/tutorials/skills-getting-started.md
@@ -84,6 +84,32 @@ your new skill.
You should see `api-auditor` in the list of available skills.
+### If your skill doesn't appear
+
+If `/skills list` doesn't show your skill, check the following:
+
+1. **The folder must be trusted (workspace skills only).** Skills under
+ `/.gemini/skills/` are only loaded when the workspace folder is
+ marked as trusted. Run `/trust` and restart the session if needed. Skills
+ under `~/.gemini/skills/` (user scope) are not affected by trust.
+2. **Check the path layout.** `SKILL.md` is discovered either at the root of
+ the skills directory (`.gemini/skills/SKILL.md`) or one directory deep
+ (`.gemini/skills//SKILL.md`). The recommended layout uses a
+ subdirectory per skill so you can bundle scripts and other resources
+ alongside it. Files nested more than one directory deep are not discovered.
+3. **The filename must be exactly `SKILL.md`.** Capitalization matters on
+ case-sensitive filesystems (Linux, and macOS when configured as such):
+ `skill.md` or `Skill.md` will be ignored.
+4. **Frontmatter must include both `name:` and `description:`, and must be the
+ first thing in the file.** A `SKILL.md` is silently skipped if either field
+ is missing, if the delimiters (`---` on their own lines) are absent, or if
+ any text (an H1 title, a comment, even a blank line) appears before the
+ opening `---`.
+5. **The skill name comes from the `name:` field, not the directory name.** If
+ your frontmatter says `name: foo`, the skill appears as `foo` in
+ `/skills list` regardless of what its parent directory is called. The
+ characters `: \ / < > * ? " |` in the name are replaced with `-`.
+
## How to use the skill
Now, try it out. Start a new session and ask a question that triggers the
diff --git a/docs/core/gemma-setup.md b/docs/core/gemma-setup.md
new file mode 100644
index 0000000000..166bdc885b
--- /dev/null
+++ b/docs/core/gemma-setup.md
@@ -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.
+
+
+> [!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).
diff --git a/docs/core/index.md b/docs/core/index.md
index 2724e8e922..ca10cc6e48 100644
--- a/docs/core/index.md
+++ b/docs/core/index.md
@@ -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
diff --git a/docs/core/local-model-routing.md b/docs/core/local-model-routing.md
index 220ee13c46..3ab3709ed1 100644
--- a/docs/core/local-model-routing.md
+++ b/docs/core/local-model-routing.md
@@ -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).
+
+> [!NOTE]
+> This is an experimental feature currently under active development.
+
+
+> [!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
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index 7651539cb2..22605d9b08 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -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`
@@ -499,12 +504,12 @@ the dedicated [Custom Commands documentation](../cli/custom-commands.md).
These shortcuts apply directly to the input prompt for text manipulation.
- **Undo:**
- - **Keyboard shortcut:** Press **Alt+z** or **Cmd+z** to undo the last action
- in the input prompt.
+ - **Keyboard shortcut:** Press **Ctrl+z** (Windows), **Cmd+z** (macOS), or
+ **Alt+z** (Linux/WSL) to undo the last action in the input prompt.
- **Redo:**
- - **Keyboard shortcut:** Press **Shift+Alt+Z** or **Shift+Cmd+Z** to redo the
- last undone action in the input prompt.
+ - **Keyboard shortcut:** Press **Shift+Cmd+Z** (macOS), or **Shift+Alt+Z**
+ (Linux/WSL) to redo the last undone action in the input prompt.
## At commands (`@`)
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index b2d8955d5f..7bdd43997e 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -1199,6 +1199,42 @@ 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",
@@ -1224,7 +1260,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
- "transient": "terminal",
+ "transient": "sticky_retry",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1232,6 +1268,42 @@ 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",
@@ -1691,6 +1763,32 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
+- **`experimental.voiceMode`** (boolean):
+ - **Description:** Enable experimental voice dictation and commands (/voice,
+ /voice model).
+ - **Default:** `false`
+
+- **`experimental.voice.activationMode`** (enum):
+ - **Description:** How to trigger voice recording with the Space key.
+ - **Default:** `"push-to-talk"`
+ - **Values:** `"push-to-talk"`, `"toggle"`
+
+- **`experimental.voice.backend`** (enum):
+ - **Description:** The backend to use for voice transcription.
+ - **Default:** `"gemini-live"`
+ - **Values:** `"gemini-live"`, `"whisper"`
+
+- **`experimental.voice.whisperModel`** (enum):
+ - **Description:** The Whisper model to use for local transcription.
+ - **Default:** `"ggml-base.en.bin"`
+ - **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`,
+ `"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"`
+
+- **`experimental.voice.stopGracePeriodMs`** (number):
+ - **Description:** How long to wait for final transcription after stopping
+ recording.
+ - **Default:** `1000`
+
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
- **Description:** Enable non-interactive agent sessions.
- **Default:** `false`
@@ -1820,6 +1918,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
+- **`experimental.stressTestProfile`** (boolean):
+ - **Description:** Significantly lowers token limits to force early garbage
+ collection and distillation for testing purposes.
+ - **Default:** `false`
+ - **Requires restart:** Yes
+
- **`experimental.autoMemory`** (boolean):
- **Description:** Automatically extract reusable skills from past sessions in
the background. Review results with /memory inbox.
diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index 98d31c0ae2..a570d4a2c9 100644
--- a/docs/reference/keyboard-shortcuts.md
+++ b/docs/reference/keyboard-shortcuts.md
@@ -39,7 +39,7 @@ available combinations.
| `edit.deleteWordRight` | Delete the next word. | `Ctrl+Delete`
`Alt+Delete`
`Alt+D` |
| `edit.deleteLeft` | Delete the character to the left. | `Backspace`
`Ctrl+H` |
| `edit.deleteRight` | Delete the character to the right. | `Delete`
`Ctrl+D` |
-| `edit.undo` | Undo the most recent text edit. | `Cmd/Win+Z`
`Alt+Z` |
+| `edit.undo` | Undo the most recent text edit. | `Ctrl+Z`
`Alt+Z`
`Cmd/Win+Z` |
| `edit.redo` | Redo the most recent undone text edit. | `Ctrl+Shift+Z`
`Shift+Cmd/Win+Z`
`Alt+Shift+Z` |
#### Scrolling
@@ -115,6 +115,7 @@ available combinations.
| `app.restart` | Restart the application. | `R`
`Shift+R` |
| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` |
| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` |
+| `app.voiceModePTT` | Hold to speak in Voice Mode. | `Space` |
#### Background Shell Controls
diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md
index d9dc21f49c..95e65b33bd 100644
--- a/docs/reference/policy-engine.md
+++ b/docs/reference/policy-engine.md
@@ -71,7 +71,9 @@ primary conditions are the tool's name and its arguments.
#### Tool Name
-The `toolName` in the rule must match the name of the tool being called.
+The `toolName` in the rule must match the name of the tool being called. For a
+complete list of built-in tool names, see the
+[Tools reference](/docs/reference/tools#available-tools).
- **Wildcards**: You can use wildcards to match multiple tools.
- `*`: Matches **any tool** (built-in or MCP).
@@ -87,7 +89,9 @@ The `toolName` in the rule must match the name of the tool being called.
If `argsPattern` is specified, the tool's arguments are converted to a stable
JSON string, which is then tested against the provided regular expression. If
-the arguments don't match the pattern, the rule does not apply.
+the arguments don't match the pattern, the rule does not apply. For a list of
+argument keys available for each tool, see the **Parameters** in the
+[Tools reference](/docs/reference/tools#available-tools).
#### Execution environment
@@ -279,7 +283,11 @@ directory are **ignored**.
### TOML rule schema
-Here is a breakdown of the fields available in a TOML policy rule:
+This section describes the fields available in a TOML policy rule.
+
+For valid built-in `toolName` values and their argument structures (used by
+`argsPattern`), see the
+[Tools reference](/docs/reference/tools#available-tools).
```toml
[[rule]]
@@ -365,6 +373,9 @@ priority = 10
To simplify writing policies for `run_shell_command`, you can use
`commandPrefix` or `commandRegex` instead of the more complex `argsPattern`.
+These are policy-rule shorthands, not arguments of the `run_shell_command` tool
+itself. For the tool's invocation arguments, see [Shell tool](/docs/tools/shell)
+and [Tools reference](/docs/reference/tools#available-tools).
- `commandPrefix`: Matches if the `command` argument starts with the given
string.
diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts
index ed13f7e82e..0f8bf572c2 100644
--- a/evals/plan_mode.eval.ts
+++ b/evals/plan_mode.eval.ts
@@ -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();
+ },
+ });
});
diff --git a/evals/save_memory.eval.ts b/evals/save_memory.eval.ts
index b31167fb4a..f49624419b 100644
--- a/evals/save_memory.eval.ts
+++ b/evals/save_memory.eval.ts
@@ -5,12 +5,78 @@
*/
import { describe, expect } from 'vitest';
+import fs from 'node:fs';
+import path from 'node:path';
+import {
+ loadConversationRecord,
+ SESSION_FILE_PREFIX,
+} from '@google/gemini-cli-core';
import {
evalTest,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
+function findDir(base: string, name: string): string | null {
+ if (!fs.existsSync(base)) return null;
+ const files = fs.readdirSync(base);
+ for (const file of files) {
+ const fullPath = path.join(base, file);
+ if (fs.statSync(fullPath).isDirectory()) {
+ if (file === name) return fullPath;
+ const found = findDir(fullPath, name);
+ if (found) return found;
+ }
+ }
+ return null;
+}
+
+async function loadLatestSessionRecord(homeDir: string, sessionId: string) {
+ const chatsDir = findDir(path.join(homeDir, '.gemini'), 'chats');
+ if (!chatsDir) {
+ throw new Error('Could not find chats directory for eval session logs');
+ }
+
+ const candidates = fs
+ .readdirSync(chatsDir)
+ .filter(
+ (file) =>
+ file.startsWith(SESSION_FILE_PREFIX) &&
+ (file.endsWith('.json') || file.endsWith('.jsonl')),
+ );
+
+ const matchingRecords = [];
+ for (const file of candidates) {
+ const filePath = path.join(chatsDir, file);
+ const record = await loadConversationRecord(filePath);
+ if (record?.sessionId === sessionId) {
+ matchingRecords.push(record);
+ }
+ }
+
+ matchingRecords.sort(
+ (a, b) => Date.parse(b.lastUpdated) - Date.parse(a.lastUpdated),
+ );
+ return matchingRecords[0] ?? null;
+}
+
+async function waitForSessionScratchpad(
+ homeDir: string,
+ sessionId: string,
+ timeoutMs = 30000,
+) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const record = await loadLatestSessionRecord(homeDir, sessionId);
+ if (record?.memoryScratchpad) {
+ return record;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ }
+
+ return loadLatestSessionRecord(homeDir, sessionId);
+}
+
describe('save_memory', () => {
const TEST_PREFIX = 'Save memory test: ';
const rememberingFavoriteColor = "Agent remembers user's favorite color";
@@ -569,6 +635,103 @@ describe('save_memory', () => {
},
});
+ const memoryV2SessionScratchpad =
+ 'Session summary persists memory scratchpad for memory-saving sessions';
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: memoryV2SessionScratchpad,
+ sessionId: 'memory-scratchpad-eval',
+ params: {
+ settings: {
+ experimental: { memoryV2: true },
+ },
+ },
+ messages: [
+ {
+ id: 'msg-1',
+ type: 'user',
+ content: [
+ {
+ text: 'Across all my projects, I prefer Vitest over Jest for testing.',
+ },
+ ],
+ timestamp: '2026-01-01T00:00:00Z',
+ },
+ {
+ id: 'msg-2',
+ type: 'gemini',
+ content: [{ text: 'Noted. What else should I keep in mind?' }],
+ timestamp: '2026-01-01T00:00:05Z',
+ },
+ {
+ id: 'msg-3',
+ type: 'user',
+ content: [
+ {
+ text: 'For this repo I was debugging a flaky API test earlier, but that was just transient context.',
+ },
+ ],
+ timestamp: '2026-01-01T00:01:00Z',
+ },
+ {
+ id: 'msg-4',
+ type: 'gemini',
+ content: [
+ { text: 'Understood. I will only save the durable preference.' },
+ ],
+ timestamp: '2026-01-01T00:01:05Z',
+ },
+ ],
+ prompt:
+ 'Please save any persistent preferences or facts about me from our conversation to memory.',
+ assert: async (rig, result) => {
+ await rig.waitForToolCall('write_file').catch(() => {});
+ const writeCalls = rig
+ .readToolLogs()
+ .filter((log) =>
+ ['write_file', 'replace'].includes(log.toolRequest.name),
+ );
+
+ expect(
+ writeCalls.length,
+ 'Expected memoryV2 save flow to edit a markdown memory file',
+ ).toBeGreaterThan(0);
+
+ await rig.run({
+ args: ['--list-sessions'],
+ approvalMode: 'yolo',
+ timeout: 120000,
+ });
+
+ const record = await waitForSessionScratchpad(
+ rig.homeDir!,
+ 'memory-scratchpad-eval',
+ );
+ expect(
+ record?.memoryScratchpad,
+ 'Expected the resumed session log to contain a memoryScratchpad after session summary generation',
+ ).toBeDefined();
+ expect(record?.memoryScratchpad?.version).toBe(1);
+ expect(
+ record?.memoryScratchpad?.toolSequence?.some((toolName) =>
+ ['write_file', 'replace'].includes(toolName),
+ ),
+ 'Expected memoryScratchpad.toolSequence to include the markdown editing tool used for memory persistence',
+ ).toBe(true);
+ expect(
+ record?.memoryScratchpad?.touchedPaths?.length,
+ 'Expected memoryScratchpad to capture at least one touched path',
+ ).toBeGreaterThan(0);
+ expect(
+ record?.memoryScratchpad?.workflowSummary,
+ 'Expected memoryScratchpad.workflowSummary to be populated',
+ ).toMatch(/write_file|replace/i);
+
+ assertModelHasOutput(result);
+ },
+ });
+
const memoryV2RoutesUserProject =
'Agent routes personal-to-user project notes to user-project memory';
evalTest('USUALLY_PASSES', {
diff --git a/evals/skill_extraction.eval.ts b/evals/skill_extraction.eval.ts
index 2c80146523..d30a498a8f 100644
--- a/evals/skill_extraction.eval.ts
+++ b/evals/skill_extraction.eval.ts
@@ -6,21 +6,30 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
-import { describe, expect } from 'vitest';
+import { describe, expect, it } from 'vitest';
import {
type Config,
ApprovalMode,
+ type MemoryScratchpad,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
-import { componentEvalTest } from './component-test-helper.js';
+import { ComponentRig, componentEvalTest } from './component-test-helper.js';
+import {
+ average,
+ averageNullable,
+ countMatchingIds,
+ roundStat,
+} from './statistics-helper.js';
+import { prepareWorkspace } from './test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
timestampOffsetMinutes: number;
+ memoryScratchpad?: MemoryScratchpad;
}
interface MessageRecord {
@@ -30,6 +39,81 @@ interface MessageRecord {
content: Array<{ text: string }>;
}
+interface SessionVersion {
+ sessionId: string;
+ lastUpdated: string;
+}
+
+interface ExtractionRunSnapshot {
+ sessionIds: string[];
+ skillsCreated: string[];
+ candidateSessions: SessionVersion[];
+ processedSessions: SessionVersion[];
+ turnCount?: number;
+ durationMs?: number;
+ terminateReason?: string;
+}
+
+interface ExtractionOutcome {
+ state: { runs: ExtractionRunSnapshot[] };
+ skillsDir: string;
+ skillBodies: string[];
+}
+
+interface SkillQualitySignal {
+ label: string;
+ pattern: RegExp;
+}
+
+interface ScratchpadRunMetrics {
+ turnCount: number | null;
+ durationMs: number | null;
+ terminateReason: string | null;
+ skillsCreated: number;
+ candidateSessions: number;
+ processedSessions: number;
+ relevantReads: number;
+ distractorReads: number;
+ totalReads: number;
+ recall: number;
+ precision: number;
+ signalScore: number;
+ skillQualityScore: number;
+ skillQualityMax: number;
+ skillQualityRatio: number;
+ missingQualitySignals: string[];
+}
+
+interface ScratchpadStatsTrial {
+ trial: number;
+ baseline: ScratchpadRunMetrics;
+ enhanced: ScratchpadRunMetrics;
+}
+
+interface ScratchpadStatsAggregate {
+ turnCountAvg: number | null;
+ durationMsAvg: number | null;
+ recallAvg: number;
+ precisionAvg: number;
+ signalScoreAvg: number;
+ relevantReadsAvg: number;
+ distractorReadsAvg: number;
+ skillsCreatedAvg: number;
+ skillQualityScoreAvg: number;
+ skillQualityRatioAvg: number;
+}
+
+interface ScratchpadStatsReport {
+ generatedAt: string;
+ trials: number;
+ aggregate: {
+ baseline: ScratchpadStatsAggregate;
+ enhanced: ScratchpadStatsAggregate;
+ };
+ deltas: ScratchpadStatsAggregate;
+ results: ScratchpadStatsTrial[];
+}
+
const WORKSPACE_FILES = {
'package.json': JSON.stringify(
{
@@ -68,6 +152,143 @@ function buildMessages(userTurns: string[]): MessageRecord[] {
]);
}
+function padTurns(turns: string[]): string[] {
+ if (turns.length >= 10) {
+ return turns;
+ }
+
+ const padded = [...turns];
+ for (let i = turns.length; i < 10; i++) {
+ padded.push(`${turns[i % turns.length]} (repeat ${i + 1})`);
+ }
+ return padded;
+}
+
+function createScratchpad(
+ workflowSummary: string,
+ touchedPaths: string[],
+ validationStatus: MemoryScratchpad['validationStatus'] = 'passed',
+): MemoryScratchpad {
+ return {
+ version: 1,
+ workflowSummary,
+ toolSequence: ['run_shell_command'],
+ touchedPaths,
+ validationStatus,
+ };
+}
+
+function createWorkflowComparisonSessions(withScratchpad: boolean): {
+ sessions: SeedSession[];
+ relevantSessionIds: string[];
+ distractorSessionIds: string[];
+} {
+ const relevantWorkflowSummary =
+ 'run_shell_command -> run_shell_command | paths packages/cli/src/config/settings.ts, docs/settings.md | validated';
+
+ const relevantScratchpad = withScratchpad
+ ? createScratchpad(relevantWorkflowSummary, [
+ 'packages/cli/src/config/settings.ts',
+ 'docs/settings.md',
+ ])
+ : undefined;
+
+ const sessions: SeedSession[] = [
+ {
+ sessionId: 'hidden-settings-workflow-a',
+ summary: 'Prepare release notes for settings launch',
+ timestampOffsetMinutes: 420,
+ memoryScratchpad: relevantScratchpad,
+ userTurns: padTurns([
+ 'When we add a new setting, the durable workflow is to regenerate the settings docs instead of editing them by hand.',
+ 'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
+ 'Skipping predocs leaves stale defaults in the generated docs.',
+ 'We verify the workflow by checking that both the schema output and docs update together.',
+ 'This exact command order is the recurring workflow we use for settings changes.',
+ ]),
+ },
+ {
+ sessionId: 'hidden-settings-workflow-b',
+ summary: 'Investigate CI drift in generated config reference',
+ timestampOffsetMinutes: 390,
+ memoryScratchpad: relevantScratchpad,
+ userTurns: padTurns([
+ 'The config reference drift was fixed by rerunning the standard settings regeneration workflow.',
+ 'We again used npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
+ 'The recurring rule is never to hand-edit generated settings docs.',
+ 'The validation step is to confirm the schema artifact and docs changed together after regeneration.',
+ 'This is the same recurring workflow we use every time a setting changes.',
+ ]),
+ },
+ {
+ sessionId: 'distractor-release-notes',
+ summary: 'Prepare release notes for auth launch',
+ timestampOffsetMinutes: 360,
+ memoryScratchpad: undefined,
+ userTurns: padTurns([
+ 'This release-notes task was one-off and just needed manual wording updates.',
+ 'I edited CHANGELOG.md and docs/release-notes.md directly.',
+ 'There was no reusable command sequence here beyond proofreading the copy.',
+ 'This task should not become a standing workflow.',
+ 'Once the wording landed, we were done.',
+ ]),
+ },
+ {
+ sessionId: 'distractor-ci-snapshots',
+ summary: 'Investigate CI drift in auth snapshots',
+ timestampOffsetMinutes: 330,
+ memoryScratchpad: undefined,
+ userTurns: padTurns([
+ 'This auth snapshot issue was specific to a flaky test in CI.',
+ 'The only commands we ran were npm test -- auth and an isolated snapshot update.',
+ 'It was not the recurring settings-doc workflow.',
+ 'Once the flaky snapshot passed, there was no broader reusable procedure.',
+ 'Treat this as a one-off CI cleanup.',
+ ]),
+ },
+ {
+ sessionId: 'distractor-onboarding-docs',
+ summary: 'Refresh onboarding documentation copy',
+ timestampOffsetMinutes: 300,
+ memoryScratchpad: undefined,
+ userTurns: padTurns([
+ 'This was just a docs wording cleanup in docs/onboarding.md.',
+ 'No command sequence was involved.',
+ 'We manually edited the copy and reviewed it.',
+ 'There is no recurring operational workflow to capture here.',
+ 'This should stay a one-off docs edit.',
+ ]),
+ },
+ {
+ sessionId: 'distractor-deploy-copy',
+ summary: 'Adjust deployment checklist wording',
+ timestampOffsetMinutes: 270,
+ memoryScratchpad: undefined,
+ userTurns: padTurns([
+ 'This was a wording-only change to docs/deploy.md.',
+ 'We did not run a reusable command sequence.',
+ 'It should not become a skill.',
+ 'The edit was only for this deploy checklist cleanup.',
+ 'After the copy change, the task was complete.',
+ ]),
+ },
+ ];
+
+ return {
+ sessions,
+ relevantSessionIds: [
+ 'hidden-settings-workflow-a',
+ 'hidden-settings-workflow-b',
+ ],
+ distractorSessionIds: [
+ 'distractor-release-notes',
+ 'distractor-ci-snapshots',
+ 'distractor-onboarding-docs',
+ 'distractor-deploy-copy',
+ ],
+ };
+}
+
async function seedSessions(
config: Config,
sessions: SeedSession[],
@@ -78,9 +299,10 @@ async function seedSessions(
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
- const timestamp = new Date(
+ const sessionTimestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
- )
+ );
+ const timestamp = sessionTimestamp
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
@@ -89,8 +311,9 @@ async function seedSessions(
sessionId: session.sessionId,
projectHash: getProjectHash(projectRoot),
summary: session.summary,
+ memoryScratchpad: session.memoryScratchpad,
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
- lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
+ lastUpdated: sessionTimestamp.toISOString(),
messages: buildMessages(session.userTurns),
};
@@ -101,10 +324,9 @@ async function seedSessions(
}
}
-async function runExtractionAndReadState(config: Config): Promise<{
- state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
- skillsDir: string;
-}> {
+async function runExtractionAndReadState(
+ config: Config,
+): Promise {
await startMemoryService(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
@@ -113,7 +335,15 @@ async function runExtractionAndReadState(config: Config): Promise<{
const raw = await fsp.readFile(statePath, 'utf-8');
const state = JSON.parse(raw) as {
- runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
+ runs?: Array<{
+ sessionIds?: string[];
+ skillsCreated?: string[];
+ candidateSessions?: SessionVersion[];
+ processedSessions?: SessionVersion[];
+ turnCount?: number;
+ durationMs?: number;
+ terminateReason?: string;
+ }>;
};
if (!Array.isArray(state.runs) || state.runs.length === 0) {
throw new Error('Skill extraction finished without writing any run state');
@@ -126,27 +356,292 @@ async function runExtractionAndReadState(config: Config): Promise<{
skillsCreated: Array.isArray(run.skillsCreated)
? run.skillsCreated
: [],
+ candidateSessions: Array.isArray(run.candidateSessions)
+ ? run.candidateSessions
+ : [],
+ processedSessions: Array.isArray(run.processedSessions)
+ ? run.processedSessions
+ : [],
+ turnCount:
+ typeof run.turnCount === 'number' ? run.turnCount : undefined,
+ durationMs:
+ typeof run.durationMs === 'number' ? run.durationMs : undefined,
+ terminateReason:
+ typeof run.terminateReason === 'string'
+ ? run.terminateReason
+ : undefined,
})),
},
skillsDir,
+ skillBodies: await readSkillBodies(skillsDir),
};
}
+async function summarizeScratchpadRun(
+ outcome: ExtractionOutcome,
+ run: ExtractionRunSnapshot,
+ scenario: ReturnType,
+): Promise {
+ const relevantReads = countMatchingIds(
+ run.processedSessions,
+ scenario.relevantSessionIds,
+ );
+ const distractorReads = countMatchingIds(
+ run.processedSessions,
+ scenario.distractorSessionIds,
+ );
+ const totalReads = run.processedSessions.length;
+ const quality = scoreSkillQuality(
+ outcome.skillBodies,
+ SETTINGS_SKILL_QUALITY_SIGNALS,
+ );
+
+ return {
+ turnCount: run.turnCount ?? null,
+ durationMs: run.durationMs ?? null,
+ terminateReason: run.terminateReason ?? null,
+ skillsCreated: run.skillsCreated.length,
+ candidateSessions: run.candidateSessions.length,
+ processedSessions: totalReads,
+ relevantReads,
+ distractorReads,
+ totalReads,
+ recall: relevantReads / scenario.relevantSessionIds.length,
+ precision: totalReads === 0 ? 0 : relevantReads / totalReads,
+ signalScore: relevantReads - distractorReads,
+ skillQualityScore: quality.score,
+ skillQualityMax: quality.maxScore,
+ skillQualityRatio:
+ quality.maxScore === 0 ? 0 : quality.score / quality.maxScore,
+ missingQualitySignals: quality.missing,
+ };
+}
+
+function averageScratchpadRuns(
+ runs: ScratchpadRunMetrics[],
+): ScratchpadStatsAggregate {
+ return {
+ turnCountAvg: roundStat(averageNullable(runs.map((run) => run.turnCount))),
+ durationMsAvg: roundStat(
+ averageNullable(runs.map((run) => run.durationMs)),
+ ),
+ recallAvg: roundStat(average(runs.map((run) => run.recall))) ?? 0,
+ precisionAvg: roundStat(average(runs.map((run) => run.precision))) ?? 0,
+ signalScoreAvg: roundStat(average(runs.map((run) => run.signalScore))) ?? 0,
+ relevantReadsAvg:
+ roundStat(average(runs.map((run) => run.relevantReads))) ?? 0,
+ distractorReadsAvg:
+ roundStat(average(runs.map((run) => run.distractorReads))) ?? 0,
+ skillsCreatedAvg:
+ roundStat(average(runs.map((run) => run.skillsCreated))) ?? 0,
+ skillQualityScoreAvg:
+ roundStat(average(runs.map((run) => run.skillQualityScore))) ?? 0,
+ skillQualityRatioAvg:
+ roundStat(average(runs.map((run) => run.skillQualityRatio))) ?? 0,
+ };
+}
+
+function diffScratchpadAggregates(
+ baseline: ScratchpadStatsAggregate,
+ enhanced: ScratchpadStatsAggregate,
+): ScratchpadStatsAggregate {
+ return {
+ turnCountAvg:
+ baseline.turnCountAvg === null || enhanced.turnCountAvg === null
+ ? null
+ : roundStat(enhanced.turnCountAvg - baseline.turnCountAvg),
+ durationMsAvg:
+ baseline.durationMsAvg === null || enhanced.durationMsAvg === null
+ ? null
+ : roundStat(enhanced.durationMsAvg - baseline.durationMsAvg),
+ recallAvg: roundStat(enhanced.recallAvg - baseline.recallAvg) ?? 0,
+ precisionAvg: roundStat(enhanced.precisionAvg - baseline.precisionAvg) ?? 0,
+ signalScoreAvg:
+ roundStat(enhanced.signalScoreAvg - baseline.signalScoreAvg) ?? 0,
+ relevantReadsAvg:
+ roundStat(enhanced.relevantReadsAvg - baseline.relevantReadsAvg) ?? 0,
+ distractorReadsAvg:
+ roundStat(enhanced.distractorReadsAvg - baseline.distractorReadsAvg) ?? 0,
+ skillsCreatedAvg:
+ roundStat(enhanced.skillsCreatedAvg - baseline.skillsCreatedAvg) ?? 0,
+ skillQualityScoreAvg:
+ roundStat(
+ enhanced.skillQualityScoreAvg - baseline.skillQualityScoreAvg,
+ ) ?? 0,
+ skillQualityRatioAvg:
+ roundStat(
+ enhanced.skillQualityRatioAvg - baseline.skillQualityRatioAvg,
+ ) ?? 0,
+ };
+}
+
+async function runScenarioWithFreshRig(
+ sessions: SeedSession[],
+): Promise {
+ const rig = new ComponentRig({
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ });
+ try {
+ await rig.initialize();
+ await prepareWorkspace(rig.testDir, rig.testDir, WORKSPACE_FILES);
+ await seedSessions(rig.config!, sessions);
+ return await runExtractionAndReadState(rig.config!);
+ } finally {
+ await rig.cleanup();
+ }
+}
+
+async function runScratchpadStatsTrial(
+ trial: number,
+): Promise {
+ const baselineScenario = createWorkflowComparisonSessions(false);
+ const enhancedScenario = createWorkflowComparisonSessions(true);
+
+ const baselineOutcome = await runScenarioWithFreshRig(
+ baselineScenario.sessions,
+ );
+ const enhancedOutcome = await runScenarioWithFreshRig(
+ enhancedScenario.sessions,
+ );
+
+ const baselineRun = baselineOutcome.state.runs.at(-1);
+ const enhancedRun = enhancedOutcome.state.runs.at(-1);
+ if (!baselineRun || !enhancedRun) {
+ throw new Error('Expected both baseline and scratchpad runs to exist');
+ }
+
+ expectSuccessfulExtractionRun(baselineRun);
+ expectSuccessfulExtractionRun(enhancedRun);
+
+ return {
+ trial,
+ baseline: await summarizeScratchpadRun(
+ baselineOutcome,
+ baselineRun,
+ baselineScenario,
+ ),
+ enhanced: await summarizeScratchpadRun(
+ enhancedOutcome,
+ enhancedRun,
+ enhancedScenario,
+ ),
+ };
+}
+
+async function runScratchpadStatsReport(
+ trials: number,
+): Promise {
+ const results: ScratchpadStatsTrial[] = [];
+
+ for (let trial = 1; trial <= trials; trial++) {
+ results.push(await runScratchpadStatsTrial(trial));
+ }
+
+ const baseline = averageScratchpadRuns(
+ results.map((result) => result.baseline),
+ );
+ const enhanced = averageScratchpadRuns(
+ results.map((result) => result.enhanced),
+ );
+
+ return {
+ generatedAt: new Date().toISOString(),
+ trials,
+ aggregate: {
+ baseline,
+ enhanced,
+ },
+ deltas: diffScratchpadAggregates(baseline, enhanced),
+ results,
+ };
+}
+
+async function writeScratchpadStatsReport(
+ report: ScratchpadStatsReport,
+): Promise {
+ const outputPath = path.resolve(
+ process.cwd(),
+ 'evals/logs/skill_extraction_scratchpad_stats.json',
+ );
+ await fsp.mkdir(path.dirname(outputPath), { recursive: true });
+ await fsp.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
+ return outputPath;
+}
+
async function readSkillBodies(skillsDir: string): Promise {
+ const bodies: string[] = [];
+
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
- const skillDirs = entries.filter((entry) => entry.isDirectory());
- const bodies = await Promise.all(
- skillDirs.map((entry) =>
- fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
- ),
- );
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+
+ try {
+ bodies.push(
+ await fsp.readFile(
+ path.join(skillsDir, entry.name, 'SKILL.md'),
+ 'utf-8',
+ ),
+ );
+ } catch {
+ // Ignore incomplete skill directories so one bad artifact does not hide
+ // valid skills created in the same eval run.
+ }
+ }
return bodies;
} catch {
return [];
}
}
+function expectSuccessfulExtractionRun(run: ExtractionRunSnapshot): void {
+ expect(run.turnCount).toBeGreaterThan(0);
+ expect(run.turnCount).toBeLessThanOrEqual(30);
+ expect(run.durationMs).toBeGreaterThan(0);
+ expect(run.terminateReason).toBe('GOAL');
+}
+
+function scoreSkillQuality(
+ skillBodies: string[],
+ signals: SkillQualitySignal[],
+): { score: number; maxScore: number; missing: string[] } {
+ const combined = skillBodies.join('\n\n');
+ const matched = signals.filter((signal) => signal.pattern.test(combined));
+
+ return {
+ score: matched.length,
+ maxScore: signals.length,
+ missing: signals
+ .filter((signal) => !signal.pattern.test(combined))
+ .map((signal) => signal.label),
+ };
+}
+
+const SETTINGS_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
+ { label: 'predocs command', pattern: /npm run predocs:settings/i },
+ { label: 'schema command', pattern: /npm run schema:settings/i },
+ { label: 'docs command', pattern: /npm run docs:settings/i },
+ { label: 'verification guidance', pattern: /verif(?:y|ication)/i },
+ {
+ label: 'generated docs warning or ordering constraint',
+ pattern:
+ /do not hand-edit|manual edits|exact command order|preserve.*order/i,
+ },
+];
+
+const DB_MIGRATION_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
+ { label: 'db check command', pattern: /npm run db:check/i },
+ { label: 'db migrate command', pattern: /npm run db:migrate/i },
+ { label: 'db validate command', pattern: /npm run db:validate/i },
+ { label: 'rollback guidance', pattern: /npm run db:rollback|rollback/i },
+ {
+ label: 'ordering constraint',
+ pattern: /check.*migrate.*validate|ordering is critical|mandatory/i,
+ },
+];
+
/**
* Shared configOverrides for all skill extraction component evals.
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
@@ -158,6 +653,16 @@ const EXTRACTION_CONFIG_OVERRIDES = {
approvalMode: ApprovalMode.YOLO,
};
+function parseScratchpadStatsTrials(): number {
+ const configured = Number.parseInt(
+ process.env['SCRATCHPAD_STATS_TRIALS'] ?? '8',
+ 10,
+ );
+ return Number.isFinite(configured) && configured > 0 ? configured : 8;
+}
+
+const SCRATCHPAD_STATS_TRIALS = parseScratchpadStatsTrials();
+
describe('Skill Extraction', () => {
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
@@ -264,15 +769,24 @@ describe('Skill Extraction', () => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
+ const quality = scoreSkillQuality(
+ skillBodies,
+ SETTINGS_SKILL_QUALITY_SIGNALS,
+ );
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
+ expectSuccessfulExtractionRun(state.runs[0]);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run predocs:settings');
expect(combinedSkills).toContain('npm run schema:settings');
expect(combinedSkills).toContain('npm run docs:settings');
- expect(combinedSkills).toMatch(/Verification/i);
+ expect(combinedSkills).toMatch(/verif(?:y|ication)/i);
+ expect(
+ quality.score,
+ `missing quality signals: ${quality.missing.join(', ')}`,
+ ).toBeGreaterThanOrEqual(4);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
@@ -281,6 +795,96 @@ describe('Skill Extraction', () => {
},
});
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'skill-extraction',
+ suiteType: 'component-level',
+ name: 'memory scratchpad improves repeated-workflow recall versus summary-only index',
+ files: WORKSPACE_FILES,
+ timeout: 360000,
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ assert: async () => {
+ const baselineScenario = createWorkflowComparisonSessions(false);
+ const enhancedScenario = createWorkflowComparisonSessions(true);
+
+ const baselineOutcome = await runScenarioWithFreshRig(
+ baselineScenario.sessions,
+ );
+ const enhancedOutcome = await runScenarioWithFreshRig(
+ enhancedScenario.sessions,
+ );
+
+ const baselineRun = baselineOutcome.state.runs.at(-1);
+ const enhancedRun = enhancedOutcome.state.runs.at(-1);
+ if (!baselineRun || !enhancedRun) {
+ throw new Error('Expected both baseline and scratchpad runs to exist');
+ }
+
+ expectSuccessfulExtractionRun(baselineRun);
+ expectSuccessfulExtractionRun(enhancedRun);
+
+ const baselineRelevantReads = countMatchingIds(
+ baselineRun.processedSessions,
+ baselineScenario.relevantSessionIds,
+ );
+ const enhancedRelevantReads = countMatchingIds(
+ enhancedRun.processedSessions,
+ enhancedScenario.relevantSessionIds,
+ );
+ const baselineDistractorReads = countMatchingIds(
+ baselineRun.processedSessions,
+ baselineScenario.distractorSessionIds,
+ );
+ const enhancedDistractorReads = countMatchingIds(
+ enhancedRun.processedSessions,
+ enhancedScenario.distractorSessionIds,
+ );
+ const baselineSignalScore =
+ baselineRelevantReads - baselineDistractorReads;
+ const enhancedSignalScore =
+ enhancedRelevantReads - enhancedDistractorReads;
+
+ expect(enhancedRun.candidateSessions).toHaveLength(
+ enhancedScenario.sessions.length,
+ );
+ expect(enhancedRelevantReads).toBeGreaterThanOrEqual(2);
+ expect(enhancedRelevantReads).toBeGreaterThanOrEqual(
+ baselineRelevantReads,
+ );
+ expect(enhancedDistractorReads).toBeLessThanOrEqual(
+ baselineDistractorReads,
+ );
+ expect(enhancedSignalScore).toBeGreaterThan(baselineSignalScore);
+ },
+ });
+
+ if (process.env['RUN_SCRATCHPAD_STATS'] === '1') {
+ componentEvalTest('USUALLY_PASSES', {
+ suiteName: 'skill-extraction',
+ suiteType: 'component-level',
+ name: 'reports memory scratchpad retrieval statistics',
+ timeout: Math.max(360000, SCRATCHPAD_STATS_TRIALS * 150000),
+ configOverrides: EXTRACTION_CONFIG_OVERRIDES,
+ assert: async () => {
+ const report = await runScratchpadStatsReport(SCRATCHPAD_STATS_TRIALS);
+ const outputPath = await writeScratchpadStatsReport(report);
+
+ console.info(
+ `Wrote scratchpad stats report to ${outputPath}\n${JSON.stringify(
+ report.aggregate,
+ null,
+ 2,
+ )}`,
+ );
+
+ expect(report.results).toHaveLength(SCRATCHPAD_STATS_TRIALS);
+ expect(report.aggregate.baseline.recallAvg).toBeGreaterThan(0);
+ expect(report.aggregate.enhanced.recallAvg).toBeGreaterThan(0);
+ },
+ });
+ } else {
+ it.skip('reports memory scratchpad retrieval statistics', () => {});
+ }
+
componentEvalTest('USUALLY_PASSES', {
suiteName: 'skill-extraction',
suiteType: 'component-level',
@@ -330,15 +934,24 @@ describe('Skill Extraction', () => {
const { state, skillsDir } = await runExtractionAndReadState(config);
const skillBodies = await readSkillBodies(skillsDir);
const combinedSkills = skillBodies.join('\n\n');
+ const quality = scoreSkillQuality(
+ skillBodies,
+ DB_MIGRATION_SKILL_QUALITY_SIGNALS,
+ );
expect(state.runs).toHaveLength(1);
expect(state.runs[0].sessionIds).toHaveLength(2);
+ expectSuccessfulExtractionRun(state.runs[0]);
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
expect(combinedSkills).toContain('npm run db:check');
expect(combinedSkills).toContain('npm run db:migrate');
expect(combinedSkills).toContain('npm run db:validate');
expect(combinedSkills).toMatch(/rollback/i);
+ expect(
+ quality.score,
+ `missing quality signals: ${quality.missing.join(', ')}`,
+ ).toBeGreaterThanOrEqual(4);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
diff --git a/evals/statistics-helper.ts b/evals/statistics-helper.ts
new file mode 100644
index 0000000000..dff3baeb19
--- /dev/null
+++ b/evals/statistics-helper.ts
@@ -0,0 +1,26 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+export function countMatchingIds(
+ items: T[],
+ expectedIds: string[],
+): number {
+ const expected = new Set(expectedIds);
+ return items.filter((item) => expected.has(item.sessionId)).length;
+}
+
+export function roundStat(value: number | null): number | null {
+ return value === null ? null : Number(value.toFixed(4));
+}
+
+export function average(values: number[]): number {
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
+}
+
+export function averageNullable(values: Array): number | null {
+ const numericValues = values.filter((value) => value !== null);
+ return numericValues.length === 0 ? null : average(numericValues);
+}
diff --git a/evals/tracker.eval.ts b/evals/tracker.eval.ts
index 83ffc61d68..d4044b8cb6 100644
--- a/evals/tracker.eval.ts
+++ b/evals/tracker.eval.ts
@@ -62,11 +62,13 @@ describe('tracker_mode', () => {
'Expected tracker_update_task tool to be called',
).toBe(true);
- const updateCall = toolLogs.find(
+ const updateCalls = toolLogs.filter(
(log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME,
);
- expect(updateCall).toBeDefined();
- const updateArgs = JSON.parse(updateCall!.toolRequest.args);
+ expect(updateCalls.length).toBeGreaterThan(0);
+ const updateArgs = JSON.parse(
+ updateCalls[updateCalls.length - 1].toolRequest.args,
+ );
expect(updateArgs.status).toBe('closed');
const loginContent = fs.readFileSync(
@@ -128,12 +130,52 @@ describe('tracker_mode', () => {
prompt:
'Where is my task tracker storage located? Please provide the absolute path in your response.',
assert: async (rig, result) => {
- // The rig sets GEMINI_CLI_HOME to rig.homeDir
- const homeDir = rig.homeDir!;
- // The response should contain the dynamic path which includes the home directory
- // and follows the .gemini/tmp/.../tracker structure.
- expect(result).toContain(homeDir);
+ // The response should contain the dynamic path which follows the .gemini/tmp/.../tracker structure.
expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/);
},
});
+
+ evalTest('USUALLY_PASSES', {
+ suiteName: 'default',
+ suiteType: 'behavioral',
+ name: 'should update the tracker in the same turn as the task completion to save turns',
+ params: {
+ settings: { experimental: { taskTracker: true } },
+ },
+ files: FILES,
+ prompt:
+ 'We have a bug in src/login.js: the password check is missing. Fix this bug. Then, create a new file src/auth.js that exports a simple verifyToken function. Please organize this into tasks and execute them.',
+ assert: async (rig, result) => {
+ await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
+ await rig.waitForToolCall(TRACKER_UPDATE_TASK_TOOL_NAME);
+
+ const toolLogs = rig.readToolLogs();
+
+ // Get the prompt ID of the fix for login.js
+ const loginEditCalls = toolLogs.filter(
+ (log) =>
+ (log.toolRequest.name === 'replace' ||
+ log.toolRequest.name === 'write_file') &&
+ log.toolRequest.args.includes('login.js'),
+ );
+
+ expect(loginEditCalls.length).toBeGreaterThan(0);
+ const loginEditPromptId =
+ loginEditCalls[loginEditCalls.length - 1].toolRequest.prompt_id;
+
+ // Verify there is an update to the tracker in the exact same turn
+ const parallelTrackerUpdates = toolLogs.filter(
+ (log) =>
+ log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME &&
+ log.toolRequest.prompt_id === loginEditPromptId,
+ );
+
+ expect(
+ parallelTrackerUpdates.length,
+ 'Expected tracker_update_task to be called in the same turn as the login.js fix',
+ ).toBeGreaterThan(0);
+
+ assertModelHasOutput(result);
+ },
+ });
});
diff --git a/integration-tests/voice-mode.test.ts b/integration-tests/voice-mode.test.ts
new file mode 100644
index 0000000000..49844494a8
--- /dev/null
+++ b/integration-tests/voice-mode.test.ts
@@ -0,0 +1,76 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { TestRig } from './test-helper.js';
+import {
+ WhisperModelManager,
+ WhisperTranscriptionProvider,
+} from '@google/gemini-cli-core';
+import * as fs from 'node:fs';
+import commandExists from 'command-exists';
+
+describe('Voice Mode Integration', () => {
+ let rig: TestRig;
+
+ beforeEach(() => {
+ rig = new TestRig();
+ });
+
+ afterEach(async () => await rig.cleanup());
+
+ it('should be able to download tiny whisper model', async () => {
+ // This test doesn't require the binary, only network access.
+ // However, it's slow and downloads 75MB. We'll keep it for now but
+ // wrap it in a try-catch to avoid failing on network flakiness in CI.
+ const manager = new WhisperModelManager();
+ const modelName = 'ggml-tiny.en.bin';
+
+ try {
+ // Cleanup if already exists to ensure we actually test download
+ const modelPath = manager.getModelPath(modelName);
+ if (fs.existsSync(modelPath)) {
+ fs.unlinkSync(modelPath);
+ }
+
+ await manager.downloadModel(modelName);
+ expect(fs.existsSync(modelPath)).toBe(true);
+ expect(fs.statSync(modelPath).size).toBeGreaterThan(70 * 1024 * 1024); // ~75MB
+ } catch (e) {
+ console.warn(
+ 'Skipping whisper model download test due to error (possibly network):',
+ e,
+ );
+ }
+ }, 300000); // 5 min timeout for download
+
+ it('should initialize WhisperTranscriptionProvider and handle process', async () => {
+ // Skip this test if whisper-stream is not installed (typical for CI)
+ try {
+ await commandExists('whisper-stream');
+ } catch {
+ console.log(
+ 'Skipping Whisper transcription test: whisper-stream not found',
+ );
+ return;
+ }
+
+ const manager = new WhisperModelManager();
+ const modelName = 'ggml-tiny.en.bin';
+ if (!manager.isModelInstalled(modelName)) {
+ await manager.downloadModel(modelName);
+ }
+
+ const provider = new WhisperTranscriptionProvider({
+ modelPath: manager.getModelPath(modelName),
+ });
+
+ // Since we can't easily provide real mic input in CI,
+ // we just verify it can start and be disconnected.
+ await provider.connect();
+ provider.disconnect();
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 89a358ef9e..9ced540f9a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
@@ -18390,6 +18390,7 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
+ "command-exists": "^1.2.9",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
@@ -18664,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"
@@ -18679,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",
@@ -18710,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",
@@ -18742,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",
diff --git a/package.json b/package.json
index 42be8e3962..6699efbd60 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json
index 0e3f6b9385..ee2d9c8b20 100644
--- a/packages/a2a-server/package.json
+++ b/packages/a2a-server/package.json
@@ -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",
diff --git a/packages/cli/index.ts b/packages/cli/index.ts
index d857831fb7..ade92995e1 100644
--- a/packages/cli/index.ts
+++ b/packages/cli/index.ts
@@ -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 ---
@@ -70,22 +75,18 @@ async function getMemoryNodeArgs(): Promise {
}
async function run() {
- if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) {
+ if (
+ !process.env['GEMINI_CLI_NO_RELAUNCH'] &&
+ !process.env['SANDBOX'] &&
+ process.env['IS_BINARY'] !== 'true'
+ ) {
// --- 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 +98,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,
});
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 2a744fcfdb..404aaecbaa 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -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",
diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md
new file mode 100644
index 0000000000..e8b4b4be70
--- /dev/null
+++ b/packages/cli/src/acp/README.md
@@ -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/.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.
diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts
deleted file mode 100644
index 10c90824f9..0000000000
--- a/packages/cli/src/acp/acpClient.test.ts
+++ /dev/null
@@ -1,2294 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
- describe,
- it,
- expect,
- vi,
- beforeEach,
- afterEach,
- type Mock,
- type Mocked,
-} from 'vitest';
-import { GeminiAgent, Session } from './acpClient.js';
-import type { CommandHandler } from './commandHandler.js';
-import * as acp from '@agentclientprotocol/sdk';
-import {
- AuthType,
- ToolConfirmationOutcome,
- StreamEventType,
- ReadManyFilesTool,
- type GeminiChat,
- type Config,
- type MessageBus,
- LlmRole,
- type GitService,
- type ModelRouterService,
- processSingleFileContent,
- InvalidStreamError,
-} from '@google/gemini-cli-core';
-import {
- SettingScope,
- type LoadedSettings,
- loadSettings,
-} from '../config/settings.js';
-import { loadCliConfig, type CliArgs } from '../config/config.js';
-import * as fs from 'node:fs/promises';
-import * as path from 'node:path';
-import { ApprovalMode } from '@google/gemini-cli-core/src/policy/types.js';
-
-const startMemoryServiceMock = vi.hoisted(() => vi.fn());
-
-vi.mock('../config/config.js', () => ({
- loadCliConfig: vi.fn(),
-}));
-
-vi.mock('../config/settings.js', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- loadSettings: vi.fn(),
- };
-});
-
-vi.mock('node:crypto', () => ({
- randomUUID: () => 'test-session-id',
-}));
-
-vi.mock('node:fs/promises');
-vi.mock('node:path', async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- resolve: vi.fn(),
- };
-});
-
-vi.mock('../ui/commands/memoryCommand.js', () => ({
- memoryCommand: {
- name: 'memory',
- action: vi.fn(),
- },
-}));
-
-vi.mock('../ui/commands/extensionsCommand.js', () => ({
- extensionsCommand: vi.fn().mockReturnValue({
- name: 'extensions',
- action: vi.fn(),
- }),
-}));
-
-vi.mock('../ui/commands/restoreCommand.js', () => ({
- restoreCommand: vi.fn().mockReturnValue({
- name: 'restore',
- action: vi.fn(),
- }),
-}));
-
-vi.mock('../ui/commands/initCommand.js', () => ({
- initCommand: {
- name: 'init',
- action: vi.fn(),
- },
-}));
-vi.mock(
- '@google/gemini-cli-core',
- async (
- importOriginal: () => Promise,
- ) => {
- const actual = await importOriginal();
- return {
- ...actual,
- startMemoryService: startMemoryServiceMock,
- updatePolicy: vi.fn(),
- createPolicyUpdater: vi.fn(),
- ReadManyFilesTool: vi.fn(),
- logToolCall: vi.fn(),
- LlmRole: {
- MAIN: 'main',
- SUBAGENT: 'subagent',
- UTILITY_TOOL: 'utility_tool',
- UTILITY_COMPRESSOR: 'utility_compressor',
- UTILITY_SUMMARIZER: 'utility_summarizer',
- UTILITY_ROUTER: 'utility_router',
- UTILITY_LOOP_DETECTOR: 'utility_loop_detector',
- UTILITY_NEXT_SPEAKER: 'utility_next_speaker',
- UTILITY_EDIT_CORRECTOR: 'utility_edit_corrector',
- UTILITY_AUTOCOMPLETE: 'utility_autocomplete',
- UTILITY_FAST_ACK_HELPER: 'utility_fast_ack_helper',
- },
- CoreToolCallStatus: {
- Validating: 'validating',
- Scheduled: 'scheduled',
- Error: 'error',
- Success: 'success',
- Executing: 'executing',
- Cancelled: 'cancelled',
- AwaitingApproval: 'awaiting_approval',
- },
- processSingleFileContent: vi.fn(),
- };
- },
-);
-
-// Helper to create mock streams
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-async function* createMockStream(items: any[]) {
- for (const item of items) {
- yield item;
- }
-}
-
-describe('GeminiAgent', () => {
- let mockConfig: Mocked>>;
- let mockSettings: Mocked;
- let mockArgv: CliArgs;
- let mockConnection: Mocked;
- let agent: GeminiAgent;
-
- beforeEach(() => {
- vi.clearAllMocks();
- startMemoryServiceMock.mockResolvedValue(undefined);
- mockConfig = {
- refreshAuth: vi.fn(),
- initialize: vi.fn(),
- waitForMcpInit: vi.fn(),
- getFileSystemService: vi.fn(),
- setFileSystemService: vi.fn(),
- getContentGeneratorConfig: vi.fn(),
- isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
- 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(),
- },
- storage: {
- getWorkspaceAutoSavedPolicyPath: vi.fn(),
- getAutoSavedPolicyPath: vi.fn(),
- setClientName: vi.fn(),
- },
- setClientName: vi.fn(),
- get config() {
- return this;
- },
- } as unknown as Mocked>>;
- mockSettings = {
- merged: {
- security: { auth: { selectedType: 'login_with_google' } },
- mcpServers: {},
- },
- setValue: vi.fn(),
- } as unknown as Mocked;
- mockArgv = {} as unknown as CliArgs;
- mockConnection = {
- sessionUpdate: vi.fn(),
- requestPermission: vi.fn(),
- } as unknown as Mocked;
-
- (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: {
- // Invalid baseUrl
- baseUrl: 123,
- headers: { Authorization: 'Bearer token' },
- },
- },
- } as unknown as acp.AuthenticateRequest),
- ).rejects.toThrow(/Malformed gateway payload/);
- });
-
- it('should create a new session', async () => {
- vi.useFakeTimers();
- mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
- apiKey: 'test-key',
- });
- const response = await agent.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
- await vi.runAllTimersAsync();
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'available_commands_update',
- }),
- }),
- );
- vi.useRealTimers();
- });
-
- it('should start auto memory for new ACP sessions when enabled', async () => {
- mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
- apiKey: 'test-key',
- });
- mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(true);
-
- await agent.newSession({
- cwd: '/tmp',
- mcpServers: [],
- });
-
- expect(startMemoryServiceMock).toHaveBeenCalledWith(mockConfig);
- });
-
- it('should not start auto memory for new ACP sessions when disabled', async () => {
- mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
- apiKey: 'test-key',
- });
- mockConfig.isAutoMemoryEnabled = vi.fn().mockReturnValue(false);
-
- await agent.newSession({
- cwd: '/tmp',
- mcpServers: [],
- });
-
- expect(startMemoryServiceMock).not.toHaveBeenCalled();
- });
-
- 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 agent.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',
- });
- expect(response.models).toEqual({
- availableModels: expect.arrayContaining([
- expect.objectContaining({
- modelId: 'auto-gemini-2.5',
- name: 'Auto (Gemini 2.5)',
- }),
- ]),
- currentModelId: 'gemini-pro',
- });
- });
-
- it('should include preview models when user has access', async () => {
- mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
- mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
-
- const response = await agent.newSession({
- cwd: '/tmp',
- mcpServers: [],
- });
-
- expect(response.models?.availableModels).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- modelId: 'auto-gemini-3',
- name: expect.stringContaining('Auto'),
- }),
- expect.objectContaining({
- modelId: 'gemini-3.1-pro-preview',
- name: 'gemini-3.1-pro-preview',
- }),
- ]),
- );
- });
-
- it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
- mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
- mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
- mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
-
- const response = await agent.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 agent.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',
- });
- expect(response.models).toEqual({
- availableModels: expect.arrayContaining([
- expect.objectContaining({
- modelId: 'auto-gemini-2.5',
- name: 'Auto (Gemini 2.5)',
- }),
- ]),
- currentModelId: 'gemini-pro',
- });
- });
-
- 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(
- agent.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 () => {
- const mcpServers = [
- {
- name: 'test-server',
- command: 'node',
- args: ['server.js'],
- env: [{ name: 'KEY', value: 'VALUE' }],
- },
- ];
-
- await agent.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'));
- const debugSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- // Should throw RequestError with custom message
- await expect(
- agent.newSession({
- cwd: '/tmp',
- mcpServers: [],
- }),
- ).rejects.toMatchObject({
- message: 'Auth failed',
- });
-
- debugSpy.mockRestore();
- });
-
- it('should initialize file system service if client supports it', async () => {
- agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
- await agent.initialize({
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
- protocolVersion: 1,
- });
-
- await agent.newSession({
- cwd: '/tmp',
- mcpServers: [],
- });
-
- expect(mockConfig.setFileSystemService).toHaveBeenCalled();
- });
-
- it('should cancel a session', async () => {
- await agent.newSession({ cwd: '/tmp', mcpServers: [] });
- // Mock the session's cancelPendingPrompt
- const session = (
- agent as unknown as { sessions: Map }
- ).sessions.get('test-session-id');
- if (!session) throw new Error('Session not found');
- session.cancelPendingPrompt = vi.fn();
-
- await agent.cancel({ sessionId: 'test-session-id' });
-
- expect(session.cancelPendingPrompt).toHaveBeenCalled();
- });
-
- it('should throw error when cancelling non-existent session', async () => {
- await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
- 'Session not found',
- );
- });
-
- it('should delegate prompt to session', async () => {
- await agent.newSession({ cwd: '/tmp', mcpServers: [] });
- const session = (
- agent as unknown as { sessions: Map }
- ).sessions.get('test-session-id');
- if (!session) throw new Error('Session not found');
- session.prompt = vi.fn().mockResolvedValue({ stopReason: 'end_turn' });
-
- const result = await agent.prompt({
- sessionId: 'test-session-id',
- prompt: [],
- });
-
- expect(session.prompt).toHaveBeenCalled();
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- });
-
- it('should delegate setMode to session', async () => {
- await agent.newSession({ cwd: '/tmp', mcpServers: [] });
- const session = (
- agent as unknown as { sessions: Map }
- ).sessions.get('test-session-id');
- if (!session) throw new Error('Session not found');
- session.setMode = vi.fn().mockReturnValue({});
-
- const result = await agent.setSessionMode({
- sessionId: 'test-session-id',
- modeId: 'plan',
- });
-
- expect(session.setMode).toHaveBeenCalledWith('plan');
- expect(result).toEqual({});
- });
-
- it('should throw error when setting mode on non-existent session', async () => {
- await expect(
- agent.setSessionMode({
- sessionId: 'unknown',
- modeId: 'plan',
- }),
- ).rejects.toThrow('Session not found: unknown');
- });
-
- it('should delegate setModel to session (unstable)', async () => {
- await agent.newSession({ cwd: '/tmp', mcpServers: [] });
- const session = (
- agent as unknown as { sessions: Map }
- ).sessions.get('test-session-id');
- if (!session) throw new Error('Session not found');
- session.setModel = vi.fn().mockReturnValue({});
-
- const result = await agent.unstable_setSessionModel({
- sessionId: 'test-session-id',
- modelId: 'gemini-2.0-pro-exp',
- });
-
- expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
- expect(result).toEqual({});
- });
-
- it('should throw error when setting model on non-existent session (unstable)', async () => {
- await expect(
- agent.unstable_setSessionModel({
- sessionId: 'unknown',
- modelId: 'gemini-2.0-pro-exp',
- }),
- ).rejects.toThrow('Session not found: unknown');
- });
-});
-
-describe('Session', () => {
- let mockChat: Mocked;
- let mockConfig: Mocked;
- let mockConnection: Mocked;
- let session: Session;
- let mockToolRegistry: { getTool: Mock };
- let mockTool: { kind: string; build: Mock };
- let mockMessageBus: Mocked;
-
- beforeEach(() => {
- mockChat = {
- sendMessageStream: vi.fn(),
- addHistory: vi.fn(),
- recordCompletedToolCalls: vi.fn(),
- getHistory: vi.fn().mockReturnValue([]),
- } as unknown as Mocked;
- 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;
- 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),
- getMcpServers: vi.fn(),
- 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),
- get config() {
- return this;
- },
- get toolRegistry() {
- return mockToolRegistry;
- },
- } as unknown as Mocked;
- mockConnection = {
- sessionUpdate: vi.fn(),
- requestPermission: vi.fn(),
- sendNotification: vi.fn(),
- } as unknown as Mocked;
-
- session = new Session('session-1', mockChat, mockConfig, mockConnection, {
- system: { settings: {} },
- systemDefaults: { settings: {} },
- user: { settings: {} },
- workspace: { settings: {} },
- 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',
- availableCommands: expect.arrayContaining([
- expect.objectContaining({ name: 'memory' }),
- expect.objectContaining({ name: 'extensions' }),
- expect.objectContaining({ name: 'restore' }),
- expect.objectContaining({ name: 'init' }),
- ]),
- }),
- }),
- );
- });
-
- it('should await MCP initialization before processing a prompt', async () => {
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'test' }],
- });
-
- expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce();
- const waitOrder = (mockConfig.waitForMcpInit as Mock).mock
- .invocationCallOrder[0];
- const sendOrder = (mockChat.sendMessageStream as Mock).mock
- .invocationCallOrder[0];
- expect(waitOrder).toBeLessThan(sendOrder);
- });
-
- it('should handle prompt with text response', async () => {
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
- },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockChat.sendMessageStream).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 use model router to determine model', async () => {
- const mockRouter = {
- route: vi.fn().mockResolvedValue({ model: 'routed-model' }),
- } as unknown as ModelRouterService;
- mockConfig.getModelRouterService.mockReturnValue(mockRouter);
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
- },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockRouter.route).toHaveBeenCalledWith(
- expect.objectContaining({
- requestedModel: 'gemini-pro',
- request: [{ text: 'Hi' }],
- }),
- );
- expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
- expect.objectContaining({ model: 'routed-model' }),
- expect.any(Array),
- expect.any(String),
- expect.any(Object),
- expect.any(String),
- );
- });
-
- it('should handle prompt with empty response (InvalidStreamError)', async () => {
- mockChat.sendMessageStream.mockRejectedValue(
- new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
- );
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockChat.sendMessageStream).toHaveBeenCalled();
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- });
-
- it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
- mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockChat.sendMessageStream).toHaveBeenCalled();
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- });
-
- it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
- mockChat.sendMessageStream.mockRejectedValue(
- new InvalidStreamError('No finish reason', 'NO_FINISH_REASON'),
- );
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockChat.sendMessageStream).toHaveBeenCalled();
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- });
-
- it('should handle prompt with no finish reason (NO_FINISH_REASON anomaly)', async () => {
- mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_FINISH_REASON' });
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- expect(mockChat.sendMessageStream).toHaveBeenCalled();
- 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),
- );
- expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
- });
-
- it('should handle /extensions 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: '/extensions list' }],
- });
-
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- expect(handleCommandSpy).toHaveBeenCalledWith(
- '/extensions list',
- expect.any(Object),
- );
- expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
- });
-
- it('should handle /extensions explore 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: '/extensions explore' }],
- });
-
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- expect(handleCommandSpy).toHaveBeenCalledWith(
- '/extensions explore',
- expect.any(Object),
- );
- expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
- });
-
- it('should handle /restore 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: '/restore' }],
- });
-
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- expect(handleCommandSpy).toHaveBeenCalledWith(
- '/restore',
- expect.any(Object),
- );
- expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
- });
-
- it('should handle /init 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: '/init' }],
- });
-
- expect(result).toMatchObject({ stopReason: 'end_turn' });
- expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
- expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
- });
-
- it('should handle tool calls', async () => {
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: { foo: 'bar' } }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- candidates: [{ content: { parts: [{ text: 'Result' }] } }],
- },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- const result = await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool');
- expect(mockTool.build).toHaveBeenCalledWith({ foo: 'bar' });
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'tool_call',
- status: 'in_progress',
- kind: 'read',
- }),
- }),
- );
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'tool_call_update',
- status: 'completed',
- title: 'Test Tool',
- locations: [],
- kind: 'read',
- }),
- }),
- );
- 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: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalled();
- expect(confirmationDetails.onConfirm).toHaveBeenCalledWith(
- ToolConfirmationOutcome.ProceedOnce,
- );
- });
-
- it('should exclude always allow options when disableAlwaysAllow is true', async () => {
- mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(true);
- 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: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- options: expect.not.arrayContaining([
- expect.objectContaining({
- optionId: ToolConfirmationOutcome.ProceedAlways,
- }),
- ]),
- }),
- );
- });
-
- it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
- mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
- const confirmationDetails = {
- type: 'edit',
- onConfirm: vi.fn(),
- };
- mockTool.build.mockReturnValue({
- getDescription: () => 'Test Tool',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
- execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
- });
-
- const customSettings = {
- system: { settings: {} },
- systemDefaults: { settings: {} },
- user: { settings: {} },
- workspace: { settings: {} },
- merged: {
- security: { enablePermanentToolApproval: false },
- mcpServers: {},
- },
- errors: [],
- } as unknown as LoadedSettings;
-
- const localSession = new Session(
- 'session-2',
- mockChat,
- mockConfig,
- mockConnection,
- customSettings,
- );
-
- mockConnection.requestPermission.mockResolvedValueOnce({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await localSession.prompt({
- sessionId: 'session-2',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- options: expect.not.arrayContaining([
- expect.objectContaining({
- optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
- }),
- ]),
- }),
- );
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- options: expect.arrayContaining([
- expect.objectContaining({
- optionId: ToolConfirmationOutcome.ProceedAlways,
- }),
- ]),
- }),
- );
- });
-
- it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
- mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
- const confirmationDetails = {
- type: 'edit',
- onConfirm: vi.fn(),
- };
- mockTool.build.mockReturnValue({
- getDescription: () => 'Test Tool',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
- execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
- });
-
- const customSettings = {
- system: { settings: {} },
- systemDefaults: { settings: {} },
- user: { settings: {} },
- workspace: { settings: {} },
- merged: {
- security: { enablePermanentToolApproval: true },
- mcpServers: {},
- },
- errors: [],
- } as unknown as LoadedSettings;
-
- const localSession = new Session(
- 'session-2',
- mockChat,
- mockConfig,
- mockConnection,
- customSettings,
- );
-
- mockConnection.requestPermission.mockResolvedValueOnce({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await localSession.prompt({
- sessionId: 'session-2',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- options: expect.arrayContaining([
- expect.objectContaining({
- optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
- name: 'Allow for this file in all future sessions',
- }),
- ]),
- }),
- );
- });
-
- it('should use filePath for ACP diff content in permission request', async () => {
- const confirmationDetails = {
- type: 'edit',
- title: 'Confirm Write: test.txt',
- fileName: 'test.txt',
- filePath: '/tmp/test.txt',
- originalContent: 'old',
- newContent: 'new',
- 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: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- toolCall: expect.objectContaining({
- content: expect.arrayContaining([
- expect.objectContaining({
- type: 'diff',
- path: '/tmp/test.txt',
- oldText: 'old',
- newText: 'new',
- }),
- ]),
- }),
- }),
- );
- });
-
- it('should split getDisplayTitle and getExplanation for title and content in permission request', async () => {
- const confirmationDetails = {
- type: 'info',
- onConfirm: vi.fn(),
- };
- mockTool.build.mockReturnValue({
- getDescription: () => 'Original Description',
- getDisplayTitle: () => 'Display Title Only',
- getExplanation: () => 'A detailed explanation text',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
- execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
- });
-
- mockConnection.requestPermission.mockResolvedValue({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- toolCall: expect.objectContaining({
- title: 'Display Title Only',
- content: [],
- }),
- }),
- );
-
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'agent_thought_chunk',
- content: { type: 'text', text: 'A detailed explanation text' },
- }),
- }),
- );
- });
-
- it('should call updatePolicy when tool permission triggers always allow', 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: ToolConfirmationOutcome.ProceedAlways,
- },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- const { updatePolicy } = await import('@google/gemini-cli-core');
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(confirmationDetails.onConfirm).toHaveBeenCalled();
-
- expect(updatePolicy).toHaveBeenCalled();
- });
-
- it('should use filePath for ACP diff content in tool result', async () => {
- mockTool.build.mockReturnValue({
- getDescription: () => 'Test Tool',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(null),
- execute: vi.fn().mockResolvedValue({
- llmContent: 'Tool Result',
- returnDisplay: {
- fileName: 'test.txt',
- filePath: '/tmp/test.txt',
- originalContent: 'old',
- newContent: 'new',
- },
- }),
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- const updateCalls = mockConnection.sessionUpdate.mock.calls.map(
- (call) => call[0],
- );
- const toolCallUpdate = updateCalls.find(
- (call) => call.update?.sessionUpdate === 'tool_call_update',
- );
-
- expect(toolCallUpdate).toEqual(
- expect.objectContaining({
- update: expect.objectContaining({
- content: expect.arrayContaining([
- expect.objectContaining({
- type: 'diff',
- path: '/tmp/test.txt',
- oldText: 'old',
- newText: 'new',
- }),
- ]),
- }),
- }),
- );
- });
-
- it('should handle tool call cancellation by user', 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: 'cancelled' },
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- // When cancelled, it sends an error response to the model
- // We can verify that the second call to sendMessageStream contains the error
- expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
- const secondCallArgs = mockChat.sendMessageStream.mock.calls[1];
- const parts = secondCallArgs[1]; // parts
- expect(parts).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- functionResponse: expect.objectContaining({
- response: {
- error: expect.stringContaining('canceled by the user'),
- },
- }),
- }),
- ]),
- );
- });
-
- it('should include _meta.kind in diff tool calls', async () => {
- // Test 'add' (no original content)
- const addConfirmation = {
- type: 'edit',
- fileName: 'new.txt',
- originalContent: null,
- newContent: 'New content',
- onConfirm: vi.fn(),
- };
-
- // Test 'modify' (original and new content)
- const modifyConfirmation = {
- type: 'edit',
- fileName: 'existing.txt',
- originalContent: 'Old content',
- newContent: 'New content',
- onConfirm: vi.fn(),
- };
-
- // Test 'delete' (original content, no new content)
- const deleteConfirmation = {
- type: 'edit',
- fileName: 'deleted.txt',
- originalContent: 'Old content',
- newContent: '',
- onConfirm: vi.fn(),
- };
-
- const mockBuild = vi.fn();
- mockTool.build = mockBuild;
-
- // Helper to simulate tool call and check permission request
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const checkDiffKind = async (confirmation: any, expectedKind: string) => {
- mockBuild.mockReturnValueOnce({
- getDescription: () => 'Test Tool',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(confirmation),
- execute: vi.fn().mockResolvedValue({ llmContent: 'Result' }),
- });
-
- mockConnection.requestPermission.mockResolvedValueOnce({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- });
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const emptyStream = createMockStream([]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream)
- .mockResolvedValueOnce(emptyStream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.requestPermission).toHaveBeenCalledWith(
- expect.objectContaining({
- toolCall: expect.objectContaining({
- content: expect.arrayContaining([
- expect.objectContaining({
- type: 'diff',
- _meta: { kind: expectedKind },
- }),
- ]),
- }),
- }),
- );
- };
-
- await checkDiffKind(addConfirmation, 'add');
- await checkDiffKind(modifyConfirmation, 'modify');
- await checkDiffKind(deleteConfirmation, 'delete');
- });
-
- 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: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(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(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'tool_call_update',
- status: 'completed',
- title: 'Read files',
- locations: [],
- kind: 'read',
- }),
- }),
- );
-
- // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content)
- // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream
- expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
- expect.anything(),
- expect.arrayContaining([
- expect.objectContaining({
- text: expect.stringContaining('Content from @file.txt'),
- }),
- ]),
- expect.anything(),
- expect.any(AbortSignal),
- LlmRole.MAIN,
- );
- });
-
- it('should handle @path resolution error', async () => {
- (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt');
- (fs.stat as unknown as Mock).mockResolvedValue({
- isDirectory: () => false,
- });
-
- const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
- MockReadManyFilesTool.mockImplementationOnce(() => ({
- name: 'read_many_files',
- kind: 'read',
- build: vi.fn().mockReturnValue({
- getDescription: () => 'Read files',
- toolLocations: () => [],
- execute: vi.fn().mockRejectedValue(new Error('File read failed')),
- }),
- }));
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await expect(
- session.prompt({
- sessionId: 'session-1',
- prompt: [
- { type: 'text', text: 'Read' },
- {
- type: 'resource_link',
- uri: 'file://error.txt',
- mimeType: 'text/plain',
- name: 'error.txt',
- },
- ],
- }),
- ).rejects.toThrow('File read failed');
-
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'tool_call_update',
- status: 'failed',
- content: expect.arrayContaining([
- expect.objectContaining({
- content: expect.objectContaining({
- text: expect.stringMatching(/File read failed/),
- }),
- }),
- ]),
- kind: 'read',
- }),
- }),
- );
- });
-
- it('should handle @path validation error and bubble it to user', async () => {
- mockConfig.getTargetDir.mockReturnValue('/workspace');
- (path.resolve as unknown as Mock).mockReturnValue('/tmp/disallowed.txt');
- mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
-
- // Force fs.stat to fail to skip direct reading and triggers the warning
- (fs.stat as unknown as Mock).mockRejectedValue(new Error('File not found'));
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- {
- type: 'resource_link',
- uri: 'file://disallowed.txt',
- mimeType: 'text/plain',
- name: 'disallowed.txt',
- },
- ],
- });
-
- // Verify warning sent via sendUpdate
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'agent_thought_chunk',
- content: expect.objectContaining({
- text: expect.stringContaining(
- 'Warning: skipping access to `disallowed.txt`. Reason: Path is outside workspace',
- ),
- }),
- }),
- }),
- );
- });
-
- it('should read absolute file directly if outside workspace', async () => {
- mockConfig.getTargetDir.mockReturnValue('/workspace');
- const testFilePath = '/tmp/custom.txt';
- (path.resolve as unknown as Mock).mockReturnValue(testFilePath);
- mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
-
- mockConnection.requestPermission.mockResolvedValue({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- } as unknown as acp.RequestPermissionResponse);
-
- const mockStats = {
- isFile: () => true,
- isDirectory: () => false,
- };
- (fs.stat as unknown as Mock).mockResolvedValue(mockStats);
- (processSingleFileContent as unknown as Mock).mockResolvedValue({
- llmContent: 'Absolute File Content',
- });
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- {
- type: 'resource_link',
- uri: `file://${testFilePath}`,
- mimeType: 'text/plain',
- name: 'custom.txt',
- },
- ],
- });
-
- expect(processSingleFileContent).toHaveBeenCalledWith(
- testFilePath,
- expect.anything(),
- expect.anything(),
- );
-
- // Verify content appended to sendMessageStream parts
- expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
- expect.anything(),
- expect.arrayContaining([
- expect.objectContaining({
- text: 'Absolute File Content',
- }),
- ]),
- expect.anything(),
- expect.any(AbortSignal),
- expect.anything(),
- );
- });
-
- it('should read escaping relative file directly if outside workspace', async () => {
- mockConfig.getTargetDir.mockReturnValue('/workspace');
- const testFilePath = '../../custom.txt';
- (path.resolve as unknown as Mock).mockReturnValue('/custom.txt');
- mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace');
-
- mockConnection.requestPermission.mockResolvedValue({
- outcome: {
- outcome: 'selected',
- optionId: ToolConfirmationOutcome.ProceedOnce,
- },
- } as unknown as acp.RequestPermissionResponse);
-
- const mockStats = {
- isFile: () => true,
- isDirectory: () => false,
- };
- (fs.stat as unknown as Mock).mockResolvedValue(mockStats);
- (processSingleFileContent as unknown as Mock).mockResolvedValue({
- llmContent: 'Escaping Relative File Content',
- });
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- {
- type: 'resource_link',
- uri: `file://${testFilePath}`,
- mimeType: 'text/plain',
- name: 'custom.txt',
- },
- ],
- });
-
- expect(processSingleFileContent).toHaveBeenCalledWith(
- '/custom.txt',
- expect.any(String),
- expect.anything(),
- );
-
- expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
- expect.anything(),
- expect.arrayContaining([
- expect.objectContaining({
- text: 'Escaping Relative File Content',
- }),
- ]),
- expect.anything(),
- expect.any(AbortSignal),
- expect.anything(),
- );
- });
-
- it('should handle cancellation during prompt', async () => {
- let streamController: ReadableStreamDefaultController;
- const stream = new ReadableStream({
- start(controller) {
- streamController = controller;
- },
- });
-
- let streamStarted: (value: unknown) => void;
- const streamStartedPromise = new Promise((resolve) => {
- streamStarted = resolve;
- });
-
- // Adapt web stream to async iterable
- async function* asyncStream() {
- process.stdout.write('TEST: asyncStream started\n');
- streamStarted(true);
- const reader = stream.getReader();
- try {
- while (true) {
- process.stdout.write('TEST: waiting for read\n');
- const { done, value } = await reader.read();
- process.stdout.write(`TEST: read returned done=${done}\n`);
- if (done) break;
- yield value;
- }
- } finally {
- process.stdout.write('TEST: releasing lock\n');
- reader.releaseLock();
- }
- }
-
- mockChat.sendMessageStream.mockResolvedValue(asyncStream());
-
- process.stdout.write('TEST: calling prompt\n');
- const promptPromise = session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Hi' }],
- });
-
- process.stdout.write('TEST: waiting for streamStarted\n');
- await streamStartedPromise;
- process.stdout.write('TEST: streamStarted\n');
- await session.cancelPendingPrompt();
- process.stdout.write('TEST: cancelled\n');
-
- // Close the stream to allow prompt loop to continue and check aborted signal
- streamController!.close();
- process.stdout.write('TEST: stream closed\n');
-
- const result = await promptPromise;
- process.stdout.write(`TEST: result received ${JSON.stringify(result)}\n`);
- expect(result).toEqual({ stopReason: 'cancelled' });
- });
-
- it('should handle rate limit error', async () => {
- const error = new Error('Rate limit');
- (error as unknown as { status: number }).status = 429;
- mockChat.sendMessageStream.mockRejectedValue(error);
-
- 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 tool execution error', async () => {
- mockTool.build.mockReturnValue({
- getDescription: () => 'Test Tool',
- toolLocations: () => [],
- shouldConfirmExecute: vi.fn().mockResolvedValue(null),
- execute: vi.fn().mockRejectedValue(new Error('Tool failed')),
- });
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'test_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
- expect.objectContaining({
- update: expect.objectContaining({
- sessionUpdate: 'tool_call_update',
- status: 'failed',
- content: expect.arrayContaining([
- expect.objectContaining({
- content: expect.objectContaining({ text: 'Tool failed' }),
- }),
- ]),
- kind: 'read',
- }),
- }),
- );
- });
-
- it('should handle missing tool', async () => {
- mockToolRegistry.getTool.mockReturnValue(undefined);
-
- const stream1 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: {
- functionCalls: [{ name: 'unknown_tool', args: {} }],
- },
- },
- ]);
- const stream2 = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
-
- mockChat.sendMessageStream
- .mockResolvedValueOnce(stream1)
- .mockResolvedValueOnce(stream2);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [{ type: 'text', text: 'Call tool' }],
- });
-
- // Should send error response to model
- expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
- const secondCallArgs = mockChat.sendMessageStream.mock.calls[1];
- const parts = secondCallArgs[1];
- expect(parts).toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- functionResponse: expect.objectContaining({
- response: {
- error: expect.stringContaining('not found in registry'),
- },
- }),
- }),
- ]),
- );
- });
-
- it('should ignore files based on configuration', async () => {
- (
- mockConfig.getFileService().shouldIgnoreFile as unknown as Mock
- ).mockReturnValue(true);
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- {
- type: 'resource_link',
- uri: 'file://ignored.txt',
- mimeType: 'text/plain',
- name: 'ignored.txt',
- },
- ],
- });
-
- // Should not read file
- expect(mockToolRegistry.getTool).not.toHaveBeenCalledWith(
- 'read_many_files',
- );
- });
-
- it('should handle directory resolution with glob', async () => {
- (path.resolve as unknown as Mock).mockReturnValue('/tmp/dir');
- (fs.stat as unknown as Mock).mockResolvedValue({
- isDirectory: () => true,
- });
-
- const stream = createMockStream([
- {
- type: StreamEventType.CHUNK,
- value: { candidates: [] },
- },
- ]);
- mockChat.sendMessageStream.mockResolvedValue(stream);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- {
- type: 'resource_link',
- uri: 'file://dir',
- mimeType: 'text/plain',
- name: 'dir',
- },
- ],
- });
-
- // Should use glob
- // ReadManyFilesTool is instantiated directly, so we check if the mock instance's build method was called
- const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock;
- const mockInstance =
- MockReadManyFilesTool.mock.results[
- MockReadManyFilesTool.mock.results.length - 1
- ].value;
- expect(mockInstance.build).toHaveBeenCalled();
- });
-
- it('should set mode on config', () => {
- session.setMode(ApprovalMode.AUTO_EDIT);
- expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
- ApprovalMode.AUTO_EDIT,
- );
- });
-
- it('should throw error for invalid mode', () => {
- expect(() => session.setMode('invalid-mode')).toThrow(
- 'Invalid or unavailable mode: invalid-mode',
- );
- });
-
- it('should set model on config', () => {
- session.setModel('gemini-2.0-flash-exp');
- expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
- });
-
- it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
- // Mock handleCommand to verify it gets called
- const handleCommandSpy = vi
- .spyOn(
- (session as unknown as { commandHandler: CommandHandler })
- .commandHandler,
- 'handleCommand',
- )
- .mockResolvedValue(true);
-
- await session.prompt({
- sessionId: 'session-1',
- prompt: [
- { type: 'text', text: '' },
- { type: 'text', text: '/memory' },
- ],
- });
-
- expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
- });
-});
diff --git a/packages/cli/src/acp/commandHandler.test.ts b/packages/cli/src/acp/acpCommandHandler.test.ts
similarity index 94%
rename from packages/cli/src/acp/commandHandler.test.ts
rename to packages/cli/src/acp/acpCommandHandler.test.ts
index 4a1ce6d2e5..7cc1670688 100644
--- a/packages/cli/src/acp/commandHandler.test.ts
+++ b/packages/cli/src/acp/acpCommandHandler.test.ts
@@ -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', () => {
diff --git a/packages/cli/src/acp/commandHandler.ts b/packages/cli/src/acp/acpCommandHandler.ts
similarity index 99%
rename from packages/cli/src/acp/commandHandler.ts
rename to packages/cli/src/acp/acpCommandHandler.ts
index b35512adb2..6b171b5532 100644
--- a/packages/cli/src/acp/commandHandler.ts
+++ b/packages/cli/src/acp/acpCommandHandler.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/acpErrors.test.ts b/packages/cli/src/acp/acpErrors.test.ts
index 2ea4d528d0..1eeb78d59d 100644
--- a/packages/cli/src/acp/acpErrors.test.ts
+++ b/packages/cli/src/acp/acpErrors.test.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/acpErrors.ts b/packages/cli/src/acp/acpErrors.ts
index 57067115bf..c2988b8c4f 100644
--- a/packages/cli/src/acp/acpErrors.ts
+++ b/packages/cli/src/acp/acpErrors.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/fileSystemService.test.ts b/packages/cli/src/acp/acpFileSystemService.test.ts
similarity index 98%
rename from packages/cli/src/acp/fileSystemService.test.ts
rename to packages/cli/src/acp/acpFileSystemService.test.ts
index 188aadbc09..7ddbb21537 100644
--- a/packages/cli/src/acp/fileSystemService.test.ts
+++ b/packages/cli/src/acp/acpFileSystemService.test.ts
@@ -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';
diff --git a/packages/cli/src/acp/fileSystemService.ts b/packages/cli/src/acp/acpFileSystemService.ts
similarity index 98%
rename from packages/cli/src/acp/fileSystemService.ts
rename to packages/cli/src/acp/acpFileSystemService.ts
index b020cd27f2..c11dc7f6cf 100644
--- a/packages/cli/src/acp/fileSystemService.ts
+++ b/packages/cli/src/acp/acpFileSystemService.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts
index 6a92d68814..d8bbe7e5db 100644
--- a/packages/cli/src/acp/acpResume.test.ts
+++ b/packages/cli/src/acp/acpResume.test.ts
@@ -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({
diff --git a/packages/cli/src/acp/acpRpcDispatcher.test.ts b/packages/cli/src/acp/acpRpcDispatcher.test.ts
new file mode 100644
index 0000000000..a677c5631b
--- /dev/null
+++ b/packages/cli/src/acp/acpRpcDispatcher.test.ts
@@ -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();
+ return {
+ ...actual,
+ loadSettings: vi.fn(),
+ };
+});
+
+describe('GeminiAgent - RPC Dispatcher', () => {
+ let mockConfig: Mocked;
+ let mockSettings: Mocked;
+ let mockArgv: CliArgs;
+ let mockConnection: Mocked;
+ 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;
+ mockSettings = {
+ merged: {
+ security: { auth: { selectedType: 'login_with_google' } },
+ mcpServers: {},
+ },
+ setValue: vi.fn(),
+ } as unknown as Mocked;
+ mockArgv = {} as unknown as CliArgs;
+ mockConnection = {
+ sessionUpdate: vi.fn(),
+ requestPermission: vi.fn(),
+ } as unknown as Mocked;
+
+ (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');
+ });
+});
diff --git a/packages/cli/src/acp/acpRpcDispatcher.ts b/packages/cli/src/acp/acpRpcDispatcher.ts
new file mode 100644
index 0000000000..97fb0d4011
--- /dev/null
+++ b/packages/cli/src/acp/acpRpcDispatcher.ts
@@ -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 | 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 {
+ 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 {
+ 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 | 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 {
+ return this.sessionManager.newSession(params, this.getAuthDetails());
+ }
+
+ async loadSession(
+ params: acp.LoadSessionRequest,
+ ): Promise {
+ return this.sessionManager.loadSession(params, this.getAuthDetails());
+ }
+
+ async cancel(params: acp.CancelNotification): Promise {
+ 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 {
+ 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 {
+ 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 {
+ const session = this.sessionManager.getSession(params.sessionId);
+ if (!session) {
+ throw new acp.RequestError(
+ -32602,
+ `Session not found: ${params.sessionId}`,
+ );
+ }
+ return session.setModel(params.modelId);
+ }
+}
diff --git a/packages/cli/src/acp/acpSession.test.ts b/packages/cli/src/acp/acpSession.test.ts
new file mode 100644
index 0000000000..c87c1cc4b4
--- /dev/null
+++ b/packages/cli/src/acp/acpSession.test.ts
@@ -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();
+ return {
+ ...actual,
+ resolve: vi.fn(),
+ };
+});
+
+vi.mock(
+ '@google/gemini-cli-core',
+ async (
+ importOriginal: () => Promise,
+ ) => {
+ 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 {
+ 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;
+ let mockConfig: Mocked;
+ let mockConnection: Mocked;
+ let session: Session;
+ let mockToolRegistry: { getTool: Mock };
+ let mockTool: { kind: string; build: Mock };
+ let mockMessageBus: Mocked;
+ let mockSendMessageStream: MockInstance<
+ (
+ request: Part[],
+ signal: AbortSignal,
+ promptId: string,
+ ) => AsyncGenerator
+ >;
+
+ beforeEach(() => {
+ mockChat = {
+ sendMessageStream: vi.fn(),
+ addHistory: vi.fn(),
+ recordCompletedToolCalls: vi.fn(),
+ getHistory: vi.fn().mockReturnValue([]),
+ } as unknown as Mocked;
+ 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;
+ 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;
+ mockConnection = {
+ sessionUpdate: vi.fn(),
+ requestPermission: vi.fn(),
+ } as unknown as Mocked;
+
+ 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');
+ });
+});
diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpSession.ts
similarity index 57%
rename from packages/cli/src/acp/acpClient.ts
rename to packages/cli/src/acp/acpSession.ts
index 57c7790b05..bcc8a86248 100644
--- a/packages/cli/src/acp/acpClient.ts
+++ b/packages/cli/src/acp/acpSession.ts
@@ -1,575 +1,67 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
- type Config,
- type GeminiChat,
- type ToolResult,
- type ToolCallConfirmationDetails,
- type FilterFilesOptions,
+ type ApprovalMode,
type ConversationRecord,
CoreToolCallStatus,
- AuthType,
logToolCall,
convertToFunctionResponse,
ToolConfirmationOutcome,
- clearCachedCredentialFile,
- isNodeError,
- getErrorMessage,
- isWithinRoot,
getErrorStatus,
- MCPServerConfig,
DiscoveredMCPTool,
- StreamEventType,
ToolCallEvent,
debugLogger,
ReadManyFilesTool,
- REFERENCE_CONTENT_START,
- type RoutingContext,
- createWorkingStdio,
- startupProfiler,
- Kind,
partListUnionToString,
- LlmRole,
- ApprovalMode,
- getVersion,
- convertSessionToClientHistory,
- DEFAULT_GEMINI_MODEL,
- DEFAULT_GEMINI_FLASH_MODEL,
- DEFAULT_GEMINI_FLASH_LITE_MODEL,
- PREVIEW_GEMINI_MODEL,
- PREVIEW_GEMINI_3_1_MODEL,
- PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
- PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
- PREVIEW_GEMINI_FLASH_MODEL,
- DEFAULT_GEMINI_MODEL_AUTO,
- PREVIEW_GEMINI_MODEL_AUTO,
- getDisplayString,
- processSingleFileContent,
- InvalidStreamError,
type AgentLoopContext,
updatePolicy,
+ getErrorMessage,
+ type FilterFilesOptions,
+ isTextPart,
+ GeminiEventType,
+ type ToolCallRequestInfo,
+ type GeminiChat,
+ type ToolResult,
+ isWithinRoot,
+ processSingleFileContent,
+ isNodeError,
+ REFERENCE_CONTENT_START,
+ InvalidStreamError,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
-import { AcpFileSystemService } from './fileSystemService.js';
-import { getAcpErrorMessage } from './acpErrors.js';
-import { Readable, Writable } from 'node:stream';
-
-function hasMeta(obj: unknown): obj is { _meta?: Record } {
- return typeof obj === 'object' && obj !== null && '_meta' in obj;
-}
-import type { Content, Part, FunctionCall } from '@google/genai';
-import {
- SettingScope,
- loadSettings,
- type LoadedSettings,
-} from '../config/settings.js';
-import { createPolicyUpdater } from '../config/policy.js';
+import type { Part, FunctionCall } from '@google/genai';
+import type { LoadedSettings } from '../config/settings.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
-import { z } from 'zod';
-
import { randomUUID } from 'node:crypto';
-import { loadCliConfig, type CliArgs } from '../config/config.js';
-import { runExitCleanup } from '../utils/cleanup.js';
-import { SessionSelector } from '../utils/sessionUtils.js';
-import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
+import { CommandHandler } from './acpCommandHandler.js';
+import {
+ toToolCallContent,
+ toPermissionOptions,
+ toAcpToolKind,
+ buildAvailableModes,
+ RequestPermissionResponseSchema,
+} from './acpUtils.js';
+import { z } from 'zod';
+import { getAcpErrorMessage } from './acpErrors.js';
-import { CommandHandler } from './commandHandler.js';
-
-const RequestPermissionResponseSchema = z.object({
- outcome: z.discriminatedUnion('outcome', [
- z.object({ outcome: z.literal('cancelled') }),
- z.object({
- outcome: z.literal('selected'),
- optionId: z.string(),
- }),
- ]),
+const StructuredErrorSchema = z.object({
+ status: z.number().optional(),
+ message: z.string().optional(),
});
-export async function runAcpClient(
- config: Config,
- settings: LoadedSettings,
- argv: CliArgs,
-) {
- // ... (skip unchanged lines) ...
-
- 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;
-
- 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);
-}
-
-export class GeminiAgent {
- private static callIdCounter = 0;
-
- static generateCallId(name: string): string {
- return `${name}-${Date.now()}-${++GeminiAgent.callIdCounter}`;
- }
-
- private sessions: Map = new Map();
- private clientCapabilities: acp.ClientCapabilities | undefined;
- private apiKey: string | undefined;
- private baseUrl: string | undefined;
- private customHeaders: Record | undefined;
-
- constructor(
- private context: AgentLoopContext,
- private settings: LoadedSettings,
- private argv: CliArgs,
- private connection: acp.AgentSideConnection,
- ) {}
-
- async initialize(
- args: acp.InitializeRequest,
- ): Promise {
- this.clientCapabilities = 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 {
- 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 | 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,
- );
- }
-
- async newSession({
- cwd,
- mcpServers,
- }: acp.NewSessionRequest): Promise {
- 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,
- this.apiKey,
- this.baseUrl,
- this.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): Promise {
- const config = await this.initializeSessionConfig(
- sessionId,
- cwd,
- mcpServers,
- );
-
- 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[],
- ): Promise {
- 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,
- this.apiKey,
- this.baseUrl,
- this.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 {
- 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 = {};
- 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;
- }
-
- async cancel(params: acp.CancelNotification): Promise {
- const session = this.sessions.get(params.sessionId);
- if (!session) {
- throw new Error(`Session not found: ${params.sessionId}`);
- }
- await session.cancelPendingPrompt();
- }
-
- async prompt(params: acp.PromptRequest): Promise {
- const session = this.sessions.get(params.sessionId);
- if (!session) {
- throw new Error(`Session not found: ${params.sessionId}`);
- }
- return session.prompt(params);
- }
-
- async setSessionMode(
- params: acp.SetSessionModeRequest,
- ): Promise {
- const session = this.sessions.get(params.sessionId);
- if (!session) {
- throw new Error(`Session not found: ${params.sessionId}`);
- }
- return session.setMode(params.modeId);
- }
-
- async unstable_setSessionModel(
- params: acp.SetSessionModelRequest,
- ): Promise {
- const session = this.sessions.get(params.sessionId);
- if (!session) {
- throw new Error(`Session not found: ${params.sessionId}`);
- }
- return session.setModel(params.modelId);
- }
-}
-
export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
+ private callIdCounter = 0;
+
+ private generateCallId(name: string): string {
+ return `${name}-${Date.now()}-${++this.callIdCounter}`;
+ }
constructor(
private readonly id: string,
@@ -700,7 +192,6 @@ export class Session {
await this.context.config.waitForMcpInit();
const promptId = Math.random().toString(16).slice(2);
- const chat = this.chat;
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
@@ -709,13 +200,10 @@ export class Session {
for (const part of parts) {
if (typeof part === 'object' && part !== null) {
- if ('text' in part) {
+ if (isTextPart(part)) {
// It is a text part
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
- const text = (part as any).text;
- if (typeof text === 'string') {
- commandText += text;
- }
+ const text = part.text;
+ commandText += text;
} else {
// Non-text part (image, embedded resource)
// Stop looking for command
@@ -751,100 +239,125 @@ export class Session {
let totalOutputTokens = 0;
const modelUsageMap = new Map();
- let nextMessage: Content | null = { role: 'user', parts };
+ let currentParts: Part[] = parts;
+ let turnCount = 0;
+ const maxTurns = this.context.config.getMaxSessionTurns();
- while (nextMessage !== null) {
- if (pendingSend.signal.aborted) {
- chat.addHistory(nextMessage);
- return { stopReason: CoreToolCallStatus.Cancelled };
+ while (true) {
+ turnCount++;
+ if (maxTurns >= 0 && turnCount > maxTurns) {
+ return {
+ stopReason: 'max_turn_requests',
+ _meta: {
+ quota: {
+ token_count: {
+ input_tokens: totalInputTokens,
+ output_tokens: totalOutputTokens,
+ },
+ model_usage: Array.from(modelUsageMap.entries()).map(
+ ([modelName, counts]) => ({
+ model: modelName,
+ token_count: {
+ input_tokens: counts.input,
+ output_tokens: counts.output,
+ },
+ }),
+ ),
+ },
+ },
+ };
}
- const functionCalls: FunctionCall[] = [];
+ if (pendingSend.signal.aborted) {
+ return { stopReason: 'cancelled' };
+ }
+
+ const toolCallRequests: ToolCallRequestInfo[] = [];
+ let stopReason: acp.StopReason = 'end_turn';
+ let turnModelId = this.context.config.getModel();
+ let turnInputTokens = 0;
+ let turnOutputTokens = 0;
try {
- const routingContext: RoutingContext = {
- history: chat.getHistory(/*curated=*/ true),
- request: nextMessage?.parts ?? [],
- signal: pendingSend.signal,
- requestedModel: this.context.config.getModel(),
- };
-
- const router = this.context.config.getModelRouterService();
- const { model } = await router.route(routingContext);
- const responseStream = await chat.sendMessageStream(
- { model },
- nextMessage?.parts ?? [],
- promptId,
+ const responseStream = this.context.geminiClient.sendMessageStream(
+ currentParts,
pendingSend.signal,
- LlmRole.MAIN,
+ promptId,
);
- nextMessage = null;
- let turnInputTokens = 0;
- let turnOutputTokens = 0;
- let turnModelId = model;
-
- for await (const resp of responseStream) {
+ for await (const event of responseStream) {
if (pendingSend.signal.aborted) {
- return { stopReason: CoreToolCallStatus.Cancelled };
+ return { stopReason: 'cancelled' };
}
- if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
- turnInputTokens =
- resp.value.usageMetadata.promptTokenCount ?? turnInputTokens;
- turnOutputTokens =
- resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens;
- if (resp.value.modelVersion) {
- turnModelId = resp.value.modelVersion;
- }
- }
-
- if (
- resp.type === StreamEventType.CHUNK &&
- resp.value.candidates &&
- resp.value.candidates.length > 0
- ) {
- const candidate = resp.value.candidates[0];
- for (const part of candidate.content?.parts ?? []) {
- if (!part.text) {
- continue;
- }
-
+ switch (event.type) {
+ case GeminiEventType.Content: {
const content: acp.ContentBlock = {
type: 'text',
- text: part.text,
+ text: event.value,
};
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- this.sendUpdate({
- sessionUpdate: part.thought
- ? 'agent_thought_chunk'
- : 'agent_message_chunk',
+ await this.sendUpdate({
+ sessionUpdate: 'agent_message_chunk',
content,
});
+ break;
}
+
+ case GeminiEventType.Thought: {
+ const thoughtText = `**${event.value.subject}**\n${event.value.description}`;
+ await this.sendUpdate({
+ sessionUpdate: 'agent_thought_chunk',
+ content: { type: 'text', text: thoughtText },
+ });
+ break;
+ }
+
+ case GeminiEventType.ToolCallRequest:
+ toolCallRequests.push(event.value);
+ break;
+
+ case GeminiEventType.Finished: {
+ const usage = event.value.usageMetadata;
+ if (usage) {
+ turnInputTokens = usage.promptTokenCount ?? turnInputTokens;
+ turnOutputTokens =
+ usage.candidatesTokenCount ?? turnOutputTokens;
+ }
+ break;
+ }
+
+ case GeminiEventType.ModelInfo:
+ turnModelId = event.value;
+ break;
+
+ case GeminiEventType.MaxSessionTurns:
+ stopReason = 'max_turn_requests';
+ break;
+
+ case GeminiEventType.LoopDetected:
+ stopReason = 'max_turn_requests';
+ break;
+
+ case GeminiEventType.ContextWindowWillOverflow:
+ stopReason = 'max_tokens';
+ break;
+
+ case GeminiEventType.Error: {
+ const parseResult = StructuredErrorSchema.safeParse(
+ event.value.error,
+ );
+ const errData = parseResult.success ? parseResult.data : {};
+
+ throw new acp.RequestError(
+ errData.status ?? 500,
+ errData.message ?? 'Unknown stream execution error.',
+ );
+ }
+
+ default:
+ break;
}
-
- if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
- functionCalls.push(...resp.value.functionCalls);
- }
- }
-
- totalInputTokens += turnInputTokens;
- totalOutputTokens += turnOutputTokens;
-
- if (turnInputTokens > 0 || turnOutputTokens > 0) {
- const existing = modelUsageMap.get(turnModelId) ?? {
- input: 0,
- output: 0,
- };
- existing.input += turnInputTokens;
- existing.output += turnOutputTokens;
- modelUsageMap.set(turnModelId, existing);
- }
-
- if (pendingSend.signal.aborted) {
- return { stopReason: CoreToolCallStatus.Cancelled };
}
} catch (error) {
if (getErrorStatus(error) === 429) {
@@ -858,7 +371,11 @@ export class Session {
pendingSend.signal.aborted ||
(error instanceof Error && error.name === 'AbortError')
) {
- return { stopReason: CoreToolCallStatus.Cancelled };
+ return { stopReason: 'cancelled' };
+ }
+
+ if (error instanceof acp.RequestError) {
+ throw error;
}
if (
@@ -901,16 +418,59 @@ export class Session {
);
}
- if (functionCalls.length > 0) {
- const toolResponseParts: Part[] = [];
+ totalInputTokens += turnInputTokens;
+ totalOutputTokens += turnOutputTokens;
- for (const fc of functionCalls) {
- const response = await this.runTool(pendingSend.signal, promptId, fc);
- toolResponseParts.push(...response);
- }
-
- nextMessage = { role: 'user', parts: toolResponseParts };
+ if (turnInputTokens > 0 || turnOutputTokens > 0) {
+ const existing = modelUsageMap.get(turnModelId) ?? {
+ input: 0,
+ output: 0,
+ };
+ existing.input += turnInputTokens;
+ existing.output += turnOutputTokens;
+ modelUsageMap.set(turnModelId, existing);
}
+
+ if (stopReason !== 'end_turn') {
+ return {
+ stopReason,
+ _meta: {
+ quota: {
+ token_count: {
+ input_tokens: totalInputTokens,
+ output_tokens: totalOutputTokens,
+ },
+ model_usage: Array.from(modelUsageMap.entries()).map(
+ ([modelName, counts]) => ({
+ model: modelName,
+ token_count: {
+ input_tokens: counts.input,
+ output_tokens: counts.output,
+ },
+ }),
+ ),
+ },
+ },
+ };
+ }
+
+ if (toolCallRequests.length === 0) {
+ break;
+ }
+
+ const toolResponseParts: Part[] = [];
+ for (const tReq of toolCallRequests) {
+ const fc: FunctionCall = {
+ id: tReq.callId,
+ name: tReq.name,
+ args: tReq.args,
+ };
+
+ const response = await this.runTool(pendingSend.signal, promptId, fc);
+ toolResponseParts.push(...response);
+ }
+
+ currentParts = toolResponseParts;
}
const modelUsageArray = Array.from(modelUsageMap.entries()).map(
@@ -972,7 +532,7 @@ export class Session {
promptId: string,
fc: FunctionCall,
): Promise {
- const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
+ const callId = fc.id ?? this.generateCallId(fc.name || 'unknown');
const args = fc.args ?? {};
const startTime = Date.now();
@@ -1661,7 +1221,7 @@ export class Session {
include: pathSpecsToRead,
};
- const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
+ const callId = this.generateCallId(readManyFilesTool.name);
try {
const invocation = readManyFilesTool.build(toolArgs);
@@ -1799,333 +1359,3 @@ export class Session {
}
}
}
-
-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;
-
-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;
-}
-
-/**
- * Maps our internal tool kind to the ACP ToolKind.
- * Fallback to 'other' for kinds that are not supported by the ACP protocol.
- */
-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';
- }
-}
-
-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;
-}
-
-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,
- };
-}
diff --git a/packages/cli/src/acp/acpSessionManager.test.ts b/packages/cli/src/acp/acpSessionManager.test.ts
new file mode 100644
index 0000000000..81a556a952
--- /dev/null
+++ b/packages/cli/src/acp/acpSessionManager.test.ts
@@ -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();
+ return {
+ ...actual,
+ loadSettings: vi.fn(),
+ };
+});
+
+const startAutoMemoryIfEnabledMock = vi.fn();
+vi.mock('../utils/autoMemory.js', () => ({
+ startAutoMemoryIfEnabled: (config: Config) =>
+ startAutoMemoryIfEnabledMock(config),
+}));
+
+describe('AcpSessionManager', () => {
+ let mockConfig: Mocked;
+ let mockSettings: Mocked;
+ let mockArgv: CliArgs;
+ let mockConnection: Mocked;
+ 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;
+ mockSettings = {
+ merged: {
+ security: { auth: { selectedType: 'login_with_google' } },
+ mcpServers: {},
+ },
+ setValue: vi.fn(),
+ } as unknown as Mocked;
+ mockArgv = {} as unknown as CliArgs;
+ mockConnection = {
+ sessionUpdate: vi.fn(),
+ requestPermission: vi.fn(),
+ } as unknown as Mocked;
+
+ (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);
+ });
+});
diff --git a/packages/cli/src/acp/acpSessionManager.ts b/packages/cli/src/acp/acpSessionManager.ts
new file mode 100644
index 0000000000..828dae9b14
--- /dev/null
+++ b/packages/cli/src/acp/acpSessionManager.ts
@@ -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;
+}
+
+export class AcpSessionManager {
+ private sessions: Map = 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 = {};
+ 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;
+ }
+}
diff --git a/packages/cli/src/acp/acpStdioTransport.ts b/packages/cli/src/acp/acpStdioTransport.ts
new file mode 100644
index 0000000000..59198dee62
--- /dev/null
+++ b/packages/cli/src/acp/acpStdioTransport.ts
@@ -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;
+
+ 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);
+}
diff --git a/packages/cli/src/acp/acpUtils.ts b/packages/cli/src/acp/acpUtils.ts
new file mode 100644
index 0000000000..403227628e
--- /dev/null
+++ b/packages/cli/src/acp/acpUtils.ts
@@ -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 } {
+ 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,
+ };
+}
diff --git a/packages/cli/src/acp/commands/commandRegistry.ts b/packages/cli/src/acp/commands/commandRegistry.ts
index b689d5d602..e8af6c4048 100644
--- a/packages/cli/src/acp/commands/commandRegistry.ts
+++ b/packages/cli/src/acp/commands/commandRegistry.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts
index 7ebe922402..52f17c48b3 100644
--- a/packages/cli/src/acp/commands/extensions.ts
+++ b/packages/cli/src/acp/commands/extensions.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts
index a9104aa84f..954be123d0 100644
--- a/packages/cli/src/acp/commands/init.ts
+++ b/packages/cli/src/acp/commands/init.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts
index 8e990e12a7..bb91e5dbdd 100644
--- a/packages/cli/src/acp/commands/memory.ts
+++ b/packages/cli/src/acp/commands/memory.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/commands/restore.test.ts b/packages/cli/src/acp/commands/restore.test.ts
new file mode 100644
index 0000000000..c9bfaf5063
--- /dev/null
+++ b/packages/cli/src/acp/commands/restore.test.ts
@@ -0,0 +1,244 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
+import * as fs from 'node:fs/promises';
+import {
+ getCheckpointInfoList,
+ getToolCallDataSchema,
+ isNodeError,
+ performRestore,
+} from '@google/gemini-cli-core';
+import type { CommandContext } from './types.js';
+import type { Mock } from 'vitest';
+
+vi.mock('node:fs/promises');
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ getCheckpointInfoList: vi.fn(),
+ getToolCallDataSchema: vi.fn(),
+ isNodeError: vi.fn(),
+ performRestore: vi.fn(),
+ };
+});
+
+describe('RestoreCommand', () => {
+ let context: CommandContext;
+ let restoreCommand: RestoreCommand;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ restoreCommand = new RestoreCommand();
+ context = {
+ agentContext: {
+ config: {
+ getCheckpointingEnabled: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectTempCheckpointsDir: vi
+ .fn()
+ .mockReturnValue('/tmp/checkpoints'),
+ },
+ },
+ },
+ git: {},
+ sendMessage: vi.fn(),
+ } as unknown as CommandContext;
+ });
+
+ it('delegates to list behavior when invoked without args', async () => {
+ const listExecuteSpy = vi
+ .spyOn(ListCheckpointsCommand.prototype, 'execute')
+ .mockResolvedValue({
+ name: 'restore list',
+ data: 'list data',
+ });
+
+ const response = await restoreCommand.execute(context, []);
+
+ expect(listExecuteSpy).toHaveBeenCalledWith(context);
+ expect(response).toEqual({
+ name: 'restore list',
+ data: 'list data',
+ });
+ });
+
+ it('returns checkpointing-disabled message when disabled', async () => {
+ (
+ context.agentContext.config.getCheckpointingEnabled as Mock
+ ).mockReturnValue(false);
+
+ const response = await restoreCommand.execute(context, ['checkpoint1']);
+
+ expect(response.data).toContain('Checkpointing is not enabled');
+ });
+
+ it('returns file-not-found message for missing checkpoint', async () => {
+ const error = new Error('ENOENT');
+ (error as Error & { code: string }).code = 'ENOENT';
+ vi.mocked(fs.readFile).mockRejectedValue(error);
+ vi.mocked(isNodeError).mockReturnValue(true);
+
+ const response = await restoreCommand.execute(context, ['missing']);
+
+ expect(response.data).toBe('File not found: missing.json');
+ });
+
+ it('handles checkpoint filename already ending in .json', async () => {
+ const error = new Error('ENOENT');
+ (error as Error & { code: string }).code = 'ENOENT';
+ vi.mocked(fs.readFile).mockRejectedValue(error);
+ vi.mocked(isNodeError).mockReturnValue(true);
+
+ const response = await restoreCommand.execute(context, ['existing.json']);
+
+ expect(response.data).toBe('File not found: existing.json');
+ expect(fs.readFile).toHaveBeenCalledWith(
+ expect.stringContaining('existing.json'),
+ 'utf-8',
+ );
+ });
+
+ it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
+ vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
+ vi.mocked(getToolCallDataSchema).mockReturnValue({
+ safeParse: vi.fn().mockReturnValue({ success: false }),
+ } as unknown as ReturnType);
+
+ const response = await restoreCommand.execute(context, ['invalid']);
+
+ expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
+ });
+
+ it('formats streamed restore results correctly', async () => {
+ vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
+ vi.mocked(getToolCallDataSchema).mockReturnValue({
+ safeParse: vi
+ .fn()
+ .mockReturnValue({ success: true, data: { some: 'data' } }),
+ } as unknown as ReturnType);
+
+ async function* mockRestoreGenerator() {
+ yield { type: 'message', messageType: 'info', content: 'Restoring...' };
+ yield { type: 'load_history', clientHistory: [{}, {}] };
+ yield { type: 'other', some: 'other' };
+ }
+ vi.mocked(performRestore).mockReturnValue(
+ mockRestoreGenerator() as unknown as ReturnType,
+ );
+
+ const response = await restoreCommand.execute(context, ['valid']);
+
+ expect(response.data).toContain('[INFO] Restoring...');
+ expect(response.data).toContain('Loaded history with 2 messages.');
+ expect(response.data).toContain(
+ 'Restored: {"type":"other","some":"other"}',
+ );
+ });
+
+ it('returns generic unexpected error message for non-ENOENT failures', async () => {
+ vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
+ vi.mocked(isNodeError).mockReturnValue(false);
+
+ const response = await restoreCommand.execute(context, ['error']);
+
+ expect(response.data).toContain(
+ 'An unexpected error occurred during restore: Error: Random error',
+ );
+ });
+});
+
+describe('ListCheckpointsCommand', () => {
+ let context: CommandContext;
+ let listCommand: ListCheckpointsCommand;
+ let mockReaddir: Mock<(path: string) => Promise>;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ listCommand = new ListCheckpointsCommand();
+ mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
+ (path: string) => Promise
+ >;
+
+ context = {
+ agentContext: {
+ config: {
+ getCheckpointingEnabled: vi.fn().mockReturnValue(true),
+ storage: {
+ getProjectTempCheckpointsDir: vi
+ .fn()
+ .mockReturnValue('/tmp/checkpoints'),
+ },
+ },
+ },
+ } as unknown as CommandContext;
+ });
+
+ it('returns checkpointing-disabled message when disabled', async () => {
+ (
+ context.agentContext.config.getCheckpointingEnabled as Mock
+ ).mockReturnValue(false);
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toContain('Checkpointing is not enabled');
+ });
+
+ it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
+ mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toBe('No checkpoints found.');
+ });
+
+ it('ignores error when mkdir fails', async () => {
+ vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
+ mockReaddir.mockResolvedValue([]);
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toBe('No checkpoints found.');
+ expect(fs.mkdir).toHaveBeenCalled();
+ });
+
+ it('formats checkpoint summary output from checkpoint metadata', async () => {
+ mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
+ vi.mocked(getCheckpointInfoList).mockReturnValue([
+ { messageId: 'id1', checkpoint: 'cp1' },
+ { messageId: 'id2', checkpoint: 'cp2' },
+ ]);
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toContain('Available Checkpoints:');
+ // Note: The current implementation of ListCheckpointsCommand incorrectly accesses
+ // fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
+ expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
+ });
+
+ it('handles empty checkpoint info list', async () => {
+ mockReaddir.mockResolvedValue(['some.json']);
+ vi.mocked(getCheckpointInfoList).mockReturnValue([]);
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toBe('Available Checkpoints:\n');
+ });
+
+ it('returns generic unexpected error message on failures', async () => {
+ mockReaddir.mockRejectedValue(new Error('Readdir fail'));
+
+ const response = await listCommand.execute(context);
+
+ expect(response.data).toBe(
+ 'An unexpected error occurred while listing checkpoints.',
+ );
+ });
+});
diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts
index 4ffc5dfba2..e45dec67e2 100644
--- a/packages/cli/src/acp/commands/restore.ts
+++ b/packages/cli/src/acp/commands/restore.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts
index 6f5656bd89..a175d5fc82 100644
--- a/packages/cli/src/acp/commands/types.ts
+++ b/packages/cli/src/acp/commands/types.ts
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts
index 578894845e..b75556f1cf 100644
--- a/packages/cli/src/commands/mcp/list.test.ts
+++ b/packages/cli/src/commands/mcp/list.test.ts
@@ -217,6 +217,78 @@ describe('mcp list command', () => {
);
});
+ it('should display connected status even if ping fails', async () => {
+ const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ ...defaultMergedSettings,
+ mcpServers: {
+ 'test-server': { command: '/test/server' },
+ },
+ },
+ isTrusted: true,
+ });
+
+ mockClient.connect.mockResolvedValue(undefined);
+ mockClient.ping.mockRejectedValue(new Error('Ping failed'));
+
+ await listMcpServers();
+
+ expect(debugLogger.log).toHaveBeenCalledWith(
+ expect.stringContaining('test-server: /test/server (stdio) - Connected'),
+ );
+ });
+
+ it('should use configured timeout for connection', async () => {
+ const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ ...defaultMergedSettings,
+ mcpServers: {
+ 'test-server': { command: '/test/server', timeout: 12345 },
+ },
+ },
+ isTrusted: true,
+ });
+
+ mockClient.connect.mockResolvedValue(undefined);
+
+ await listMcpServers();
+
+ expect(mockClient.connect).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ timeout: 12345 }),
+ );
+ expect(mockClient.ping).toHaveBeenCalledWith(
+ expect.objectContaining({ timeout: 12345 }),
+ );
+ });
+
+ it('should use default timeout for connection when not configured', async () => {
+ const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
+ mockedLoadSettings.mockReturnValue({
+ merged: {
+ ...defaultMergedSettings,
+ mcpServers: {
+ 'test-server': { command: '/test/server' },
+ },
+ },
+ isTrusted: true,
+ });
+
+ mockClient.connect.mockResolvedValue(undefined);
+
+ await listMcpServers();
+
+ expect(mockClient.connect).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ expect(mockClient.ping).toHaveBeenCalledWith(
+ expect.objectContaining({ timeout: 5000 }),
+ );
+ });
+
it('should merge extension servers with config servers', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockedLoadSettings.mockReturnValue({
diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts
index 2747c77f00..6e57789321 100644
--- a/packages/cli/src/commands/mcp/list.ts
+++ b/packages/cli/src/commands/mcp/list.ts
@@ -67,6 +67,8 @@ export async function getMcpServersFromConfig(
return filteredResult;
}
+const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000;
+
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
@@ -127,11 +129,22 @@ async function testMCPConnection(
}
try {
- // Attempt actual MCP connection with short timeout
- await client.connect(transport, { timeout: 5000 }); // 5s timeout
+ // Attempt actual MCP connection with timeout from config or default to 5s.
+ // We use a short default for the list command to keep it responsive.
+ const timeout = config.timeout ?? MCP_LIST_DEFAULT_TIMEOUT_MSEC;
+ await client.connect(transport, { timeout });
- // Test basic MCP protocol by pinging the server
- await client.ping();
+ // Test basic MCP protocol by pinging the server.
+ // Ping is optional per MCP spec - some servers (e.g. Google first-party)
+ // don't implement it. A successful connect() is sufficient proof of connectivity.
+ try {
+ await client.ping({ timeout });
+ } catch (e) {
+ debugLogger.debug(
+ `MCP ping failed for ${serverName}, but connect succeeded:`,
+ e,
+ );
+ }
await client.close();
return MCPServerStatus.CONNECTED;
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index 936af34a43..312517db56 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -21,8 +21,6 @@ import {
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
- generalistProfile,
- type ContextManagementConfig,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -233,6 +231,45 @@ afterEach(() => {
});
describe('parseArguments', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+ it('should fail if both --resume and --session-id are provided', async () => {
+ process.argv = [
+ 'node',
+ 'script.js',
+ '--resume',
+ '--session-id',
+ 'test-uuid-1234',
+ ];
+ const mockConsoleError = vi
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+ vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit called');
+ });
+
+ await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
+ 'process.exit called',
+ );
+
+ expect(mockConsoleError).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'Cannot use both --resume (-r) and --session-id together',
+ ),
+ );
+ });
+
+ it('should parse --session-id option correctly', async () => {
+ process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234'];
+ vi.spyOn(process, 'exit').mockImplementation(() => {
+ throw new Error('process.exit called');
+ });
+
+ const parsedArgs = await parseArguments(createTestMergedSettings());
+ expect(parsedArgs.sessionId).toBe('test-uuid-1234');
+ });
+
describe('worktree', () => {
it('should parse --worktree flag when provided with a name', async () => {
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
@@ -257,7 +294,7 @@ describe('parseArguments', () => {
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
@@ -272,9 +309,6 @@ describe('parseArguments', () => {
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
});
});
@@ -306,7 +340,7 @@ describe('parseArguments', () => {
async ({ argv }) => {
process.argv = argv;
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -323,9 +357,6 @@ describe('parseArguments', () => {
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
},
);
@@ -562,7 +593,7 @@ describe('parseArguments', () => {
async ({ argv }) => {
process.argv = argv;
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -579,9 +610,6 @@ describe('parseArguments', () => {
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
},
);
@@ -606,7 +634,7 @@ describe('parseArguments', () => {
it('should reject invalid --approval-mode values', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -625,10 +653,6 @@ describe('parseArguments', () => {
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
- debugErrorSpy.mockRestore();
});
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
@@ -780,6 +804,100 @@ describe('loadCliConfig', () => {
vi.restoreAllMocks();
});
+ describe('Model resolution', () => {
+ it('should handle multiple --model flags by taking the last one', async () => {
+ const argv = {
+ query: undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ model: ['gemini-1.5-pro', 'gemini-2.0-flash'] as any,
+ sandbox: undefined,
+ debug: false,
+ prompt: undefined,
+ promptInteractive: undefined,
+ yolo: undefined,
+ approvalMode: undefined,
+ policy: undefined,
+ adminPolicy: undefined,
+ allowedMcpServerNames: undefined,
+ allowedTools: undefined,
+ extensions: undefined,
+ listExtensions: false,
+ listSessions: false,
+ deleteSession: undefined,
+ screenReader: undefined,
+ isCommand: false,
+ rawOutput: false,
+ acceptRawOutputRisk: false,
+ startupMessages: [],
+ resume: undefined,
+ includeDirectories: [],
+ useWriteTodos: false,
+ outputFormat: undefined,
+ fakeResponses: undefined,
+ recordResponses: undefined,
+ skipTrust: false,
+ };
+
+ const settings = createTestMergedSettings();
+ const config = await loadCliConfig(
+ settings,
+ 'test-session',
+ argv as unknown as CliArgs,
+ {
+ cwd: process.cwd(),
+ },
+ );
+
+ expect(config.getModel()).toBe('gemini-2.0-flash');
+ });
+
+ it('should handle non-string model flags by coercing to string', async () => {
+ const argv = {
+ query: undefined,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ model: true as any,
+ sandbox: undefined,
+ debug: false,
+ prompt: undefined,
+ promptInteractive: undefined,
+ yolo: undefined,
+ approvalMode: undefined,
+ policy: undefined,
+ adminPolicy: undefined,
+ allowedMcpServerNames: undefined,
+ allowedTools: undefined,
+ extensions: undefined,
+ listExtensions: false,
+ listSessions: false,
+ deleteSession: undefined,
+ screenReader: undefined,
+ isCommand: false,
+ rawOutput: false,
+ acceptRawOutputRisk: false,
+ startupMessages: [],
+ resume: undefined,
+ includeDirectories: [],
+ useWriteTodos: false,
+ outputFormat: undefined,
+ fakeResponses: undefined,
+ recordResponses: undefined,
+ skipTrust: false,
+ };
+
+ const settings = createTestMergedSettings();
+ const config = await loadCliConfig(
+ settings,
+ 'test-session',
+ argv as unknown as CliArgs,
+ {
+ cwd: process.cwd(),
+ },
+ );
+
+ expect(config.getModel()).toBe('true');
+ });
+ });
+
describe('Proxy configuration', () => {
const originalProxyEnv: { [key: string]: string | undefined } = {};
const proxyEnvVars = [
@@ -872,16 +990,14 @@ describe('loadCliConfig', () => {
});
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
- const resolveToRealPathSpy = vi
- .spyOn(ServerConfig, 'resolveToRealPath')
- .mockImplementation((p) => {
- if (p.toString().includes('restricted')) {
- const err = new Error('EACCES: permission denied');
- (err as NodeJS.ErrnoException).code = 'EACCES';
- throw err;
- }
- return p.toString();
- });
+ vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
+ if (p.toString().includes('restricted')) {
+ const err = new Error('EACCES: permission denied');
+ (err as NodeJS.ErrnoException).code = 'EACCES';
+ throw err;
+ }
+ return p.toString();
+ });
vi.stubEnv(
'GEMINI_CLI_IDE_WORKSPACE_PATH',
['/project/folderA', '/nonexistent/restricted/folder'].join(
@@ -895,8 +1011,6 @@ describe('loadCliConfig', () => {
const dirs = config.getPendingIncludeDirectories();
expect(dirs).toContain('/project/folderA');
expect(dirs).not.toContain('/nonexistent/restricted/folder');
-
- resolveToRealPathSpy.mockRestore();
});
it('should use default fileFilter options when unconfigured', async () => {
@@ -2217,51 +2331,6 @@ describe('loadCliConfig context management', () => {
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
- expect(config.getContextManagementConfig()).toStrictEqual(
- generalistProfile,
- );
- expect(config.isContextManagementEnabled()).toBe(true);
- });
-
- it('should be true when contextManagement is set to true in settings', async () => {
- process.argv = ['node', 'script.js'];
- const argv = await parseArguments(createTestMergedSettings());
- const contextManagementConfig: Partial = {
- historyWindow: {
- maxTokens: 100_000,
- retainedTokens: 50_000,
- },
- messageLimits: {
- normalMaxTokens: 1000,
- retainedMaxTokens: 10_000,
- normalizationHeadRatio: 0.25,
- },
- tools: {
- distillation: {
- maxOutputTokens: 10_000,
- summarizationThresholdTokens: 15_000,
- },
- outputMasking: {
- protectionThresholdTokens: 30_000,
- minPrunableThresholdTokens: 10_000,
- protectLatestTurn: false,
- },
- },
- };
- const settings = createTestMergedSettings({
- experimental: {
- contextManagement: true,
- },
- // The type of numbers is being inferred strangely, and so we have to cast
- // to `any` here.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- contextManagement: contextManagementConfig as any,
- });
- const config = await loadCliConfig(settings, 'test-session', argv);
- expect(config.getContextManagementConfig()).toStrictEqual({
- enabled: true,
- ...contextManagementConfig,
- });
expect(config.isContextManagementEnabled()).toBe(true);
});
});
@@ -3225,7 +3294,7 @@ describe('Output format', () => {
it('should error on invalid --output-format argument', async () => {
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -3243,10 +3312,6 @@ describe('Output format', () => {
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
- debugErrorSpy.mockRestore();
});
});
@@ -3277,13 +3342,11 @@ describe('parseArguments with positional prompt', () => {
'test prompt',
];
- const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
+ vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
- const mockConsoleError = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {});
+ vi.spyOn(console, 'error').mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
@@ -3297,10 +3360,6 @@ describe('parseArguments with positional prompt', () => {
'Cannot use both a positional prompt and the --prompt (-p) flag together',
),
);
-
- mockExit.mockRestore();
- mockConsoleError.mockRestore();
- debugErrorSpy.mockRestore();
});
it('should correctly parse a positional prompt to query field', async () => {
@@ -3929,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', '');
@@ -3941,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(
@@ -3953,6 +4012,6 @@ describe('loadCliConfig acpMode and clientName', () => {
argv,
);
expect(config.getAcpMode()).toBe(false);
- expect(config.getClientName()).toBeUndefined();
+ expect(config.getClientName()).toBe('tui');
});
});
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 6b99a3606d..97689b5fe5 100755
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -37,6 +37,7 @@ import {
getAdminErrorMessage,
isHeadlessMode,
Config,
+ SimpleExtensionLoader,
resolveToRealPath,
applyAdminAllowlist,
applyRequiredServers,
@@ -48,7 +49,6 @@ import {
type HookEventName,
type OutputFormat,
detectIdeFromEnv,
- generalistProfile,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -97,6 +97,7 @@ export interface CliArgs {
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
+ sessionId: string | undefined;
listSessions: boolean | undefined;
deleteSession: string | undefined;
includeDirectories: string[] | undefined;
@@ -238,6 +239,10 @@ export async function parseArguments(
? query.length > 0
: !!query;
+ if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
+ return 'Cannot use both --resume (-r) and --session-id together';
+ }
+
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
@@ -407,6 +412,25 @@ export async function parseArguments(
return trimmed;
},
})
+ .option('session-id', {
+ type: 'string',
+ nargs: 1,
+ description: 'Start a new session with a manually provided UUID.',
+ coerce: (value: string): string => {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ throw new Error('The --session-id option cannot be empty.');
+ }
+ if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) {
+ throw new Error(
+ 'Invalid session ID "' +
+ trimmed +
+ '": Only alphanumeric characters, dashes, and underscores are allowed.',
+ );
+ }
+ return trimmed;
+ },
+ })
.option('list-sessions', {
type: 'boolean',
description:
@@ -535,6 +559,7 @@ export interface LoadCliConfigOptions {
disabled?: string[];
};
worktreeSettings?: WorktreeSettings;
+ skipExtensions?: boolean;
}
export async function loadCliConfig(
@@ -543,7 +568,7 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise {
- const { cwd = process.cwd(), projectHooks } = options;
+ const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -618,21 +643,24 @@ export async function loadCliConfig(
includeDirectories.push(...ideFolders);
}
- const extensionManager = new ExtensionManager({
- settings,
- requestConsent: requestConsentNonInteractive,
- requestSetting: promptForSetting,
- workspaceDir: cwd,
- enabledExtensionOverrides: argv.extensions,
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- eventEmitter: coreEvents as EventEmitter,
- clientVersion: await getVersion(),
- });
- await extensionManager.loadExtensions();
+ let extensionManager: ExtensionManager | undefined;
+ if (!skipExtensions) {
+ extensionManager = new ExtensionManager({
+ settings,
+ requestConsent: requestConsentNonInteractive,
+ requestSetting: promptForSetting,
+ workspaceDir: cwd,
+ enabledExtensionOverrides: argv.extensions,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ eventEmitter: coreEvents as EventEmitter,
+ clientVersion: await getVersion(),
+ });
+ await extensionManager.loadExtensions();
+ }
const extensionPlanSettings = extensionManager
- .getExtensions()
- .find((ext) => ext.isActive && ext.plan?.directory)?.plan;
+ ?.getExtensions()
+ ?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext ?? true;
@@ -650,6 +678,9 @@ export async function loadCliConfig(
let fileCount = 0;
let filePaths: string[] = [];
+ const finalExtensionLoader =
+ extensionManager ?? new SimpleExtensionLoader([]);
+
if (!experimentalJitContext) {
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
@@ -658,7 +689,7 @@ export async function loadCliConfig(
? includeDirectories
: [],
fileService,
- extensionManager,
+ finalExtensionLoader,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
@@ -818,9 +849,16 @@ export async function loadCliConfig(
);
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
- const specifiedModel =
+ const rawModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
+ // Ensure specifiedModel is a string (e.g. if yargs parsed multiple --model as an array)
+ const specifiedModel = Array.isArray(rawModel)
+ ? String(rawModel.at(-1) ?? '').trim() || ''
+ : rawModel === undefined
+ ? undefined
+ : String(rawModel ?? '').trim() || '';
+
const resolvedModel =
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
? defaultModel
@@ -901,17 +939,28 @@ 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.
+ let profileSelector: string | undefined = undefined;
+ if (settings.experimental?.stressTestProfile) {
+ profileSelector = 'stressTestProfile';
+ } else if (
+ settings.experimental?.generalistProfile ||
+ settings.experimental?.contextManagement
+ ) {
+ profileSelector = 'generalistProfile';
}
- const useGeneralistProfile =
- settings.experimental?.generalistProfile ?? false;
- const useContextManagement =
- settings.experimental?.contextManagement ?? false;
const contextManagement = {
- ...(useGeneralistProfile ? generalistProfile : {}),
- ...(useContextManagement ? settings?.contextManagement : {}),
- enabled: useContextManagement || useGeneralistProfile,
+ enabled: !!profileSelector,
};
return new Config({
@@ -935,6 +984,7 @@ export async function loadCliConfig(
worktreeSettings,
coreTools: settings.tools?.core || undefined,
+ experimentalContextManagementConfig: profileSelector,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
policyEngineConfig,
policyUpdateConfirmationRequest,
@@ -995,11 +1045,12 @@ export async function loadCliConfig(
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
enabledExtensions: argv.extensions,
- extensionLoader: extensionManager,
+ extensionLoader: finalExtensionLoader,
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.general?.plan?.enabled ?? true,
+ voiceMode: settings.experimental?.voiceMode,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts
index 19ef150d22..c3f0557d3d 100644
--- a/packages/cli/src/config/extension-manager-agents.test.ts
+++ b/packages/cli/src/config/extension-manager-agents.test.ts
@@ -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);
diff --git a/packages/cli/src/config/extension-manager-hydration.test.ts b/packages/cli/src/config/extension-manager-hydration.test.ts
index ff266976a5..aa97dbd898 100644
--- a/packages/cli/src/config/extension-manager-hydration.test.ts
+++ b/packages/cli/src/config/extension-manager-hydration.test.ts
@@ -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);
diff --git a/packages/cli/src/config/settings-validation.test.ts b/packages/cli/src/config/settings-validation.test.ts
index baf9b5bbdb..01cd545cde 100644
--- a/packages/cli/src/config/settings-validation.test.ts
+++ b/packages/cli/src/config/settings-validation.test.ts
@@ -13,6 +13,7 @@ import {
settingsZodSchema,
} from './settings-validation.js';
import { z } from 'zod';
+import { type Settings } from './settingsSchema.js';
describe('settings-validation', () => {
describe('validateSettings', () => {
@@ -298,6 +299,117 @@ describe('settings-validation', () => {
expect(issue).toBeDefined();
}
});
+
+ it('should accept customThemes with text.response color override', () => {
+ // Regression test for #25610: `response` is a documented and
+ // implemented color override for model responses (see
+ // packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts),
+ // but was missing from the CustomTheme validation schema.
+ const validSettings = {
+ ui: {
+ theme: 'LimeWhite',
+ customThemes: {
+ LimeWhite: {
+ type: 'custom',
+ name: 'LimeWhite',
+ text: {
+ primary: '#00FF00',
+ response: '#FFFFFF',
+ secondary: '#a0a0a0',
+ accent: '#00FF00',
+ },
+ },
+ },
+ },
+ };
+
+ const result = validateSettings(validSettings);
+ expect(result.success).toBe(true);
+ });
+
+ describe('type casting', () => {
+ it('should cast "true" and "false" strings to booleans', () => {
+ const settings = {
+ ui: {
+ autoThemeSwitching: 'true',
+ hideWindowTitle: 'false',
+ },
+ };
+
+ const result = validateSettings(settings);
+ expect(result.success).toBe(true);
+ const data = result.data as Settings;
+ expect(data.ui?.autoThemeSwitching).toBe(true);
+ expect(data.ui?.hideWindowTitle).toBe(false);
+ });
+
+ it('should cast boolean strings case-insensitively', () => {
+ const settings = {
+ ui: {
+ autoThemeSwitching: 'TRUE',
+ hideWindowTitle: 'fAlSe',
+ },
+ };
+
+ const result = validateSettings(settings);
+ expect(result.success).toBe(true);
+ const data = result.data as Settings;
+ expect(data.ui?.autoThemeSwitching).toBe(true);
+ expect(data.ui?.hideWindowTitle).toBe(false);
+ });
+
+ it('should cast numeric strings to numbers', () => {
+ const settings = {
+ model: {
+ maxSessionTurns: '42',
+ compressionThreshold: '0.5',
+ },
+ };
+
+ const result = validateSettings(settings);
+ expect(result.success).toBe(true);
+ const data = result.data as Settings;
+ expect(data.model?.maxSessionTurns).toBe(42);
+ expect(data.model?.compressionThreshold).toBe(0.5);
+ });
+
+ it('should reject invalid castable strings', () => {
+ const settings = {
+ ui: {
+ autoThemeSwitching: 'not-a-boolean',
+ },
+ model: {
+ maxSessionTurns: 'not-a-number',
+ },
+ };
+
+ const result = validateSettings(settings);
+ expect(result.success).toBe(false);
+ expect(result.error?.issues).toHaveLength(2);
+ expect(result.error?.issues[0].message).toContain(
+ 'Expected boolean, received string',
+ );
+ expect(result.error?.issues[1].message).toContain(
+ 'Expected number, received string',
+ );
+ });
+
+ it('should cast strings to booleans/numbers in shared definitions (refs)', () => {
+ const settings = {
+ mcpServers: {
+ 'test-server': {
+ command: 'node',
+ trust: 'true', // from boolean ref
+ },
+ },
+ };
+
+ const result = validateSettings(settings);
+ expect(result.success).toBe(true);
+ const data = result.data as Settings;
+ expect(data.mcpServers?.['test-server'].trust).toBe(true);
+ });
+ });
});
describe('formatValidationError', () => {
diff --git a/packages/cli/src/config/settings-validation.ts b/packages/cli/src/config/settings-validation.ts
index 9921f6c6cc..829cec506e 100644
--- a/packages/cli/src/config/settings-validation.ts
+++ b/packages/cli/src/config/settings-validation.ts
@@ -25,10 +25,10 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
if (def.type === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
- return z.string();
+ return buildPrimitiveSchema('string');
}
- if (def.type === 'number') return z.number();
- if (def.type === 'boolean') return z.boolean();
+ if (def.type === 'number') return buildPrimitiveSchema('number');
+ if (def.type === 'boolean') return buildPrimitiveSchema('boolean');
if (def.type === 'array') {
if (def.items) {
@@ -133,9 +133,22 @@ function buildPrimitiveSchema(
case 'string':
return z.string();
case 'number':
- return z.number();
+ return z.preprocess((val) => {
+ if (typeof val === 'string' && val.trim() !== '') {
+ const num = Number(val);
+ if (!isNaN(num)) return num;
+ }
+ return val;
+ }, z.number());
case 'boolean':
- return z.boolean();
+ return z.preprocess((val) => {
+ if (typeof val === 'string') {
+ const lower = val.toLowerCase();
+ if (lower === 'true') return true;
+ if (lower === 'false') return false;
+ }
+ return val;
+ }, z.boolean());
default:
return z.unknown();
}
@@ -160,7 +173,9 @@ function buildZodSchemaFromDefinition(
if (definition.ref === 'TelemetrySettings') {
const objectSchema = REF_SCHEMAS['TelemetrySettings'];
if (objectSchema) {
- return z.union([z.boolean(), objectSchema]).optional();
+ return z
+ .union([buildPrimitiveSchema('boolean'), objectSchema])
+ .optional();
}
}
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index af0e47b99f..809b8f48ff 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -82,6 +82,7 @@ import {
FatalConfigError,
GEMINI_DIR,
Storage,
+ AuthType,
type MCPServerConfig,
} from '@google/gemini-cli-core';
import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js';
@@ -202,6 +203,7 @@ describe('Settings Loading and Merging', () => {
afterEach(() => {
vi.restoreAllMocks();
+ vi.unstubAllEnvs();
});
describe('loadSettings', () => {
@@ -479,6 +481,137 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
});
+ it('should resolve environment variables and cast them to correct types before validation', () => {
+ vi.stubEnv('TEST_AUTO_THEME', 'false');
+ vi.stubEnv('TEST_MAX_TURNS', '15');
+
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) =>
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
+ );
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
+ ) {
+ return JSON.stringify({
+ ui: { autoThemeSwitching: '$TEST_AUTO_THEME' },
+ model: { maxSessionTurns: '$TEST_MAX_TURNS' },
+ });
+ }
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.merged.ui.autoThemeSwitching).toBe(false);
+ expect(settings.merged.model.maxSessionTurns).toBe(15);
+ expect(settings.errors).toHaveLength(0);
+ });
+
+ it('should use default values from environment variable placeholders', () => {
+ vi.stubEnv('TEST_AUTO_THEME', ''); // Should trigger default
+ delete process.env['TEST_AUTO_THEME'];
+
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) =>
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
+ );
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
+ ) {
+ return JSON.stringify({
+ ui: { autoThemeSwitching: '${TEST_AUTO_THEME:-true}' },
+ });
+ }
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.merged.ui.autoThemeSwitching).toBe(true);
+ expect(settings.errors).toHaveLength(0);
+ });
+
+ it('should record validation errors if expansion result is invalid', () => {
+ vi.stubEnv('TEST_MAX_TURNS', 'not-a-number');
+
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) =>
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
+ );
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
+ ) {
+ return JSON.stringify({
+ model: { maxSessionTurns: '$TEST_MAX_TURNS' },
+ });
+ }
+ return '{}';
+ },
+ );
+
+ const settings = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(settings.errors.length).toBeGreaterThan(0);
+ expect(settings.errors[0].message).toContain(
+ 'Expected number, received string',
+ );
+ // Should fall back to the expanded string value
+ expect(settings.merged.model.maxSessionTurns).toBe('not-a-number');
+ });
+
+ it('should preserve environment variable placeholders on save', () => {
+ vi.stubEnv('TEST_AUTO_THEME', 'true');
+ const placeholder = '${TEST_AUTO_THEME:-false}';
+
+ (mockFsExistsSync as Mock).mockImplementation(
+ (p: fs.PathLike) =>
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
+ );
+ (fs.readFileSync as Mock).mockImplementation(
+ (p: fs.PathOrFileDescriptor) => {
+ if (
+ path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
+ ) {
+ return JSON.stringify({
+ ui: { autoThemeSwitching: placeholder },
+ });
+ }
+ return '{}';
+ },
+ );
+
+ // Load settings - this will expand the placeholder for runtime use
+ const loaded = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(loaded.merged.ui.autoThemeSwitching).toBe(true);
+
+ // Verify that the original settings for the user scope still have the placeholder
+ const userFile = loaded.forScope(SettingScope.User);
+ expect(userFile.originalSettings.ui?.autoThemeSwitching).toBe(
+ placeholder,
+ );
+
+ // Save settings - this should use the originalSettings (with placeholders)
+ const mockUpdate = vi.mocked(updateSettingsFilePreservingFormat);
+ saveSettings(userFile);
+
+ expect(mockUpdate).toHaveBeenCalledWith(
+ USER_SETTINGS_PATH,
+ expect.objectContaining({
+ ui: expect.objectContaining({
+ autoThemeSwitching: placeholder,
+ }),
+ }),
+ );
+ });
+
it('should use system folderTrust over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = {
@@ -2905,6 +3038,7 @@ describe('Settings Loading and Merging', () => {
delete process.env['CLOUD_SHELL'];
delete process.env['MALICIOUS_VAR'];
delete process.env['FOO'];
+ delete process.env['_GEMINI_USER_GCP_PROJECT'];
vi.resetAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(false);
});
@@ -3137,6 +3271,108 @@ MALICIOUS_VAR=allowed-because-trusted
expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
});
+ it('should not override GOOGLE_CLOUD_PROJECT in Cloud Shell when auth type is vertex-ai', () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'my-vertex-project');
+ process.argv = ['node', 'gemini', '-s', 'prompt'];
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: false,
+ source: 'file',
+ });
+
+ // No .env file
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ loadEnvironment(
+ createMockSettings({
+ tools: { sandbox: false },
+ security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
+ }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-vertex-project');
+ });
+
+ it('should clear cloudshell-gca when switching to Vertex AI without an original project', () => {
+ process.env['CLOUD_SHELL'] = 'true';
+ process.argv = ['node', 'gemini', '-s', 'prompt'];
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: false,
+ source: 'file',
+ });
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ // First call: normal Cloud Shell auth sets cloudshell-gca
+ loadEnvironment(
+ createMockSettings({ tools: { sandbox: false } }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
+
+ // Second call: user switched to Vertex AI, should remove cloudshell-gca
+ loadEnvironment(
+ createMockSettings({
+ tools: { sandbox: false },
+ security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
+ }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeUndefined();
+ });
+
+ it('should restore original project when switching to Vertex AI after Cloud Shell override', () => {
+ process.env['CLOUD_SHELL'] = 'true';
+ process.env['GOOGLE_CLOUD_PROJECT'] = 'my-real-project';
+ process.argv = ['node', 'gemini', '-s', 'prompt'];
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: false,
+ source: 'file',
+ });
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ // First call: saves original to _GEMINI_USER_GCP_PROJECT, sets cloudshell-gca
+ loadEnvironment(
+ createMockSettings({ tools: { sandbox: false } }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('cloudshell-gca');
+ expect(process.env['_GEMINI_USER_GCP_PROJECT']).toBe('my-real-project');
+
+ // Second call: switching to Vertex AI should restore the saved value
+ loadEnvironment(
+ createMockSettings({
+ tools: { sandbox: false },
+ security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
+ }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
+ });
+
+ it('should restore project after restart when child inherits cloudshell-gca', () => {
+ // Simulate child process after restart: inherits cloudshell-gca and
+ // the saved original from the parent process.
+ process.env['CLOUD_SHELL'] = 'true';
+ process.env['GOOGLE_CLOUD_PROJECT'] = 'cloudshell-gca';
+ process.env['_GEMINI_USER_GCP_PROJECT'] = 'my-real-project';
+ process.argv = ['node', 'gemini', '-s', 'prompt'];
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
+ isTrusted: false,
+ source: 'file',
+ });
+ vi.mocked(fs.existsSync).mockReturnValue(false);
+
+ loadEnvironment(
+ createMockSettings({
+ tools: { sandbox: false },
+ security: { auth: { selectedType: AuthType.USE_VERTEX_AI } },
+ }).merged,
+ MOCK_WORKSPACE_DIR,
+ );
+ expect(process.env['GOOGLE_CLOUD_PROJECT']).toBe('my-real-project');
+ });
+
it('should sanitize GOOGLE_CLOUD_PROJECT in Cloud Shell when loaded from .env in untrusted mode', () => {
process.env['CLOUD_SHELL'] = 'true';
process.argv = ['node', 'gemini', '-s', 'prompt'];
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index b84c1bda40..2d94e719b2 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -18,6 +18,7 @@ import {
Storage,
coreEvents,
homedir,
+ AuthType,
type AdminControlsSettings,
createCache,
} from '@google/gemini-cli-core';
@@ -532,16 +533,45 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
}
}
+// Internal env var used to preserve the user's original GOOGLE_CLOUD_PROJECT
+// across process restarts in Cloud Shell. This survives relaunch because child
+// processes inherit the parent's environment.
+const USER_GCP_PROJECT = '_GEMINI_USER_GCP_PROJECT';
+
export function setUpCloudShellEnvironment(
envFilePath: string | null,
isTrusted: boolean,
isSandboxed: boolean,
+ selectedAuthType?: string,
): void {
// Special handling for GOOGLE_CLOUD_PROJECT in Cloud Shell:
// Because GOOGLE_CLOUD_PROJECT in Cloud Shell tracks the project
// set by the user using "gcloud config set project" we do not want to
// use its value. So, unless the user overrides GOOGLE_CLOUD_PROJECT in
// one of the .env files, we set the Cloud Shell-specific default here.
+ //
+ // However, if the user has explicitly selected Vertex AI auth, they intend
+ // to use their own GCP project, so we restore the original value and skip
+ // the Cloud Shell override to respect their .env settings.
+ if (selectedAuthType === AuthType.USE_VERTEX_AI) {
+ const saved = process.env[USER_GCP_PROJECT];
+ if (saved !== undefined) {
+ process.env['GOOGLE_CLOUD_PROJECT'] = saved;
+ } else if (process.env['GOOGLE_CLOUD_PROJECT'] === 'cloudshell-gca') {
+ delete process.env['GOOGLE_CLOUD_PROJECT'];
+ }
+ return;
+ }
+
+ // Save the user's original value before overwriting, so it can be restored
+ // if the user later switches to Vertex AI (even after a process restart).
+ if (!process.env[USER_GCP_PROJECT]) {
+ const current = process.env['GOOGLE_CLOUD_PROJECT'];
+ if (current && current !== 'cloudshell-gca') {
+ process.env[USER_GCP_PROJECT] = current;
+ }
+ }
+
let value = 'cloudshell-gca';
if (envFilePath && fs.existsSync(envFilePath)) {
@@ -584,7 +614,13 @@ export function loadEnvironment(
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
- setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
+ const selectedAuthType = settings.security?.auth?.selectedType;
+ setUpCloudShellEnvironment(
+ envFilePath,
+ isTrusted,
+ isSandboxed,
+ selectedAuthType,
+ );
}
if (envFilePath) {
@@ -673,7 +709,9 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
const storage = new Storage(workspaceDir);
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
- const load = (filePath: string): { settings: Settings; rawJson?: string } => {
+ const load = (
+ filePath: string,
+ ): { settings: Settings; rawSettings: Settings; rawJson?: string } => {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -689,14 +727,19 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
path: filePath,
severity: 'error',
});
- return { settings: {} };
+ return { settings: {}, rawSettings: {} };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record;
- // Validate settings structure with Zod
- const validationResult = validateSettings(settingsObject);
+ // Expand environment variables
+ const expandedSettings = resolveEnvVarsInObject(
+ settingsObject as Settings,
+ );
+
+ // Validate settings structure with Zod after environment variable expansion
+ const validationResult = validateSettings(expandedSettings);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
validationResult.error,
@@ -707,9 +750,22 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
path: filePath,
severity: 'warning',
});
+ return {
+ settings: expandedSettings,
+ rawSettings: settingsObject as Settings,
+ rawJson: content,
+ };
}
- return { settings: settingsObject as Settings, rawJson: content };
+ // Return the successfully cast and validated data
+ return {
+ // Since we've successfully validated expandedSettings against settingsZodSchema,
+ // it's safe to cast the resulting data to the Settings type.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ settings: (validationResult.data as Settings) ?? expandedSettings,
+ rawSettings: settingsObject as Settings,
+ rawJson: content,
+ };
}
} catch (error: unknown) {
settingsErrors.push({
@@ -718,33 +774,40 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
severity: 'error',
});
}
- return { settings: {} };
+ return { settings: {}, rawSettings: {} };
};
const systemResult = load(systemSettingsPath);
const systemDefaultsResult = load(systemDefaultsPath);
const userResult = load(USER_SETTINGS_PATH);
- let workspaceResult: { settings: Settings; rawJson?: string } = {
+ let workspaceResult: {
+ settings: Settings;
+ rawSettings: Settings;
+ rawJson?: string;
+ } = {
settings: {} as Settings,
+ rawSettings: {} as Settings,
rawJson: undefined,
};
if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
- const systemOriginalSettings = structuredClone(systemResult.settings);
+ const systemOriginalSettings = structuredClone(systemResult.rawSettings);
const systemDefaultsOriginalSettings = structuredClone(
- systemDefaultsResult.settings,
+ systemDefaultsResult.rawSettings,
+ );
+ const userOriginalSettings = structuredClone(userResult.rawSettings);
+ const workspaceOriginalSettings = structuredClone(
+ workspaceResult.rawSettings,
);
- const userOriginalSettings = structuredClone(userResult.settings);
- const workspaceOriginalSettings = structuredClone(workspaceResult.settings);
- // Environment variables for runtime use
- systemSettings = resolveEnvVarsInObject(systemResult.settings);
- systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
- userSettings = resolveEnvVarsInObject(userResult.settings);
- workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
+ // Environment variables for runtime use are already resolved and validated in load()
+ systemSettings = systemResult.settings;
+ systemDefaultSettings = systemDefaultsResult.settings;
+ userSettings = userResult.settings;
+ workspaceSettings = workspaceResult.settings;
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 2b6c959397..5df30a20a5 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -2061,6 +2061,87 @@ const SETTINGS_SCHEMA = {
description: 'Enable access to Gemma 4 models (experimental).',
showInDialog: true,
},
+ voiceMode: {
+ type: 'boolean',
+ label: 'Voice Mode',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: false,
+ description:
+ 'Enable experimental voice dictation and commands (/voice, /voice model).',
+ showInDialog: true,
+ },
+ voice: {
+ type: 'object',
+ label: 'Voice',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: {},
+ description: 'Settings for voice mode and transcription.',
+ showInDialog: false,
+ properties: {
+ activationMode: {
+ type: 'enum',
+ label: 'Voice Activation Mode',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: 'push-to-talk',
+ description: 'How to trigger voice recording with the Space key.',
+ showInDialog: true,
+ options: [
+ { value: 'push-to-talk', label: 'Push-To-Talk (Hold Space)' },
+ { value: 'toggle', label: 'Toggle (Press Space to start/stop)' },
+ ],
+ },
+ backend: {
+ type: 'enum',
+ label: 'Voice Transcription Backend',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: 'gemini-live',
+ description: 'The backend to use for voice transcription.',
+ showInDialog: true,
+ options: [
+ { value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
+ { value: 'whisper', label: 'Whisper (Local)' },
+ ],
+ },
+ whisperModel: {
+ type: 'enum',
+ label: 'Whisper Model',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: 'ggml-base.en.bin',
+ description: 'The Whisper model to use for local transcription.',
+ showInDialog: true,
+ options: [
+ { value: 'ggml-tiny.en.bin', label: 'Tiny (EN) - Fast (~75MB)' },
+ {
+ value: 'ggml-base.en.bin',
+ label: 'Base (EN) - Balanced (~142MB)',
+ },
+ {
+ value: 'ggml-large-v3-turbo-q5_0.bin',
+ label: 'Large v3 Turbo (Q5_0) - High Accuracy (~547MB)',
+ },
+ {
+ value: 'ggml-large-v3-turbo-q8_0.bin',
+ label: 'Large v3 Turbo (Q8_0) - Max Accuracy (~834MB)',
+ },
+ ],
+ },
+ stopGracePeriodMs: {
+ type: 'number',
+ label: 'Voice Stop Grace Period (ms)',
+ category: 'Experimental',
+ requiresRestart: false,
+ default: 1000,
+ description:
+ 'How long to wait for final transcription after stopping recording.',
+ showInDialog: true,
+ },
+ },
+ },
adk: {
type: 'object',
label: 'ADK',
@@ -2307,6 +2388,17 @@ const SETTINGS_SCHEMA = {
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit โ settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
showInDialog: true,
},
+ stressTestProfile: {
+ type: 'boolean',
+ label:
+ 'Use the stress test profile to aggressively trigger context management.',
+ category: 'Experimental',
+ requiresRestart: true,
+ default: false,
+ description:
+ 'Significantly lowers token limits to force early garbage collection and distillation for testing purposes.',
+ showInDialog: false,
+ },
autoMemory: {
type: 'boolean',
label: 'Auto Memory',
@@ -3197,6 +3289,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
secondary: { type: 'string' },
link: { type: 'string' },
accent: { type: 'string' },
+ response: { type: 'string' },
},
},
background: {
diff --git a/packages/cli/src/config/skipExtensions.test.ts b/packages/cli/src/config/skipExtensions.test.ts
new file mode 100644
index 0000000000..415c3a9529
--- /dev/null
+++ b/packages/cli/src/config/skipExtensions.test.ts
@@ -0,0 +1,54 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { loadCliConfig, type CliArgs } from './config.js';
+import { ExtensionManager } from './extension-manager.js';
+import { createTestMergedSettings } from './settings.js';
+
+vi.mock('./extension-manager.js', () => ({
+ ExtensionManager: vi.fn().mockImplementation(() => ({
+ loadExtensions: vi.fn().mockResolvedValue([]),
+ getExtensions: vi.fn().mockReturnValue([]),
+ })),
+}));
+
+describe('loadCliConfig skipExtensions', () => {
+ const settings = createTestMergedSettings();
+ const argv = {
+ query: undefined,
+ model: undefined,
+ sandbox: undefined,
+ debug: undefined,
+ prompt: undefined,
+ promptInteractive: undefined,
+ yolo: undefined,
+ approvalMode: undefined,
+ policy: undefined,
+ adminPolicy: undefined,
+ allowedMcpServerNames: undefined,
+ allowedTools: undefined,
+ extensions: undefined,
+ listExtensions: undefined,
+ resume: undefined,
+ sessionId: undefined,
+ listSessions: undefined,
+ } as unknown as CliArgs;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should load extensions by default', async () => {
+ await loadCliConfig(settings, 'session-id', argv);
+ expect(ExtensionManager).toHaveBeenCalled();
+ });
+
+ it('should skip extensions when skipExtensions is true', async () => {
+ await loadCliConfig(settings, 'session-id', argv, { skipExtensions: true });
+ expect(ExtensionManager).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx
index 20fc80d190..ca990420d1 100644
--- a/packages/cli/src/gemini.test.tsx
+++ b/packages/cli/src/gemini.test.tsx
@@ -20,6 +20,7 @@ import {
validateDnsResolutionOrder,
startInteractiveUI,
getNodeMemoryArgs,
+ resolveSessionId,
} from './gemini.js';
import {
loadCliConfig,
@@ -47,10 +48,13 @@ import {
debugLogger,
coreEvents,
AuthType,
+ ExitCodes,
} from '@google/gemini-cli-core';
import { act } from 'react';
import { type InitializationResult } from './core/initializer.js';
import { runNonInteractive } from './nonInteractiveCli.js';
+import { SessionSelector, SessionError } from './utils/sessionUtils.js';
+
// Hoisted constants and mocks
const performance = vi.hoisted(() => ({
now: vi.fn(),
@@ -548,6 +552,7 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
useWriteTodos: undefined,
resume: undefined,
+ sessionId: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
@@ -607,6 +612,7 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
useWriteTodos: undefined,
resume: undefined,
+ sessionId: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
@@ -822,7 +828,6 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should handle session selector error', async () => {
- const { SessionSelector } = await import('./utils/sessionUtils.js');
vi.mocked(SessionSelector).mockImplementation(
() =>
({
@@ -879,9 +884,6 @@ describe('gemini.tsx main function kitty protocol', () => {
});
it('should start normally with a warning when no sessions found for resume', async () => {
- const { SessionSelector, SessionError } = await import(
- './utils/sessionUtils.js'
- );
vi.mocked(SessionSelector).mockImplementation(
() =>
({
@@ -1056,6 +1058,63 @@ describe('gemini.tsx main function kitty protocol', () => {
});
});
+describe('resolveSessionId', () => {
+ it('should return a new session ID when neither resume nor sessionId is provided', async () => {
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ undefined,
+ undefined,
+ );
+ expect(sessionId).toBeDefined();
+ expect(resumedSessionData).toBeUndefined();
+ });
+
+ it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
+ vi.mocked(SessionSelector).mockImplementation(
+ () =>
+ ({
+ sessionExists: vi.fn().mockResolvedValue(true),
+ }) as unknown as InstanceType,
+ );
+
+ const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
+ const processExitSpy = vi
+ .spyOn(process, 'exit')
+ .mockImplementation((code) => {
+ throw new MockProcessExitError(code);
+ });
+
+ try {
+ await resolveSessionId(undefined, 'existing-id');
+ } catch (e) {
+ if (!(e instanceof MockProcessExitError)) throw e;
+ }
+
+ expect(emitFeedbackSpy).toHaveBeenCalledWith(
+ 'error',
+ expect.stringContaining('Session ID "existing-id" already exists'),
+ );
+ expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
+
+ emitFeedbackSpy.mockRestore();
+ processExitSpy.mockRestore();
+ });
+
+ it('should return provided sessionId when it does not exist', async () => {
+ vi.mocked(SessionSelector).mockImplementation(
+ () =>
+ ({
+ sessionExists: vi.fn().mockResolvedValue(false),
+ }) as unknown as InstanceType,
+ );
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ undefined,
+ 'new-id',
+ );
+ expect(sessionId).toBe('new-id');
+ expect(resumedSessionData).toBeUndefined();
+ });
+});
+
describe('gemini.tsx main function exit codes', () => {
let originalEnvNoRelaunch: string | undefined;
let originalIsTTY: boolean | undefined;
diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx
index 28822642f3..b9cda80d8b 100644
--- a/packages/cli/src/gemini.tsx
+++ b/packages/cli/src/gemini.tsx
@@ -13,6 +13,7 @@ import {
type OutputPayload,
type ConsoleLogPayload,
type UserFeedbackPayload,
+ type CoreEvents,
createSessionId,
logUserPrompt,
AuthType,
@@ -76,7 +77,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';
@@ -85,7 +86,6 @@ import { relaunchOnExitCode } from './utils/relaunch.js';
import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
-import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { runDeferredCommand } from './deferred.js';
@@ -126,7 +126,11 @@ export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
);
}
- if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
+ if (
+ process.env['IS_BINARY'] === 'true' ||
+ process.env['GEMINI_CLI_NO_RELAUNCH'] ||
+ process.env['SANDBOX']
+ ) {
return [];
}
@@ -191,21 +195,38 @@ ${reason.stack}`
});
}
-export async function resolveSessionId(resumeArg: string | undefined): Promise<{
+export async function resolveSessionId(
+ resumeArg: string | undefined,
+ sessionIdArg?: string | undefined,
+): Promise<{
sessionId: string;
resumedSessionData?: ResumedSessionData;
}> {
- if (!resumeArg) {
+ if (!resumeArg && !sessionIdArg) {
return { sessionId: createSessionId() };
}
const storage = new Storage(process.cwd());
await storage.initialize();
+ const sessionSelector = new SessionSelector(storage);
+
+ if (sessionIdArg) {
+ if (await sessionSelector.sessionExists(sessionIdArg)) {
+ coreEvents.emitFeedback(
+ 'error',
+ `Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`,
+ );
+ await runExitCleanup();
+ process.exit(ExitCodes.FATAL_INPUT_ERROR);
+ }
+ return { sessionId: sessionIdArg };
+ }
+
try {
- const { sessionData, sessionPath } = await new SessionSelector(
- storage,
- ).resolveSession(resumeArg);
+ const { sessionData, sessionPath } = await sessionSelector.resolveSession(
+ resumeArg!,
+ );
return {
sessionId: sessionData.sessionId,
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
@@ -245,6 +266,7 @@ export async function startInteractiveUI(
}
export async function main() {
+ let config: Config | undefined;
const cliStartupHandle = startupProfiler.start('cli_startup');
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
@@ -257,7 +279,7 @@ export async function main() {
const cleanupStdio = patchStdio();
registerSyncCleanup(() => {
// This is needed to ensure we don't lose any buffered output.
- initializeOutputListenersAndFlush();
+ initializeOutputListenersAndFlush(config);
cleanupStdio();
});
@@ -319,7 +341,10 @@ export async function main() {
const argv = await argvPromise;
- const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
+ const { sessionId, resumedSessionData } = await resolveSessionId(
+ argv.resume,
+ argv.sessionId,
+ );
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -358,15 +383,14 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
- stderr: true,
- interactive: isHeadlessMode() ? false : true,
+ stderr: argv.isCommand ? false : true,
+ interactive: isHeadlessMode() && !argv.isCommand ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
});
consolePatcher.patch();
- registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
@@ -391,7 +415,9 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
+ skipExtensions: true,
});
+
adminControlsListner.setConfig(partialConfig);
// Refresh auth to fetch remote admin settings from CCPA and before entering
@@ -515,7 +541,7 @@ export async function main() {
// may have side effects.
{
const loadConfigHandle = startupProfiler.start('load_cli_config');
- const config = await loadCliConfig(settings.merged, sessionId, argv, {
+ config = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
worktreeSettings: worktreeInfo,
});
@@ -549,6 +575,12 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
+ // Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
+ // are correctly redirected to stderr (especially for non-interactive JSON output).
+ if (!config.getAcpMode()) {
+ registerCleanup(consolePatcher.cleanup);
+ }
+
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
debugLogger.error('Failed to cleanup expired sessions:', e);
@@ -644,7 +676,7 @@ export async function main() {
let input = config.getQuestion();
const useAlternateBuffer = shouldEnterAlternateScreen(
- isAlternateBufferEnabled(config),
+ config.getUseAlternateBuffer(),
config.getScreenReader(),
);
const rawStartupWarnings = await rawStartupWarningsPromise;
@@ -755,7 +787,7 @@ export async function main() {
debugLogger.log('Session ID: %s', sessionId);
}
- initializeOutputListenersAndFlush();
+ initializeOutputListenersAndFlush(config);
await runNonInteractive({
config,
@@ -770,7 +802,7 @@ export async function main() {
}
}
-export function initializeOutputListenersAndFlush() {
+export function initializeOutputListenersAndFlush(config?: Config) {
// If there are no listeners for output, make sure we flush so output is not
// lost.
if (coreEvents.listenerCount(CoreEvent.Output) === 0) {
@@ -782,28 +814,43 @@ export function initializeOutputListenersAndFlush() {
writeToStdout(payload.chunk, payload.encoding);
}
});
-
- if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
- coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
- if (payload.type === 'error' || payload.type === 'warn') {
- writeToStderr(payload.content);
- } else {
- writeToStdout(payload.content);
- }
- });
- }
-
- if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
- coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
- if (payload.severity === 'error' || payload.severity === 'warning') {
- writeToStderr(payload.message);
- } else {
- writeToStdout(payload.message);
- }
- });
- }
}
- coreEvents.drainBacklogs();
+
+ if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
+ coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
+ if (payload.type === 'error' || payload.type === 'warn') {
+ writeToStderr(payload.content + '\n');
+ } else {
+ writeToStderr(payload.content + '\n');
+ }
+ });
+ }
+
+ if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
+ coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
+ writeToStderr(payload.message + '\n');
+ });
+ }
+
+ const outputFormat = config?.getOutputFormat();
+ const forceToStderr = outputFormat === 'json' || config === undefined;
+
+ coreEvents.drainBacklogs(
+ (event: K, args: CoreEvents[K]) => {
+ if (forceToStderr && event === (CoreEvent.Output as string)) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ const payload = args[0] as OutputPayload;
+ if (!payload.isStderr) {
+ return {
+ event,
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ args: [{ ...payload, isStderr: true }] as unknown as CoreEvents[K],
+ };
+ }
+ }
+ return { event, args };
+ },
+ );
}
function setupAdminControlsListener() {
diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx
index b4c4f645b6..75e9cf3959 100644
--- a/packages/cli/src/gemini_cleanup.test.tsx
+++ b/packages/cli/src/gemini_cleanup.test.tsx
@@ -68,6 +68,13 @@ vi.mock('./config/settings.js', async (importOriginal) => {
};
});
+vi.mock('./ui/utils/ConsolePatcher.js', () => ({
+ ConsolePatcher: vi.fn().mockImplementation(() => ({
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ })),
+}));
+
vi.mock('./config/config.js', () => ({
loadCliConfig: vi.fn().mockResolvedValue({
getSandbox: vi.fn(() => false),
@@ -150,6 +157,10 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
};
});
+vi.mock('./acp/acpStdioTransport.js', () => ({
+ runAcpClient: vi.fn().mockResolvedValue(undefined),
+}));
+
vi.mock('./zed-integration/zedIntegration.js', () => ({
runZedIntegration: vi.fn().mockResolvedValue(undefined),
}));
@@ -296,6 +307,120 @@ describe('gemini.tsx main function cleanup', () => {
);
});
+ it('should not register ConsolePatcher cleanup in ACP mode', async () => {
+ const { registerCleanup } = await import('./utils/cleanup.js');
+ const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { loadSettings } = await import('./config/settings.js');
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ acp: true,
+ startupMessages: [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: {
+ tools: { allowed: [], exclude: [] },
+ advanced: { dnsResolutionOrder: 'ipv4first' },
+ security: { auth: { selectedType: 'google' } },
+ ui: { theme: 'default' },
+ },
+ workspace: { settings: {} },
+ errors: [],
+ subscribe: vi.fn(),
+ getSnapshot: vi.fn(),
+ setValue: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ buildMockConfig({
+ getAcpMode: () => true,
+ }),
+ );
+
+ let capturedCleanup: () => void;
+ vi.mocked(ConsolePatcher).mockImplementation(() => {
+ const instance = {
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ };
+ capturedCleanup = instance.cleanup;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return instance as any;
+ });
+
+ await main();
+
+ const registeredFunctions = vi
+ .mocked(registerCleanup)
+ .mock.calls.map((call) => call[0]);
+ expect(registeredFunctions).not.toContain(capturedCleanup!);
+ });
+
+ it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
+ const { registerCleanup } = await import('./utils/cleanup.js');
+ const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
+ const { loadCliConfig, parseArguments } = await import(
+ './config/config.js'
+ );
+ const { loadSettings } = await import('./config/settings.js');
+
+ vi.mocked(parseArguments).mockResolvedValue({
+ acp: false,
+ query: 'test',
+ startupMessages: [],
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadSettings).mockReturnValue({
+ merged: {
+ tools: { allowed: [], exclude: [] },
+ advanced: { dnsResolutionOrder: 'ipv4first' },
+ security: { auth: { selectedType: 'google' } },
+ ui: { theme: 'default' },
+ },
+ workspace: { settings: {} },
+ errors: [],
+ subscribe: vi.fn(),
+ getSnapshot: vi.fn(),
+ setValue: vi.fn(),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ vi.mocked(loadCliConfig).mockResolvedValue(
+ buildMockConfig({
+ getAcpMode: () => false,
+ getQuestion: () => 'test',
+ }),
+ );
+
+ let capturedCleanup: () => void;
+ vi.mocked(ConsolePatcher).mockImplementation(() => {
+ const instance = {
+ patch: vi.fn(),
+ cleanup: vi.fn(),
+ };
+ capturedCleanup = instance.cleanup;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return instance as any;
+ });
+
+ try {
+ await main();
+ } catch {
+ // Ignore errors from incomplete mocks in full main() execution
+ }
+
+ const registeredFunctions = vi
+ .mocked(registerCleanup)
+ .mock.calls.map((call) => call[0]);
+ expect(registeredFunctions).toContain(capturedCleanup!);
+ });
+
function buildMockConfig(overrides: Partial = {}): Config {
return {
isInteractive: vi.fn(() => false),
@@ -319,7 +444,6 @@ describe('gemini.tsx main function cleanup', () => {
getListExtensions: vi.fn(() => false),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
- getToolRegistry: vi.fn(),
getExtensions: vi.fn(() => []),
getModel: vi.fn(() => 'gemini-pro'),
getEmbeddingModel: vi.fn(() => 'embedding-001'),
diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts
index 5d0c3d1016..1167bbbce4 100644
--- a/packages/cli/src/nonInteractiveCli.test.ts
+++ b/packages/cli/src/nonInteractiveCli.test.ts
@@ -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,
);
diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts
index 8c1c2ca6a2..8db512f56d 100644
--- a/packages/cli/src/nonInteractiveCli.ts
+++ b/packages/cli/src/nonInteractiveCli.ts
@@ -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
diff --git a/packages/cli/src/output-redirection.test.ts b/packages/cli/src/output-redirection.test.ts
new file mode 100644
index 0000000000..2dc935b330
--- /dev/null
+++ b/packages/cli/src/output-redirection.test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { initializeOutputListenersAndFlush } from './gemini.js';
+import { coreEvents, CoreEvent, type Config } from '@google/gemini-cli-core';
+
+// Mock core dependencies
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ writeToStdout: vi.fn(),
+ writeToStderr: vi.fn(),
+ };
+});
+
+import { writeToStdout, writeToStderr } from '@google/gemini-cli-core';
+
+describe('Output Redirection', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Clear listeners to simulate a clean state for each test
+ coreEvents.removeAllListeners();
+ });
+
+ afterEach(() => {
+ coreEvents.removeAllListeners();
+ });
+
+ it('should redirect buffered stdout to stderr when output format is json', () => {
+ const mockConfig = {
+ getOutputFormat: () => 'json',
+ } as unknown as Config;
+
+ // Simulate buffered output
+ coreEvents.emitOutput(false, 'informational message');
+ coreEvents.emitOutput(true, 'error message');
+
+ // Initialize listeners and flush
+ initializeOutputListenersAndFlush(mockConfig);
+
+ // Verify informational message was forced to stderr
+ expect(writeToStderr).toHaveBeenCalledWith(
+ 'informational message',
+ undefined,
+ );
+ expect(writeToStderr).toHaveBeenCalledWith('error message', undefined);
+ expect(writeToStdout).not.toHaveBeenCalled();
+ });
+
+ it('should NOT redirect buffered stdout to stderr when output format is NOT json', () => {
+ const mockConfig = {
+ getOutputFormat: () => 'text',
+ } as unknown as Config;
+
+ // Simulate buffered output
+ coreEvents.emitOutput(false, 'regular message');
+
+ // Initialize listeners and flush
+ initializeOutputListenersAndFlush(mockConfig);
+
+ // Verify regular message went to stdout
+ expect(writeToStdout).toHaveBeenCalledWith('regular message', undefined);
+ expect(writeToStderr).not.toHaveBeenCalled();
+ });
+
+ it('should force stdout to stderr when config is undefined (early failure)', () => {
+ // Simulate buffered output during early init
+ coreEvents.emitOutput(false, 'early init message');
+
+ // Initialize with undefined config
+ initializeOutputListenersAndFlush(undefined);
+
+ // Verify it was forced to stderr
+ expect(writeToStderr).toHaveBeenCalledWith('early init message', undefined);
+ expect(writeToStdout).not.toHaveBeenCalled();
+ });
+
+ it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => {
+ // Manually attach an Output listener
+ coreEvents.on(CoreEvent.Output, vi.fn());
+
+ // Initialize - should still attach ConsoleLog and UserFeedback defaults
+ initializeOutputListenersAndFlush(undefined);
+
+ // Simulate events
+ coreEvents.emitConsoleLog('info', 'stray log');
+ coreEvents.emitFeedback('info', 'stray feedback');
+
+ // Draining happens inside initializeOutputListenersAndFlush for existing backlog,
+ // but here we check the newly attached listeners for immediate emission.
+ expect(writeToStderr).toHaveBeenCalledWith('stray log\n');
+ expect(writeToStderr).toHaveBeenCalledWith('stray feedback\n');
+ });
+});
diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts
index f166c161cd..d53273134c 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.test.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts
@@ -170,6 +170,7 @@ describe('BuiltinCommandLoader', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
+ isVoiceModeEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
@@ -396,6 +397,7 @@ describe('BuiltinCommandLoader profile', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
+ isVoiceModeEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts
index 94b5986eb3..1c5288707c 100644
--- a/packages/cli/src/services/BuiltinCommandLoader.ts
+++ b/packages/cli/src/services/BuiltinCommandLoader.ts
@@ -62,6 +62,7 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
+import { voiceCommand } from '../ui/commands/voiceCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
@@ -227,6 +228,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+ ...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []),
...(this.config?.getContentGeneratorConfig()?.authType ===
AuthType.LOGIN_WITH_GOOGLE
? [upgradeCommand]
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index a9f786f11c..5f5ae4b8dc 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -552,6 +552,8 @@ const mockUIActions: UIActions = {
exitPrivacyNotice: vi.fn(),
closeSettingsDialog: vi.fn(),
closeModelDialog: vi.fn(),
+ openVoiceModelDialog: vi.fn(),
+ closeVoiceModelDialog: vi.fn(),
openAgentConfigDialog: vi.fn(),
closeAgentConfigDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
@@ -590,6 +592,7 @@ const mockUIActions: UIActions = {
setActiveBackgroundTaskPid: vi.fn(),
setIsBackgroundTaskListOpen: vi.fn(),
setAuthContext: vi.fn(),
+ dismissLoginRestart: vi.fn(),
onHintInput: vi.fn(),
onHintBackspace: vi.fn(),
onHintClear: vi.fn(),
@@ -598,6 +601,7 @@ const mockUIActions: UIActions = {
handleNewAgentsSelect: vi.fn(),
getPreferredEditor: vi.fn(),
clearAccountSuspension: vi.fn(),
+ setVoiceModeEnabled: vi.fn(),
};
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index fdbaf57fbe..a09f477045 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -103,6 +103,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
+import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
import { useVimMode } from './contexts/VimModeContext.js';
import {
@@ -172,7 +173,6 @@ import {
QUEUE_ERROR_DISPLAY_DURATION_MS,
EXPAND_HINT_DURATION_MS,
} from './constants.js';
-import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { parseSlashCommand } from '../utils/commands.js';
@@ -312,6 +312,7 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [shellModeActive, setShellModeActive] = useState(false);
+ const [isVoiceModeEnabled, setVoiceModeEnabled] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState(false);
const [historyRemountKey, setHistoryRemountKey] = useState(0);
@@ -754,7 +755,7 @@ export const AppContainer = (props: AppContainerProps) => {
useEffect(() => {
if (authState === AuthState.Authenticated && authContext.requiresRestart) {
- setAuthState(AuthState.AwaitingGoogleLoginRestart);
+ setAuthState(AuthState.AwaitingLoginRestart);
setAuthContext({});
}
}, [authState, authContext, setAuthState]);
@@ -873,10 +874,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;
}
@@ -946,6 +945,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
+ const {
+ isVoiceModelDialogOpen,
+ openVoiceModelDialog,
+ closeVoiceModelDialog,
+ } = useVoiceModelCommand();
+
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
@@ -969,6 +974,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
+ openVoiceModelDialog,
openAgentConfigDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
@@ -981,6 +987,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
+ toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev),
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
@@ -1006,6 +1013,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
+ openVoiceModelDialog,
openAgentConfigDialog,
setQuittingMessages,
setDebugMessage,
@@ -2176,8 +2184,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
+ const isAwaitingLoginRestart = authState === AuthState.AwaitingLoginRestart;
+ const loginRestartMessage =
+ settings.merged.security.auth.selectedType === AuthType.USE_VERTEX_AI
+ ? 'Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings.'
+ : undefined;
+
const dialogsVisible =
- shouldShowIdePrompt ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
@@ -2191,6 +2204,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
+ isVoiceModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
@@ -2204,6 +2218,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!emptyWalletRequest ||
isSessionBrowserOpen ||
authState === AuthState.AwaitingApiKeyInput ||
+ isAwaitingLoginRestart ||
!!newAgents;
const hasPendingToolConfirmation = useMemo(
@@ -2437,6 +2452,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
accountSuspensionInfo,
isAuthDialogOpen,
isAwaitingApiKeyInput: authState === AuthState.AwaitingApiKeyInput,
+ isAwaitingLoginRestart,
+ loginRestartMessage,
apiKeyDefaultValue,
editorError,
isEditorDialogOpen,
@@ -2448,6 +2465,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
+ isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
@@ -2468,6 +2486,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingGeminiHistoryItems,
thought,
isInputActive,
+ isVoiceModeEnabled,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
@@ -2559,6 +2578,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
+ isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
@@ -2579,6 +2599,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingGeminiHistoryItems,
thought,
isInputActive,
+ isVoiceModeEnabled,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
@@ -2638,6 +2659,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
customDialog,
apiKeyDefaultValue,
authState,
+ isAwaitingLoginRestart,
+ loginRestartMessage,
transientMessage,
bannerData,
bannerVisible,
@@ -2671,6 +2694,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
+ openVoiceModelDialog,
+ closeVoiceModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
@@ -2710,6 +2735,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setActiveBackgroundTaskPid,
setIsBackgroundTaskListOpen,
setAuthContext,
+ dismissLoginRestart: () => {
+ setAuthContext({});
+ setAuthState(AuthState.Updating);
+ },
onHintInput: () => {},
onHintBackspace: () => {},
onHintClear: () => {},
@@ -2751,6 +2780,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
+ setVoiceModeEnabled: (value: boolean) => {
+ setVoiceModeEnabled(value);
+ },
}),
[
handleThemeSelect,
@@ -2764,6 +2796,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
+ openVoiceModelDialog,
+ closeVoiceModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
@@ -2807,21 +2841,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
+ setVoiceModeEnabled,
],
);
- if (authState === AuthState.AwaitingGoogleLoginRestart) {
- return (
- {
- setAuthContext({});
- setAuthState(AuthState.Updating);
- }}
- config={config}
- />
- );
- }
-
return (
diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx
index 69593df076..0c4ec68f93 100644
--- a/packages/cli/src/ui/auth/AuthDialog.test.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx
@@ -243,6 +243,32 @@ describe('AuthDialog', () => {
unmount();
});
+ it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
+ vi.stubEnv('CLOUD_SHELL', 'true');
+ mockedValidateAuthMethod.mockReturnValue(null);
+ const { unmount } = await renderWithProviders();
+ const { onSelect: handleAuthSelect } =
+ mockedRadioButtonSelect.mock.calls[0][0];
+ await handleAuthSelect(AuthType.USE_VERTEX_AI);
+
+ expect(props.setAuthContext).toHaveBeenCalledWith({
+ requiresRestart: true,
+ });
+ unmount();
+ });
+
+ it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
+ vi.stubEnv('CLOUD_SHELL', '');
+ mockedValidateAuthMethod.mockReturnValue(null);
+ const { unmount } = await renderWithProviders();
+ const { onSelect: handleAuthSelect } =
+ mockedRadioButtonSelect.mock.calls[0][0];
+ await handleAuthSelect(AuthType.USE_VERTEX_AI);
+
+ expect(props.setAuthContext).toHaveBeenCalledWith({});
+ unmount();
+ });
+
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
const { unmount } = await renderWithProviders();
diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx
index e73d380bf3..4c52e29bc5 100644
--- a/packages/cli/src/ui/auth/AuthDialog.tsx
+++ b/packages/cli/src/ui/auth/AuthDialog.tsx
@@ -119,7 +119,12 @@ export function AuthDialog({
return;
}
if (authType) {
- if (authType === AuthType.LOGIN_WITH_GOOGLE) {
+ const needsRestart =
+ authType === AuthType.LOGIN_WITH_GOOGLE ||
+ (authType === AuthType.USE_VERTEX_AI &&
+ process.env['CLOUD_SHELL'] === 'true');
+
+ if (needsRestart) {
setAuthContext({ requiresRestart: true });
} else {
setAuthContext({});
diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginRestartDialog.test.tsx
similarity index 76%
rename from packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx
rename to packages/cli/src/ui/auth/LoginRestartDialog.test.tsx
index 4dd13a3334..3c5f22109e 100644
--- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx
+++ b/packages/cli/src/ui/auth/LoginRestartDialog.test.tsx
@@ -1,12 +1,12 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
-import { LoginWithGoogleRestartDialog } from './LoginWithGoogleRestartDialog.js';
+import { LoginRestartDialog } from './LoginRestartDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import {
@@ -27,7 +27,7 @@ vi.mock('../../utils/cleanup.js', () => ({
const mockedUseKeypress = useKeypress as Mock;
const mockedRunExitCleanup = runExitCleanup as Mock;
-describe('LoginWithGoogleRestartDialog', () => {
+describe('LoginRestartDialog', () => {
const onDismiss = vi.fn();
const exitSpy = vi
.spyOn(process, 'exit')
@@ -44,11 +44,20 @@ describe('LoginWithGoogleRestartDialog', () => {
_resetRelaunchStateForTesting();
});
- it('renders correctly', async () => {
+ it('renders correctly with default message', async () => {
const { lastFrame, unmount } = await render(
- ,
+ );
+ expect(lastFrame()).toMatchSnapshot();
+ unmount();
+ });
+
+ it('renders correctly with custom message', async () => {
+ const { lastFrame, unmount } = await render(
+ ,
);
expect(lastFrame()).toMatchSnapshot();
@@ -57,10 +66,7 @@ describe('LoginWithGoogleRestartDialog', () => {
it('calls onDismiss when escape is pressed', async () => {
const { unmount } = await render(
- ,
+ ,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
@@ -82,10 +88,7 @@ describe('LoginWithGoogleRestartDialog', () => {
vi.useFakeTimers();
const { unmount } = await render(
- ,
+ ,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx b/packages/cli/src/ui/auth/LoginRestartDialog.tsx
similarity index 70%
rename from packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
rename to packages/cli/src/ui/auth/LoginRestartDialog.tsx
index a781828d09..324f461ed1 100644
--- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.tsx
+++ b/packages/cli/src/ui/auth/LoginRestartDialog.tsx
@@ -1,6 +1,6 @@
/**
* @license
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -10,15 +10,17 @@ import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { relaunchApp } from '../../utils/processUtils.js';
-interface LoginWithGoogleRestartDialogProps {
+interface LoginRestartDialogProps {
onDismiss: () => void;
config: Config;
+ message?: string;
}
-export const LoginWithGoogleRestartDialog = ({
+export const LoginRestartDialog = ({
onDismiss,
config,
-}: LoginWithGoogleRestartDialogProps) => {
+ message,
+}: LoginRestartDialogProps) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
@@ -44,14 +46,20 @@ export const LoginWithGoogleRestartDialog = ({
{ isActive: true },
);
- const message =
+ const displayMessage =
+ message ??
"You've successfully signed in with Google. Gemini CLI needs to be restarted.";
return (
-
+
+ {displayMessage}
- {message} Press R to restart, or Esc to choose a different
- authentication method.
+ Press R to restart, or Esc to choose a different authentication method.
);
diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap
new file mode 100644
index 0000000000..33beb0e9d5
--- /dev/null
+++ b/packages/cli/src/ui/auth/__snapshots__/LoginRestartDialog.test.tsx.snap
@@ -0,0 +1,17 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`LoginRestartDialog > renders correctly with custom message 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ Authenticating to Vertex AI in Cloud Shell requires a restart to apply project settings. โ
+โ Press R to restart, or Esc to choose a different authentication method. โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+"
+`;
+
+exports[`LoginRestartDialog > renders correctly with default message 1`] = `
+"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
+โ You've successfully signed in with Google. Gemini CLI needs to be restarted. โ
+โ Press R to restart, or Esc to choose a different authentication method. โ
+โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
+"
+`;
diff --git a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap b/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap
deleted file mode 100644
index 7c7a95e24f..0000000000
--- a/packages/cli/src/ui/auth/__snapshots__/LoginWithGoogleRestartDialog.test.tsx.snap
+++ /dev/null
@@ -1,9 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`LoginWithGoogleRestartDialog > renders correctly 1`] = `
-"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-โ You've successfully signed in with Google. Gemini CLI needs to be restarted. Press R to restart, โ
-โ or Esc to choose a different authentication method. โ
-โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
-"
-`;
diff --git a/packages/cli/src/ui/commands/quitCommand.test.ts b/packages/cli/src/ui/commands/quitCommand.test.ts
index e67723fdf1..24a4a682d6 100644
--- a/packages/cli/src/ui/commands/quitCommand.test.ts
+++ b/packages/cli/src/ui/commands/quitCommand.test.ts
@@ -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,
+ }),
+ );
+ });
});
diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts
index ab879f22ca..ebd17e2d21 100644
--- a/packages/cli/src/ui/commands/quitCommand.ts
+++ b/packages/cli/src/ui/commands/quitCommand.ts
@@ -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',
diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts
index 466e70c994..328e8fc5e4 100644
--- a/packages/cli/src/ui/commands/types.ts
+++ b/packages/cli/src/ui/commands/types.ts
@@ -72,6 +72,7 @@ export interface CommandContext {
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
+ toggleVoiceMode: () => void;
toggleDebugProfiler: () => void;
toggleVimEnabled: () => Promise;
reloadCommands: () => void;
@@ -107,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;
}
/**
@@ -125,6 +128,7 @@ export interface OpenDialogActionReturn {
| 'settings'
| 'sessionBrowser'
| 'model'
+ | 'voice-model'
| 'agentConfig'
| 'permissions';
}
diff --git a/packages/cli/src/ui/commands/voiceCommand.ts b/packages/cli/src/ui/commands/voiceCommand.ts
new file mode 100644
index 0000000000..b9df28ca27
--- /dev/null
+++ b/packages/cli/src/ui/commands/voiceCommand.ts
@@ -0,0 +1,30 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { CommandKind, type SlashCommand } from './types.js';
+
+export const voiceCommand: SlashCommand = {
+ name: 'voice',
+ altNames: [],
+ description: 'Toggle voice dictation mode',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: (context) => {
+ context.ui.toggleVoiceMode();
+ },
+ subCommands: [
+ {
+ name: 'model',
+ description: 'Manage voice transcription models',
+ kind: CommandKind.BUILT_IN,
+ autoExecute: true,
+ action: async () => ({
+ type: 'dialog',
+ dialog: 'voice-model',
+ }),
+ },
+ ],
+};
diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx
index b231a62db5..acd2f3472f 100644
--- a/packages/cli/src/ui/components/DialogManager.tsx
+++ b/packages/cli/src/ui/components/DialogManager.tsx
@@ -25,6 +25,7 @@ import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
+import { VoiceModelDialog } from './VoiceModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useQuotaState } from '../contexts/QuotaContext.js';
@@ -38,6 +39,7 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
+import { LoginRestartDialog } from '../auth/LoginRestartDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
@@ -238,6 +240,9 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return ;
}
+ if (uiState.isVoiceModelDialogOpen) {
+ return ;
+ }
if (
uiState.isAgentConfigDialogOpen &&
uiState.selectedAgentName &&
@@ -302,6 +307,17 @@ export const DialogManager = ({
);
}
+ if (uiState.isAwaitingLoginRestart) {
+ return (
+
+
+
+ );
+ }
if (uiState.isAuthDialogOpen) {
return (
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index e50a2f1d81..5be237a15f 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -9,12 +9,41 @@ import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { act, useState, useMemo } from 'react';
+import type { EventEmitter } from 'node:events';
+
+const { fakeTranscriptionProvider } = vi.hoisted(() => {
+ // Use require within hoisted block for immediate synchronous access
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
+ const { EventEmitter } = require('node:events');
+ class FakeTranscriptionProvider extends EventEmitter {
+ connect = vi.fn().mockResolvedValue(undefined);
+ disconnect = vi.fn();
+ sendAudioChunk = vi.fn();
+ getTranscription = vi.fn().mockReturnValue('');
+ }
+ return {
+ fakeTranscriptionProvider: new FakeTranscriptionProvider(),
+ };
+});
+
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const actual = (await importOriginal()) as any;
+ return {
+ ...actual,
+ TranscriptionFactory: {
+ createProvider: vi.fn(() => fakeTranscriptionProvider),
+ },
+ };
+});
+
import {
InputPrompt,
tryTogglePasteExpansion,
type InputPromptProps,
} from './InputPrompt.js';
import { InputContext } from '../contexts/InputContext.js';
+import { type UIState } from '../contexts/UIStateContext.js';
import {
calculateTransformationsForLine,
calculateTransformedLine,
@@ -417,6 +446,7 @@ describe('InputPrompt', () => {
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
}),
+ getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -4925,6 +4955,383 @@ describe('InputPrompt', () => {
},
);
});
+
+ describe('Voice Mode', () => {
+ beforeEach(() => {
+ (
+ fakeTranscriptionProvider as unknown as EventEmitter
+ ).removeAllListeners();
+ vi.clearAllMocks();
+ });
+
+ it('should start recording when space is pressed and voice mode is enabled (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Initially not recording
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+ expect(lastFrame()).toContain(
+ 'Voice mode: Space to start/stop recording',
+ );
+
+ // Press space to start
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Now should show listening
+ await waitFor(() => {
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+ });
+
+ unmount();
+ });
+
+ it('should toggle recording off when space is pressed again (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+ await waitFor(() => {
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+ });
+
+ // Stop recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+ await waitFor(() => {
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+ expect(lastFrame()).toContain(
+ 'Voice mode: Space to start/stop recording',
+ );
+ });
+
+ unmount();
+ });
+
+ it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('some existing text');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Should show voice mode hint even if buffer is not empty (new behavior)
+ expect(lastFrame()).toContain(
+ 'Voice mode: Space to start/stop recording',
+ );
+ expect(lastFrame()).toContain('some existing text');
+
+ // Press space to start recording again
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+ });
+
+ unmount();
+ });
+
+ it('should not start recording if voice mode is disabled (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('');
+ });
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: false } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Press space
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Should NOT show listening, instead should call handleInput which handles space
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+ expect(mockBuffer.handleInput).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should append transcription correctly across multiple turn updates (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('initial');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Emit first transcription
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'hello',
+ );
+ });
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 'end');
+ });
+
+ // Emit turnComplete (Gemini Live starts over after this)
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'turnComplete',
+ );
+ });
+
+ // Emit second part (Gemini Live sends new turn text starting from empty)
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'world',
+ );
+ });
+ await waitFor(() => {
+ // Should have appended 'world' to the baseline 'initial hello'
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'initial hello world',
+ 'end',
+ );
+ });
+
+ unmount();
+ });
+
+ it('should append transcription correctly when resuming voice mode (toggle)', async () => {
+ await act(async () => {
+ mockBuffer.setText('First turn.');
+ });
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'toggle' } },
+ }),
+ },
+ );
+
+ // Start recording (resumed)
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Emit transcription
+ await act(async () => {
+ (fakeTranscriptionProvider as unknown as EventEmitter).emit(
+ 'transcription',
+ 'Second turn.',
+ );
+ });
+
+ await waitFor(() => {
+ expect(mockBuffer.setText).toHaveBeenCalledWith(
+ 'First turn. Second turn.',
+ 'end',
+ );
+ });
+
+ unmount();
+ });
+
+ describe('push-to-talk', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should insert a space on a single tap', async () => {
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ expect(lastFrame()).toContain('Voice mode: Hold Space to record');
+
+ // Press space once
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Should insert space optimistically
+ expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+
+ // Advance timer past HOLD_DELAY_MS
+ await act(async () => {
+ vi.advanceTimersByTime(700);
+ });
+
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+ unmount();
+ });
+
+ it('should start recording on hold (simulated by repeat spaces)', async () => {
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // First space
+ await act(async () => {
+ stdin.write(' ');
+ });
+ expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
+
+ // Second space (repeat)
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ await waitFor(() => {
+ // Should have backspaced the optimistic space
+ expect(mockBuffer.backspace).toHaveBeenCalled();
+ // Should show listening
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+ });
+
+ unmount();
+ });
+
+ it('should stop recording when space heartbeat stops (release)', async () => {
+ const { stdin, unmount, lastFrame } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // Start hold
+ await act(async () => {
+ stdin.write(' ');
+ stdin.write(' ');
+ });
+
+ // Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
+ await waitFor(
+ () => {
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+ },
+ { interval: 10 },
+ );
+
+ // Simulate heartbeat (held key) - send space first to reset timer, then advance
+ await act(async () => {
+ stdin.write(' ');
+ vi.advanceTimersByTime(100);
+ });
+ expect(lastFrame()).toContain('๐๏ธ Listening...');
+
+ // Stop heartbeat (release)
+ await act(async () => {
+ vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
+ });
+
+ await waitFor(() => {
+ expect(lastFrame()).not.toContain('๐๏ธ Listening...');
+ });
+
+ unmount();
+ });
+
+ it('should cancel hold state if non-space key is pressed after first space', async () => {
+ const { stdin, unmount } = await renderWithProviders(
+ ,
+ {
+ uiState: { isVoiceModeEnabled: true } as UIState,
+ settings: createMockSettings({
+ experimental: { voice: { activationMode: 'push-to-talk' } },
+ }),
+ },
+ );
+
+ // First space
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ // Type 'a'
+ await act(async () => {
+ stdin.write('a');
+ });
+
+ // Should NOT start recording on next space even if fast
+ await act(async () => {
+ stdin.write(' ');
+ });
+
+ expect(mockBuffer.insert).toHaveBeenCalledTimes(2); // Two spaces inserted
+ expect(mockBuffer.handleInput).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'a' }),
+ );
+ unmount();
+ });
+ });
+ });
});
function clean(str: string | undefined): string {
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index c9f75c740b..f69138c8c7 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -56,6 +56,7 @@ import {
debugLogger,
type Config,
} from '@google/gemini-cli-core';
+import { useVoiceMode } from '../hooks/useVoiceMode.js';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -159,7 +160,6 @@ export function isLargePaste(text: string): boolean {
}
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
-
/**
* Attempt to toggle expansion of a paste placeholder in the buffer.
* Returns true if a toggle action was performed or hint was shown, false otherwise.
@@ -238,6 +238,7 @@ export const InputPrompt: React.FC = ({
setEmbeddedShellFocused,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
+ setVoiceModeEnabled,
} = useUIActions();
const {
terminalWidth,
@@ -246,6 +247,7 @@ export const InputPrompt: React.FC = ({
backgroundTasks,
backgroundTaskHeight,
shortcutsHelpVisible,
+ isVoiceModeEnabled,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -263,6 +265,7 @@ export const InputPrompt: React.FC = ({
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
+ resetTurnBaseline();
resetCompletionState();
} else if (history.length > 0) {
onSubmit('/rewind');
@@ -281,6 +284,16 @@ export const InputPrompt: React.FC = ({
const hasUserNavigatedSuggestions = useRef(false);
const listRef = useRef>(null);
+ const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
+ buffer,
+ config,
+ settings,
+ setQueueErrorMessage,
+ isVoiceModeEnabled,
+ setVoiceModeEnabled,
+ keyMatchers,
+ });
+
const [reverseSearchActive, setReverseSearchActive] = useState(false);
const [commandSearchActive, setCommandSearchActive] = useState(false);
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
@@ -387,6 +400,7 @@ export const InputPrompt: React.FC = ({
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
// if onSubmit triggers a re-render while the buffer still holds the old value.
buffer.setText('');
+ resetTurnBaseline();
onSubmit(processedValue);
resetCompletionState();
resetReverseSearchCompletionState();
@@ -398,6 +412,7 @@ export const InputPrompt: React.FC = ({
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
+ resetTurnBaseline,
],
);
@@ -647,6 +662,8 @@ export const InputPrompt: React.FC = ({
const handleInput = useCallback(
(key: Key) => {
+ if (handleVoiceInput(key)) return true;
+
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -873,9 +890,9 @@ export const InputPrompt: React.FC = ({
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
+ resetTurnBaseline();
return true;
}
-
if (keyMatchers[Command.ESCAPE](key)) {
const cancelSearch = (
setActive: (active: boolean) => void,
@@ -1360,6 +1377,7 @@ export const InputPrompt: React.FC = ({
backgroundTaskHeight,
streamingState,
handleEscPress,
+ resetTurnBaseline,
registerPlainTabPress,
resetPlainTabPress,
toggleCleanUiDetailsVisible,
@@ -1369,9 +1387,9 @@ export const InputPrompt: React.FC = ({
keyMatchers,
isHelpDismissKey,
settings,
+ handleVoiceInput,
],
);
-
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
priority: true,
@@ -1792,20 +1810,39 @@ export const InputPrompt: React.FC = ({
)}{' '}
- {buffer.text.length === 0 && placeholder ? (
- showCursor ? (
-
- {chalk.inverse(placeholder.slice(0, 1))}
-
- {placeholder.slice(1)}
-
+ {isRecording && (
+
+ ๐๏ธ Listening...
+
+ )}
+ {isVoiceModeEnabled && !isRecording && (
+
+
+ > Voice mode:{' '}
+ {(settings.experimental.voice?.activationMode ??
+ 'push-to-talk') === 'push-to-talk'
+ ? 'Hold Space to record'
+ : 'Space to start/stop recording'}{' '}
+ (Esc to exit)
- ) : (
- {placeholder}
- )
+
+ )}
+ {buffer.text.length === 0 && !isRecording ? (
+ !isVoiceModeEnabled && placeholder ? (
+ showCursor ? (
+
+ {chalk.inverse(placeholder.slice(0, 1))}
+
+ {placeholder.slice(1)}
+
+
+ ) : (
+ {placeholder}
+ )
+ ) : null
) : (
void;
+}
+
+type DialogView = 'backend' | 'whisper-models';
+
+const WHISPER_MODELS = [
+ {
+ value: 'ggml-tiny.en.bin',
+ label: 'Tiny (EN)',
+ description: 'Fastest, lower accuracy (~75MB)',
+ },
+ {
+ value: 'ggml-base.en.bin',
+ label: 'Base (EN)',
+ description: 'Balanced speed and accuracy (~142MB)',
+ },
+ {
+ value: 'ggml-large-v3-turbo-q5_0.bin',
+ label: 'Large v3 Turbo (Q5_0)',
+ description: 'High accuracy, quantized (~547MB)',
+ },
+ {
+ value: 'ggml-large-v3-turbo-q8_0.bin',
+ label: 'Large v3 Turbo (Q8_0)',
+ description: 'Maximum accuracy, high memory (~834MB)',
+ },
+];
+
+export function VoiceModelDialog({
+ onClose,
+}: VoiceModelDialogProps): React.JSX.Element {
+ const { settings, setSetting } = useSettingsStore();
+ const [view, setView] = useState('backend');
+ const [downloadProgress, setDownloadProgress] =
+ useState(null);
+ const [error, setError] = useState(null);
+
+ const whisperInstalled = useMemo(
+ () => isBinaryAvailable('whisper-stream'),
+ [],
+ );
+ const modelManager = useMemo(() => new WhisperModelManager(), []);
+
+ const currentBackend =
+ settings.merged.experimental.voice?.backend ?? 'gemini-live';
+ const currentWhisperModel =
+ settings.merged.experimental.voice?.whisperModel ?? 'ggml-base.en.bin';
+
+ const handleKeypress = useCallback(
+ (key: Key) => {
+ if (key.name === 'escape') {
+ if (view === 'whisper-models') {
+ setView('backend');
+ } else {
+ onClose();
+ }
+ return true;
+ }
+ return false;
+ },
+ [view, onClose],
+ );
+
+ useKeypress(handleKeypress, { isActive: true });
+
+ const handleBackendSelect = useCallback(
+ (value: string) => {
+ if (value === 'whisper') {
+ setView('whisper-models');
+ } else {
+ setSetting(
+ SettingScope.User,
+ 'experimental.voice.backend',
+ 'gemini-live',
+ );
+ onClose();
+ }
+ },
+ [setSetting, onClose],
+ );
+
+ const handleWhisperModelSelect = useCallback(
+ async (modelName: string) => {
+ if (modelManager.isModelInstalled(modelName)) {
+ setSetting(SettingScope.User, 'experimental.voice.backend', 'whisper');
+ setSetting(
+ SettingScope.User,
+ 'experimental.voice.whisperModel',
+ modelName,
+ );
+ onClose();
+ } else {
+ setError(null);
+ const onProgress = (p: WhisperModelProgress) => setDownloadProgress(p);
+ modelManager.on('progress', onProgress);
+
+ try {
+ await modelManager.downloadModel(modelName);
+
+ setSetting(
+ SettingScope.User,
+ 'experimental.voice.backend',
+ 'whisper',
+ );
+ setSetting(
+ SettingScope.User,
+ 'experimental.voice.whisperModel',
+ modelName,
+ );
+ onClose();
+ } catch (err) {
+ setError(
+ `Failed to download: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ } finally {
+ modelManager.off('progress', onProgress);
+ setDownloadProgress(null);
+ }
+ }
+ },
+ [modelManager, setSetting, onClose],
+ );
+
+ const backendOptions = useMemo(
+ () => [
+ {
+ value: 'gemini-live',
+ title: 'Gemini Live API (Cloud)',
+ description: 'Real-time cloud transcription via Gemini Live API.',
+ key: 'gemini-live',
+ },
+ {
+ value: 'whisper',
+ title: 'Whisper (Local)',
+ description: whisperInstalled
+ ? 'Local transcription using whisper.cpp.'
+ : 'Local transcription (Requires: brew install whisper-cpp)',
+ key: 'whisper',
+ },
+ ],
+ [whisperInstalled],
+ );
+
+ const whisperOptions = useMemo(
+ () =>
+ WHISPER_MODELS.map((m) => ({
+ value: m.value,
+ title: `${m.label}${modelManager.isModelInstalled(m.value) ? ' (Installed)' : ' (Download)'}`,
+ description: m.description,
+ key: m.value,
+ })),
+ [modelManager],
+ );
+
+ return (
+
+
+ {view === 'backend'
+ ? 'Select Voice Transcription Backend'
+ : 'Select Whisper Model'}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {downloadProgress ? (
+
+
+ Downloading {downloadProgress.modelName}...
+
+ {Math.round(downloadProgress.percentage * 100)}%
+
+
+ ) : (
+
+ {view === 'backend' ? (
+
+ ) : (
+ o.value === currentWhisperModel,
+ )}
+ showNumbers={true}
+ />
+ )}
+
+ )}
+
+
+
+ {view === 'whisper-models'
+ ? '(Press Esc to go back)'
+ : '(Press Esc to close)'}
+
+
+
+ );
+}
diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
index 4830e90db1..db449ce4d7 100644
--- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
+++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap
@@ -168,13 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
-exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
-"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- > [Pasted Text: 10 lines]
-โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
-"
-`;
-
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
> hello
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
index 676051501c..09906495dd 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx
@@ -356,4 +356,65 @@ describe('', () => {
unmount();
});
});
+
+ describe('Header Expansion', () => {
+ const LONG_DESCRIPTION = 'very long '.repeat(20);
+
+ it('truncates header by default', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // Should be a single line header
+ expect(output.split('\n')[1]).toContain(SHELL_COMMAND_NAME); // name
+ // We check if it's truncated. In our ToolInfo, it's height 1.
+ // The StickyHeader adds some structure, but the ToolInfo Box is inside.
+ });
+
+ it('expands header when availableTerminalHeight is undefined', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ { uiActions },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // When expanded, the header (ToolInfo) should wrap and take multiple lines.
+ // Since it's at the top, we check if the first few lines contain parts of the description.
+ const lines = output.split('\n');
+ expect(lines.length).toBeGreaterThan(5);
+ });
+
+ it('expands header when isExpanded is true in context', async () => {
+ const { lastFrame, waitUntilReady } = await renderWithProviders(
+ ,
+ {
+ uiActions,
+ toolActions: {
+ isExpanded: (id: string) => id === baseProps.callId,
+ },
+ },
+ );
+
+ await waitUntilReady();
+ const output = lastFrame();
+ // Should be expanded due to context
+ expect(output.split('\n').length).toBeGreaterThan(5);
+ });
+ });
});
diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
index f3694f3490..db950a6e51 100644
--- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx
@@ -24,6 +24,7 @@ import type { ToolMessageProps } from './ToolMessage.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useUIState } from '../../contexts/UIStateContext.js';
+import { useToolActions } from '../../contexts/ToolActionsContext.js';
import {
type Config,
ShellExecutionService,
@@ -41,6 +42,7 @@ export interface ShellToolMessageProps extends ToolMessageProps {
}
export const ShellToolMessage: React.FC = ({
+ callId,
name,
description,
resultDisplay,
@@ -57,6 +59,12 @@ export const ShellToolMessage: React.FC = ({
isExpandable,
originalRequestName,
}) => {
+ const { isExpanded: isExpandedInContext } = useToolActions();
+
+ const isExpanded =
+ (isExpandedInContext ? isExpandedInContext(callId) : false) ||
+ availableTerminalHeight === undefined;
+
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
@@ -169,6 +177,7 @@ export const ShellToolMessage: React.FC = ({
description={description}
emphasis={emphasis}
originalRequestName={originalRequestName}
+ isExpanded={isExpanded}
/>
= ({
+ callId,
name,
description,
resultDisplay,
@@ -63,6 +65,12 @@ export const ToolMessage: React.FC = ({
progress,
progressTotal,
}) => {
+ const { isExpanded: isExpandedInContext } = useToolActions();
+
+ const isExpanded =
+ (isExpandedInContext ? isExpandedInContext(callId) : false) ||
+ availableTerminalHeight === undefined;
+
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -102,6 +110,7 @@ export const ToolMessage: React.FC = ({
emphasis={emphasis}
progressMessage={progressMessage}
originalRequestName={originalRequestName}
+ isExpanded={isExpanded}
/>
({
GeminiRespondingSpinner: () => MockSpinner,
@@ -65,3 +66,37 @@ describe('McpProgressIndicator', () => {
expect(output).not.toContain('150%');
});
});
+
+describe('ToolInfo', () => {
+ const longDescription = 'long '.repeat(50);
+
+ it('truncates description by default', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ const output = lastFrame();
+ // In Ink, a single line Box with wrap="truncate" will be truncated.
+ // Since we don't know the exact terminal width in this test, we check if it is short.
+ expect(output.trim().split('\n').length).toBe(1);
+ });
+
+ it('wraps description when isExpanded is true', async () => {
+ const { lastFrame } = await render(
+ ,
+ );
+ const output = lastFrame();
+ // When expanded, it should wrap into multiple lines.
+ expect(output.trim().split('\n').length).toBeGreaterThan(1);
+ });
+});
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index 2aa5ed992a..b246db308b 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -194,6 +194,7 @@ type ToolInfoProps = {
emphasis: TextEmphasis;
progressMessage?: string;
originalRequestName?: string;
+ isExpanded?: boolean;
};
export const ToolInfo: React.FC = ({
@@ -203,6 +204,7 @@ export const ToolInfo: React.FC = ({
emphasis,
progressMessage: _progressMessage,
originalRequestName,
+ isExpanded = false,
}) => {
const status = mapCoreStatusToDisplayStatus(coreStatus);
const nameColor = React.useMemo(() => {
@@ -224,8 +226,16 @@ export const ToolInfo: React.FC = ({
const isCompletedAskUser = isCompletedAskUserTool(name, status);
return (
-
-
+
+
{name}
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
index f61b9274c9..b0d33feebd 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap
@@ -62,9 +62,9 @@ exports[` > Golden Snapshots > renders empty tool calls arra
exports[` > Golden Snapshots > renders header when scrolled 1`] = `
"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-โ โ tool-1 Description 1. This is a long description that will need to bโฆ โ โ
+โ โ tool-1 Description 1. This is a long description that will need to be โ
+โ truncated if the terminal width is small. โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
-โ line3 โ โ
โ line4 โ โ
โ line5 โ โ
โ โ โ
@@ -161,7 +161,10 @@ exports[` > Golden Snapshots > renders with limited terminal
exports[` > Golden Snapshots > renders with narrow terminal width 1`] = `
"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-โ โ very-long-tool-name-that-migโฆ โ
+โ โ very-long-tool-name-that-migh โ
+โ t-wrap This is a very long โ
+โ description that might cause โ
+โ wrapping issues โ
โ โ
โ Test result โ
โฐโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฏ
diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
index 8da15d7fdb..22904f2b29 100644
--- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
+++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageFocusHint.test.tsx.snap
@@ -58,7 +58,9 @@ exports[`Focus Hint > 'ToolMessage' > shows focus hint after delay with output >
exports[`Focus Hint > handles long descriptions by shrinking them to show the focus hint > long-description 1`] = `
"โญโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฎ
-โ โถ Shell Command AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAโฆ (Tab to focus) โ
+โ โถ Shell Command (Tab to focus) โ
+โ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA โ
+โ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA โ
โ โ
"
`;
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
index c49c967714..f66af9fd17 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.test.tsx
@@ -24,7 +24,7 @@ enum TerminalKeys {
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
- BACKSPACE = '\x7f',
+ BACKSPACE = '\u0008',
CTRL_L = '\u000C',
}
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
index 32077b736a..a3052f546b 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -41,6 +41,7 @@ import {
getTransformedImagePath,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
+import { type Key } from '../../hooks/useKeypress.js';
import { escapePath } from '@google/gemini-cli-core';
const defaultVisualLayout: VisualLayout = {
@@ -1799,6 +1800,229 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).text).toBe('');
});
+ it('should only handle Undo if there is something to undo', async () => {
+ const { result } = await renderHook(() => useTextBuffer({ viewport }));
+
+ // Platform-specific undo key
+ const undoKey: Key =
+ process.platform === 'win32'
+ ? {
+ name: 'z',
+ ctrl: true,
+ shift: false,
+ alt: false,
+ cmd: false,
+ insertable: false,
+ sequence: '\x1a',
+ }
+ : process.platform === 'darwin'
+ ? {
+ name: 'z',
+ ctrl: false,
+ shift: false,
+ alt: false,
+ cmd: true,
+ insertable: false,
+ sequence: '\u001b[122;D',
+ }
+ : {
+ name: 'z',
+ ctrl: false,
+ shift: false,
+ alt: true,
+ cmd: false,
+ insertable: false,
+ sequence: '\u001bz',
+ };
+
+ // 1. Initial state: nothing to undo
+ let handled = true;
+ act(() => {
+ handled = result.current.handleInput(undoKey);
+ });
+ expect(handled).toBe(false);
+
+ // 2. Insert something
+ act(() => {
+ result.current.handleInput({
+ name: 'a',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ insertable: true,
+ sequence: 'a',
+ });
+ });
+ expect(getBufferState(result).text).toBe('a');
+
+ // 3. Now undo should work
+ act(() => {
+ handled = result.current.handleInput(undoKey);
+ });
+ expect(handled).toBe(true);
+ expect(getBufferState(result).text).toBe('');
+
+ // 4. Undo again: nothing left to undo
+ act(() => {
+ handled = result.current.handleInput(undoKey);
+ });
+ expect(handled).toBe(false);
+ });
+
+ if (process.platform === 'linux') {
+ it('should handle "Ctrl+Z" for smart bubbling on Linux/WSL', async () => {
+ const { result } = await renderHook(() => useTextBuffer({ viewport }));
+
+ const ctrlZ: Key = {
+ name: 'z',
+ ctrl: true,
+ shift: false,
+ alt: false,
+ cmd: false,
+ insertable: false,
+ sequence: '\x1a',
+ };
+
+ // 1. Empty buffer: should NOT handle (bubble up to Suspend)
+ let handled = true;
+ act(() => {
+ handled = result.current.handleInput(ctrlZ);
+ });
+ expect(handled).toBe(false);
+
+ // 2. Add text
+ act(() => {
+ result.current.handleInput({
+ name: 'x',
+ insertable: true,
+ sequence: 'x',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ });
+ });
+
+ // 3. Has history: should handle (perform Undo)
+ act(() => {
+ handled = result.current.handleInput(ctrlZ);
+ });
+ expect(handled).toBe(true);
+ expect(getBufferState(result).text).toBe('');
+
+ // 4. Empty again: should NOT handle
+ act(() => {
+ handled = result.current.handleInput(ctrlZ);
+ });
+ expect(handled).toBe(false);
+ });
+ }
+
+ it('should only handle Redo if there is something to redo', async () => {
+ const { result } = await renderHook(() => useTextBuffer({ viewport }));
+
+ // Platform-specific redo key (first in list)
+ const redoKey: Key =
+ process.platform === 'win32'
+ ? {
+ name: 'z',
+ ctrl: true,
+ shift: true,
+ alt: false,
+ cmd: false,
+ insertable: false,
+ sequence: '\x1a',
+ }
+ : process.platform === 'darwin'
+ ? {
+ name: 'z',
+ ctrl: false,
+ shift: true,
+ alt: false,
+ cmd: true,
+ insertable: false,
+ sequence: '\u001b[122;2D',
+ }
+ : {
+ name: 'z',
+ ctrl: false,
+ shift: true,
+ alt: true,
+ cmd: false,
+ insertable: false,
+ sequence: '\u001bZ',
+ };
+
+ const undoKey: Key =
+ process.platform === 'win32'
+ ? {
+ name: 'z',
+ ctrl: true,
+ shift: false,
+ alt: false,
+ cmd: false,
+ insertable: false,
+ sequence: '\x1a',
+ }
+ : process.platform === 'darwin'
+ ? {
+ name: 'z',
+ ctrl: false,
+ shift: false,
+ alt: false,
+ cmd: true,
+ insertable: false,
+ sequence: '\u001b[122;D',
+ }
+ : {
+ name: 'z',
+ ctrl: false,
+ shift: false,
+ alt: true,
+ cmd: false,
+ insertable: false,
+ sequence: '\u001bz',
+ };
+
+ // 1. Initial state: nothing to redo
+ let handled = true;
+ act(() => {
+ handled = result.current.handleInput(redoKey);
+ });
+ expect(handled).toBe(false);
+
+ // 2. Insert and Undo
+ act(() => {
+ result.current.handleInput({
+ name: 'a',
+ shift: false,
+ alt: false,
+ ctrl: false,
+ cmd: false,
+ insertable: true,
+ sequence: 'a',
+ });
+ });
+ act(() => {
+ result.current.handleInput(undoKey);
+ });
+ expect(getBufferState(result).text).toBe('');
+
+ // 3. Now redo should work
+ act(() => {
+ handled = result.current.handleInput(redoKey);
+ });
+ expect(handled).toBe(true);
+ expect(getBufferState(result).text).toBe('a');
+
+ // 4. Redo again: nothing left to redo
+ act(() => {
+ handled = result.current.handleInput(redoKey);
+ });
+ expect(handled).toBe(false);
+ });
+
it('should handle multiple delete characters in one input', async () => {
const { result } = await renderHook(() =>
useTextBuffer({
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index d6b95d6016..89b6f8f158 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -2889,6 +2889,8 @@ export function useTextBuffer({
transformationsByLine,
pastedContent,
expandedPaste,
+ undoStack,
+ redoStack,
} = state;
const text = useMemo(() => lines.join('\n'), [lines]);
@@ -3454,10 +3456,16 @@ export function useTextBuffer({
return true;
}
if (keyMatchers[Command.UNDO](key)) {
+ if (undoStack.length === 0) {
+ return false;
+ }
undo();
return true;
}
if (keyMatchers[Command.REDO](key)) {
+ if (redoStack.length === 0) {
+ return false;
+ }
redo();
return true;
}
@@ -3486,6 +3494,8 @@ export function useTextBuffer({
visualCursor,
visualLines,
keyMatchers,
+ undoStack.length,
+ redoStack.length,
],
);
diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 26f1c1cf35..e7d0406dd7 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
@@ -9,17 +9,7 @@ import { act } from 'react';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
-import type { Mock } from 'vitest';
-import {
- vi,
- afterAll,
- beforeAll,
- describe,
- it,
- expect,
- beforeEach,
- afterEach,
-} from 'vitest';
+import { vi, afterAll, beforeAll, type Mock } from 'vitest';
import {
useKeypressContext,
ESC_TIMEOUT,
@@ -441,80 +431,6 @@ describe('KeypressContext', () => {
);
});
- describe('Windows Terminal Backspace handling', () => {
- afterEach(() => {
- vi.unstubAllEnvs();
- });
-
- it('should NOT treat \\b as ctrl when WT_SESSION is NOT present and OS is not Windows_NT', async () => {
- vi.stubEnv('WT_SESSION', '');
- vi.stubEnv('OS', 'Linux');
- const { keyHandler } = await setupKeypressTest();
-
- act(() => {
- stdin.write('\b');
- });
-
- expect(keyHandler).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'backspace',
- ctrl: false,
- }),
- );
- });
-
- it('should treat \\b as ctrl when WT_SESSION IS present (even if not Windows_NT)', async () => {
- vi.stubEnv('WT_SESSION', 'some-id');
- vi.stubEnv('OS', 'Linux');
- const { keyHandler } = await setupKeypressTest();
-
- act(() => {
- stdin.write('\b');
- });
-
- expect(keyHandler).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'backspace',
- ctrl: true,
- }),
- );
- });
-
- it('should treat \\b as ctrl when OS is Windows_NT', async () => {
- vi.stubEnv('WT_SESSION', '');
- vi.stubEnv('OS', 'Windows_NT');
- const { keyHandler } = await setupKeypressTest();
-
- act(() => {
- stdin.write('\b');
- });
-
- expect(keyHandler).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'backspace',
- ctrl: true,
- }),
- );
- });
-
- it('should treat \\x7f as regular backspace regardless of WT_SESSION or OS', async () => {
- vi.stubEnv('WT_SESSION', 'some-id');
- vi.stubEnv('OS', 'Windows_NT');
- const { keyHandler } = await setupKeypressTest();
-
- act(() => {
- stdin.write('\x7f');
- });
-
- expect(keyHandler).toHaveBeenCalledWith(
- expect.objectContaining({
- name: 'backspace',
- ctrl: false,
- }),
- );
- });
- });
-
describe('paste mode', () => {
it.each([
{
diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx
index d834608fbe..4744ecc348 100644
--- a/packages/cli/src/ui/contexts/KeypressContext.tsx
+++ b/packages/cli/src/ui/contexts/KeypressContext.tsx
@@ -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' },
@@ -651,20 +652,8 @@ function* emitKeys(
// tab
name = 'tab';
alt = escaped;
- } else if (ch === '\b') {
- // ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
- name = 'backspace';
- // In Windows environments, \b is sent for Ctrl+Backspace (standard backspace is translated to \x7f).
- // We scope this to Windows/WT_SESSION to avoid breaking other unixes where \b is a plain backspace.
- if (
- typeof process !== 'undefined' &&
- (process.env?.['OS'] === 'Windows_NT' || !!process.env?.['WT_SESSION'])
- ) {
- ctrl = true;
- }
- alt = escaped;
- } else if (ch === '\x7f') {
- // backspace
+ } else if (ch === '\b' || ch === '\x7f') {
+ // backspace or ctrl+h
name = 'backspace';
alt = escaped;
} else if (ch === ESC) {
diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx
index f1959c0173..cb89758300 100644
--- a/packages/cli/src/ui/contexts/UIActionsContext.tsx
+++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx
@@ -41,6 +41,8 @@ export interface UIActions {
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
+ openVoiceModelDialog: () => void;
+ closeVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
displayName: string,
@@ -85,6 +87,7 @@ export interface UIActions {
setActiveBackgroundTaskPid: (pid: number) => void;
setIsBackgroundTaskListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
+ dismissLoginRestart: () => void;
onHintInput: (char: string) => void;
onHintBackspace: () => void;
onHintClear: () => void;
@@ -93,6 +96,7 @@ export interface UIActions {
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise;
getPreferredEditor: () => EditorType | undefined;
clearAccountSuspension: () => void;
+ setVoiceModeEnabled: (value: boolean) => void;
}
export const UIActionsContext = createContext(null);
diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx
index ed33c21ee5..eb998a9de0 100644
--- a/packages/cli/src/ui/contexts/UIStateContext.tsx
+++ b/packages/cli/src/ui/contexts/UIStateContext.tsx
@@ -101,6 +101,8 @@ export interface UIState {
accountSuspensionInfo: AccountSuspensionInfo | null;
isAuthDialogOpen: boolean;
isAwaitingApiKeyInput: boolean;
+ isAwaitingLoginRestart: boolean;
+ loginRestartMessage?: string;
apiKeyDefaultValue?: string;
editorError: string | null;
isEditorDialogOpen: boolean;
@@ -112,6 +114,7 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
+ isVoiceModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
selectedAgentName?: string;
selectedAgentDisplayName?: string;
@@ -132,6 +135,7 @@ export interface UIState {
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
isInputActive: boolean;
+ isVoiceModeEnabled: boolean;
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
index 3e521a6627..e4f0066189 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx
@@ -205,11 +205,13 @@ describe('useSlashCommandProcessor', () => {
openSettingsDialog: vi.fn(),
openSessionBrowser: vi.fn(),
openModelDialog: mockOpenModelDialog,
+ openVoiceModelDialog: vi.fn(),
openAgentConfigDialog,
openPermissionsDialog: vi.fn(),
quit: mockSetQuittingMessages,
setDebugMessage: vi.fn(),
toggleCorgiMode: vi.fn(),
+ toggleVoiceMode: vi.fn(),
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
@@ -644,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(
{
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 20de86002c..6e880ed4bb 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -72,6 +72,7 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openSessionBrowser: () => void;
openModelDialog: () => void;
+ openVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
displayName: string,
@@ -81,6 +82,7 @@ interface SlashCommandProcessorActions {
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
+ toggleVoiceMode: () => void;
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
@@ -232,6 +234,7 @@ export const useSlashCommandProcessor = (
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
+ toggleVoiceMode: actions.toggleVoiceMode,
toggleDebugProfiler: actions.toggleDebugProfiler,
toggleVimEnabled,
reloadCommands,
@@ -503,6 +506,9 @@ export const useSlashCommandProcessor = (
case 'model':
actions.openModelDialog();
return { type: 'handled' };
+ case 'voice-model':
+ actions.openVoiceModelDialog();
+ return { type: 'handled' };
case 'agentConfig': {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const props = result.props as Record;
@@ -551,6 +557,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' };
diff --git a/packages/cli/src/ui/hooks/useVoiceMode.ts b/packages/cli/src/ui/hooks/useVoiceMode.ts
new file mode 100644
index 0000000000..0f37c66357
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useVoiceMode.ts
@@ -0,0 +1,429 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useRef, useCallback, useEffect } from 'react';
+import {
+ AudioRecorder,
+ TranscriptionFactory,
+ debugLogger,
+ type Config,
+ type TranscriptionProvider,
+} from '@google/gemini-cli-core';
+import type { TextBuffer } from '../components/shared/text-buffer.js';
+import type { MergedSettings } from '../../config/settingsSchema.js';
+import type { Key } from './useKeypress.js';
+import { Command } from '../key/keyMatchers.js';
+
+interface UseVoiceModeProps {
+ buffer: TextBuffer;
+ config: Config;
+ settings: MergedSettings;
+ setQueueErrorMessage: (message: string | null) => void;
+ isVoiceModeEnabled: boolean;
+ setVoiceModeEnabled: (enabled: boolean) => void;
+ keyMatchers: Record boolean>;
+}
+
+const HOLD_DELAY_MS = 600;
+const RELEASE_DELAY_MS = 300;
+
+export function useVoiceMode({
+ buffer,
+ config,
+ settings,
+ setQueueErrorMessage,
+ isVoiceModeEnabled,
+ setVoiceModeEnabled,
+ keyMatchers,
+}: UseVoiceModeProps) {
+ const [isRecording, setIsRecording] = useState(false);
+ const [isConnecting, setIsConnecting] = useState(false);
+
+ const liveTranscriptionRef = useRef('');
+ const stopRequestedRef = useRef(false);
+ const isRecordingRef = useRef(false);
+ const lastFailureTimeRef = useRef(0);
+ const recordingInProgressRef = useRef(false);
+ const voiceTimeoutRef = useRef(null);
+ const recorderRef = useRef(null);
+ const transcriptionServiceRef = useRef(null);
+ const turnBaselineRef = useRef(null);
+
+ const pttStateRef = useRef<'idle' | 'possible-hold' | 'recording'>('idle');
+ const pttTimerRef = useRef(null);
+ const disconnectTimerRef = useRef(null);
+
+ const bufferRef = useRef(buffer);
+ bufferRef.current = buffer;
+
+ const stopVoiceRecording = useCallback(() => {
+ if (stopRequestedRef.current) return;
+ debugLogger.debug('[Voice] Stop requested');
+ stopRequestedRef.current = true;
+
+ setIsRecording(false);
+ isRecordingRef.current = false;
+ setIsConnecting(false);
+
+ if (recorderRef.current) {
+ recorderRef.current.stop();
+ recorderRef.current = null;
+ }
+
+ const serviceToDisconnect = transcriptionServiceRef.current;
+ transcriptionServiceRef.current = null;
+
+ if (serviceToDisconnect) {
+ const isLive = settings.experimental.voice?.backend === 'gemini-live';
+ const gracePeriodMs =
+ settings.experimental.voice?.stopGracePeriodMs ??
+ (isLive ? 2000 : 1000);
+ debugLogger.debug(
+ `[Voice] Draining transcription for ${gracePeriodMs}ms`,
+ );
+
+ if (disconnectTimerRef.current) clearTimeout(disconnectTimerRef.current);
+ disconnectTimerRef.current = setTimeout(() => {
+ debugLogger.debug('[Voice] Grace period ended, disconnecting service');
+ serviceToDisconnect.disconnect();
+ disconnectTimerRef.current = null;
+ }, gracePeriodMs);
+ }
+
+ liveTranscriptionRef.current = '';
+ pttStateRef.current = 'idle';
+ }, [settings.experimental.voice]);
+
+ const startVoiceRecording = useCallback(() => {
+ if (
+ isRecordingRef.current ||
+ Date.now() - lastFailureTimeRef.current < 2000
+ ) {
+ return;
+ }
+
+ if (disconnectTimerRef.current) {
+ clearTimeout(disconnectTimerRef.current);
+ disconnectTimerRef.current = null;
+ }
+
+ recordingInProgressRef.current = true;
+ turnBaselineRef.current = bufferRef.current.text;
+
+ setIsConnecting(true);
+ setIsRecording(true);
+ isRecordingRef.current = true;
+
+ liveTranscriptionRef.current = '';
+ stopRequestedRef.current = false;
+
+ const apiKey =
+ config.getContentGeneratorConfig()?.apiKey ||
+ process.env['GEMINI_API_KEY'] ||
+ '';
+
+ const startAsync = async () => {
+ // If there's an active draining service, disconnect it immediately
+ // before starting a new one to prevent orphaned event collisions.
+ if (disconnectTimerRef.current) {
+ clearTimeout(disconnectTimerRef.current);
+ disconnectTimerRef.current = null;
+ }
+ if (transcriptionServiceRef.current) {
+ transcriptionServiceRef.current.disconnect();
+ transcriptionServiceRef.current = null;
+ }
+
+ const cleanupIfStopped = () => {
+ if (stopRequestedRef.current) {
+ if (recorderRef.current) {
+ recorderRef.current.stop();
+ recorderRef.current = null;
+ }
+ if (transcriptionServiceRef.current) {
+ transcriptionServiceRef.current.disconnect();
+ transcriptionServiceRef.current = null;
+ }
+ setIsRecording(false);
+ isRecordingRef.current = false;
+ setIsConnecting(false);
+ recordingInProgressRef.current = false;
+ return true;
+ }
+ return false;
+ };
+
+ if (cleanupIfStopped()) return;
+
+ const voiceBackend =
+ settings.experimental.voice?.backend ?? 'gemini-live';
+
+ if (!apiKey && voiceBackend === 'gemini-live') {
+ setQueueErrorMessage(
+ 'Cloud voice mode requires a GEMINI_API_KEY. Please set it in your environment or ~/.gemini/.env.',
+ );
+ setIsRecording(false);
+ isRecordingRef.current = false;
+ setIsConnecting(false);
+ recordingInProgressRef.current = false;
+ lastFailureTimeRef.current = Date.now();
+ return;
+ }
+
+ if (voiceBackend === 'gemini-live') {
+ recorderRef.current = new AudioRecorder();
+ }
+
+ const currentService = TranscriptionFactory.createProvider(
+ settings.experimental.voice,
+ apiKey,
+ );
+ transcriptionServiceRef.current = currentService;
+
+ currentService.on('transcription', (text) => {
+ if (
+ transcriptionServiceRef.current !== currentService &&
+ stopRequestedRef.current
+ ) {
+ // If this is an orphaned service that was replaced by a new session, ignore its events
+ return;
+ }
+
+ if (text) {
+ const currentBufferText = bufferRef.current.text;
+ const previousTranscription = liveTranscriptionRef.current;
+
+ let newTotalText = currentBufferText;
+
+ if (
+ previousTranscription &&
+ currentBufferText.endsWith(previousTranscription)
+ ) {
+ newTotalText = currentBufferText.slice(
+ 0,
+ -previousTranscription.length,
+ );
+ } else if (
+ currentBufferText &&
+ !currentBufferText.endsWith(' ') &&
+ !currentBufferText.endsWith('\n')
+ ) {
+ newTotalText += ' ';
+ }
+
+ newTotalText += text;
+ bufferRef.current.setText(newTotalText, 'end');
+ }
+ liveTranscriptionRef.current = text;
+ });
+
+ currentService.on('turnComplete', () => {
+ if (
+ transcriptionServiceRef.current !== currentService &&
+ stopRequestedRef.current
+ )
+ return;
+ liveTranscriptionRef.current = '';
+ });
+
+ currentService.on('error', (err) => {
+ if (transcriptionServiceRef.current !== currentService) return;
+ debugLogger.error('[Voice] Transcription error:', err);
+ lastFailureTimeRef.current = Date.now();
+ recordingInProgressRef.current = false;
+ });
+
+ currentService.on('close', () => {
+ if (transcriptionServiceRef.current !== currentService) return;
+ if (!stopRequestedRef.current) {
+ setIsRecording(false);
+ isRecordingRef.current = false;
+ setIsConnecting(false);
+ recordingInProgressRef.current = false;
+ lastFailureTimeRef.current = Date.now();
+ }
+ });
+
+ try {
+ await currentService.connect();
+ if (cleanupIfStopped()) return;
+
+ await recorderRef.current?.start();
+ if (cleanupIfStopped()) return;
+
+ setIsConnecting(false);
+
+ const currentVoiceBackend =
+ settings.experimental.voice?.backend ?? 'gemini-live';
+
+ recorderRef.current?.on('data', (chunk) => {
+ if (currentVoiceBackend === 'gemini-live') {
+ currentService.sendAudioChunk(chunk);
+ }
+ });
+ recorderRef.current?.on('error', (err) => {
+ debugLogger.error('[Voice] Recorder error:', err);
+ stopVoiceRecording();
+ lastFailureTimeRef.current = Date.now();
+ });
+ } catch (err: unknown) {
+ if (transcriptionServiceRef.current !== currentService) return;
+ const message = err instanceof Error ? err.message : String(err);
+ setQueueErrorMessage(`Voice mode failure: ${message}`);
+ setIsRecording(false);
+ isRecordingRef.current = false;
+ setIsConnecting(false);
+ recordingInProgressRef.current = false;
+ lastFailureTimeRef.current = Date.now();
+
+ if (recorderRef.current) {
+ recorderRef.current.stop();
+ recorderRef.current = null;
+ }
+ if (transcriptionServiceRef.current) {
+ transcriptionServiceRef.current.disconnect();
+ transcriptionServiceRef.current = null;
+ }
+ }
+ };
+
+ void startAsync();
+ }, [
+ config,
+ settings.experimental.voice,
+ setQueueErrorMessage,
+ stopVoiceRecording,
+ ]);
+
+ useEffect(
+ () => () => {
+ if (voiceTimeoutRef.current) clearTimeout(voiceTimeoutRef.current);
+ if (recorderRef.current) {
+ recorderRef.current.stop();
+ recorderRef.current = null;
+ }
+ if (transcriptionServiceRef.current) {
+ transcriptionServiceRef.current.disconnect();
+ transcriptionServiceRef.current = null;
+ }
+ if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
+ if (disconnectTimerRef.current) clearTimeout(disconnectTimerRef.current);
+ },
+ [],
+ );
+
+ const handleVoiceInput = useCallback(
+ (key: Key): boolean => {
+ const activeRecording = isRecording || isRecordingRef.current;
+
+ if (activeRecording) {
+ const activationMode =
+ settings.experimental.voice?.activationMode ?? 'push-to-talk';
+
+ if (keyMatchers[Command.ESCAPE](key)) {
+ stopVoiceRecording();
+ return true;
+ }
+
+ if (keyMatchers[Command.VOICE_MODE_PTT](key)) {
+ if (activationMode === 'push-to-talk') {
+ if (pttTimerRef.current) {
+ clearTimeout(pttTimerRef.current);
+ }
+ pttTimerRef.current = setTimeout(() => {
+ stopVoiceRecording();
+ pttTimerRef.current = null;
+ }, RELEASE_DELAY_MS);
+ return true;
+ } else {
+ stopVoiceRecording();
+ return true;
+ }
+ }
+ return true;
+ }
+
+ if (isVoiceModeEnabled) {
+ const activationMode =
+ settings.experimental.voice?.activationMode ?? 'push-to-talk';
+
+ if (keyMatchers[Command.ESCAPE](key) && buffer.text === '') {
+ setVoiceModeEnabled(false);
+ return true;
+ }
+
+ if (keyMatchers[Command.VOICE_MODE_PTT](key)) {
+ if (
+ key.name === 'space' &&
+ !key.ctrl &&
+ !key.alt &&
+ !key.shift &&
+ !key.cmd
+ ) {
+ if (activationMode === 'toggle') {
+ startVoiceRecording();
+ return true;
+ } else {
+ if (pttStateRef.current === 'idle') {
+ buffer.insert(' ');
+ pttStateRef.current = 'possible-hold';
+
+ if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
+ pttTimerRef.current = setTimeout(() => {
+ pttStateRef.current = 'idle';
+ pttTimerRef.current = null;
+ }, HOLD_DELAY_MS);
+ return true;
+ } else if (pttStateRef.current === 'possible-hold') {
+ if (pttTimerRef.current) clearTimeout(pttTimerRef.current);
+ buffer.backspace();
+ pttStateRef.current = 'recording';
+ startVoiceRecording();
+
+ pttTimerRef.current = setTimeout(() => {
+ stopVoiceRecording();
+ pttTimerRef.current = null;
+ }, RELEASE_DELAY_MS);
+ return true;
+ }
+ }
+ }
+ }
+
+ if (pttStateRef.current === 'possible-hold') {
+ pttStateRef.current = 'idle';
+ if (pttTimerRef.current) {
+ clearTimeout(pttTimerRef.current);
+ pttTimerRef.current = null;
+ }
+ }
+ }
+
+ return false;
+ },
+ [
+ isRecording,
+ isVoiceModeEnabled,
+ settings.experimental.voice,
+ keyMatchers,
+ stopVoiceRecording,
+ startVoiceRecording,
+ buffer,
+ setVoiceModeEnabled,
+ ],
+ );
+
+ return {
+ isRecording,
+ isConnecting,
+ startVoiceRecording,
+ stopVoiceRecording,
+ handleVoiceInput,
+ resetTurnBaseline: () => {
+ turnBaselineRef.current = null;
+ },
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useVoiceModelCommand.ts b/packages/cli/src/ui/hooks/useVoiceModelCommand.ts
new file mode 100644
index 0000000000..943c65ce30
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useVoiceModelCommand.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useCallback } from 'react';
+
+interface UseVoiceModelCommandReturn {
+ isVoiceModelDialogOpen: boolean;
+ openVoiceModelDialog: () => void;
+ closeVoiceModelDialog: () => void;
+}
+
+export const useVoiceModelCommand = (): UseVoiceModelCommandReturn => {
+ const [isVoiceModelDialogOpen, setIsVoiceModelDialogOpen] = useState(false);
+
+ const openVoiceModelDialog = useCallback(() => {
+ setIsVoiceModelDialogOpen(true);
+ }, []);
+
+ const closeVoiceModelDialog = useCallback(() => {
+ setIsVoiceModelDialogOpen(false);
+ }, []);
+
+ return {
+ isVoiceModelDialogOpen,
+ openVoiceModelDialog,
+ closeVoiceModelDialog,
+ };
+};
diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts
index 10f88dd4d9..dc590e99c4 100644
--- a/packages/cli/src/ui/key/keyBindings.test.ts
+++ b/packages/cli/src/ui/key/keyBindings.test.ts
@@ -108,6 +108,30 @@ describe('keyBindings config', () => {
}
});
+ it('should have platform-specific UNDO bindings', () => {
+ const undoBindings = defaultKeyBindingConfig.get(Command.UNDO);
+ if (process.platform === 'win32') {
+ expect(undoBindings?.[0].name).toBe('z');
+ expect(undoBindings?.[0].ctrl).toBe(true);
+ } else if (process.platform === 'darwin') {
+ expect(undoBindings?.[0].name).toBe('z');
+ expect(undoBindings?.[0].cmd).toBe(true);
+ } else {
+ expect(undoBindings?.[0].name).toBe('z');
+ expect(undoBindings?.[0].alt).toBe(true);
+ // Ensure ctrl+z is also present for smart bubbling
+ expect(undoBindings?.some((b) => b.name === 'z' && b.ctrl)).toBe(true);
+ }
+ });
+
+ it('should have platform-specific REDO bindings', () => {
+ const redoBindings = defaultKeyBindingConfig.get(Command.REDO);
+ // Ctrl+Shift+Z is now the universal primary to avoid conflict with YOLO (Ctrl+Y)
+ expect(redoBindings?.[0].name).toBe('z');
+ expect(redoBindings?.[0].shift).toBe(true);
+ expect(redoBindings?.[0].ctrl).toBe(true);
+ });
+
describe('command metadata', () => {
const commandValues = Object.values(Command);
diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts
index e3fbcd8262..67e2ff1941 100644
--- a/packages/cli/src/ui/key/keyBindings.ts
+++ b/packages/cli/src/ui/key/keyBindings.ts
@@ -97,6 +97,7 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
+ VOICE_MODE_PTT = 'app.voiceModePTT',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -311,15 +312,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
Command.DELETE_CHAR_RIGHT,
[new KeyBinding('delete'), new KeyBinding('ctrl+d')],
],
- [Command.UNDO, [new KeyBinding('cmd+z'), new KeyBinding('alt+z')]],
- [
- Command.REDO,
- [
- new KeyBinding('ctrl+shift+z'),
- new KeyBinding('cmd+shift+z'),
- new KeyBinding('alt+shift+z'),
- ],
- ],
+ [Command.UNDO, getPlatformUndoBindings(process.platform)],
+ [Command.REDO, getPlatformRedoBindings(process.platform)],
// Scrolling
[Command.SCROLL_UP, [new KeyBinding('shift+up')]],
@@ -407,9 +401,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
- [Command.DUMP_FRAME, [new KeyBinding('f8')]],
- [Command.START_RECORDING, [new KeyBinding('f6')]],
- [Command.STOP_RECORDING, [new KeyBinding('f7')]],
+ [Command.VOICE_MODE_PTT, [new KeyBinding('space')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -424,6 +416,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Extension Controls
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
+
+ [Command.DUMP_FRAME, [new KeyBinding('f8')]],
+ [Command.START_RECORDING, [new KeyBinding('f6')]],
+ [Command.STOP_RECORDING, [new KeyBinding('f7')]],
]);
interface CommandCategory {
@@ -538,6 +534,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.RESTART_APP,
Command.SUSPEND_APP,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
+ Command.VOICE_MODE_PTT,
],
},
{
@@ -658,6 +655,7 @@ export const commandDescriptions: Readonly> = {
[Command.SUSPEND_APP]: 'Suspend the CLI and move it to the background.',
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from shell input.',
+ [Command.VOICE_MODE_PTT]: 'Hold to speak in Voice Mode.',
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
@@ -777,3 +775,33 @@ export async function loadCustomKeybindings(): Promise<{
return { config, errors };
}
+
+export function getPlatformUndoBindings(
+ platform: string,
+): readonly KeyBinding[] {
+ if (platform === 'win32') {
+ return [new KeyBinding('ctrl+z'), new KeyBinding('alt+z')];
+ }
+ if (platform === 'darwin') {
+ return [new KeyBinding('cmd+z'), new KeyBinding('alt+z')];
+ }
+ // Linux / WSL: Promote Alt+Z to avoid Windows interception,
+ // but keep Ctrl+Z for smart bubbling.
+ return [
+ new KeyBinding('alt+z'),
+ new KeyBinding('cmd+z'),
+ new KeyBinding('ctrl+z'),
+ ];
+}
+
+export function getPlatformRedoBindings(
+ _platform: string,
+): readonly KeyBinding[] {
+ // Use a stable order for all platforms to minimize churn.
+ // Ctrl+Shift+Z is the universal primary.
+ return [
+ new KeyBinding('ctrl+shift+z'),
+ new KeyBinding('cmd+shift+z'),
+ new KeyBinding('alt+shift+z'),
+ ];
+}
diff --git a/packages/cli/src/ui/key/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts
index 0fc2f00ac7..a5f28d8cb7 100644
--- a/packages/cli/src/ui/key/keyMatchers.test.ts
+++ b/packages/cli/src/ui/key/keyMatchers.test.ts
@@ -149,23 +149,44 @@ describe('keyMatchers', () => {
{
command: Command.UNDO,
positive: [
- createKey('z', { shift: false, cmd: true }),
- createKey('z', { shift: false, alt: true }),
+ ...(process.platform === 'win32'
+ ? [createKey('z', { shift: false, ctrl: true })]
+ : process.platform === 'darwin'
+ ? [createKey('z', { shift: false, cmd: true })]
+ : [
+ createKey('z', { shift: false, alt: true }),
+ createKey('z', { shift: false, cmd: true }),
+ createKey('z', { shift: false, ctrl: true }),
+ ]),
+ ...(process.platform !== 'linux'
+ ? [createKey('z', { shift: false, alt: true })]
+ : []),
],
negative: [
createKey('z'),
createKey('z', { shift: true, cmd: true }),
- createKey('z', { shift: false, ctrl: true }),
+ ...(process.platform === 'darwin'
+ ? [createKey('z', { shift: false, ctrl: true })]
+ : []),
+ ...(process.platform === 'win32'
+ ? [createKey('z', { shift: false, cmd: true })]
+ : []),
],
},
{
command: Command.REDO,
positive: [
- createKey('z', { shift: true, cmd: true }),
+ ...(process.platform === 'win32'
+ ? []
+ : [createKey('z', { shift: true, cmd: true })]),
createKey('z', { shift: true, alt: true }),
createKey('z', { shift: true, ctrl: true }),
],
- negative: [createKey('z'), createKey('z', { shift: false, cmd: true })],
+ negative: [
+ createKey('z'),
+ createKey('z', { shift: false, cmd: true }),
+ createKey('y', { shift: false, ctrl: true }),
+ ],
},
// Screen control
diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
index 3aff41d2de..9118518455 100644
--- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
+++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts
@@ -43,5 +43,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
removeComponent: () => {},
toggleBackgroundTasks: () => {},
toggleShortcutsHelp: () => {},
+ toggleVoiceMode: () => {},
};
}
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index 2808d716b7..cdaf37e342 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -42,8 +42,8 @@ export enum AuthState {
AwaitingApiKeyInput = 'awaiting_api_key_input',
// Successfully authenticated
Authenticated = 'authenticated',
- // Waiting for the user to restart after a Google login
- AwaitingGoogleLoginRestart = 'awaiting_google_login_restart',
+ // Waiting for the user to restart after a login
+ AwaitingLoginRestart = 'awaiting_login_restart',
}
// Only defining the state enum needed by the UI
diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts
index 17b6c62121..76c0014314 100644
--- a/packages/cli/src/ui/utils/updateCheck.test.ts
+++ b/packages/cli/src/ui/utils/updateCheck.test.ts
@@ -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');
+ });
+ });
});
diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts
index 9f80beee08..bac07be07e 100644
--- a/packages/cli/src/ui/utils/updateCheck.ts
+++ b/packages/cli/src/ui/utils/updateCheck.ts
@@ -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 {
diff --git a/packages/cli/src/utils/activityLogger.test.ts b/packages/cli/src/utils/activityLogger.test.ts
index e80eff6485..9d73c0d19f 100644
--- a/packages/cli/src/utils/activityLogger.test.ts
+++ b/packages/cli/src/utils/activityLogger.test.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { describe, it, expect, beforeEach } from 'vitest';
+import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ActivityLogger, type NetworkLog } from './activityLogger.js';
import type { ConsoleLogPayload } from '@google/gemini-cli-core';
@@ -132,4 +132,95 @@ describe('ActivityLogger', () => {
expect(after.console.length).toBe(0);
expect(after.network.length).toBe(0);
});
+
+ it('preserves headers and method from Request object when intercepting fetch', async () => {
+ const originalFetch = global.fetch;
+
+ const mockFetch = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ status: 200,
+ headers: new Headers(),
+ body: null,
+ clone: () => ({
+ body: null,
+ status: 200,
+ headers: new Headers(),
+ text: async () => 'ok',
+ json: async () => ({}),
+ }),
+ } as unknown as Response),
+ );
+
+ global.fetch = mockFetch;
+
+ try {
+ // @ts-expect-error - accessing private property for testing
+ logger.isInterceptionEnabled = false;
+ logger.enable();
+
+ const request = new Request('https://api.example.com/data', {
+ headers: { Authorization: 'Bearer test-token' },
+ method: 'POST',
+ });
+
+ await global.fetch(request);
+
+ expect(mockFetch).toHaveBeenCalled();
+ const [, calledInit] = mockFetch.mock.calls[0];
+
+ expect(calledInit?.headers).toBeDefined();
+ const headers = new Headers(calledInit?.headers as HeadersInit);
+ expect(headers.get('Authorization')).toBe('Bearer test-token');
+ expect(headers.has('x-activity-request-id')).toBe(true);
+ expect(calledInit?.method).toBe('POST');
+ } finally {
+ global.fetch = originalFetch;
+ // @ts-expect-error - reset private property
+ logger.isInterceptionEnabled = false;
+ }
+ });
+
+ it('replaces Request headers with init headers (Fetch spec compliance)', async () => {
+ const originalFetch = global.fetch;
+ const mockFetch = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ status: 200,
+ headers: new Headers(),
+ body: null,
+ clone: () => ({
+ body: null,
+ status: 200,
+ headers: new Headers(),
+ text: async () => 'ok',
+ }),
+ } as unknown as Response),
+ );
+ global.fetch = mockFetch;
+
+ try {
+ // @ts-expect-error - accessing private property for testing
+ logger.isInterceptionEnabled = false;
+ logger.enable();
+
+ const request = new Request('https://api.example.com/data', {
+ headers: { 'X-Old': 'old-value', 'X-Shared': 'old-shared' },
+ });
+
+ await global.fetch(request, {
+ headers: { 'X-New': 'new-value', 'X-Shared': 'new-shared' },
+ });
+
+ const [, calledInit] = mockFetch.mock.calls[0];
+ const headers = new Headers(calledInit?.headers as HeadersInit);
+
+ expect(headers.get('X-New')).toBe('new-value');
+ expect(headers.get('X-Shared')).toBe('new-shared');
+ expect(headers.has('X-Old')).toBe(false);
+ expect(headers.has('x-activity-request-id')).toBe(true);
+ } finally {
+ global.fetch = originalFetch;
+ // @ts-expect-error - reset private property
+ logger.isInterceptionEnabled = false;
+ }
+ });
});
diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts
index 8118ccdde9..8f47b39808 100644
--- a/packages/cli/src/utils/activityLogger.ts
+++ b/packages/cli/src/utils/activityLogger.ts
@@ -302,18 +302,31 @@ export class ActivityLogger extends EventEmitter {
return originalFetch(input, init);
const id = Math.random().toString(36).substring(7);
- const method = (init?.method || 'GET').toUpperCase();
- const newInit = { ...init };
- const headers = new Headers(init?.headers || {});
+ const inputMethod =
+ typeof input === 'object' && 'method' in input
+ ? input.method
+ : undefined;
+ const inputHeaders =
+ typeof input === 'object' && 'headers' in input
+ ? input.headers
+ : undefined;
+
+ const method = (init?.method ?? inputMethod ?? 'GET').toUpperCase();
+ const headers = new Headers(init?.headers ?? inputHeaders ?? {});
headers.set(ACTIVITY_ID_HEADER, id);
- newInit.headers = headers;
+
+ const newInit = {
+ ...init,
+ method,
+ headers,
+ };
let reqBody = '';
- if (init?.body) {
- if (typeof init.body === 'string') reqBody = init.body;
- else if (init.body instanceof URLSearchParams)
- reqBody = init.body.toString();
+ const body = newInit.body;
+ if (body) {
+ if (typeof body === 'string') reqBody = body;
+ else if (body instanceof URLSearchParams) reqBody = body.toString();
}
this.requestStartTimes.set(id, Date.now());
diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts
index 6035c1e6d1..ef18864d81 100644
--- a/packages/cli/src/utils/handleAutoUpdate.test.ts
+++ b/packages/cli/src/utils/handleAutoUpdate.test.ts
@@ -301,7 +301,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
message:
- 'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0)',
+ 'Automatic update failed. Please try updating manually:\n\nnpm i -g @google/gemini-cli@2.0.0',
});
});
@@ -325,7 +325,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
message:
- 'Automatic update failed. Please try updating manually. (error: Spawn error)',
+ 'Automatic update failed. Please try updating manually. (error: Spawn error)\n\nnpm i -g @google/gemini-cli@2.0.0',
});
});
@@ -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((resolve) => {
mockGetInstallationInfo.mockReturnValue({
@@ -435,13 +456,15 @@ describe('setUpdateHandler', () => {
});
it('should handle update-failed event', () => {
- updateEventEmitter.emit('update-failed', { message: 'Failed' });
+ updateEventEmitter.emit('update-failed', {
+ message: 'Failed message with command',
+ });
expect(setUpdateInfo).toHaveBeenCalledWith(null);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
- text: 'Automatic update failed. Please try updating manually',
+ text: 'Failed message with command',
},
expect.any(Number),
);
diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts
index 4f8ca69ed3..cd39cd926d 100644
--- a/packages/cli/src/utils/handleAutoUpdate.ts
+++ b/packages/cli/src/utils/handleAutoUpdate.ts
@@ -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(
@@ -148,7 +171,7 @@ export function handleAutoUpdate(
});
} else {
updateEventEmitter.emit('update-failed', {
- message: `Automatic update failed. Please try updating manually. (command: ${updateCommand})`,
+ message: `Automatic update failed. Please try updating manually:\n\n${updateCommand}`,
});
}
});
@@ -156,7 +179,7 @@ export function handleAutoUpdate(
updateProcess.on('error', (err) => {
_updateInProgress = false;
updateEventEmitter.emit('update-failed', {
- message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
+ message: `Automatic update failed. Please try updating manually. (error: ${err.message})\n\n${updateCommand}`,
});
});
return updateProcess;
@@ -184,12 +207,14 @@ export function setUpdateHandler(
}, 60000);
};
- const handleUpdateFailed = () => {
+ const handleUpdateFailed = (data?: { message: string }) => {
setUpdateInfo(null);
addItem(
{
type: MessageType.ERROR,
- text: `Automatic update failed. Please try updating manually`,
+ text:
+ data?.message ||
+ `Automatic update failed. Please try updating manually`,
},
Date.now(),
);
diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts
index 3e6b7913e9..29b0eef66c 100644
--- a/packages/cli/src/utils/processUtils.test.ts
+++ b/packages/cli/src/utils/processUtils.test.ts
@@ -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();
+ });
+ });
+});
diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts
index c43f5c54fd..5848056da7 100644
--- a/packages/cli/src/utils/processUtils.ts
+++ b/packages/cli/src/utils/processUtils.ts
@@ -29,3 +29,101 @@ export async function relaunchApp(): Promise {
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,
+ };
+}
diff --git a/packages/cli/src/utils/readStdin.test.ts b/packages/cli/src/utils/readStdin.test.ts
index 1a5202173c..4a0d4d42ea 100644
--- a/packages/cli/src/utils/readStdin.test.ts
+++ b/packages/cli/src/utils/readStdin.test.ts
@@ -140,6 +140,49 @@ describe('readStdin', () => {
expect(mockStdin.destroy).toHaveBeenCalled();
});
+ it('should truncate multi-byte characters at byte boundary', async () => {
+ const MAX_STDIN_SIZE = 8 * 1024 * 1024;
+ // 'ํ' is 3 bytes. 2,796,202 * 3 = 8,388,606 bytes.
+ // 2,796,203 * 3 = 8,388,609 bytes.
+ const charCount = Math.floor(MAX_STDIN_SIZE / 3) + 1;
+ const multiByteChunk = 'ํ'.repeat(charCount);
+
+ mockStdin.read
+ .mockReturnValueOnce(multiByteChunk)
+ .mockReturnValueOnce(null);
+
+ const promise = readStdin();
+ onReadableHandler();
+
+ const result = await promise;
+ const resultBytes = Buffer.byteLength(result, 'utf8');
+
+ expect(resultBytes).toBeLessThanOrEqual(MAX_STDIN_SIZE);
+ expect(resultBytes).toBe(Math.floor(MAX_STDIN_SIZE / 3) * 3);
+ expect(result).not.toContain('\uFFFD'); // No replacement characters
+ });
+
+ it('should use byte length instead of string length for limit', async () => {
+ const MAX_STDIN_SIZE = 8 * 1024 * 1024;
+ // 'ํ' is 3 bytes. If we use string length, we'd allow 8M characters = 24MB.
+ // We want to ensure it stops at 8MB.
+ const charCount = MAX_STDIN_SIZE; // 8M characters = 24MB
+ const multiByteChunk = 'ํ'.repeat(charCount);
+
+ mockStdin.read
+ .mockReturnValueOnce(multiByteChunk)
+ .mockReturnValueOnce(null);
+
+ const promise = readStdin();
+ onReadableHandler();
+
+ const result = await promise;
+ expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(
+ MAX_STDIN_SIZE,
+ );
+ expect(result.length).toBeLessThan(charCount);
+ });
+
it('should handle stdin error', async () => {
const promise = readStdin();
const error = new Error('stdin error');
diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts
index 3b5b17fe04..f828bca88c 100644
--- a/packages/cli/src/utils/readStdin.ts
+++ b/packages/cli/src/utils/readStdin.ts
@@ -6,6 +6,23 @@
import { debugLogger } from '@google/gemini-cli-core';
+/**
+ * Truncates a string to fit within a UTF-8 byte limit without splitting
+ * multi-byte characters. Walks back from the cut point to find the last
+ * complete character boundary.
+ */
+function truncateUtf8Bytes(str: string, maxBytes: number): string {
+ const buf = Buffer.from(str, 'utf8');
+ if (buf.length <= maxBytes) return str;
+ let end = maxBytes;
+ // Walk backward past any UTF-8 continuation bytes (10xxxxxx)
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) {
+ end--;
+ }
+ // end now points to the lead byte of an incomplete sequence โ exclude it
+ return buf.subarray(0, end).toString('utf8');
+}
+
export async function readStdin(): Promise {
const MAX_STDIN_SIZE = 8 * 1024 * 1024; // 8MB
return new Promise((resolve, reject) => {
@@ -30,9 +47,10 @@ export async function readStdin(): Promise {
pipedInputTimerId = null;
}
- if (totalSize + chunk.length > MAX_STDIN_SIZE) {
- const remainingSize = MAX_STDIN_SIZE - totalSize;
- data += chunk.slice(0, remainingSize);
+ const chunkByteLength = Buffer.byteLength(chunk, 'utf8');
+ if (totalSize + chunkByteLength > MAX_STDIN_SIZE) {
+ const remainingBytes = MAX_STDIN_SIZE - totalSize;
+ data += truncateUtf8Bytes(chunk, remainingBytes);
debugLogger.warn(
`Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`,
);
@@ -41,7 +59,7 @@ export async function readStdin(): Promise {
break;
}
data += chunk;
- totalSize += chunk.length;
+ totalSize += chunkByteLength;
}
};
diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts
index 255671e27f..56282b4612 100644
--- a/packages/cli/src/utils/relaunch.test.ts
+++ b/packages/cli/src/utils/relaunch.test.ts
@@ -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) {
diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts
index 7e287e4565..d7a6ab52ae 100644
--- a/packages/cli/src/utils/relaunch.ts
+++ b/packages/cli/src/utils/relaunch.ts
@@ -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,
});
diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts
index 1dda7b355a..256b418e83 100644
--- a/packages/cli/src/utils/sandbox.test.ts
+++ b/packages/cli/src/utils/sandbox.test.ts
@@ -719,6 +719,65 @@ describe('sandbox', () => {
expect(entrypointCmd).toContain('su -p gemini');
});
+ it('should register and unregister proxy exit handlers', async () => {
+ vi.stubEnv('GEMINI_SANDBOX_PROXY_COMMAND', 'some-proxy-cmd');
+ const config: SandboxConfig = createMockSandboxConfig({
+ command: 'docker',
+ image: 'gemini-cli-sandbox',
+ });
+
+ const onSpy = vi.spyOn(process, 'on');
+ const offSpy = vi.spyOn(process, 'off');
+
+ interface MockProcessWithStdout extends EventEmitter {
+ stdout: EventEmitter;
+ }
+
+ vi.mocked(spawn).mockImplementation((cmd, args) => {
+ const a = args as string[];
+ if (cmd === 'docker' && a && a[0] === 'images') {
+ const mockImageCheckProcess =
+ new EventEmitter() as MockProcessWithStdout;
+ mockImageCheckProcess.stdout = new EventEmitter();
+ setTimeout(() => {
+ mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
+ mockImageCheckProcess.emit('close', 0);
+ }, 1);
+ return mockImageCheckProcess as unknown as ReturnType;
+ }
+ if (cmd === 'docker' && a && a[0] === 'run') {
+ const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
+ typeof spawn
+ >;
+ mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
+ if (event === 'close') {
+ if (a.includes('gemini-cli-sandbox-proxy')) {
+ // Proxy container shouldn't exit during the test
+ } else {
+ setTimeout(() => cb(0), 10);
+ }
+ }
+ return mockSpawnProcess;
+ });
+ return mockSpawnProcess;
+ }
+ return new EventEmitter() as unknown as ReturnType;
+ });
+
+ await start_sandbox(config);
+
+ expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function));
+ expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
+ expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
+
+ expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function));
+ expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
+ expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
+
+ onSpy.mockRestore();
+ offSpy.mockRestore();
+ });
+
describe('LXC sandbox', () => {
const LXC_RUNNING = JSON.stringify([
{ name: 'gemini-sandbox', status: 'Running' },
diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts
index 6001725cdd..ccd1f2e608 100644
--- a/packages/cli/src/utils/sandbox.ts
+++ b/packages/cli/src/utils/sandbox.ts
@@ -55,6 +55,8 @@ export async function start_sandbox(
});
patcher.patch();
+ let stopProxy: (() => void) | undefined = undefined;
+
try {
if (config.command === 'sandbox-exec') {
// disallow BUILD_SANDBOX
@@ -188,17 +190,18 @@ export async function start_sandbox(
detached: true,
});
// install handlers to stop proxy on exit/signal
- const stopProxy = () => {
+ stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
- process.kill(-proxyProcess.pid, 'SIGTERM');
+ try {
+ process.kill(-proxyProcess.pid, 'SIGTERM');
+ } catch {
+ // ignore
+ }
}
};
- process.off('exit', stopProxy);
process.on('exit', stopProxy);
- process.off('SIGINT', stopProxy);
process.on('SIGINT', stopProxy);
- process.off('SIGTERM', stopProxy);
process.on('SIGTERM', stopProxy);
// commented out as it disrupts ink rendering
@@ -746,15 +749,18 @@ export async function start_sandbox(
detached: true,
});
// install handlers to stop proxy on exit/signal
- const stopProxy = () => {
+ stopProxy = () => {
debugLogger.log('stopping proxy container ...');
- execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
+ try {
+ spawnSync(command, ['rm', '-f', SANDBOX_PROXY_NAME], {
+ stdio: 'ignore',
+ });
+ } catch {
+ // ignore
+ }
};
- process.off('exit', stopProxy);
process.on('exit', stopProxy);
- process.off('SIGINT', stopProxy);
process.on('SIGINT', stopProxy);
- process.off('SIGTERM', stopProxy);
process.on('SIGTERM', stopProxy);
// commented out as it disrupts ink rendering
@@ -806,6 +812,12 @@ export async function start_sandbox(
});
});
} finally {
+ if (stopProxy) {
+ stopProxy();
+ process.off('exit', stopProxy);
+ process.off('SIGINT', stopProxy);
+ process.off('SIGTERM', stopProxy);
+ }
patcher.cleanup();
}
}
diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts
index 0495bf5588..0bc1183e71 100644
--- a/packages/cli/src/utils/sessionUtils.test.ts
+++ b/packages/cli/src/utils/sessionUtils.test.ts
@@ -47,6 +47,47 @@ describe('SessionSelector', () => {
}
});
+ describe('sessionExists', () => {
+ it('should return true if a session file with the exact UUID exists', async () => {
+ const sessionId = randomUUID();
+ const chatsDir = path.join(tmpDir, 'chats');
+ await fs.mkdir(chatsDir, { recursive: true });
+ await fs.writeFile(
+ path.join(
+ chatsDir,
+ `session-20240101T000000-${sessionId.slice(0, 8)}.jsonl`,
+ ),
+ JSON.stringify({ sessionId }),
+ );
+
+ const selector = new SessionSelector(storage);
+ const exists = await selector.sessionExists(sessionId);
+ expect(exists).toBe(true);
+ });
+
+ it('should return false if no session file matches the UUID', async () => {
+ const sessionId = randomUUID();
+ const chatsDir = path.join(tmpDir, 'chats');
+ await fs.mkdir(chatsDir, { recursive: true });
+ await fs.writeFile(
+ path.join(chatsDir, `session-different-uuid-20240101.jsonl`),
+ '{}',
+ );
+
+ const selector = new SessionSelector(storage);
+ const exists = await selector.sessionExists(sessionId);
+ expect(exists).toBe(false);
+ });
+
+ it('should return false if the chats directory does not exist', async () => {
+ const sessionId = randomUUID();
+ // Notice we do NOT create chatsDir here.
+ const selector = new SessionSelector(storage);
+ const exists = await selector.sessionExists(sessionId);
+ expect(exists).toBe(false);
+ });
+ });
+
it('should resolve session by UUID', async () => {
const sessionId1 = randomUUID();
const sessionId2 = randomUUID();
diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts
index 647ed77727..437e32c465 100644
--- a/packages/cli/src/utils/sessionUtils.ts
+++ b/packages/cli/src/utils/sessionUtils.ts
@@ -408,6 +408,36 @@ export const getSessionFiles = async (
export class SessionSelector {
constructor(private storage: Storage) {}
+ /**
+ * Checks if a session with the given ID already exists on disk.
+ */
+ async sessionExists(id: string): Promise {
+ const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
+ const files = await fs.readdir(chatsDir).catch(() => []);
+
+ // The filename format is `session--.jsonl`
+ const shortId = id.slice(0, 8);
+ const candidateFiles = files.filter(
+ (f) =>
+ f.startsWith(SESSION_FILE_PREFIX) &&
+ (f.endsWith(`-${shortId}.json`) || f.endsWith(`-${shortId}.jsonl`)),
+ );
+
+ for (const fileName of candidateFiles) {
+ try {
+ const sessionPath = path.join(chatsDir, fileName);
+ const sessionData = await loadConversationRecord(sessionPath);
+ if (sessionData && sessionData.sessionId === id) {
+ return true;
+ }
+ } catch {
+ // Ignore unparseable files
+ }
+ }
+
+ return false;
+ }
+
/**
* Lists all available sessions for the current project.
*/
diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts
index 120ac36c3b..d255dc1d3a 100644
--- a/packages/cli/src/utils/userStartupWarnings.test.ts
+++ b/packages/cli/src/utils/userStartupWarnings.test.ts
@@ -220,5 +220,22 @@ describe('getUserStartupWarnings', () => {
);
expect(warnings).not.toContainEqual(compWarning);
});
+
+ it('should correctly pass isAlternateBuffer option to getCompatibilityWarnings', async () => {
+ const projectDir = path.join(testRootDir, 'project-alt');
+ await fs.mkdir(projectDir);
+
+ await getUserStartupWarnings({}, projectDir, { isAlternateBuffer: true });
+ expect(getCompatibilityWarnings).toHaveBeenCalledWith({
+ isAlternateBuffer: true,
+ });
+
+ await getUserStartupWarnings({}, projectDir, {
+ isAlternateBuffer: false,
+ });
+ expect(getCompatibilityWarnings).toHaveBeenCalledWith({
+ isAlternateBuffer: false,
+ });
+ });
});
});
diff --git a/packages/core/package.json b/packages/core/package.json
index eda0e1e5fe..bfc7b78064 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -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": {
@@ -56,6 +56,7 @@
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
"chokidar": "^5.0.0",
+ "command-exists": "^1.2.9",
"diff": "^8.0.3",
"dotenv": "^17.2.4",
"dotenv-expand": "^12.0.3",
diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts
index 14d9fd061e..543acd1050 100644
--- a/packages/core/src/agents/a2aUtils.test.ts
+++ b/packages/core/src/agents/a2aUtils.test.ts
@@ -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');
+ });
});
});
diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts
index db08fdb871..2d146fc420 100644
--- a/packages/core/src/agents/a2aUtils.ts
+++ b/packages/core/src/agents/a2aUtils.ts
@@ -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);
}
}
diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts
index f80a1e8cb3..01e4dbe8d3 100644
--- a/packages/core/src/agents/agentLoader.test.ts
+++ b/packages/core/src/agents/agentLoader.test.ts
@@ -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',
},
});
});
diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts
index 4c5e771af9..940acde38f 100644
--- a/packages/core/src/agents/agentLoader.ts
+++ b/packages/core/src/agents/agentLoader.ts
@@ -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,
};
}
}
diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts
index 0808f54ae2..6ba3bbbd43 100644
--- a/packages/core/src/agents/auth-provider/types.ts
+++ b/packages/core/src/agents/auth-provider/types.ts
@@ -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. */
diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts
index 478521cd9e..ca856d8b8e 100644
--- a/packages/core/src/agents/local-executor.ts
+++ b/packages/core/src/agents/local-executor.ts
@@ -779,6 +779,8 @@ export class LocalAgentExecutor {
return {
result: finalResult || 'Task completed.',
terminate_reason: terminateReason,
+ turn_count: turnCounter,
+ duration_ms: Date.now() - startTime,
};
}
@@ -786,6 +788,8 @@ export class LocalAgentExecutor {
result:
finalResult || 'Agent execution was terminated before completion.',
terminate_reason: terminateReason,
+ turn_count: turnCounter,
+ duration_ms: Date.now() - startTime,
};
} catch (error) {
// Check if the error is an AbortError caused by our internal timeout.
@@ -826,6 +830,8 @@ export class LocalAgentExecutor {
return {
result: finalResult,
terminate_reason: terminateReason,
+ turn_count: turnCounter,
+ duration_ms: Date.now() - startTime,
};
}
}
@@ -840,6 +846,8 @@ export class LocalAgentExecutor {
return {
result: finalResult,
terminate_reason: terminateReason,
+ turn_count: turnCounter,
+ duration_ms: Date.now() - startTime,
};
}
diff --git a/packages/core/src/agents/skill-extraction-agent.test.ts b/packages/core/src/agents/skill-extraction-agent.test.ts
index a67c7db270..280cbc33e3 100644
--- a/packages/core/src/agents/skill-extraction-agent.test.ts
+++ b/packages/core/src/agents/skill-extraction-agent.test.ts
@@ -74,12 +74,14 @@ describe('SkillExtractionAgent', () => {
expect(query).toContain(existingSkillsSummary);
expect(query).toContain(sessionIndex);
+ expect(query).toContain('optional workflow hint');
expect(query).toContain(
- 'The summary is a user-intent summary, not a workflow summary.',
+ 'workflow hints alone is never enough evidence for a reusable skill.',
);
expect(query).toContain(
- 'The session summaries describe user intent, not workflow details.',
+ 'Session summaries describe user intent; optional workflow hints describe likely procedural traces.',
);
+ expect(query).toContain('Use workflow hints for routing');
expect(query).toContain(
'Only write a skill if the evidence shows a durable, recurring workflow',
);
diff --git a/packages/core/src/agents/skill-extraction-agent.ts b/packages/core/src/agents/skill-extraction-agent.ts
index 4aa18af388..eea2a4727d 100644
--- a/packages/core/src/agents/skill-extraction-agent.ts
+++ b/packages/core/src/agents/skill-extraction-agent.ts
@@ -303,10 +303,11 @@ export const SkillExtractionAgent = (
'# Session Index',
'',
'Below is an index of past conversation sessions. Each line shows:',
- '[NEW] or [old] status, a 1-line summary, message count, and the file path.',
+ '[NEW] or [old] status, a 1-line user-intent summary, optional workflow hint, message count, and the file path.',
'',
- 'The summary is a user-intent summary, not a workflow summary.',
- 'Matching summary text alone is never enough evidence for a reusable skill.',
+ 'Some lines may include "| workflow: ..."; this is a compact workflow hint from session metadata.',
+ 'Use workflow hints to prioritize which sessions to read and to group likely recurring workflows.',
+ 'Matching summary text or workflow hints alone is never enough evidence for a reusable skill.',
'',
'[NEW] = not yet processed for skill extraction (focus on these)',
'[old] = previously processed (read only if a [NEW] session hints at a repeated pattern)',
@@ -326,7 +327,7 @@ export const SkillExtractionAgent = (
return {
systemPrompt: buildSystemPrompt(skillsDir),
- query: `${initialContext}\n\nAnalyze the session index above. The session summaries describe user intent, not workflow details. Read sessions that suggest repeated workflows using read_file. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
+ query: `${initialContext}\n\nAnalyze the session index above. Session summaries describe user intent; optional workflow hints describe likely procedural traces. Use workflow hints for routing, then read sessions that suggest repeated workflows using read_file to verify recurrence from transcript evidence. Only write a skill if the evidence shows a durable, recurring workflow or a stable recurring repo procedure. If recurrence or future reuse is unclear, create no skill and explain why.`,
};
},
runConfig: {
diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts
index 6cf30bcfb4..732dec1809 100644
--- a/packages/core/src/agents/types.ts
+++ b/packages/core/src/agents/types.ts
@@ -36,6 +36,8 @@ export enum AgentTerminateMode {
export interface OutputObject {
result: string;
terminate_reason: AgentTerminateMode;
+ turn_count?: number;
+ duration_ms?: number;
}
/**
diff --git a/packages/core/src/availability/autoRoutingFallback.integration.test.ts b/packages/core/src/availability/autoRoutingFallback.integration.test.ts
new file mode 100644
index 0000000000..f4e157503b
--- /dev/null
+++ b/packages/core/src/availability/autoRoutingFallback.integration.test.ts
@@ -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 => {
+ 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 =>
+ '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 => {
+ 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 =>
+ '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',
+ );
+ });
+});
diff --git a/packages/core/src/availability/fallbackIntegration.test.ts b/packages/core/src/availability/fallbackIntegration.test.ts
index 62174c9abb..6c49938ed9 100644
--- a/packages/core/src/availability/fallbackIntegration.test.ts
+++ b/packages/core/src/availability/fallbackIntegration.test.ts
@@ -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);
+ });
});
diff --git a/packages/core/src/availability/modelAvailabilityService.test.ts b/packages/core/src/availability/modelAvailabilityService.test.ts
index 32b8e000ee..2dcc90f477 100644
--- a/packages/core/src/availability/modelAvailabilityService.test.ts
+++ b/packages/core/src/availability/modelAvailabilityService.test.ts
@@ -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({
diff --git a/packages/core/src/availability/modelAvailabilityService.ts b/packages/core/src/availability/modelAvailabilityService.ts
index 051003f667..9ef83230ec 100644
--- a/packages/core/src/availability/modelAvailabilityService.ts
+++ b/packages/core/src/availability/modelAvailabilityService.ts
@@ -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' });
diff --git a/packages/core/src/availability/modelPolicy.ts b/packages/core/src/availability/modelPolicy.ts
index 199c13d7a5..46a65f53db 100644
--- a/packages/core/src/availability/modelPolicy.ts
+++ b/packages/core/src/availability/modelPolicy.ts
@@ -46,6 +46,7 @@ export interface ModelPolicy {
actions: ModelPolicyActionMap;
stateTransitions: ModelPolicyStateMap;
isLastResort?: boolean;
+ maxAttempts?: number;
}
/**
diff --git a/packages/core/src/availability/policyCatalog.test.ts b/packages/core/src/availability/policyCatalog.test.ts
index 63bca63336..04e35018d5 100644
--- a/packages/core/src/availability/policyCatalog.test.ts
+++ b/packages/core/src/availability/policyCatalog.test.ts
@@ -53,10 +53,13 @@ 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('terminal');
+ expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
});
it('applies default actions and state transitions for unspecified kinds', () => {
diff --git a/packages/core/src/availability/policyCatalog.ts b/packages/core/src/availability/policyCatalog.ts
index 4ae8aeea7f..a5694e94b8 100644
--- a/packages/core/src/availability/policyCatalog.ts
+++ b/packages/core/src/availability/policyCatalog.ts
@@ -28,6 +28,7 @@ type PolicyConfig = Omit & {
export interface ModelPolicyOptions {
previewEnabled: boolean;
+ isAutoSelection?: boolean;
userTier?: UserTierId;
useGemini31?: boolean;
useGemini31FlashLite?: boolean;
@@ -55,10 +56,14 @@ const DEFAULT_STATE: ModelPolicyStateMap = {
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,
diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts
index 42344f9bb9..945de646e0 100644
--- a/packages/core/src/availability/policyHelpers.test.ts
+++ b/packages/core/src/availability/policyHelpers.test.ts
@@ -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 => {
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',
+ );
+ });
+ });
});
diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts
index 033443ad5c..5d65a7598e 100644
--- a/packages/core/src/availability/policyHelpers.ts
+++ b/packages/core/src/availability/policyHelpers.ts
@@ -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);
}
}
diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts
index 67c2cab67d..8e7f21c3b7 100644
--- a/packages/core/src/code_assist/server.test.ts
+++ b/packages/core/src/code_assist/server.test.ts
@@ -644,6 +644,28 @@ describe('CodeAssistServer', () => {
);
});
+ it('should throw friendly error for 403 on cloudshell-gca project', async () => {
+ const { server } = createTestServer();
+ const mock403Error = {
+ response: {
+ status: 403,
+ data: {
+ error: {
+ message: 'Permission denied',
+ },
+ },
+ },
+ };
+ vi.spyOn(server, 'requestPost').mockRejectedValue(mock403Error);
+
+ await expect(
+ server.loadCodeAssist({
+ cloudaicompanionProject: 'cloudshell-gca',
+ metadata: {},
+ }),
+ ).rejects.toThrow(/Access to the default Cloud Shell Gemini project/);
+ });
+
it('should call the listExperiments endpoint with metadata', async () => {
const { server } = createTestServer();
const mockResponse = {
diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts
index 4ed8328f3d..92fc558ebb 100644
--- a/packages/core/src/code_assist/server.ts
+++ b/packages/core/src/code_assist/server.ts
@@ -273,6 +273,16 @@ export class CodeAssistServer implements ContentGenerator {
return {
currentTier: { id: UserTierId.STANDARD },
};
+ } else if (
+ isPermissionDeniedError(e) &&
+ req.cloudaicompanionProject === 'cloudshell-gca'
+ ) {
+ throw new Error(
+ 'Access to the default Cloud Shell Gemini project was denied.\n' +
+ 'Please set your own Google Cloud project by running:\n' +
+ 'gcloud config set project [PROJECT_ID]\n' +
+ 'or setting export GOOGLE_CLOUD_PROJECT=...',
+ );
} else {
throw e;
}
@@ -572,3 +582,15 @@ function isVpcScAffectedUser(error: unknown): boolean {
}
return false;
}
+
+function isPermissionDeniedError(error: unknown): boolean {
+ return (
+ !!error &&
+ typeof error === 'object' &&
+ 'response' in error &&
+ !!error.response &&
+ typeof error.response === 'object' &&
+ 'status' in error.response &&
+ error.response.status === 403
+ );
+}
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 939fa77d70..11f7a24841 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -691,6 +691,7 @@ export interface ConfigParameters {
ptyInfo?: string;
disableYoloMode?: boolean;
disableAlwaysAllow?: boolean;
+ voiceMode?: boolean;
rawOutput?: boolean;
acceptRawOutputRisk?: boolean;
dynamicModelConfiguration?: boolean;
@@ -963,6 +964,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
+ private readonly voiceMode: boolean;
private readonly trackerEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
@@ -1117,6 +1119,7 @@ export class Config implements McpContext, AgentLoopContext {
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true;
+ this.voiceMode = params.voiceMode ?? false;
this.trackerEnabled = params.tracker ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
@@ -2969,6 +2972,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.planEnabled;
}
+ isVoiceModeEnabled(): boolean {
+ return this.voiceMode;
+ }
+
isTrackerEnabled(): boolean {
return this.trackerEnabled;
}
diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts
index 74445afd39..a4f8cbaf57 100644
--- a/packages/core/src/config/defaultModelConfigs.ts
+++ b/packages/core/src/config/defaultModelConfigs.ts
@@ -565,6 +565,42 @@ 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',
+ not_found: 'terminal',
+ unknown: 'terminal',
+ },
+ },
+ {
+ model: 'gemini-3-flash-preview',
+ isLastResort: true,
+ maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
@@ -590,7 +626,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
},
stateTransitions: {
terminal: 'terminal',
- transient: 'terminal',
+ transient: 'sticky_retry',
not_found: 'terminal',
unknown: 'terminal',
},
@@ -598,6 +634,42 @@ 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',
+ not_found: 'terminal',
+ unknown: 'terminal',
+ },
+ },
+ {
+ model: 'gemini-2.5-flash',
+ isLastResort: true,
+ maxAttempts: 10,
actions: {
terminal: 'prompt',
transient: 'prompt',
diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts
index 155b7f509b..51846262dc 100644
--- a/packages/core/src/config/models.test.ts
+++ b/packages/core/src/config/models.test.ts
@@ -273,6 +273,13 @@ describe('isCustomModel', () => {
expect(isCustomModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false);
expect(isCustomModel(GEMINI_MODEL_ALIAS_PRO)).toBe(false);
});
+
+ it('should not throw if the model is an array (e.g. from yargs)', () => {
+ // @ts-expect-error - testing invalid runtime input
+ expect(() => isCustomModel(['gemini-2.0-flash', 'gpt-4'])).not.toThrow();
+ // @ts-expect-error - testing invalid runtime input
+ expect(isCustomModel(['gemini-2.0-flash', 'gpt-4'])).toBe(true); // last one is custom
+ });
});
describe('supportsModernFeatures', () => {
@@ -431,6 +438,15 @@ describe('resolveModel', () => {
const model = resolveModel(customModel);
expect(model).toBe(customModel);
});
+
+ it('should handle non-string inputs gracefully', () => {
+ // @ts-expect-error - testing invalid runtime input
+ expect(resolveModel(['a', 'b'])).toBe('b');
+ // @ts-expect-error - testing invalid runtime input
+ expect(resolveModel(true)).toBe('true');
+ // @ts-expect-error - testing invalid runtime input
+ expect(resolveModel(null)).toBe('');
+ });
});
describe('hasAccessToPreview logic', () => {
diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts
index 6dd32ab920..6e936182cd 100644
--- a/packages/core/src/config/models.ts
+++ b/packages/core/src/config/models.ts
@@ -109,8 +109,15 @@ export function resolveModel(
hasAccessToPreview: boolean = true,
config?: ModelCapabilityContext,
): string {
+ // Defensive check against non-string inputs at runtime
+ const normalizedModel = Array.isArray(requestedModel)
+ ? String(requestedModel.at(-1) ?? '').trim() || ''
+ : typeof requestedModel !== 'string'
+ ? String(requestedModel ?? '').trim() || ''
+ : requestedModel.trim() || '';
+
if (config?.getExperimentalDynamicModelConfiguration?.() === true) {
- const resolved = config.modelConfigService.resolveModelId(requestedModel, {
+ const resolved = config.modelConfigService.resolveModelId(normalizedModel, {
useGemini3_1,
useGemini3_1FlashLite,
useCustomTools: useCustomToolModel,
@@ -132,7 +139,7 @@ export function resolveModel(
}
let resolved: string;
- switch (requestedModel) {
+ switch (normalizedModel) {
case PREVIEW_GEMINI_MODEL:
case PREVIEW_GEMINI_MODEL_AUTO:
case GEMINI_MODEL_ALIAS_AUTO:
@@ -161,7 +168,7 @@ export function resolveModel(
break;
}
default: {
- resolved = requestedModel;
+ resolved = normalizedModel;
break;
}
}
diff --git a/packages/core/src/context/config/configLoader.test.ts b/packages/core/src/context/config/configLoader.test.ts
index b4c9885c9a..809c1ce3cc 100644
--- a/packages/core/src/context/config/configLoader.test.ts
+++ b/packages/core/src/context/config/configLoader.test.ts
@@ -6,19 +6,17 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadContextManagementConfig } from './configLoader.js';
-import { defaultContextProfile } from './profiles.js';
+import { generalistProfile } from './profiles.js';
import { ContextProcessorRegistry } from './registry.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
-import type { Config } from '../../config/config.js';
import type { JSONSchemaType } from 'ajv';
describe('SidecarLoader (Real FS)', () => {
let tmpDir: string;
let registry: ContextProcessorRegistry;
let sidecarPath: string;
- let mockConfig: Config;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-sidecar-test-'));
@@ -32,10 +30,6 @@ describe('SidecarLoader (Real FS)', () => {
required: ['maxTokens'],
} as unknown as JSONSchemaType<{ maxTokens: number }>,
});
-
- mockConfig = {
- getExperimentalContextManagementConfig: () => sidecarPath,
- } as unknown as Config;
});
afterEach(async () => {
@@ -43,14 +37,14 @@ describe('SidecarLoader (Real FS)', () => {
});
it('returns default profile if file does not exist', async () => {
- const result = await loadContextManagementConfig(mockConfig, registry);
- expect(result).toBe(defaultContextProfile);
+ const result = await loadContextManagementConfig(sidecarPath, registry);
+ expect(result).toBe(generalistProfile);
});
it('returns default profile if file exists but is 0 bytes', async () => {
await fs.writeFile(sidecarPath, '');
- const result = await loadContextManagementConfig(mockConfig, registry);
- expect(result).toBe(defaultContextProfile);
+ const result = await loadContextManagementConfig(sidecarPath, registry);
+ expect(result).toBe(generalistProfile);
});
it('returns parsed config if file is valid', async () => {
@@ -64,7 +58,7 @@ describe('SidecarLoader (Real FS)', () => {
},
};
await fs.writeFile(sidecarPath, JSON.stringify(validConfig));
- const result = await loadContextManagementConfig(mockConfig, registry);
+ const result = await loadContextManagementConfig(sidecarPath, registry);
expect(result.config.budget?.maxTokens).toBe(2000);
expect(result.config.processorOptions?.['myTruncation']).toBeDefined();
});
@@ -81,14 +75,14 @@ describe('SidecarLoader (Real FS)', () => {
};
await fs.writeFile(sidecarPath, JSON.stringify(invalidConfig));
await expect(
- loadContextManagementConfig(mockConfig, registry),
+ loadContextManagementConfig(sidecarPath, registry),
).rejects.toThrow('Validation error');
});
it('throws validation error if file is empty whitespace', async () => {
await fs.writeFile(sidecarPath, ' \n ');
await expect(
- loadContextManagementConfig(mockConfig, registry),
+ loadContextManagementConfig(sidecarPath, registry),
).rejects.toThrow('Unexpected end of JSON input');
});
});
diff --git a/packages/core/src/context/config/configLoader.ts b/packages/core/src/context/config/configLoader.ts
index b010f47e61..611141cddc 100644
--- a/packages/core/src/context/config/configLoader.ts
+++ b/packages/core/src/context/config/configLoader.ts
@@ -4,11 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Config } from '../../config/config.js';
import * as fsSync from 'node:fs';
import * as fs from 'node:fs/promises';
import type { ContextManagementConfig } from './types.js';
-import { defaultContextProfile, type ContextProfile } from './profiles.js';
+import {
+ generalistProfile,
+ stressTestProfile,
+ type ContextProfile,
+} from './profiles.js';
import { SchemaValidator } from '../../utils/schemaValidator.js';
import { getContextManagementConfigSchema } from './schema.js';
import type { ContextProcessorRegistry } from './registry.js';
@@ -54,9 +57,9 @@ async function loadConfigFromFile(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const validConfig = parsed as ContextManagementConfig;
return {
- ...defaultContextProfile,
+ ...generalistProfile,
config: {
- ...defaultContextProfile.config,
+ ...generalistProfile.config,
...(validConfig.budget ? { budget: validConfig.budget } : {}),
...(validConfig.processorOptions
? { processorOptions: validConfig.processorOptions }
@@ -70,21 +73,27 @@ async function loadConfigFromFile(
* If a config file is present but invalid, this will THROW to prevent silent misconfiguration.
*/
export async function loadContextManagementConfig(
- config: Config,
+ sidecarPath: string | undefined,
registry: ContextProcessorRegistry,
): Promise {
- const sidecarPath = config.getExperimentalContextManagementConfig();
+ if (sidecarPath === 'stressTestProfile') {
+ return stressTestProfile;
+ }
+
+ if (sidecarPath === 'generalistProfile') {
+ return generalistProfile;
+ }
if (sidecarPath && fsSync.existsSync(sidecarPath)) {
const size = fsSync.statSync(sidecarPath).size;
// If the file exists but is completely empty (0 bytes), it's safe to fallback.
if (size === 0) {
- return defaultContextProfile;
+ return generalistProfile;
}
// If the file has content, enforce strict validation and throw on failure.
return loadConfigFromFile(sidecarPath, registry);
}
- return defaultContextProfile;
+ return generalistProfile;
}
diff --git a/packages/core/src/context/config/profiles.ts b/packages/core/src/context/config/profiles.ts
index d55c49fc48..e938668500 100644
--- a/packages/core/src/context/config/profiles.ts
+++ b/packages/core/src/context/config/profiles.ts
@@ -62,7 +62,7 @@ export interface ContextProfile {
* The standard default context management profile.
* Optimized for safety, precision, and reliable summarization.
*/
-export const defaultContextProfile: ContextProfile = {
+export const generalistProfile: ContextProfile = {
config: {
budget: {
retainedTokens: 65000,
@@ -88,24 +88,32 @@ export const defaultContextProfile: ContextProfile = {
}),
),
createBlobDegradationProcessor('BlobDegradation', env), // No options
+ // Automatically distill extremely large blocks (e.g. huge source files pasted by the user)
+ createNodeDistillationProcessor(
+ 'ImmediateNodeDistillation',
+ env,
+ resolveProcessorOptions(config, 'ImmediateNodeDistillation', {
+ nodeThresholdTokens: 15000,
+ }),
+ ),
],
},
{
name: 'Normalization',
triggers: ['retained_exceeded'],
processors: [
- createNodeTruncationProcessor(
- 'NodeTruncation',
- env,
- resolveProcessorOptions(config, 'NodeTruncation', {
- maxTokensPerNode: 3000,
- }),
- ),
createNodeDistillationProcessor(
'NodeDistillation',
env,
resolveProcessorOptions(config, 'NodeDistillation', {
- nodeThresholdTokens: 5000,
+ nodeThresholdTokens: 3000,
+ }),
+ ),
+ createNodeTruncationProcessor(
+ 'NodeTruncation',
+ env,
+ resolveProcessorOptions(config, 'NodeTruncation', {
+ maxTokensPerNode: 2000,
}),
),
],
@@ -143,3 +151,41 @@ export const defaultContextProfile: ContextProfile = {
},
],
};
+
+/**
+ * A highly aggressive profile designed exclusively for testing Context Management.
+ * Lowers token limits dramatically to force garbage collection and distillation loops
+ * within a few conversational turns.
+ */
+export const stressTestProfile: ContextProfile = {
+ config: {
+ budget: {
+ retainedTokens: 4000,
+ maxTokens: 10000,
+ },
+ processorOptions: {
+ ToolMasking: {
+ type: 'ToolMaskingProcessor',
+ options: {
+ stringLengthThresholdTokens: 500,
+ },
+ },
+ NodeTruncation: {
+ type: 'NodeTruncationProcessor',
+ options: {
+ maxTokensPerNode: 1000,
+ },
+ },
+ NodeDistillation: {
+ type: 'NodeDistillationProcessor',
+ options: {
+ nodeThresholdTokens: 1500,
+ },
+ },
+ },
+ },
+ // Re-use the generalist pipeline architecture exactly, but the `config` above
+ // will be passed into `resolveProcessorOptions` to aggressively override the thresholds.
+ buildPipelines: generalistProfile.buildPipelines,
+ buildAsyncPipelines: generalistProfile.buildAsyncPipelines,
+};
diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts
index b4e1e11858..fc03a9c127 100644
--- a/packages/core/src/context/contextManager.ts
+++ b/packages/core/src/context/contextManager.ts
@@ -44,12 +44,10 @@ export class ContextManager {
this.env.tokenCalculator,
this.env.graphMapper,
);
- this.historyObserver.start();
this.eventBus.onPristineHistoryUpdated((event) => {
- const existingIds = new Set(this.buffer.nodes.map((n) => n.id));
const newIds = new Set(event.nodes.map((n) => n.id));
- const addedNodes = event.nodes.filter((n) => !existingIds.has(n.id));
+ const addedNodes = event.nodes.filter((n) => event.newNodes.has(n.id));
// Prune any pristine nodes that were dropped from the upstream history
this.buffer = this.buffer.prunePristineNodes(newIds);
@@ -60,6 +58,15 @@ export class ContextManager {
this.evaluateTriggers(event.newNodes);
});
+ this.eventBus.onProcessorResult((event) => {
+ this.buffer = this.buffer.applyProcessorResult(
+ event.processorId,
+ event.targets,
+ event.returnedNodes,
+ );
+ });
+
+ this.historyObserver.start();
}
/**
@@ -153,6 +160,7 @@ export class ContextManager {
activeTaskIds: Set = new Set(),
): Promise {
this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context');
+
// Apply final GC Backstop pressure barrier synchronously before mapping
const finalHistory = await render(
this.buffer.nodes,
diff --git a/packages/core/src/context/eventBus.ts b/packages/core/src/context/eventBus.ts
index f63cf4294f..82e4d45e67 100644
--- a/packages/core/src/context/eventBus.ts
+++ b/packages/core/src/context/eventBus.ts
@@ -7,6 +7,12 @@
import { EventEmitter } from 'node:events';
import type { ConcreteNode } from './graph/types.js';
+export interface ProcessorResultEvent {
+ processorId: string;
+ targets: readonly ConcreteNode[];
+ returnedNodes: readonly ConcreteNode[];
+}
+
export interface PristineHistoryUpdatedEvent {
nodes: readonly ConcreteNode[];
newNodes: Set;
@@ -49,4 +55,12 @@ export class ContextEventBus extends EventEmitter {
onConsolidationNeeded(listener: (event: ContextConsolidationEvent) => void) {
this.on('BUDGET_RETAINED_CROSSED', listener);
}
+
+ emitProcessorResult(event: ProcessorResultEvent) {
+ this.emit('PROCESSOR_RESULT', event);
+ }
+
+ onProcessorResult(listener: (event: ProcessorResultEvent) => void) {
+ this.on('PROCESSOR_RESULT', listener);
+ }
}
diff --git a/packages/core/src/context/graph/builtinBehaviors.ts b/packages/core/src/context/graph/builtinBehaviors.ts
index 0a3c931b3d..61741d10ba 100644
--- a/packages/core/src/context/graph/builtinBehaviors.ts
+++ b/packages/core/src/context/graph/builtinBehaviors.ts
@@ -122,9 +122,9 @@ export const AgentYieldBehavior: NodeBehavior = {
getEstimatableParts(yieldNode) {
return [{ text: yieldNode.text }];
},
- serialize(yieldNode, writer) {
- writer.appendModelPart({ text: yieldNode.text });
- writer.flushModelParts();
+ serialize() {
+ // AGENT_YIELD is a synthetic marker node used for internal graph tracking.
+ // We intentionally do NOT serialize it to the LLM to prevent prompt corruption.
},
};
diff --git a/packages/core/src/context/graph/mapper.ts b/packages/core/src/context/graph/mapper.ts
index 92b9d836db..4e7eef202b 100644
--- a/packages/core/src/context/graph/mapper.ts
+++ b/packages/core/src/context/graph/mapper.ts
@@ -3,9 +3,10 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+import type { ConcreteNode } from './types.js';
+import { ContextGraphBuilder } from './toGraph.js';
import type { Content } from '@google/genai';
-import type { Episode, ConcreteNode } from './types.js';
-import { toGraph } from './toGraph.js';
+import type { HistoryEvent } from '../../core/agentChatHistory.js';
import { fromGraph } from './fromGraph.js';
import type { ContextTokenCalculator } from '../utils/contextTokenCalculator.js';
import type { NodeBehaviorRegistry } from './behaviorRegistry.js';
@@ -15,11 +16,30 @@ export class ContextGraphMapper {
constructor(private readonly registry: NodeBehaviorRegistry) {}
- toGraph(
- history: readonly Content[],
+ private builder?: ContextGraphBuilder;
+
+ applyEvent(
+ event: HistoryEvent,
tokenCalculator: ContextTokenCalculator,
- ): Episode[] {
- return toGraph(history, tokenCalculator, this.nodeIdentityMap);
+ ): ConcreteNode[] {
+ if (!this.builder) {
+ this.builder = new ContextGraphBuilder(
+ tokenCalculator,
+ this.nodeIdentityMap,
+ );
+ }
+
+ if (event.type === 'CLEAR') {
+ this.builder.clear();
+ return [];
+ }
+
+ if (event.type === 'SYNC_FULL') {
+ this.builder.clear();
+ }
+
+ this.builder.processHistory(event.payload);
+ return this.builder.getNodes();
}
fromGraph(nodes: readonly ConcreteNode[]): Content[] {
diff --git a/packages/core/src/context/graph/toGraph.ts b/packages/core/src/context/graph/toGraph.ts
index 7238c6ae5c..1c14d24c86 100644
--- a/packages/core/src/context/graph/toGraph.ts
+++ b/packages/core/src/context/graph/toGraph.ts
@@ -6,6 +6,7 @@
import type { Content, Part } from '@google/genai';
import type {
+ ConcreteNode,
Episode,
SemanticPart,
ToolExecution,
@@ -38,67 +39,98 @@ function isCompleteEpisode(ep: Partial): ep is Episode {
);
}
-export function toGraph(
- history: readonly Content[],
- tokenCalculator: ContextTokenCalculator,
- nodeIdentityMap: WeakMap