Compare commits

..

2 Commits

Author SHA1 Message Date
jacob314 68e33a1103 fix(test): regenerate settings documentation and schema 2026-04-23 14:34:43 -07:00
jacob314 19601955fd feat(routing): availability-aware auto-routing with best-effort pro
Adds settings and logic to detect slow/hanging Pro model requests, marking them as temporarily unavailable and automatically triggering a fallback to Flash. Introduces a proTimeoutMinutes and bestEffortPro strategy configuration.
2026-04-23 13:50:24 -07:00
195 changed files with 1639 additions and 11338 deletions
+1 -5
View File
@@ -2,11 +2,7 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true,
"gemma": true,
"memoryManager": true,
"topicUpdateNarration": true,
"voiceMode": true
"autoMemory": true
},
"general": {
"devtools": true
-1
View File
@@ -337,7 +337,6 @@ 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'
-1
View File
@@ -66,7 +66,6 @@ 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' }}"
-221
View File
@@ -1,221 +0,0 @@
name: '🧠 Gemini CLI Bot: Brain'
on:
schedule:
- cron: '0 0 * * *' # Every 24 hours
workflow_dispatch:
inputs:
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.ref }}'
cancel-in-progress: true
jobs:
reasoning:
name: 'Brain (Reasoning Layer)'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
# 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 (all state in one artifact)
gh run download "$LAST_RUN_ID" -n brain-data -D . || echo "brain-data not found"
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' }}"
run: 'node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/metrics.md)"'
- name: 'Run Critique Phase'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
# This token is strictly readonly as enforced by the job-level permissions.
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
GEMINI_MODEL: 'gemini-3-flash-preview'
run: |
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
# PIPESTATUS[0] captures the exit code of the node command before the pipe
if [ "${PIPESTATUS[0]}" -ne 0 ] || grep -q "\[REJECTED\]" critique_output.log; then
echo "Critique failed or rejected changes. Skipping PR creation."
echo "[REJECTED]" > critique_result.txt
else
echo "[APPROVED]" > critique_result.txt
fi
fi
- name: 'Generate Patch'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
run: |
touch bot-changes.patch
touch pr-description.md
if [ -f critique_result.txt ] && grep -q "\[REJECTED\]" critique_result.txt; then
echo "Critique rejected. Skipping patch generation."
else
git diff --staged > bot-changes.patch
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
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: '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' }}"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
run: |
if [ -s "${{ runner.temp }}/brain-data/bot-changes.patch" ]; then
git config user.name "gemini-cli-robot"
git config user.email "gemini-cli-robot@google.com"
git 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
# SECURITY: Only allow pushing to branches starting with 'bot/'
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
exit 1
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
# Use force to update existing PR branches
git push origin "$BRANCH_NAME" --force
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
fi
# Create PR if it doesn't exist
if ! 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
fi
fi
- name: 'Post PR Comment'
if: "${{ github.event.inputs.enable_prs == 'true' }}"
env:
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
run: |
if [ -s "${{ runner.temp }}/brain-data/pr-comment.md" ] && [ -f "${{ runner.temp }}/brain-data/pr-number.txt" ]; then
PR_NUM=$(cat "${{ runner.temp }}/brain-data/pr-number.txt")
# SECURITY: Only allow commenting on PRs authored by the bot
PR_AUTHOR=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
if [ "$PR_AUTHOR" != "gemini-cli-robot" ]; then
echo "Error: PR #$PR_NUM is authored by '$PR_AUTHOR', not 'gemini-cli-robot'. Safety abort."
exit 1
fi
gh pr comment "$PR_NUM" -F "${{ runner.temp }}/brain-data/pr-comment.md"
fi
@@ -1,48 +0,0 @@
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
+2 -2
View File
@@ -40,8 +40,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
USER node
# install gemini-cli and clean up
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
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
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'));" \
-2
View File
@@ -371,8 +371,6 @@ 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.
-18
View File
@@ -18,24 +18,6 @@ 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
+255 -243
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.39.0
# Latest stable release: v0.38.2
Released: April 23, 2026
Released: April 17, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,252 +11,264 @@ npm install -g @google/gemini-cli
## Highlights
- **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.
- **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.
## 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
- 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
@SandyTao520 in
[#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
[#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
[#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
[#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
[#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
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
@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)
- 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)
[#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)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.38.2...v0.39.0
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
+18 -168
View File
@@ -1,6 +1,6 @@
# Preview release: v0.40.0-preview.3
# Preview release: v0.39.0-preview.0
Released: April 24, 2026
Released: April 14, 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,174 +13,24 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Ripgrep Binary Bundling:** Ripgrep binaries are now bundled into the Single
Executable Application (SEA), enabling grep functionality in offline
environments.
- **MCP Resource Tools:** New core tools added to list and read MCP (Model
Context Protocol) resources, expanding the agent's ability to interact with
MCP servers.
- **Local Model Setup:** Introduced a streamlined `gemini gemma` command for
easier local model setup and integration.
- **Prompt-Driven Memory Management:** Refactored memory management into a
prompt-driven, four-tier system and integrated `skill-creator` for robust
skill extraction.
- **Enhanced UI and Accessibility:** Added support for OSC 777 terminal
notifications and GitHub colorblind themes for better user feedback and
accessibility.
- **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.
## What's Changed
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
- feat(core): enhance shell command validation and add core tools allowlist by
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
- 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)
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
@gemini-cli-robot in
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
by @rcleveng in
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
- feat(test): add large conversation scenario for performance test by
@cynthialong0-0 in
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
- improve(core): require recurrence evidence before extracting skills by
@SandyTao520 in
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
- test(evals): add subagent delegation evaluation tests by @anj-s in
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
- feat: add github colorblind themes by @Z1xus in
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
@chrisjcthomas in
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
- Changelog for v0.38.0 by @gemini-cli-robot in
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
memory by @SandyTao520 in
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
- Changelog for v0.38.1 by @gemini-cli-robot in
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
- feat(core): integrate skill-creator into skill extraction agent by
@SandyTao520 in
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
- feat(core): add tools to list and read MCP resources by @ruomengz in
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
- fix(evals): add typecheck coverage for evals, integration-tests, and
memory-tests by @SandyTao520 in
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
- Use OSC 777 for terminal notifications by @jackyliuxx in
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
- fix(extensions): fix bundling for examples by @abhipatel12 in
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
- docs(policy): mention that workspace policies are broken by @6112 in
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
- fix(core): allow explicit write permissions to override governance file
protections in sandboxes by @galz10 in
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
- Reduce blank lines. by @gundermanc in
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
backoff by @mahimashanware in
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
- feat(core): Disable topic updates for subagents by @gundermanc in
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
- feat(core): enable topic update narration by default and promote to general by
@gundermanc in
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
- docs: migrate installation and authentication to mdx with tabbed layouts by
@g-samroberts in
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
- fix(cli): round slow render latency to avoid opentelemetry float warning by
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
- docs(tracker): introduce experimental task tracker feature by @anj-s in
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
- Changelog for v0.38.2 by @gemini-cli-robot in
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
- feat(test): refactor the memory usage test to use metrics from CLI process
instead of test runner by @cynthialong0-0 in
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
- Fix/allow for session persistence by @ahsanfarooq210 in
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
- Allow dots on GEMINI_API_KEY by @DKbyo in
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
- fix(core): resolve nested plan directory duplication and relative path
policies by @mahimashanware in
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
- feat: detect new files in @ recommendations with watcher based updates by
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
@cocosheng-g in
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
- fix(cli): ensure theme dialog labels are rendered for all themes by
@JayadityaGit in
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
processes by @euxaristia in
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
- feat: add /new as alias for /clear and refine command description by @ved015
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
- fix(core): remove duplicate initialize call on agents refreshed by
@adamfweidman in
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
across four tiers by @SandyTao520 in
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
- fix(cli): fix "/clear (new)" command by @mini2s in
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
by @thekishandev in
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
- Fix/command injection shell by @Famous077 in
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
- fix(ui): removed background color for input by @devr0306 in
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
- fix(core): support jsonl session logs in memory and summary services by
@SandyTao520 in
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
- 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
@@ -404,4 +254,4 @@ npm install -g @google/gemini-cli@preview
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.40.0-preview.3
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
+48 -165
View File
@@ -31,53 +31,6 @@ 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
@@ -90,92 +43,12 @@ 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 using container
technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image.
Cross-platform sandboxing with complete process isolation.
**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"
```
**Note**: Requires building the sandbox image locally or using a published image
from your organization's registry.
### 3. Windows Native Sandbox (Windows only)
@@ -315,49 +188,59 @@ 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.
### 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**:
## Quickstart
```bash
export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro"
# Enable sandboxing with command flag
gemini -s -p "analyze the code structure"
```
## Running inside a Docker container
**Use environment variable**
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**:
**macOS/Linux**
```bash
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
export GEMINI_SANDBOX=true
gemini -p "run the test suite"
```
## Advanced settings
**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
### Custom sandbox flags
@@ -396,7 +279,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:
+23 -26
View File
@@ -99,13 +99,16 @@ they appear in the UI.
### Model
| UI Label | Setting | Description | Default |
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| UI Label | Setting | Description | Default |
| --------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| Best Effort Pro | `model.autoRouting.bestEffortPro` | Always prefer the Pro model unless it is unavailable (e.g., due to timeouts or quota), ignoring other routing hints. | `false` |
| Pro Timeout (Minutes) | `model.autoRouting.proTimeoutMinutes` | If a Pro request takes longer than this many minutes, it will be marked as temporarily unavailable and fallback to Flash. | `5` |
| Pro Timeout Fallback Duration (Minutes) | `model.autoRouting.proTimeoutFallbackDurationMinutes` | How long to route to Flash after Pro times out. | `60` |
### Agents
@@ -161,25 +164,19 @@ they appear in the UI.
### Experimental
| 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` |
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| 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
-4
View File
@@ -117,10 +117,6 @@ the following methods:
These methods will trust the current workspace for the duration of the session
without prompting.
For detailed instructions on managing folder trust within CI/CD workflows,
review the
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
## Overriding the trust file location
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
+21 -90
View File
@@ -483,6 +483,20 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Skip the next speaker check.
- **Default:** `true`
- **`model.autoRouting.bestEffortPro`** (boolean):
- **Description:** Always prefer the Pro model unless it is unavailable (e.g.,
due to timeouts or quota), ignoring other routing hints.
- **Default:** `false`
- **`model.autoRouting.proTimeoutMinutes`** (number):
- **Description:** If a Pro request takes longer than this many minutes, it
will be marked as temporarily unavailable and fallback to Flash.
- **Default:** `5`
- **`model.autoRouting.proTimeoutFallbackDurationMinutes`** (number):
- **Description:** How long to route to Flash after Pro times out.
- **Default:** `60`
#### `modelConfigs`
- **`modelConfigs.aliases`** (object):
@@ -563,18 +577,6 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-2.5-flash-lite"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-31b-it"
}
},
"gemma-4-26b-a4b-it": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemma-4-26b-a4b-it"
}
},
"gemini-2.5-flash-base": {
"extends": "base",
"modelConfig": {
@@ -846,28 +848,6 @@ their corresponding top-level category object in your `settings.json` file.
"multimodalToolUse": false
}
},
"gemma-4-31b-it": {
"displayName": "gemma-4-31b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"gemma-4-26b-a4b-it": {
"displayName": "gemma-4-26b-a4b-it",
"tier": "custom",
"family": "gemma-4",
"isPreview": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
}
},
"auto": {
"tier": "auto",
"isPreview": true,
@@ -938,12 +918,6 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemma-4-31b-it": {
"default": "gemma-4-31b-it"
},
"gemma-4-26b-a4b-it": {
"default": "gemma-4-26b-a4b-it"
},
"gemini-3.1-pro-preview": {
"default": "gemini-3.1-pro-preview",
"contexts": [
@@ -1191,7 +1165,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1207,7 +1181,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1224,7 +1198,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1240,7 +1214,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1257,7 +1231,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1272,7 +1246,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1288,7 +1262,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"stateTransitions": {
"terminal": "terminal",
"transient": "sticky_retry",
"transient": "terminal",
"not_found": "terminal",
"unknown": "terminal"
}
@@ -1507,12 +1481,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.confirmationRequired`** (array):
- **Description:** Tool names that always require user confirmation. Takes
precedence over allowed tools and core tool allowlists.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`tools.exclude`** (array):
- **Description:** Tool names to exclude from discovery.
- **Default:** `undefined`
@@ -1686,37 +1654,6 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.gemma`** (boolean):
- **Description:** Enable access to Gemma 4 models (experimental).
- **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`
@@ -1846,12 +1783,6 @@ 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.
-1
View File
@@ -115,7 +115,6 @@ available combinations.
| `app.restart` | Restart the application. | `R`<br />`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
+3 -3
View File
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
params: {
settings: {
security: {
folderTrust: { enabled: false },
folderTrust: { enabled: true },
},
},
},
-218
View File
@@ -5,78 +5,12 @@
*/
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";
@@ -84,11 +18,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingFavoriteColor,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `remember that my favorite color is blue.
@@ -111,11 +40,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandRestrictions,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
@@ -137,11 +61,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingWorkflow,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
@@ -164,11 +83,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringTemporaryInformation,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
@@ -194,11 +108,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingPetName,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
@@ -220,11 +129,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandAlias,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
@@ -247,11 +151,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingDbSchemaLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -281,11 +180,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCodingStyle,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
@@ -308,11 +202,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingBuildArtifactLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -342,11 +231,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: savingMainEntryPointAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
@@ -375,11 +259,6 @@ describe('save_memory', () => {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingBirthday,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
@@ -635,103 +514,6 @@ 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', {
+17 -630
View File
@@ -6,30 +6,21 @@
import fsp from 'node:fs/promises';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { describe, expect } from 'vitest';
import {
type Config,
ApprovalMode,
type MemoryScratchpad,
SESSION_FILE_PREFIX,
getProjectHash,
startMemoryService,
} from '@google/gemini-cli-core';
import { ComponentRig, componentEvalTest } from './component-test-helper.js';
import {
average,
averageNullable,
countMatchingIds,
roundStat,
} from './statistics-helper.js';
import { prepareWorkspace } from './test-helper.js';
import { componentEvalTest } from './component-test-helper.js';
interface SeedSession {
sessionId: string;
summary: string;
userTurns: string[];
timestampOffsetMinutes: number;
memoryScratchpad?: MemoryScratchpad;
}
interface MessageRecord {
@@ -39,81 +30,6 @@ 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(
{
@@ -152,143 +68,6 @@ 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[],
@@ -299,10 +78,9 @@ async function seedSessions(
const projectRoot = config.storage.getProjectRoot();
for (const session of sessions) {
const sessionTimestamp = new Date(
const timestamp = new Date(
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
);
const timestamp = sessionTimestamp
)
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
@@ -311,9 +89,8 @@ 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: sessionTimestamp.toISOString(),
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
messages: buildMessages(session.userTurns),
};
@@ -324,9 +101,10 @@ async function seedSessions(
}
}
async function runExtractionAndReadState(
config: Config,
): Promise<ExtractionOutcome> {
async function runExtractionAndReadState(config: Config): Promise<{
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
skillsDir: string;
}> {
await startMemoryService(config);
const memoryDir = config.storage.getProjectMemoryTempDir();
@@ -335,15 +113,7 @@ async function runExtractionAndReadState(
const raw = await fsp.readFile(statePath, 'utf-8');
const state = JSON.parse(raw) as {
runs?: Array<{
sessionIds?: string[];
skillsCreated?: string[];
candidateSessions?: SessionVersion[];
processedSessions?: SessionVersion[];
turnCount?: number;
durationMs?: number;
terminateReason?: string;
}>;
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
};
if (!Array.isArray(state.runs) || state.runs.length === 0) {
throw new Error('Skill extraction finished without writing any run state');
@@ -356,292 +126,27 @@ async function runExtractionAndReadState(
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<typeof createWorkflowComparisonSessions>,
): Promise<ScratchpadRunMetrics> {
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<ExtractionOutcome> {
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<ScratchpadStatsTrial> {
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<ScratchpadStatsReport> {
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<string> {
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<string[]> {
const bodies: string[] = [];
try {
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
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.
}
}
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'),
),
);
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.
@@ -653,16 +158,6 @@ 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',
@@ -769,24 +264,15 @@ 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(/verif(?:y|ication)/i);
expect(
quality.score,
`missing quality signals: ${quality.missing.join(', ')}`,
).toBeGreaterThanOrEqual(4);
expect(combinedSkills).toMatch(/Verification/i);
// Verify the extraction agent activated skill-creator for design guidance.
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
@@ -795,96 +281,6 @@ 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',
@@ -934,24 +330,15 @@ 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(
-26
View File
@@ -1,26 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export function countMatchingIds<T extends { sessionId: string }>(
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>): number | null {
const numericValues = values.filter((value) => value !== null);
return numericValues.length === 0 ? null : average(numericValues);
}
-1
View File
@@ -172,7 +172,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
timeout: evalCase.timeout,
env: {
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
GEMINI_CLI_TRUST_WORKSPACE: 'true',
},
});
+8 -50
View File
@@ -62,13 +62,11 @@ describe('tracker_mode', () => {
'Expected tracker_update_task tool to be called',
).toBe(true);
const updateCalls = toolLogs.filter(
const updateCall = toolLogs.find(
(log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME,
);
expect(updateCalls.length).toBeGreaterThan(0);
const updateArgs = JSON.parse(
updateCalls[updateCalls.length - 1].toolRequest.args,
);
expect(updateCall).toBeDefined();
const updateArgs = JSON.parse(updateCall!.toolRequest.args);
expect(updateArgs.status).toBe('closed');
const loginContent = fs.readFileSync(
@@ -130,52 +128,12 @@ describe('tracker_mode', () => {
prompt:
'Where is my task tracker storage located? Please provide the absolute path in your response.',
assert: async (rig, result) => {
// The response should contain the dynamic path which follows the .gemini/tmp/.../tracker structure.
// 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);
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);
},
});
});
-76
View File
@@ -1,76 +0,0 @@
/**
* @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();
});
});
+4 -390
View File
@@ -991,37 +991,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/config-helpers": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
@@ -1069,24 +1038,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
@@ -1100,19 +1051,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@eslint/js": {
"version": "9.29.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
@@ -3795,37 +3733,6 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -4986,13 +4893,6 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5032,30 +4932,6 @@
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -6553,13 +6429,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -7075,23 +6944,6 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/depcheck/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/depcheck/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/depcheck/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
@@ -7151,22 +7003,6 @@
"node": ">=6"
}
},
"node_modules/depcheck/node_modules/minimatch": {
"version": "7.4.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/depcheck/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
@@ -8057,24 +7893,6 @@
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
}
},
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-import/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@@ -8085,19 +7903,6 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-import/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-import/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8154,37 +7959,6 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
}
},
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-react/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -8243,37 +8017,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/espree": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
@@ -11914,12 +11657,12 @@
}
},
"node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -12100,37 +11843,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/multimatch/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -12371,24 +12083,6 @@
"node": ">=4"
}
},
"node_modules/npm-run-all/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/npm-run-all/node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/npm-run-all/node_modules/chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -12438,19 +12132,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/npm-run-all/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/npm-run-all/node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
@@ -15817,39 +15498,6 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
@@ -16608,23 +16256,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/typescript-eslint/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript-eslint/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typescript-eslint/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -16635,22 +16266,6 @@
"node": ">= 4"
}
},
"node_modules/typescript-eslint/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -18390,7 +18005,6 @@
"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",
+2 -1
View File
@@ -81,7 +81,8 @@
"glob": "^12.0.0",
"node-domexception": "npm:empty@^0.10.1",
"prebuild-install": "npm:nop@1.0.0",
"cross-spawn": "^7.0.6"
"cross-spawn": "^7.0.6",
"minimatch": "^10.2.2"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -112,7 +112,6 @@ export function createMockConfig(
}),
isContextManagementEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
getExperimentalGemma: vi.fn().mockReturnValue(false),
...overrides,
} as unknown as Config;
@@ -1,247 +0,0 @@
/**
* @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<typeof import('@google/gemini-cli-core')>();
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<typeof getToolCallDataSchema>);
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<typeof getToolCallDataSchema>);
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<typeof performRestore>,
);
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;
beforeEach(() => {
vi.resetAllMocks();
listCommand = new ListCheckpointsCommand();
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 () => {
vi.mocked(fs.readdir).mockResolvedValue([
'not-a-checkpoint.txt',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
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'));
vi.mocked(fs.readdir).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 () => {
vi.mocked(fs.readdir).mockResolvedValue([
'cp1.json',
'cp2.json',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
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 () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue(['some.json'] as any);
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 () => {
vi.mocked(fs.readdir).mockRejectedValue(new Error('Readdir fail'));
const response = await listCommand.execute(context);
expect(response.data).toBe(
'An unexpected error occurred while listing checkpoints.',
);
});
});
@@ -217,78 +217,6 @@ 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({
+4 -17
View File
@@ -67,8 +67,6 @@ export async function getMcpServersFromConfig(
return filteredResult;
}
const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000;
async function testMCPConnection(
serverName: string,
config: MCPServerConfig,
@@ -129,22 +127,11 @@ async function testMCPConnection(
}
try {
// 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 });
// Attempt actual MCP connection with short timeout
await client.connect(transport, { timeout: 5000 }); // 5s timeout
// 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,
);
}
// Test basic MCP protocol by pinging the server
await client.ping();
await client.close();
return MCPServerStatus.CONNECTED;
+89 -160
View File
@@ -21,6 +21,8 @@ import {
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
generalistProfile,
type ContextManagementConfig,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -231,45 +233,6 @@ 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'];
@@ -294,7 +257,7 @@ describe('parseArguments', () => {
const settings = createTestMergedSettings();
settings.experimental.worktrees = false;
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
const mockConsoleError = vi
@@ -309,6 +272,9 @@ describe('parseArguments', () => {
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
});
});
@@ -340,7 +306,7 @@ describe('parseArguments', () => {
async ({ argv }) => {
process.argv = argv;
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -357,6 +323,9 @@ describe('parseArguments', () => {
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
@@ -593,7 +562,7 @@ describe('parseArguments', () => {
async ({ argv }) => {
process.argv = argv;
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -610,6 +579,9 @@ describe('parseArguments', () => {
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
),
);
mockExit.mockRestore();
mockConsoleError.mockRestore();
},
);
@@ -634,7 +606,7 @@ describe('parseArguments', () => {
it('should reject invalid --approval-mode values', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -653,6 +625,10 @@ 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 () => {
@@ -804,100 +780,6 @@ 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 = [
@@ -990,14 +872,16 @@ describe('loadCliConfig', () => {
});
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
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();
});
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.stubEnv(
'GEMINI_CLI_IDE_WORKSPACE_PATH',
['/project/folderA', '/nonexistent/restricted/folder'].join(
@@ -1011,6 +895,8 @@ 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 () => {
@@ -2331,6 +2217,51 @@ 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<ContextManagementConfig> = {
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);
});
});
@@ -3124,18 +3055,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
});
it('should load experimental.gemma setting from merged settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
gemma: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should handle partial gemmaModelRouter settings', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
@@ -3294,7 +3213,7 @@ describe('Output format', () => {
it('should error on invalid --output-format argument', async () => {
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
@@ -3312,6 +3231,10 @@ describe('Output format', () => {
expect.stringContaining('Invalid values:'),
);
expect(mockConsoleError).toHaveBeenCalled();
mockExit.mockRestore();
mockConsoleError.mockRestore();
debugErrorSpy.mockRestore();
});
});
@@ -3342,11 +3265,13 @@ describe('parseArguments with positional prompt', () => {
'test prompt',
];
vi.spyOn(process, 'exit').mockImplementation(() => {
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
vi.spyOn(console, 'error').mockImplementation(() => {});
const mockConsoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const debugErrorSpy = vi
.spyOn(debugLogger, 'error')
.mockImplementation(() => {});
@@ -3360,6 +3285,10 @@ 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 () => {
+10 -47
View File
@@ -48,6 +48,7 @@ import {
type HookEventName,
type OutputFormat,
detectIdeFromEnv,
generalistProfile,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -96,7 +97,6 @@ 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,10 +238,6 @@ 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';
}
@@ -411,25 +407,6 @@ 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:
@@ -841,16 +818,9 @@ export async function loadCliConfig(
);
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const rawModel =
const specifiedModel =
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
@@ -934,19 +904,14 @@ export async function loadCliConfig(
}
}
// 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 = {
enabled: !!profileSelector,
...(useGeneralistProfile ? generalistProfile : {}),
...(useContextManagement ? settings?.contextManagement : {}),
enabled: useContextManagement || useGeneralistProfile,
};
return new Config({
@@ -970,7 +935,6 @@ export async function loadCliConfig(
worktreeSettings,
coreTools: settings.tools?.core || undefined,
experimentalContextManagementConfig: profileSelector,
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
policyEngineConfig,
policyUpdateConfirmationRequest,
@@ -1036,7 +1000,6 @@ export async function loadCliConfig(
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
@@ -1048,7 +1011,6 @@ export async function loadCliConfig(
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
experimentalGemma: settings.experimental?.gemma,
contextManagement,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration:
@@ -1082,6 +1044,7 @@ export async function loadCliConfig(
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
autoRouting: settings.model?.autoRouting,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
@@ -13,7 +13,6 @@ import {
settingsZodSchema,
} from './settings-validation.js';
import { z } from 'zod';
import { type Settings } from './settingsSchema.js';
describe('settings-validation', () => {
describe('validateSettings', () => {
@@ -299,117 +298,6 @@ 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', () => {
+6 -21
View File
@@ -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 buildPrimitiveSchema('string');
return z.string();
}
if (def.type === 'number') return buildPrimitiveSchema('number');
if (def.type === 'boolean') return buildPrimitiveSchema('boolean');
if (def.type === 'number') return z.number();
if (def.type === 'boolean') return z.boolean();
if (def.type === 'array') {
if (def.items) {
@@ -133,22 +133,9 @@ function buildPrimitiveSchema(
case 'string':
return z.string();
case 'number':
return z.preprocess((val) => {
if (typeof val === 'string' && val.trim() !== '') {
const num = Number(val);
if (!isNaN(num)) return num;
}
return val;
}, z.number());
return z.number();
case '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());
return z.boolean();
default:
return z.unknown();
}
@@ -173,9 +160,7 @@ function buildZodSchemaFromDefinition(
if (definition.ref === 'TelemetrySettings') {
const objectSchema = REF_SCHEMAS['TelemetrySettings'];
if (objectSchema) {
return z
.union([buildPrimitiveSchema('boolean'), objectSchema])
.optional();
return z.union([z.boolean(), objectSchema]).optional();
}
}
-131
View File
@@ -479,137 +479,6 @@ 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 = {
+16 -43
View File
@@ -673,9 +673,7 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
const storage = new Storage(workspaceDir);
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
const load = (
filePath: string,
): { settings: Settings; rawSettings: Settings; rawJson?: string } => {
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8');
@@ -691,19 +689,14 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
path: filePath,
severity: 'error',
});
return { settings: {}, rawSettings: {} };
return { settings: {} };
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const settingsObject = rawSettings as Record<string, unknown>;
// Expand environment variables
const expandedSettings = resolveEnvVarsInObject(
settingsObject as Settings,
);
// Validate settings structure with Zod after environment variable expansion
const validationResult = validateSettings(expandedSettings);
// Validate settings structure with Zod
const validationResult = validateSettings(settingsObject);
if (!validationResult.success && validationResult.error) {
const errorMessage = formatValidationError(
validationResult.error,
@@ -714,22 +707,9 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
path: filePath,
severity: 'warning',
});
return {
settings: expandedSettings,
rawSettings: 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,
};
return { settings: settingsObject as Settings, rawJson: content };
}
} catch (error: unknown) {
settingsErrors.push({
@@ -738,40 +718,33 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
severity: 'error',
});
}
return { settings: {}, rawSettings: {} };
return { settings: {} };
};
const systemResult = load(systemSettingsPath);
const systemDefaultsResult = load(systemDefaultsPath);
const userResult = load(USER_SETTINGS_PATH);
let workspaceResult: {
settings: Settings;
rawSettings: Settings;
rawJson?: string;
} = {
let workspaceResult: { settings: Settings; rawJson?: string } = {
settings: {} as Settings,
rawSettings: {} as Settings,
rawJson: undefined,
};
if (!storage.isWorkspaceHomeDir()) {
workspaceResult = load(workspaceSettingsPath);
}
const systemOriginalSettings = structuredClone(systemResult.rawSettings);
const systemOriginalSettings = structuredClone(systemResult.settings);
const systemDefaultsOriginalSettings = structuredClone(
systemDefaultsResult.rawSettings,
);
const userOriginalSettings = structuredClone(userResult.rawSettings);
const workspaceOriginalSettings = structuredClone(
workspaceResult.rawSettings,
systemDefaultsResult.settings,
);
const userOriginalSettings = structuredClone(userResult.settings);
const workspaceOriginalSettings = structuredClone(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;
// Environment variables for runtime use
systemSettings = resolveEnvVarsInObject(systemResult.settings);
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
userSettings = resolveEnvVarsInObject(userResult.settings);
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
// Support legacy theme names
if (userSettings.ui?.theme === 'VS') {
+40 -115
View File
@@ -1112,6 +1112,46 @@ const SETTINGS_SCHEMA = {
description: 'Skip the next speaker check.',
showInDialog: true,
},
autoRouting: {
type: 'object',
label: 'Auto Routing',
category: 'Model',
requiresRestart: false,
default: {},
description: 'Settings for automatic model routing.',
showInDialog: false,
properties: {
bestEffortPro: {
type: 'boolean',
label: 'Best Effort Pro',
category: 'Model',
requiresRestart: false,
default: false,
description:
'Always prefer the Pro model unless it is unavailable (e.g., due to timeouts or quota), ignoring other routing hints.',
showInDialog: true,
},
proTimeoutMinutes: {
type: 'number',
label: 'Pro Timeout (Minutes)',
category: 'Model',
requiresRestart: false,
default: 5,
description:
'If a Pro request takes longer than this many minutes, it will be marked as temporarily unavailable and fallback to Flash.',
showInDialog: true,
},
proTimeoutFallbackDurationMinutes: {
type: 'number',
label: 'Pro Timeout Fallback Duration (Minutes)',
category: 'Model',
requiresRestart: false,
default: 60,
description: 'How long to route to Flash after Pro times out.',
showInDialog: true,
},
},
},
},
},
@@ -1667,19 +1707,6 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
items: { type: 'string' },
},
confirmationRequired: {
type: 'array',
label: 'Confirmation Required',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description: oneLine`
Tool names that always require user confirmation.
Takes precedence over allowed tools and core tool allowlists.
`,
showInDialog: false,
items: { type: 'string' },
},
exclude: {
type: 'array',
label: 'Exclude Tools',
@@ -2052,96 +2079,6 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
gemma: {
type: 'boolean',
label: 'Gemma Models',
category: 'Experimental',
requiresRestart: true,
default: false,
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',
@@ -2388,17 +2325,6 @@ 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',
@@ -3289,7 +3215,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
secondary: { type: 'string' },
link: { type: 'string' },
accent: { type: 'string' },
response: { type: 'string' },
},
},
background: {
+4 -63
View File
@@ -20,7 +20,6 @@ import {
validateDnsResolutionOrder,
startInteractiveUI,
getNodeMemoryArgs,
resolveSessionId,
} from './gemini.js';
import {
loadCliConfig,
@@ -48,13 +47,10 @@ 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(),
@@ -552,7 +548,6 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
useWriteTodos: undefined,
resume: undefined,
sessionId: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
@@ -612,7 +607,6 @@ describe('gemini.tsx main function kitty protocol', () => {
screenReader: undefined,
useWriteTodos: undefined,
resume: undefined,
sessionId: undefined,
listSessions: undefined,
deleteSession: undefined,
outputFormat: undefined,
@@ -828,6 +822,7 @@ 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(
() =>
({
@@ -884,6 +879,9 @@ 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(
() =>
({
@@ -1058,63 +1056,6 @@ 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<typeof SessionSelector>,
);
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<typeof SessionSelector>,
);
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;
+18 -39
View File
@@ -85,6 +85,7 @@ 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';
@@ -190,38 +191,21 @@ ${reason.stack}`
});
}
export async function resolveSessionId(
resumeArg: string | undefined,
sessionIdArg?: string | undefined,
): Promise<{
export async function resolveSessionId(resumeArg: string | undefined): Promise<{
sessionId: string;
resumedSessionData?: ResumedSessionData;
}> {
if (!resumeArg && !sessionIdArg) {
if (!resumeArg) {
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 sessionSelector.resolveSession(
resumeArg!,
);
const { sessionData, sessionPath } = await new SessionSelector(
storage,
).resolveSession(resumeArg);
return {
sessionId: sessionData.sessionId,
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
@@ -335,10 +319,7 @@ export async function main() {
const argv = await argvPromise;
const { sessionId, resumedSessionData } = await resolveSessionId(
argv.resume,
argv.sessionId,
);
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -377,14 +358,15 @@ export async function main() {
const isDebugMode = cliConfig.isDebugMode(argv);
const consolePatcher = new ConsolePatcher({
stderr: argv.isCommand ? false : true,
interactive: isHeadlessMode() && !argv.isCommand ? false : true,
stderr: true,
interactive: isHeadlessMode() ? false : true,
debugMode: isDebugMode,
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
});
consolePatcher.patch();
registerCleanup(consolePatcher.cleanup);
dns.setDefaultResultOrder(
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
@@ -410,7 +392,6 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
});
adminControlsListner.setConfig(partialConfig);
// Refresh auth to fetch remote admin settings from CCPA and before entering
@@ -568,12 +549,6 @@ 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);
@@ -669,7 +644,7 @@ export async function main() {
let input = config.getQuestion();
const useAlternateBuffer = shouldEnterAlternateScreen(
config.getUseAlternateBuffer(),
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await rawStartupWarningsPromise;
@@ -811,16 +786,20 @@ export function initializeOutputListenersAndFlush() {
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
if (payload.type === 'error' || payload.type === 'warn') {
writeToStderr(payload.content + '\n');
writeToStderr(payload.content);
} else {
writeToStderr(payload.content + '\n');
writeToStdout(payload.content);
}
});
}
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
writeToStderr(payload.message + '\n');
if (payload.severity === 'error' || payload.severity === 'warning') {
writeToStderr(payload.message);
} else {
writeToStdout(payload.message);
}
});
}
}
+1 -126
View File
@@ -68,13 +68,6 @@ 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),
@@ -157,10 +150,6 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
};
});
vi.mock('./acp/acpClient.js', () => ({
runAcpClient: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./zed-integration/zedIntegration.js', () => ({
runZedIntegration: vi.fn().mockResolvedValue(undefined),
}));
@@ -307,120 +296,6 @@ 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> = {}): Config {
return {
isInteractive: vi.fn(() => false),
@@ -431,7 +306,6 @@ describe('gemini.tsx main function cleanup', () => {
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
getExperimentalGemma: vi.fn(() => false),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
@@ -444,6 +318,7 @@ 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'),
@@ -170,7 +170,6 @@ describe('BuiltinCommandLoader', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
@@ -397,7 +396,6 @@ describe('BuiltinCommandLoader profile', () => {
getAllSkills: vi.fn().mockReturnValue([]),
isAdminEnabled: vi.fn().mockReturnValue(true),
}),
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
getContentGeneratorConfig: vi.fn().mockReturnValue({
authType: 'other',
}),
@@ -62,7 +62,6 @@ 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
@@ -228,7 +227,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
vimCommand,
setupGithubCommand,
terminalSetupCommand,
...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []),
...(this.config?.getContentGeneratorConfig()?.authType ===
AuthType.LOGIN_WITH_GOOGLE
? [upgradeCommand]
@@ -89,7 +89,6 @@ describe('ShellProcessor', () => {
getPolicyEngine: vi.fn().mockReturnValue({
check: mockPolicyEngineCheck,
}),
getExperimentalGemma: vi.fn().mockReturnValue(false),
get config() {
return this as unknown as Config;
},
+3 -1
View File
@@ -124,6 +124,9 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getBestEffortProEnabled: vi.fn().mockResolvedValue(false),
getProTimeoutMinutes: vi.fn().mockResolvedValue(5),
getProTimeoutFallbackDurationMinutes: vi.fn().mockResolvedValue(60),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
@@ -168,7 +171,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getExperimentalGemma: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
-3
View File
@@ -552,8 +552,6 @@ 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(),
@@ -600,7 +598,6 @@ 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';
-24
View File
@@ -103,7 +103,6 @@ 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 {
@@ -313,7 +312,6 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [shellModeActive, setShellModeActive] = useState(false);
const [isVoiceModeEnabled, setVoiceModeEnabled] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
const [historyRemountKey, setHistoryRemountKey] = useState(0);
@@ -948,12 +946,6 @@ 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>(
@@ -977,7 +969,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openVoiceModelDialog,
openAgentConfigDialog,
openPermissionsDialog,
quit: (messages: HistoryItem[]) => {
@@ -990,7 +981,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
},
setDebugMessage,
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev),
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
@@ -1016,7 +1006,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
openSettingsDialog,
openSessionBrowser,
openModelDialog,
openVoiceModelDialog,
openAgentConfigDialog,
setQuittingMessages,
setDebugMessage,
@@ -2202,7 +2191,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isVoiceModelDialogOpen ||
isAgentConfigDialogOpen ||
isPermissionsDialogOpen ||
isAuthenticating ||
@@ -2460,7 +2448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
@@ -2481,7 +2468,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingGeminiHistoryItems,
thought,
isInputActive,
isVoiceModeEnabled,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
@@ -2573,7 +2559,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
isSettingsDialogOpen,
isSessionBrowserOpen,
isModelDialogOpen,
isVoiceModelDialogOpen,
isAgentConfigDialogOpen,
selectedAgentName,
selectedAgentDisplayName,
@@ -2594,7 +2579,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
pendingGeminiHistoryItems,
thought,
isInputActive,
isVoiceModeEnabled,
isResuming,
shouldShowIdePrompt,
isFolderTrustDialogOpen,
@@ -2687,8 +2671,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openVoiceModelDialog,
closeVoiceModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
@@ -2769,9 +2751,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setAccountSuspensionInfo(null);
setAuthState(AuthState.Updating);
},
setVoiceModeEnabled: (value: boolean) => {
setVoiceModeEnabled(value);
},
}),
[
handleThemeSelect,
@@ -2785,8 +2764,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
exitPrivacyNotice,
closeSettingsDialog,
closeModelDialog,
openVoiceModelDialog,
closeVoiceModelDialog,
openAgentConfigDialog,
closeAgentConfigDialog,
openPermissionsDialog,
@@ -2830,7 +2807,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
setVoiceModeEnabled,
],
);
-2
View File
@@ -72,7 +72,6 @@ export interface CommandContext {
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
/** Toggles a special display mode. */
toggleCorgiMode: () => void;
toggleVoiceMode: () => void;
toggleDebugProfiler: () => void;
toggleVimEnabled: () => Promise<boolean>;
reloadCommands: () => void;
@@ -126,7 +125,6 @@ export interface OpenDialogActionReturn {
| 'settings'
| 'sessionBrowser'
| 'model'
| 'voice-model'
| 'agentConfig'
| 'permissions';
}
@@ -1,30 +0,0 @@
/**
* @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',
}),
},
],
};
@@ -25,7 +25,6 @@ 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';
@@ -239,9 +238,6 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.isVoiceModelDialogOpen) {
return <VoiceModelDialog onClose={uiActions.closeVoiceModelDialog} />;
}
if (
uiState.isAgentConfigDialogOpen &&
uiState.selectedAgentName &&
@@ -9,41 +9,12 @@ 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,
@@ -446,7 +417,6 @@ describe('InputPrompt', () => {
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
}),
getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -4955,383 +4925,6 @@ 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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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(
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
{
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 {
+16 -53
View File
@@ -56,7 +56,6 @@ import {
debugLogger,
type Config,
} from '@google/gemini-cli-core';
import { useVoiceMode } from '../hooks/useVoiceMode.js';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -160,6 +159,7 @@ 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,7 +238,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setEmbeddedShellFocused,
setShortcutsHelpVisible,
toggleCleanUiDetailsVisible,
setVoiceModeEnabled,
} = useUIActions();
const {
terminalWidth,
@@ -247,7 +246,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundTasks,
backgroundTaskHeight,
shortcutsHelpVisible,
isVoiceModeEnabled,
} = useUIState();
const [suppressCompletion, setSuppressCompletion] = useState(false);
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
@@ -265,7 +263,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetEscapeState();
if (buffer.text.length > 0) {
buffer.setText('');
resetTurnBaseline();
resetCompletionState();
} else if (history.length > 0) {
onSubmit('/rewind');
@@ -284,16 +281,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const hasUserNavigatedSuggestions = useRef(false);
const listRef = useRef<ScrollableListRef<ScrollableItem>>(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('');
@@ -400,7 +387,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// 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();
@@ -412,7 +398,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellModeActive,
shellHistory,
resetReverseSearchCompletionState,
resetTurnBaseline,
],
);
@@ -662,8 +647,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (handleVoiceInput(key)) return true;
// Determine if this keypress is a history navigation command
const isHistoryUp =
!shellModeActive &&
@@ -890,9 +873,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
resetTurnBaseline();
return true;
}
if (keyMatchers[Command.ESCAPE](key)) {
const cancelSearch = (
setActive: (active: boolean) => void,
@@ -1377,7 +1360,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
backgroundTaskHeight,
streamingState,
handleEscPress,
resetTurnBaseline,
registerPlainTabPress,
resetPlainTabPress,
toggleCleanUiDetailsVisible,
@@ -1387,9 +1369,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers,
isHelpDismissKey,
settings,
handleVoiceInput,
],
);
useKeypress(handleInput, {
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
priority: true,
@@ -1810,39 +1792,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
{isRecording && (
<Box flexDirection="row" marginBottom={0}>
<Text color={theme.status.success}>🎙 Listening...</Text>
</Box>
)}
{isVoiceModeEnabled && !isRecording && (
<Box flexDirection="row" marginBottom={0}>
<Text color={theme.text.secondary}>
&gt; Voice mode:{' '}
{(settings.experimental.voice?.activationMode ??
'push-to-talk') === 'push-to-talk'
? 'Hold Space to record'
: 'Space to start/stop recording'}{' '}
(Esc to exit)
</Text>
</Box>
)}
{buffer.text.length === 0 && !isRecording ? (
!isVoiceModeEnabled && placeholder ? (
showCursor ? (
<Text
terminalCursorFocus={showCursor}
terminalCursorPosition={0}
>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>
{placeholder.slice(1)}
</Text>
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text
terminalCursorFocus={showCursor}
terminalCursorPosition={0}
>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>
{placeholder.slice(1)}
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : null
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
<Box
flexDirection="column"
@@ -65,7 +65,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getExperimentalGemma: () => boolean;
getLastRetrievedQuota: () =>
| {
buckets: Array<{
@@ -86,7 +85,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getExperimentalGemma: () => false,
getLastRetrievedQuota: () => ({ buckets: [] }),
getSessionId: () => 'test-session-id',
};
+4 -23
View File
@@ -19,8 +19,6 @@ import {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
ModelSlashCommandEvent,
logModelSlashCommand,
getDisplayString,
@@ -224,9 +222,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}
// --- LEGACY PATH ---
const showGemmaModels = config?.getExperimentalGemma() ?? false;
const options = [
const list = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
@@ -244,21 +240,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (showGemmaModels) {
options.push(
{
value: GEMMA_4_31B_IT_MODEL,
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
key: GEMMA_4_31B_IT_MODEL,
},
{
value: GEMMA_4_26B_A4B_IT_MODEL,
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
key: GEMMA_4_26B_A4B_IT_MODEL,
},
);
}
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
@@ -289,15 +270,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
});
}
options.unshift(...previewOptions);
list.unshift(...previewOptions);
}
if (!hasAccessToProModel) {
// Filter out all Pro models for free tier
return options.filter((option) => !isProModel(option.value));
return list.filter((option) => !isProModel(option.value));
}
return options;
return list;
}, [
shouldShowPreviewModels,
useGemini31,
@@ -86,7 +86,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getProjectTempDir: () => '/tmp/test',
},
getSessionId: () => 'default-session-id',
getExperimentalGemma: () => false,
...overrides,
}) as Config;
@@ -44,7 +44,7 @@ enum TerminalKeys {
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
BACKSPACE = '\u0008',
BACKSPACE = '\x7f',
CTRL_P = '\u0010',
CTRL_N = '\u000E',
}
@@ -1,236 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { isBinaryAvailable } from '@google/gemini-cli-core';
import {
WhisperModelManager,
type WhisperModelProgress,
} from '@google/gemini-cli-core';
import { CliSpinner } from './CliSpinner.js';
interface VoiceModelDialogProps {
onClose: () => 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<DialogView>('backend');
const [downloadProgress, setDownloadProgress] =
useState<WhisperModelProgress | null>(null);
const [error, setError] = useState<string | null>(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 (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>
{view === 'backend'
? 'Select Voice Transcription Backend'
: 'Select Whisper Model'}
</Text>
{error && (
<Box marginTop={1}>
<Text color={theme.status.error}>{error}</Text>
</Box>
)}
{downloadProgress ? (
<Box marginTop={1} flexDirection="column">
<Box>
<Text>Downloading {downloadProgress.modelName}... </Text>
<CliSpinner />
<Text> {Math.round(downloadProgress.percentage * 100)}%</Text>
</Box>
</Box>
) : (
<Box marginTop={1}>
{view === 'backend' ? (
<DescriptiveRadioButtonSelect
items={backendOptions}
onSelect={handleBackendSelect}
initialIndex={currentBackend === 'whisper' ? 1 : 0}
showNumbers={true}
/>
) : (
<DescriptiveRadioButtonSelect
items={whisperOptions}
onSelect={handleWhisperModelSelect}
initialIndex={whisperOptions.findIndex(
(o) => o.value === currentWhisperModel,
)}
showNumbers={true}
/>
)}
</Box>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{view === 'whisper-models'
? '(Press Esc to go back)'
: '(Press Esc to close)'}
</Text>
</Box>
</Box>
);
}
@@ -1,56 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { HintMessage } from './HintMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('HintMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal hint message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('💡');
expect(output).toContain('Steering Hint: Try this instead');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<HintMessage text="Try this instead" />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('💡');
expect(lines[0]).toContain('Steering Hint: Try this instead');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -19,9 +19,7 @@ export const HintMessage: React.FC<HintMessageProps> = ({ text }) => {
const prefix = '💡 ';
const prefixWidth = prefix.length;
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
return (
<HalfLinePaddedBox
@@ -7,7 +7,6 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserMessage } from './UserMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
// Mock the commandUtils to control isSlashCommand behavior
vi.mock('../../utils/commandUtils.js', () => ({
@@ -15,11 +14,6 @@ vi.mock('../../utils/commandUtils.js', () => ({
}));
describe('UserMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal user message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
@@ -66,32 +60,4 @@ describe('UserMessage', () => {
expect(output).toMatchSnapshot();
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserMessage text="Hello Gemini" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
// There should be empty lines above and below the message due to marginY={1}.
// lastFrame() returns the full buffer, so we can check for leading/trailing newlines or empty lines.
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('> Hello Gemini');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -27,9 +27,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
const textColor = isSlashCommand ? theme.text.accent : theme.text.primary;
@@ -1,54 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { UserShellMessage } from './UserShellMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
describe('UserShellMessage', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('renders normal shell message with correct prefix', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80 },
);
const output = lastFrame();
expect(output).toContain('$ ls -la');
unmount();
});
describe('with NO_COLOR set', () => {
beforeEach(() => {
vi.stubEnv('NO_COLOR', '1');
});
it('uses margins instead of background blocks when NO_COLOR is set', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<UserShellMessage text="ls -la" width={80} />,
{ width: 80, config: makeFakeConfig({ useBackgroundColor: true }) },
);
const output = lastFrame();
// In NO_COLOR mode, the block characters (▄/▀) should NOT be present.
expect(output).not.toContain('▄');
expect(output).not.toContain('▀');
const lines = output.split('\n').filter((l) => l.trim() !== '');
expect(lines).toHaveLength(1);
expect(lines[0]).toContain('$ ls -la');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -20,9 +20,7 @@ export const UserShellMessage: React.FC<UserShellMessageProps> = ({
width,
}) => {
const config = useConfig();
const useBackgroundColorSetting = config.getUseBackgroundColor();
const useBackgroundColor =
useBackgroundColorSetting && !!theme.background.message;
const useBackgroundColor = config.getUseBackgroundColor();
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
@@ -1,7 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`HintMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
💡 Steering Hint: Try this instead
"
`;
@@ -28,9 +28,3 @@ exports[`UserMessage > transforms image paths in user message 1`] = `
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`UserMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
> Hello Gemini
"
`;
@@ -1,7 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserShellMessage > with NO_COLOR set > uses margins instead of background blocks when NO_COLOR is set 1`] = `
"
$ ls -la
"
`;
@@ -24,7 +24,7 @@ enum TerminalKeys {
LEFT_ARROW = '\u001B[D',
RIGHT_ARROW = '\u001B[C',
ESCAPE = '\u001B',
BACKSPACE = '\u0008',
BACKSPACE = '\x7f',
CTRL_L = '\u000C',
}
@@ -9,7 +9,17 @@ 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 { vi, afterAll, beforeAll, type Mock } from 'vitest';
import type { Mock } from 'vitest';
import {
vi,
afterAll,
beforeAll,
describe,
it,
expect,
beforeEach,
afterEach,
} from 'vitest';
import {
useKeypressContext,
ESC_TIMEOUT,
@@ -431,6 +441,80 @@ 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([
{
@@ -651,8 +651,20 @@ function* emitKeys(
// tab
name = 'tab';
alt = escaped;
} else if (ch === '\b' || ch === '\x7f') {
// backspace or ctrl+h
} 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
name = 'backspace';
alt = escaped;
} else if (ch === ESC) {
@@ -41,8 +41,6 @@ export interface UIActions {
exitPrivacyNotice: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
openVoiceModelDialog: () => void;
closeVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
displayName: string,
@@ -95,7 +93,6 @@ export interface UIActions {
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
getPreferredEditor: () => EditorType | undefined;
clearAccountSuspension: () => void;
setVoiceModeEnabled: (value: boolean) => void;
}
export const UIActionsContext = createContext<UIActions | null>(null);
@@ -112,7 +112,6 @@ export interface UIState {
isSettingsDialogOpen: boolean;
isSessionBrowserOpen: boolean;
isModelDialogOpen: boolean;
isVoiceModelDialogOpen: boolean;
isAgentConfigDialogOpen: boolean;
selectedAgentName?: string;
selectedAgentDisplayName?: string;
@@ -133,7 +132,6 @@ export interface UIState {
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
isInputActive: boolean;
isVoiceModeEnabled: boolean;
isResuming: boolean;
shouldShowIdePrompt: boolean;
isFolderTrustDialogOpen: boolean;
@@ -205,13 +205,11 @@ 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(),
@@ -72,7 +72,6 @@ interface SlashCommandProcessorActions {
openSettingsDialog: () => void;
openSessionBrowser: () => void;
openModelDialog: () => void;
openVoiceModelDialog: () => void;
openAgentConfigDialog: (
name: string,
displayName: string,
@@ -82,7 +81,6 @@ interface SlashCommandProcessorActions {
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
toggleVoiceMode: () => void;
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
@@ -234,7 +232,6 @@ export const useSlashCommandProcessor = (
pendingItem,
setPendingItem,
toggleCorgiMode: actions.toggleCorgiMode,
toggleVoiceMode: actions.toggleVoiceMode,
toggleDebugProfiler: actions.toggleDebugProfiler,
toggleVimEnabled,
reloadCommands,
@@ -506,9 +503,6 @@ 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<string, unknown>;
-429
View File
@@ -1,429 +0,0 @@
/**
* @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<Command, (key: Key) => 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<NodeJS.Timeout | null>(null);
const recorderRef = useRef<AudioRecorder | null>(null);
const transcriptionServiceRef = useRef<TranscriptionProvider | null>(null);
const turnBaselineRef = useRef<string | null>(null);
const pttStateRef = useRef<'idle' | 'possible-hold' | 'recording'>('idle');
const pttTimerRef = useRef<NodeJS.Timeout | null>(null);
const disconnectTimerRef = useRef<NodeJS.Timeout | null>(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;
},
};
}
@@ -1,31 +0,0 @@
/**
* @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,
};
};
+3 -8
View File
@@ -97,7 +97,6 @@ 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',
@@ -408,7 +407,9 @@ 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.VOICE_MODE_PTT, [new KeyBinding('space')]],
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
[Command.START_RECORDING, [new KeyBinding('f6')]],
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -423,10 +424,6 @@ 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 {
@@ -541,7 +538,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.RESTART_APP,
Command.SUSPEND_APP,
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
Command.VOICE_MODE_PTT,
],
},
{
@@ -662,7 +658,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[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.',
@@ -43,6 +43,5 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
removeComponent: () => {},
toggleBackgroundTasks: () => {},
toggleShortcutsHelp: () => {},
toggleVoiceMode: () => {},
};
}
@@ -15,25 +15,6 @@ 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());
@@ -171,68 +152,4 @@ 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');
});
});
});
+2 -21
View File
@@ -6,12 +6,7 @@
import latestVersion from 'latest-version';
import semver from 'semver';
import {
getPackageJson,
debugLogger,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
import { getPackageJson, debugLogger } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -70,7 +65,6 @@ export async function checkForUpdates(
}
const { name, version: currentVersion } = packageJson;
const currentChannel = getChannelFromVersion(currentVersion);
const isNightly = currentVersion.includes('nightly');
if (isNightly) {
@@ -96,21 +90,8 @@ export async function checkForUpdates(
}
} else {
const latestUpdate = await latestVersion(name);
if (!latestUpdate) {
return null;
}
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)) {
if (latestUpdate && semver.gt(latestUpdate, currentVersion)) {
const message = `Gemini CLI update available! ${currentVersion}${latestUpdate}`;
const type = semver.diff(latestUpdate, currentVersion) || undefined;
return {
+1 -92
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach } from 'vitest';
import { ActivityLogger, type NetworkLog } from './activityLogger.js';
import type { ConsoleLogPayload } from '@google/gemini-cli-core';
@@ -132,95 +132,4 @@ 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;
}
});
});
+8 -21
View File
@@ -302,31 +302,18 @@ export class ActivityLogger extends EventEmitter {
return originalFetch(input, init);
const id = Math.random().toString(36).substring(7);
const method = (init?.method || 'GET').toUpperCase();
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 ?? {});
const newInit = { ...init };
const headers = new Headers(init?.headers || {});
headers.set(ACTIVITY_ID_HEADER, id);
const newInit = {
...init,
method,
headers,
};
newInit.headers = headers;
let reqBody = '';
const body = newInit.body;
if (body) {
if (typeof body === 'string') reqBody = body;
else if (body instanceof URLSearchParams) reqBody = body.toString();
if (init?.body) {
if (typeof init.body === 'string') reqBody = init.body;
else if (init.body instanceof URLSearchParams)
reqBody = init.body.toString();
}
this.requestStartTimes.set(id, Date.now());
@@ -301,7 +301,7 @@ describe('handleAutoUpdate', () => {
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
message:
'Automatic update failed. Please try updating manually:\n\nnpm i -g @google/gemini-cli@2.0.0',
'Automatic update failed. Please try updating manually. (command: npm 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)\n\nnpm i -g @google/gemini-cli@2.0.0',
'Automatic update failed. Please try updating manually. (error: Spawn error)',
});
});
@@ -334,8 +334,7 @@ describe('handleAutoUpdate', () => {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
current: '1.0.0-nightly.0',
latest: '2.0.0-nightly.1',
latest: '2.0.0-nightly',
},
};
mockGetInstallationInfo.mockReturnValue({
@@ -357,26 +356,6 @@ describe('handleAutoUpdate', () => {
);
});
it('should NOT update if target is less stable than current (defense-in-depth)', async () => {
mockUpdateInfo = {
...mockUpdateInfo,
update: {
...mockUpdateInfo.update,
current: '1.0.0',
latest: '1.1.0-nightly.1',
},
};
mockGetInstallationInfo.mockReturnValue({
updateCommand: 'npm i -g @google/gemini-cli@latest',
isGlobal: false,
packageManager: PackageManager.NPM,
});
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should emit "update-success" when the update process succeeds', async () => {
await new Promise<void>((resolve) => {
mockGetInstallationInfo.mockReturnValue({
@@ -456,15 +435,13 @@ describe('setUpdateHandler', () => {
});
it('should handle update-failed event', () => {
updateEventEmitter.emit('update-failed', {
message: 'Failed message with command',
});
updateEventEmitter.emit('update-failed', { message: 'Failed' });
expect(setUpdateInfo).toHaveBeenCalledWith(null);
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed message with command',
text: 'Automatic update failed. Please try updating manually',
},
expect.any(Number),
);
+5 -30
View File
@@ -11,11 +11,7 @@ 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,
getChannelFromVersion,
RELEASE_CHANNEL_STABILITY,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
let _updateInProgress = false;
@@ -126,25 +122,6 @@ 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(
@@ -171,7 +148,7 @@ export function handleAutoUpdate(
});
} else {
updateEventEmitter.emit('update-failed', {
message: `Automatic update failed. Please try updating manually:\n\n${updateCommand}`,
message: `Automatic update failed. Please try updating manually. (command: ${updateCommand})`,
});
}
});
@@ -179,7 +156,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})\n\n${updateCommand}`,
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
});
});
return updateProcess;
@@ -207,14 +184,12 @@ export function setUpdateHandler(
}, 60000);
};
const handleUpdateFailed = (data?: { message: string }) => {
const handleUpdateFailed = () => {
setUpdateInfo(null);
addItem(
{
type: MessageType.ERROR,
text:
data?.message ||
`Automatic update failed. Please try updating manually`,
text: `Automatic update failed. Please try updating manually`,
},
Date.now(),
);
-59
View File
@@ -719,65 +719,6 @@ 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<typeof spawn>;
}
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<typeof spawn>;
});
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' },
+10 -22
View File
@@ -55,8 +55,6 @@ export async function start_sandbox(
});
patcher.patch();
let stopProxy: (() => void) | undefined = undefined;
try {
if (config.command === 'sandbox-exec') {
// disallow BUILD_SANDBOX
@@ -190,18 +188,17 @@ export async function start_sandbox(
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
const stopProxy = () => {
debugLogger.log('stopping proxy ...');
if (proxyProcess?.pid) {
try {
process.kill(-proxyProcess.pid, 'SIGTERM');
} catch {
// ignore
}
process.kill(-proxyProcess.pid, 'SIGTERM');
}
};
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
@@ -749,18 +746,15 @@ export async function start_sandbox(
detached: true,
});
// install handlers to stop proxy on exit/signal
stopProxy = () => {
const stopProxy = () => {
debugLogger.log('stopping proxy container ...');
try {
spawnSync(command, ['rm', '-f', SANDBOX_PROXY_NAME], {
stdio: 'ignore',
});
} catch {
// ignore
}
execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
};
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
@@ -812,12 +806,6 @@ export async function start_sandbox(
});
});
} finally {
if (stopProxy) {
stopProxy();
process.off('exit', stopProxy);
process.off('SIGINT', stopProxy);
process.off('SIGTERM', stopProxy);
}
patcher.cleanup();
}
}
@@ -69,7 +69,6 @@ describe('Session Cleanup (Refactored)', () => {
},
getSessionId: () => 'current123',
getDebugMode: () => false,
getExperimentalGemma: () => false,
initialize: async () => {},
...overrides,
} as unknown as Config;
@@ -47,47 +47,6 @@ 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();
-30
View File
@@ -408,36 +408,6 @@ 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<boolean> {
const chatsDir = path.join(this.storage.getProjectTempDir(), 'chats');
const files = await fs.readdir(chatsDir).catch(() => []);
// The filename format is `session-<TIMESTAMP>-<ID_SLICE(0,8)>.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.
*/
@@ -220,22 +220,5 @@ 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,
});
});
});
});
@@ -96,9 +96,7 @@ const folderTrustCheck: WarningCheck = {
if (isHeadlessMode()) {
throw new FatalUntrustedWorkspaceError(
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, ' +
'set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode. ' +
'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments',
'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode.',
);
}
-2
View File
@@ -12,8 +12,6 @@ export {
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
DEFAULT_GEMINI_EMBEDDING_MODEL,
GEMMA_4_31B_IT_MODEL,
GEMMA_4_26B_A4B_IT_MODEL,
} from './src/config/models.js';
export {
serializeTerminalToObject,
-1
View File
@@ -56,7 +56,6 @@
"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",
@@ -779,8 +779,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return {
result: finalResult || 'Task completed.',
terminate_reason: terminateReason,
turn_count: turnCounter,
duration_ms: Date.now() - startTime,
};
}
@@ -788,8 +786,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
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.
@@ -830,8 +826,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return {
result: finalResult,
terminate_reason: terminateReason,
turn_count: turnCounter,
duration_ms: Date.now() - startTime,
};
}
}
@@ -846,8 +840,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return {
result: finalResult,
terminate_reason: terminateReason,
turn_count: turnCounter,
duration_ms: Date.now() - startTime,
};
}
@@ -74,14 +74,12 @@ describe('SkillExtractionAgent', () => {
expect(query).toContain(existingSkillsSummary);
expect(query).toContain(sessionIndex);
expect(query).toContain('optional workflow hint');
expect(query).toContain(
'workflow hints alone is never enough evidence for a reusable skill.',
'The summary is a user-intent summary, not a workflow summary.',
);
expect(query).toContain(
'Session summaries describe user intent; optional workflow hints describe likely procedural traces.',
'The session summaries describe user intent, not workflow details.',
);
expect(query).toContain('Use workflow hints for routing');
expect(query).toContain(
'Only write a skill if the evidence shows a durable, recurring workflow',
);
@@ -303,11 +303,10 @@ export const SkillExtractionAgent = (
'# Session Index',
'',
'Below is an index of past conversation sessions. Each line shows:',
'[NEW] or [old] status, a 1-line user-intent summary, optional workflow hint, message count, and the file path.',
'[NEW] or [old] status, a 1-line summary, message count, and the file path.',
'',
'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.',
'The summary is a user-intent summary, not a workflow summary.',
'Matching summary text 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)',
@@ -327,7 +326,7 @@ export const SkillExtractionAgent = (
return {
systemPrompt: buildSystemPrompt(skillsDir),
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.`,
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.`,
};
},
runConfig: {
-2
View File
@@ -36,8 +36,6 @@ export enum AgentTerminateMode {
export interface OutputObject {
result: string;
terminate_reason: AgentTerminateMode;
turn_count?: number;
duration_ms?: number;
}
/**
@@ -8,13 +8,15 @@ export type ModelId = string;
type TerminalUnavailabilityReason = 'quota' | 'capacity';
export type TurnUnavailabilityReason = 'retry_once_per_turn';
export type TemporaryUnavailabilityReason = 'timeout';
export type UnavailabilityReason =
| TerminalUnavailabilityReason
| TurnUnavailabilityReason
| TemporaryUnavailabilityReason
| 'unknown';
export type ModelHealthStatus = 'terminal' | 'sticky_retry';
export type ModelHealthStatus = 'terminal' | 'sticky_retry' | 'temporary';
type HealthState =
| { status: 'terminal'; reason: TerminalUnavailabilityReason }
@@ -22,6 +24,11 @@ type HealthState =
status: 'sticky_retry';
reason: TurnUnavailabilityReason;
consumed: boolean;
}
| {
status: 'temporary';
reason: TemporaryUnavailabilityReason;
untilMs: number;
};
export interface ModelAvailabilitySnapshot {
@@ -48,6 +55,18 @@ export class ModelAvailabilityService {
});
}
markTemporarilyUnavailable(
model: ModelId,
reason: TemporaryUnavailabilityReason,
durationMs: number,
) {
this.setState(model, {
status: 'temporary',
reason,
untilMs: Date.now() + durationMs,
});
}
markHealthy(model: ModelId) {
this.clearState(model);
}
@@ -95,6 +114,15 @@ export class ModelAvailabilityService {
return { available: false, reason: state.reason };
}
if (state.status === 'temporary') {
if (Date.now() < state.untilMs) {
return { available: false, reason: state.reason };
} else {
this.clearState(model);
return { available: true };
}
}
return { available: true };
}
@@ -56,7 +56,7 @@ describe('policyCatalog', () => {
it('marks preview transients as sticky retries', () => {
const [previewPolicy] = getModelPolicyChain({ previewEnabled: true });
expect(previewPolicy.model).toBe(PREVIEW_GEMINI_MODEL);
expect(previewPolicy.stateTransitions.transient).toBe('sticky_retry');
expect(previewPolicy.stateTransitions.transient).toBe('terminal');
});
it('applies default actions and state transitions for unspecified kinds', () => {
@@ -50,7 +50,7 @@ export const SILENT_ACTIONS: ModelPolicyActionMap = {
const DEFAULT_STATE: ModelPolicyStateMap = {
terminal: 'terminal',
transient: 'sticky_retry',
transient: 'terminal',
not_found: 'terminal',
unknown: 'terminal',
};
@@ -644,28 +644,6 @@ 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 = {
-22
View File
@@ -273,16 +273,6 @@ 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;
}
@@ -582,15 +572,3 @@ 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
);
}
+1 -45
View File
@@ -960,11 +960,8 @@ describe('Server Config (config.ts)', () => {
});
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
await config.getExperimentsAsync();
await vi.waitFor(() => {
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
expect(config.getModel()).toBe(PREVIEW_GEMINI_FLASH_MODEL);
});
it('should NOT switch to flash model if user has Pro access and model is auto', async () => {
@@ -3645,47 +3642,6 @@ describe('Config JIT Initialization', () => {
expect(config.isAutoMemoryEnabled()).toBe(true);
});
it('should return true when experimentalGemma is true', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: true,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(true);
});
it('should return false when experimentalGemma is false', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalGemma: false,
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should return false when experimentalGemma is not provided', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
};
config = new Config(params);
expect(config.getExperimentalGemma()).toBe(false);
});
it('should be independent of experimentalMemoryV2', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
+39 -35
View File
@@ -679,6 +679,11 @@ export interface ConfigParameters {
policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest;
output?: OutputSettings;
gemmaModelRouter?: GemmaModelRouterSettings;
autoRouting?: {
bestEffortPro?: boolean;
proTimeoutMinutes?: number;
proTimeoutFallbackDurationMinutes?: number;
};
adk?: ADKSettings;
disableModelRouterForAuth?: AuthType[];
continueOnFailedApiCall?: boolean;
@@ -691,7 +696,6 @@ export interface ConfigParameters {
ptyInfo?: string;
disableYoloMode?: boolean;
disableAlwaysAllow?: boolean;
voiceMode?: boolean;
rawOutput?: boolean;
acceptRawOutputRisk?: boolean;
dynamicModelConfiguration?: boolean;
@@ -712,7 +716,6 @@ export interface ConfigParameters {
autoDistillation?: boolean;
experimentalMemoryV2?: boolean;
experimentalAutoMemory?: boolean;
experimentalGemma?: boolean;
experimentalContextManagementConfig?: string;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
@@ -958,15 +961,16 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryV2: boolean;
private readonly experimentalAutoMemory: boolean;
private readonly experimentalGemma: boolean;
private readonly experimentalContextManagementConfig?: string;
private readonly memoryBoundaryMarkers: readonly string[];
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 autoRoutingBestEffortPro: boolean;
private readonly autoRoutingProTimeoutMinutes: number;
private readonly autoRoutingProTimeoutFallbackDurationMinutes: number;
private readonly modelSteering: boolean;
private memoryContextManager?: MemoryContextManager;
private readonly contextManagement: ContextManagementConfig;
@@ -1119,9 +1123,13 @@ 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.autoRoutingBestEffortPro = params.autoRouting?.bestEffortPro ?? false;
this.autoRoutingProTimeoutMinutes =
params.autoRouting?.proTimeoutMinutes ?? 5;
this.autoRoutingProTimeoutFallbackDurationMinutes =
params.autoRouting?.proTimeoutFallbackDurationMinutes ?? 60;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
@@ -1179,7 +1187,6 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryV2 = params.experimentalMemoryV2 ?? true;
this.experimentalAutoMemory = params.experimentalAutoMemory ?? false;
this.experimentalGemma = params.experimentalGemma ?? false;
this.experimentalContextManagementConfig =
params.experimentalContextManagementConfig;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
@@ -1596,12 +1603,8 @@ export class Config implements McpContext, AgentLoopContext {
return undefined;
});
const [experiments] = await Promise.all([
this.experimentsPromise,
quotaPromise.catch((e) => {
debugLogger.error('Failed to fetch user quota', e);
}),
]);
// Fetch experiments and update timeouts before continuing initialization
const experiments = await this.experimentsPromise;
const requestTimeoutMs = this.getRequestTimeoutMs();
if (requestTimeoutMs !== undefined) {
@@ -1611,6 +1614,8 @@ export class Config implements McpContext, AgentLoopContext {
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
await quotaPromise;
const authType = this.contentGeneratorConfig.authType;
if (
authType === AuthType.USE_GEMINI ||
@@ -1631,21 +1636,16 @@ export class Config implements McpContext, AgentLoopContext {
const adminControlsEnabled =
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
false;
try {
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
} catch (e) {
debugLogger.error('Failed to fetch admin controls', e);
}
const adminControls = await fetchAdminControls(
codeAssistServer,
this.getRemoteAdminSettings(),
adminControlsEnabled,
(newSettings: AdminControlsSettings) => {
this.setRemoteAdminSettings(newSettings);
coreEvents.emitAdminSettingsChanged();
},
);
this.setRemoteAdminSettings(adminControls);
if ((await this.getProModelNoAccess()) && isAutoModel(this.model)) {
this.setModel(PREVIEW_GEMINI_FLASH_MODEL);
@@ -2527,10 +2527,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalAutoMemory;
}
getExperimentalGemma(): boolean {
return this.experimentalGemma;
}
getExperimentalContextManagementConfig(): string | undefined {
return this.experimentalContextManagementConfig;
}
@@ -2972,10 +2968,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.planEnabled;
}
isVoiceModeEnabled(): boolean {
return this.voiceMode;
}
isTrackerEnabled(): boolean {
return this.trackerEnabled;
}
@@ -3165,6 +3157,18 @@ export class Config implements McpContext, AgentLoopContext {
return flag?.boolValue ?? true;
}
async getBestEffortProEnabled(): Promise<boolean> {
return this.autoRoutingBestEffortPro;
}
async getProTimeoutMinutes(): Promise<number> {
return this.autoRoutingProTimeoutMinutes;
}
async getProTimeoutFallbackDurationMinutes(): Promise<number> {
return this.autoRoutingProTimeoutFallbackDurationMinutes;
}
/**
* Returns the resolved complexity threshold for routing.
* If a remote threshold is provided and within range (0-100), it is returned.

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