mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
38 Commits
debug-fix
...
issue-24028
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e1180dd6b | |||
| 7ab932c8bf | |||
| c2e5b28e94 | |||
| c7d5fcff95 | |||
| 6d99113936 | |||
| fbd8aaad57 | |||
| 9e7c924f7b | |||
| 4edd7c745c | |||
| 12a77da45c | |||
| 8cfebb9e31 | |||
| f8603e990b | |||
| 59b2dea0e5 | |||
| c841070582 | |||
| 4b8d5e7624 | |||
| 7a3f7c383e | |||
| 8e1cecac06 | |||
| b0ffa3b51e | |||
| 58a57b72ae | |||
| 54b7586106 | |||
| c17400b830 | |||
| 47bca39eeb | |||
| 07506dcd0d | |||
| 6cc0b1b136 | |||
| 820a4e3c92 | |||
| 7d08f84305 | |||
| 31337b9269 | |||
| b1a50a58af | |||
| 71f313b51a | |||
| 98aca28985 | |||
| 2de81902c3 | |||
| 1cdfeb6633 | |||
| 743518e1b8 | |||
| 42587de733 | |||
| a5b030b424 | |||
| 2e0641c83b | |||
| 048bf6e514 | |||
| ed469e492b | |||
| cfd7541ad4 |
@@ -3,7 +3,10 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true
|
||||
"gemma": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -337,6 +337,7 @@ jobs:
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Only run always passes behavioral tests.
|
||||
EVAL_SUITE_TYPE: 'behavioral'
|
||||
|
||||
@@ -28,6 +28,8 @@ jobs:
|
||||
- name: 'Run Docs Audit with Gemini'
|
||||
id: 'run_gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
|
||||
@@ -66,6 +66,7 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: 'true'
|
||||
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
|
||||
|
||||
@@ -4,26 +4,39 @@ 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
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
brain:
|
||||
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
|
||||
@@ -37,9 +50,172 @@ jobs:
|
||||
- name: 'Build Gemini CLI'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Download Previous Metrics'
|
||||
- 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: 'metrics-before'
|
||||
path: 'tools/gemini-cli-bot/history/'
|
||||
continue-on-error: true
|
||||
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
|
||||
|
||||
@@ -34,23 +34,12 @@ jobs:
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Collect Metrics'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npm run metrics'
|
||||
|
||||
- name: 'Archive Metrics'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'metrics-before'
|
||||
path: 'metrics-before.csv'
|
||||
|
||||
- name: 'Run Reflex Processes'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ -d "tools/gemini-cli-bot/processes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/processes/scripts)" ]; then
|
||||
for script in tools/gemini-cli-bot/processes/scripts/*.ts; do
|
||||
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
|
||||
|
||||
@@ -70,6 +70,8 @@ jobs:
|
||||
- name: 'Generate Changelog with Gemini'
|
||||
if: "steps.validate_version.outputs.CONTINUE == 'true'"
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
USER node
|
||||
|
||||
# install gemini-cli and clean up
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
COPY --chown=node:node packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY --chown=node:node packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
|
||||
@@ -18,6 +18,24 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.39.0 - 2026-04-23
|
||||
|
||||
- **Skill Management:** Added a new `/memory` inbox command for reviewing and
|
||||
patching skills extracted during sessions
|
||||
([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by
|
||||
@SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
by @SandyTao520).
|
||||
- **Improved Transparency:** Plan Mode now requires confirmation for skill
|
||||
activation and allows plan inspection
|
||||
([#24946](https://github.com/google-gemini/gemini-cli/pull/24946),
|
||||
[#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by
|
||||
@ruomengz).
|
||||
- **Architecture & Reliability:** Introduced a decoupled `ContextManager`
|
||||
architecture and resolved several critical memory leaks and PTY exhaustion
|
||||
issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by
|
||||
@joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
by @spencer426).
|
||||
|
||||
## Announcements: v0.38.0 - 2026-04-14
|
||||
|
||||
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
|
||||
|
||||
+243
-255
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.38.2
|
||||
# Latest stable release: v0.39.0
|
||||
|
||||
Released: April 17, 2026
|
||||
Released: April 23, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,264 +11,252 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
|
||||
to provide better session structure and narrative continuity in long-running
|
||||
tasks.
|
||||
- **Context Compression Service:** Implemented a dedicated service for advanced
|
||||
context management, efficiently distilling conversation history to preserve
|
||||
focus and tokens.
|
||||
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
|
||||
solve rendering flicker, along with selective topic expansion and improved
|
||||
tool confirmation layouts.
|
||||
- **Context-Aware Policy Approvals:** Users can now grant persistent,
|
||||
context-aware approvals for tools, significantly reducing manual confirmation
|
||||
overhead for trusted workflows.
|
||||
- **Background Process Monitoring:** New tools for monitoring and inspecting
|
||||
background shell processes, providing better visibility into asynchronous
|
||||
tasks.
|
||||
- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
|
||||
and patch skills extracted during agent sessions, streamlining the continuous
|
||||
learning workflow.
|
||||
- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
|
||||
requiring user confirmation for skill activation and allowing users to view
|
||||
the full content of generated plans.
|
||||
- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
|
||||
enabling agents to provide richer, more structured visual feedback during
|
||||
execution.
|
||||
- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
|
||||
`Sidecar` architecture to improve state management and session resilience.
|
||||
- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
|
||||
text in responses, ensuring full visibility into the agent's reasoning
|
||||
process.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
|
||||
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
|
||||
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
|
||||
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
|
||||
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
|
||||
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
|
||||
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
|
||||
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
|
||||
- Update README.md for links. by @g-samroberts in
|
||||
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
|
||||
- fix(core): ensure complete_task tool calls are recorded in chat history by
|
||||
@abhipatel12 in
|
||||
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
|
||||
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
|
||||
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
|
||||
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
|
||||
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
|
||||
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
|
||||
by @adamfweidman in
|
||||
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
|
||||
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
|
||||
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
|
||||
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
|
||||
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
|
||||
- fix(cli): ensure agent stops when all declinable tools are cancelled by
|
||||
@NTaylorMullen in
|
||||
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
|
||||
- fix(core): enhance sandbox usability and fix build error by @galz10 in
|
||||
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
|
||||
- Terminal Serializer Optimization by @jacob314 in
|
||||
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
|
||||
- Auto configure memory. by @jacob314 in
|
||||
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
|
||||
- Unused error variables in catch block are not allowed by @alisa-alisa in
|
||||
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
|
||||
- feat(core): add background memory service for skill extraction by @SandyTao520
|
||||
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
|
||||
- feat: implement high-signal PR regression check for evaluations by
|
||||
@alisa-alisa in
|
||||
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
|
||||
- Fix shell output display by @jacob314 in
|
||||
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
|
||||
- fix(ui): resolve unwanted vertical spacing around various tool output
|
||||
treatments by @jwhelangoog in
|
||||
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
|
||||
- revert(cli): bring back input box and footer visibility in copy mode by
|
||||
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
|
||||
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
|
||||
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
|
||||
- feat(cli): support default values for environment variables by @ruomengz in
|
||||
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
|
||||
- Implement background process monitoring and inspection tools by @cocosheng-g
|
||||
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
|
||||
- docs(browser-agent): update stale browser agent documentation by @gsquared94
|
||||
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
|
||||
- fix: enable browser_agent in integration tests and add localhost fixture tests
|
||||
by @gsquared94 in
|
||||
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
|
||||
- fix(browser): handle computer-use model detection for analyze_screenshot by
|
||||
@gsquared94 in
|
||||
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
|
||||
- feat(core): Land ContextCompressionService by @joshualitt in
|
||||
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
|
||||
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
@SandyTao520 in
|
||||
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
|
||||
- Update ink version to 6.6.7 by @jacob314 in
|
||||
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
|
||||
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- Fix crash when vim editor is not found in PATH on Windows by
|
||||
@Nagajyothi-tammisetti in
|
||||
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
|
||||
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
|
||||
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
|
||||
- Enable 'Other' option for yesno question type by @ruomengz in
|
||||
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
|
||||
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
|
||||
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
|
||||
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
|
||||
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
|
||||
- feat(core): implement context-aware persistent policy approvals by @jerop in
|
||||
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
|
||||
- docs: move agent disabling instructions and update remote agent status by
|
||||
@jackwotherspoon in
|
||||
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
|
||||
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
|
||||
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
|
||||
- fix(core): unsafe type assertions in Core File System #19712 by
|
||||
@aniketsaurav18 in
|
||||
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
|
||||
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
|
||||
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
|
||||
- Changelog for v0.36.0 by @gemini-cli-robot in
|
||||
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
|
||||
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
|
||||
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
|
||||
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
|
||||
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
|
||||
- fix(ui): fixed table styling by @devr0306 in
|
||||
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
|
||||
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
|
||||
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
|
||||
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
|
||||
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
|
||||
- docs: clarify release coordination by @scidomino in
|
||||
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
|
||||
- fix(core): remove broken PowerShell translation and fix native \_\_write in
|
||||
Windows sandbox by @scidomino in
|
||||
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
|
||||
- Add instructions for how to start react in prod and force react to prod mode
|
||||
by @jacob314 in
|
||||
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
|
||||
- feat(cli): minimalist sandbox status labels by @galz10 in
|
||||
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
|
||||
- Feat/browser agent metrics by @kunal-10-cloud in
|
||||
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
|
||||
- test: fix Windows CI execution and resolve exposed platform failures by
|
||||
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
|
||||
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
|
||||
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
|
||||
- show color by @jacob314 in
|
||||
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
|
||||
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
|
||||
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
|
||||
- fix(core): inject skill system instructions into subagent prompts if activated
|
||||
by @abhipatel12 in
|
||||
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
|
||||
- fix(core): improve windows sandbox reliability and fix integration tests by
|
||||
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
|
||||
- fix(core): ensure sandbox approvals are correctly persisted and matched for
|
||||
proactive expansions by @galz10 in
|
||||
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
|
||||
- feat(cli) Scrollbar for input prompt by @jacob314 in
|
||||
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
|
||||
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
|
||||
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
|
||||
- Fix restoration of topic headers. by @gundermanc in
|
||||
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
|
||||
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
|
||||
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
|
||||
- fix(core): ensure global temp directory is always in sandbox allowed paths by
|
||||
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
|
||||
- fix(core): detect uninitialized lines by @jacob314 in
|
||||
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
|
||||
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
|
||||
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
|
||||
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
|
||||
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
|
||||
- feat(acp): add support for `/about` command by @sripasg in
|
||||
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
|
||||
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
|
||||
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
|
||||
- split context by @jacob314 in
|
||||
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
|
||||
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
|
||||
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
|
||||
- Fix issue where topic headers can be posted back to back by @gundermanc in
|
||||
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
|
||||
- fix(core): handle partial llm_request in BeforeModel hook override by
|
||||
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
|
||||
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
|
||||
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
|
||||
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
|
||||
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
|
||||
- fix(browser): remove premature browser cleanup after subagent invocation by
|
||||
@gsquared94 in
|
||||
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
|
||||
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
|
||||
@Abhijit-2592 in
|
||||
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
|
||||
- relax tool sandboxing overrides for plan mode to match defaults. by
|
||||
@DavidAPierce in
|
||||
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
|
||||
- fix(cli): respect global environment variable allowlist by @scidomino in
|
||||
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
|
||||
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
|
||||
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
|
||||
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
|
||||
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
|
||||
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
|
||||
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
|
||||
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
|
||||
- feat(cli): support selective topic expansion and click-to-expand by
|
||||
@Abhijit-2592 in
|
||||
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
|
||||
- temporarily disable sandbox integration test on windows by @ehedlund in
|
||||
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
|
||||
- Remove flakey test by @scidomino in
|
||||
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
|
||||
- Alisa/approve button by @alisa-alisa in
|
||||
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
|
||||
- feat(hooks): display hook system messages in UI by @mbleigh in
|
||||
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
|
||||
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
|
||||
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
|
||||
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
|
||||
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
|
||||
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
|
||||
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
|
||||
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
|
||||
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
|
||||
- feat(acp): add /help command by @sripasg in
|
||||
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
|
||||
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
|
||||
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
|
||||
- Improve sandbox error matching and caching by @DavidAPierce in
|
||||
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
|
||||
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
|
||||
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
|
||||
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
|
||||
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
|
||||
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
@gundermanc in
|
||||
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
|
||||
- refactor(cli): remove duplication in interactive shell awaiting input hint by
|
||||
@JayadityaGit in
|
||||
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
|
||||
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
|
||||
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
|
||||
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
|
||||
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
|
||||
- fix(cli): always show shell command description or actual command by @jacob314
|
||||
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
|
||||
- Added flag for ept size and increased default size by @devr0306 in
|
||||
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
|
||||
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
|
||||
@Anjaligarhwal in
|
||||
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
|
||||
- fix(cli): switch default back to terminalBuffer=false and fix regressions
|
||||
introduced for that mode by @jacob314 in
|
||||
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
|
||||
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
|
||||
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
|
||||
- fix: isolate concurrent browser agent instances by @gsquared94 in
|
||||
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
|
||||
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
|
||||
version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
|
||||
- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
|
||||
version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.2...v0.39.0
|
||||
|
||||
+168
-18
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.39.0-preview.0
|
||||
# Preview release: v0.40.0-preview.3
|
||||
|
||||
Released: April 14, 2026
|
||||
Released: April 24, 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,24 +13,174 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
|
||||
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
|
||||
turn limits for codebase investigator.
|
||||
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
|
||||
reviewing extracted skills and added skill patching support, enhancing agent
|
||||
learning and persistence.
|
||||
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
|
||||
performance integration test harnesses and generalized evaluation
|
||||
infrastructure for better suite organization.
|
||||
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
|
||||
macOS, enforced read-only security for async git worktree resolution, and
|
||||
optimized Windows sandbox initialization.
|
||||
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
|
||||
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
|
||||
issues for a smoother terminal experience.
|
||||
- **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.
|
||||
|
||||
## 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
|
||||
@@ -254,4 +404,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.39.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.40.0-preview.3
|
||||
|
||||
@@ -470,7 +470,8 @@ associated plan files and task trackers.
|
||||
|
||||
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
|
||||
- **Configuration:** You can customize this behavior via the `/settings` command
|
||||
(search for **Session Retention**) or in your `settings.json` file. See
|
||||
(search for **Enable Session Cleanup** or **Keep chat history**) or in your
|
||||
`settings.json` file. See
|
||||
[session retention](../cli/session-management.md#session-retention) for more
|
||||
details.
|
||||
|
||||
|
||||
+165
-48
@@ -31,6 +31,53 @@ The benefits of sandboxing include:
|
||||
- **Safety**: Reduce risk when working with untrusted code or experimental
|
||||
commands.
|
||||
|
||||
## Quickstart
|
||||
|
||||
You can enable sandboxing using a command flag, environment variable, or
|
||||
configuration file.
|
||||
|
||||
### Using the command flag
|
||||
|
||||
```bash
|
||||
gemini -s -p "analyze the code structure"
|
||||
```
|
||||
|
||||
### Using an environment variable
|
||||
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
### Configuring via settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable sandboxing using one of the following methods (in order of precedence):
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
## Sandboxing methods
|
||||
|
||||
Your ideal method of sandboxing may differ depending on your platform and your
|
||||
@@ -43,12 +90,92 @@ Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
|
||||
### 2. Container-based (Docker/Podman)
|
||||
|
||||
Cross-platform sandboxing with complete process isolation.
|
||||
Cross-platform sandboxing with complete process isolation using container
|
||||
technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image.
|
||||
|
||||
**Note**: Requires building the sandbox image locally or using a published image
|
||||
from your organization's registry.
|
||||
**Prerequisites:**
|
||||
|
||||
- Docker or Podman must be installed and running on your system.
|
||||
|
||||
**How it works (Workspace directory):**
|
||||
|
||||
Inside the sandbox container, your current working directory is mounted at the
|
||||
**exact same absolute path** as it is on your host machine. For example, if you
|
||||
run the CLI from `/Users/you/project` on your host machine, the sandbox will
|
||||
mount your local project folder and operate within `/Users/you/project` inside
|
||||
the container. This allows the AI to seamlessly read and modify your project
|
||||
files while remaining isolated from the rest of your system.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
To enable Docker sandboxing, run Gemini CLI with the sandbox flag and specify
|
||||
Docker as the provider:
|
||||
|
||||
```bash
|
||||
# Using the environment variable (Recommended)
|
||||
export GEMINI_SANDBOX=docker
|
||||
gemini -p "build the project"
|
||||
|
||||
# Or configure it permanently in your settings.json
|
||||
# {"tools": {"sandbox": "docker"}}
|
||||
```
|
||||
|
||||
**Customizing the Sandbox Image:**
|
||||
|
||||
If your project requires specific dependencies, you can specify a custom image
|
||||
name or have Gemini CLI build one for you automatically. You can use any Docker
|
||||
or Podman image as your sandbox, provided it has standard shell utilities (like
|
||||
`bash`) available.
|
||||
|
||||
**Option A: Using an existing custom image (e.g., Artifact Registry)**
|
||||
|
||||
To configure a custom image that is hosted on a registry (or built locally),
|
||||
update your `settings.json` to use an object for the sandbox configuration, or
|
||||
set the `GEMINI_SANDBOX_IMAGE` environment variable.
|
||||
|
||||
_Example: Configuring via `settings.json`_
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": {
|
||||
"command": "docker",
|
||||
"image": "us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_Example: Configuring via environment variable_
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX_IMAGE="us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
```
|
||||
|
||||
**Option B: Building a local custom image automatically**
|
||||
|
||||
If you prefer to define your environment as code, you can provide a Dockerfile
|
||||
and Gemini CLI will build the image automatically.
|
||||
|
||||
1. Create a `.gemini/sandbox.Dockerfile` in your project root.
|
||||
2. Ensure you have the `gh` CLI installed and authenticated (if you are using
|
||||
the default `ghcr.io/google/gemini-cli` image as a base).
|
||||
3. Run your command with the `BUILD_SANDBOX` environment variable set:
|
||||
|
||||
```bash
|
||||
BUILD_SANDBOX=1 GEMINI_SANDBOX=docker gemini -p "run my custom build"
|
||||
```
|
||||
|
||||
### 3. Windows Native Sandbox (Windows only)
|
||||
|
||||
@@ -188,59 +315,49 @@ This mechanism ensures you don't have to manually re-run commands with more
|
||||
permissive sandbox settings, while still maintaining control over what the AI
|
||||
can access.
|
||||
|
||||
## Quickstart
|
||||
### Including files outside the workspace
|
||||
|
||||
By default, the sandbox only has access to the current project workspace. If you
|
||||
need the sandbox to have permission to operate on certain files or directories
|
||||
from the local file system outside of the project workspace, you can mount them
|
||||
using the `SANDBOX_MOUNTS` environment variable.
|
||||
|
||||
Provide a comma-separated list of mount definitions in the format
|
||||
`from:to:opts`. If `to` is omitted, it defaults to the same path as `from`. If
|
||||
`opts` is omitted, it defaults to `ro` (read-only). Note that the `from` path
|
||||
must be an absolute path.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
# Enable sandboxing with command flag
|
||||
gemini -s -p "analyze the code structure"
|
||||
export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro"
|
||||
```
|
||||
|
||||
**Use environment variable**
|
||||
## Running inside a Docker container
|
||||
|
||||
**macOS/Linux**
|
||||
If you are running Gemini CLI itself from within an official or custom Docker
|
||||
container and want to enable sandboxing, you must share the host's Docker socket
|
||||
and ensure your workspace paths align.
|
||||
|
||||
1. **Mount the Docker socket**: Map `/var/run/docker.sock` so the CLI can spawn
|
||||
sibling sandbox containers via the host's Docker daemon.
|
||||
2. **Align workspace paths**: The path to your workspace inside the container
|
||||
must exactly match the absolute path on the host. Because the sandbox
|
||||
container is spawned by the host's Docker daemon, it resolves volume mounts
|
||||
against the host file system.
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /absolute/path/on/host/project:/absolute/path/on/host/project \
|
||||
-w /absolute/path/on/host/project \
|
||||
-e GEMINI_SANDBOX=docker \
|
||||
ghcr.io/google/gemini-cli:latest
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Configure in settings.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable sandboxing (in order of precedence)
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
### macOS Seatbelt profiles
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
## Advanced settings
|
||||
|
||||
### Custom sandbox flags
|
||||
|
||||
@@ -279,7 +396,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
$env:SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
```
|
||||
|
||||
## Linux UID/GID handling
|
||||
### Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these
|
||||
permissions with:
|
||||
|
||||
+19
-14
@@ -161,20 +161,25 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
|
||||
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
|
||||
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
|
||||
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
|
||||
### Skills
|
||||
|
||||
|
||||
@@ -1191,7 +1191,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1207,7 +1207,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1224,7 +1224,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1240,7 +1240,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1257,7 +1257,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1272,7 +1272,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1288,7 +1288,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1691,6 +1691,32 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.voiceMode`** (boolean):
|
||||
- **Description:** Enable experimental voice dictation and commands (/voice,
|
||||
/voice model).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.voice.activationMode`** (enum):
|
||||
- **Description:** How to trigger voice recording with the Space key.
|
||||
- **Default:** `"push-to-talk"`
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
- **`experimental.voice.whisperModel`** (enum):
|
||||
- **Description:** The Whisper model to use for local transcription.
|
||||
- **Default:** `"ggml-base.en.bin"`
|
||||
- **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`,
|
||||
`"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"`
|
||||
|
||||
- **`experimental.voice.stopGracePeriodMs`** (number):
|
||||
- **Description:** How long to wait for final transcription after stopping
|
||||
recording.
|
||||
- **Default:** `1000`
|
||||
|
||||
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
|
||||
- **Description:** Enable non-interactive agent sessions.
|
||||
- **Default:** `false`
|
||||
@@ -1820,6 +1846,12 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.stressTestProfile`** (boolean):
|
||||
- **Description:** Significantly lowers token limits to force early garbage
|
||||
collection and distillation for testing purposes.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.autoMemory`** (boolean):
|
||||
- **Description:** Automatically extract reusable skills from past sessions in
|
||||
the background. Review results with /memory inbox.
|
||||
|
||||
@@ -115,6 +115,7 @@ 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
|
||||
|
||||
|
||||
@@ -5,12 +5,78 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadConversationRecord,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
function findDir(base: string, name: string): string | null {
|
||||
if (!fs.existsSync(base)) return null;
|
||||
const files = fs.readdirSync(base);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(base, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
if (file === name) return fullPath;
|
||||
const found = findDir(fullPath, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadLatestSessionRecord(homeDir: string, sessionId: string) {
|
||||
const chatsDir = findDir(path.join(homeDir, '.gemini'), 'chats');
|
||||
if (!chatsDir) {
|
||||
throw new Error('Could not find chats directory for eval session logs');
|
||||
}
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.startsWith(SESSION_FILE_PREFIX) &&
|
||||
(file.endsWith('.json') || file.endsWith('.jsonl')),
|
||||
);
|
||||
|
||||
const matchingRecords = [];
|
||||
for (const file of candidates) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
const record = await loadConversationRecord(filePath);
|
||||
if (record?.sessionId === sessionId) {
|
||||
matchingRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
matchingRecords.sort(
|
||||
(a, b) => Date.parse(b.lastUpdated) - Date.parse(a.lastUpdated),
|
||||
);
|
||||
return matchingRecords[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForSessionScratchpad(
|
||||
homeDir: string,
|
||||
sessionId: string,
|
||||
timeoutMs = 30000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const record = await loadLatestSessionRecord(homeDir, sessionId);
|
||||
if (record?.memoryScratchpad) {
|
||||
return record;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return loadLatestSessionRecord(homeDir, sessionId);
|
||||
}
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
@@ -569,6 +635,103 @@ describe('save_memory', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2SessionScratchpad =
|
||||
'Session summary persists memory scratchpad for memory-saving sessions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2SessionScratchpad,
|
||||
sessionId: 'memory-scratchpad-eval',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'Across all my projects, I prefer Vitest over Jest for testing.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [{ text: 'Noted. What else should I keep in mind?' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this repo I was debugging a flaky API test earlier, but that was just transient context.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:00Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Understood. I will only save the durable preference.' },
|
||||
],
|
||||
timestamp: '2026-01-01T00:01:05Z',
|
||||
},
|
||||
],
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'Expected memoryV2 save flow to edit a markdown memory file',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await rig.run({
|
||||
args: ['--list-sessions'],
|
||||
approvalMode: 'yolo',
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const record = await waitForSessionScratchpad(
|
||||
rig.homeDir!,
|
||||
'memory-scratchpad-eval',
|
||||
);
|
||||
expect(
|
||||
record?.memoryScratchpad,
|
||||
'Expected the resumed session log to contain a memoryScratchpad after session summary generation',
|
||||
).toBeDefined();
|
||||
expect(record?.memoryScratchpad?.version).toBe(1);
|
||||
expect(
|
||||
record?.memoryScratchpad?.toolSequence?.some((toolName) =>
|
||||
['write_file', 'replace'].includes(toolName),
|
||||
),
|
||||
'Expected memoryScratchpad.toolSequence to include the markdown editing tool used for memory persistence',
|
||||
).toBe(true);
|
||||
expect(
|
||||
record?.memoryScratchpad?.touchedPaths?.length,
|
||||
'Expected memoryScratchpad to capture at least one touched path',
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
record?.memoryScratchpad?.workflowSummary,
|
||||
'Expected memoryScratchpad.workflowSummary to be populated',
|
||||
).toMatch(/write_file|replace/i);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesUserProject =
|
||||
'Agent routes personal-to-user project notes to user-project memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
|
||||
+630
-17
@@ -6,21 +6,30 @@
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
type MemoryScratchpad,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
import { ComponentRig, componentEvalTest } from './component-test-helper.js';
|
||||
import {
|
||||
average,
|
||||
averageNullable,
|
||||
countMatchingIds,
|
||||
roundStat,
|
||||
} from './statistics-helper.js';
|
||||
import { prepareWorkspace } from './test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
timestampOffsetMinutes: number;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
@@ -30,6 +39,81 @@ interface MessageRecord {
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface ExtractionRunSnapshot {
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
candidateSessions: SessionVersion[];
|
||||
processedSessions: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}
|
||||
|
||||
interface ExtractionOutcome {
|
||||
state: { runs: ExtractionRunSnapshot[] };
|
||||
skillsDir: string;
|
||||
skillBodies: string[];
|
||||
}
|
||||
|
||||
interface SkillQualitySignal {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ScratchpadRunMetrics {
|
||||
turnCount: number | null;
|
||||
durationMs: number | null;
|
||||
terminateReason: string | null;
|
||||
skillsCreated: number;
|
||||
candidateSessions: number;
|
||||
processedSessions: number;
|
||||
relevantReads: number;
|
||||
distractorReads: number;
|
||||
totalReads: number;
|
||||
recall: number;
|
||||
precision: number;
|
||||
signalScore: number;
|
||||
skillQualityScore: number;
|
||||
skillQualityMax: number;
|
||||
skillQualityRatio: number;
|
||||
missingQualitySignals: string[];
|
||||
}
|
||||
|
||||
interface ScratchpadStatsTrial {
|
||||
trial: number;
|
||||
baseline: ScratchpadRunMetrics;
|
||||
enhanced: ScratchpadRunMetrics;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsAggregate {
|
||||
turnCountAvg: number | null;
|
||||
durationMsAvg: number | null;
|
||||
recallAvg: number;
|
||||
precisionAvg: number;
|
||||
signalScoreAvg: number;
|
||||
relevantReadsAvg: number;
|
||||
distractorReadsAvg: number;
|
||||
skillsCreatedAvg: number;
|
||||
skillQualityScoreAvg: number;
|
||||
skillQualityRatioAvg: number;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsReport {
|
||||
generatedAt: string;
|
||||
trials: number;
|
||||
aggregate: {
|
||||
baseline: ScratchpadStatsAggregate;
|
||||
enhanced: ScratchpadStatsAggregate;
|
||||
};
|
||||
deltas: ScratchpadStatsAggregate;
|
||||
results: ScratchpadStatsTrial[];
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
@@ -68,6 +152,143 @@ function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
]);
|
||||
}
|
||||
|
||||
function padTurns(turns: string[]): string[] {
|
||||
if (turns.length >= 10) {
|
||||
return turns;
|
||||
}
|
||||
|
||||
const padded = [...turns];
|
||||
for (let i = turns.length; i < 10; i++) {
|
||||
padded.push(`${turns[i % turns.length]} (repeat ${i + 1})`);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
|
||||
function createScratchpad(
|
||||
workflowSummary: string,
|
||||
touchedPaths: string[],
|
||||
validationStatus: MemoryScratchpad['validationStatus'] = 'passed',
|
||||
): MemoryScratchpad {
|
||||
return {
|
||||
version: 1,
|
||||
workflowSummary,
|
||||
toolSequence: ['run_shell_command'],
|
||||
touchedPaths,
|
||||
validationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflowComparisonSessions(withScratchpad: boolean): {
|
||||
sessions: SeedSession[];
|
||||
relevantSessionIds: string[];
|
||||
distractorSessionIds: string[];
|
||||
} {
|
||||
const relevantWorkflowSummary =
|
||||
'run_shell_command -> run_shell_command | paths packages/cli/src/config/settings.ts, docs/settings.md | validated';
|
||||
|
||||
const relevantScratchpad = withScratchpad
|
||||
? createScratchpad(relevantWorkflowSummary, [
|
||||
'packages/cli/src/config/settings.ts',
|
||||
'docs/settings.md',
|
||||
])
|
||||
: undefined;
|
||||
|
||||
const sessions: SeedSession[] = [
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-a',
|
||||
summary: 'Prepare release notes for settings launch',
|
||||
timestampOffsetMinutes: 420,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'When we add a new setting, the durable workflow is to regenerate the settings docs instead of editing them by hand.',
|
||||
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
|
||||
'Skipping predocs leaves stale defaults in the generated docs.',
|
||||
'We verify the workflow by checking that both the schema output and docs update together.',
|
||||
'This exact command order is the recurring workflow we use for settings changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-b',
|
||||
summary: 'Investigate CI drift in generated config reference',
|
||||
timestampOffsetMinutes: 390,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'The config reference drift was fixed by rerunning the standard settings regeneration workflow.',
|
||||
'We again used npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
|
||||
'The recurring rule is never to hand-edit generated settings docs.',
|
||||
'The validation step is to confirm the schema artifact and docs changed together after regeneration.',
|
||||
'This is the same recurring workflow we use every time a setting changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-release-notes',
|
||||
summary: 'Prepare release notes for auth launch',
|
||||
timestampOffsetMinutes: 360,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This release-notes task was one-off and just needed manual wording updates.',
|
||||
'I edited CHANGELOG.md and docs/release-notes.md directly.',
|
||||
'There was no reusable command sequence here beyond proofreading the copy.',
|
||||
'This task should not become a standing workflow.',
|
||||
'Once the wording landed, we were done.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-ci-snapshots',
|
||||
summary: 'Investigate CI drift in auth snapshots',
|
||||
timestampOffsetMinutes: 330,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This auth snapshot issue was specific to a flaky test in CI.',
|
||||
'The only commands we ran were npm test -- auth and an isolated snapshot update.',
|
||||
'It was not the recurring settings-doc workflow.',
|
||||
'Once the flaky snapshot passed, there was no broader reusable procedure.',
|
||||
'Treat this as a one-off CI cleanup.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-onboarding-docs',
|
||||
summary: 'Refresh onboarding documentation copy',
|
||||
timestampOffsetMinutes: 300,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was just a docs wording cleanup in docs/onboarding.md.',
|
||||
'No command sequence was involved.',
|
||||
'We manually edited the copy and reviewed it.',
|
||||
'There is no recurring operational workflow to capture here.',
|
||||
'This should stay a one-off docs edit.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-deploy-copy',
|
||||
summary: 'Adjust deployment checklist wording',
|
||||
timestampOffsetMinutes: 270,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was a wording-only change to docs/deploy.md.',
|
||||
'We did not run a reusable command sequence.',
|
||||
'It should not become a skill.',
|
||||
'The edit was only for this deploy checklist cleanup.',
|
||||
'After the copy change, the task was complete.',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sessions,
|
||||
relevantSessionIds: [
|
||||
'hidden-settings-workflow-a',
|
||||
'hidden-settings-workflow-b',
|
||||
],
|
||||
distractorSessionIds: [
|
||||
'distractor-release-notes',
|
||||
'distractor-ci-snapshots',
|
||||
'distractor-onboarding-docs',
|
||||
'distractor-deploy-copy',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
@@ -78,9 +299,10 @@ async function seedSessions(
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const timestamp = new Date(
|
||||
const sessionTimestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
)
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
@@ -89,8 +311,9 @@ async function seedSessions(
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
memoryScratchpad: session.memoryScratchpad,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
|
||||
@@ -101,10 +324,9 @@ async function seedSessions(
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtractionAndReadState(config: Config): Promise<{
|
||||
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
|
||||
skillsDir: string;
|
||||
}> {
|
||||
async function runExtractionAndReadState(
|
||||
config: Config,
|
||||
): Promise<ExtractionOutcome> {
|
||||
await startMemoryService(config);
|
||||
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
@@ -113,7 +335,15 @@ async function runExtractionAndReadState(config: Config): Promise<{
|
||||
|
||||
const raw = await fsp.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
|
||||
runs?: Array<{
|
||||
sessionIds?: string[];
|
||||
skillsCreated?: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}>;
|
||||
};
|
||||
if (!Array.isArray(state.runs) || state.runs.length === 0) {
|
||||
throw new Error('Skill extraction finished without writing any run state');
|
||||
@@ -126,27 +356,292 @@ async function runExtractionAndReadState(config: Config): Promise<{
|
||||
skillsCreated: Array.isArray(run.skillsCreated)
|
||||
? run.skillsCreated
|
||||
: [],
|
||||
candidateSessions: Array.isArray(run.candidateSessions)
|
||||
? run.candidateSessions
|
||||
: [],
|
||||
processedSessions: Array.isArray(run.processedSessions)
|
||||
? run.processedSessions
|
||||
: [],
|
||||
turnCount:
|
||||
typeof run.turnCount === 'number' ? run.turnCount : undefined,
|
||||
durationMs:
|
||||
typeof run.durationMs === 'number' ? run.durationMs : undefined,
|
||||
terminateReason:
|
||||
typeof run.terminateReason === 'string'
|
||||
? run.terminateReason
|
||||
: undefined,
|
||||
})),
|
||||
},
|
||||
skillsDir,
|
||||
skillBodies: await readSkillBodies(skillsDir),
|
||||
};
|
||||
}
|
||||
|
||||
async function summarizeScratchpadRun(
|
||||
outcome: ExtractionOutcome,
|
||||
run: ExtractionRunSnapshot,
|
||||
scenario: ReturnType<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 });
|
||||
const skillDirs = entries.filter((entry) => entry.isDirectory());
|
||||
const bodies = await Promise.all(
|
||||
skillDirs.map((entry) =>
|
||||
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
|
||||
),
|
||||
);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
bodies.push(
|
||||
await fsp.readFile(
|
||||
path.join(skillsDir, entry.name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore incomplete skill directories so one bad artifact does not hide
|
||||
// valid skills created in the same eval run.
|
||||
}
|
||||
}
|
||||
return bodies;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function expectSuccessfulExtractionRun(run: ExtractionRunSnapshot): void {
|
||||
expect(run.turnCount).toBeGreaterThan(0);
|
||||
expect(run.turnCount).toBeLessThanOrEqual(30);
|
||||
expect(run.durationMs).toBeGreaterThan(0);
|
||||
expect(run.terminateReason).toBe('GOAL');
|
||||
}
|
||||
|
||||
function scoreSkillQuality(
|
||||
skillBodies: string[],
|
||||
signals: SkillQualitySignal[],
|
||||
): { score: number; maxScore: number; missing: string[] } {
|
||||
const combined = skillBodies.join('\n\n');
|
||||
const matched = signals.filter((signal) => signal.pattern.test(combined));
|
||||
|
||||
return {
|
||||
score: matched.length,
|
||||
maxScore: signals.length,
|
||||
missing: signals
|
||||
.filter((signal) => !signal.pattern.test(combined))
|
||||
.map((signal) => signal.label),
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'predocs command', pattern: /npm run predocs:settings/i },
|
||||
{ label: 'schema command', pattern: /npm run schema:settings/i },
|
||||
{ label: 'docs command', pattern: /npm run docs:settings/i },
|
||||
{ label: 'verification guidance', pattern: /verif(?:y|ication)/i },
|
||||
{
|
||||
label: 'generated docs warning or ordering constraint',
|
||||
pattern:
|
||||
/do not hand-edit|manual edits|exact command order|preserve.*order/i,
|
||||
},
|
||||
];
|
||||
|
||||
const DB_MIGRATION_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'db check command', pattern: /npm run db:check/i },
|
||||
{ label: 'db migrate command', pattern: /npm run db:migrate/i },
|
||||
{ label: 'db validate command', pattern: /npm run db:validate/i },
|
||||
{ label: 'rollback guidance', pattern: /npm run db:rollback|rollback/i },
|
||||
{
|
||||
label: 'ordering constraint',
|
||||
pattern: /check.*migrate.*validate|ordering is critical|mandatory/i,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
|
||||
@@ -158,6 +653,16 @@ const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function parseScratchpadStatsTrials(): number {
|
||||
const configured = Number.parseInt(
|
||||
process.env['SCRATCHPAD_STATS_TRIALS'] ?? '8',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 8;
|
||||
}
|
||||
|
||||
const SCRATCHPAD_STATS_TRIALS = parseScratchpadStatsTrials();
|
||||
|
||||
describe('Skill Extraction', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
@@ -264,15 +769,24 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run predocs:settings');
|
||||
expect(combinedSkills).toContain('npm run schema:settings');
|
||||
expect(combinedSkills).toContain('npm run docs:settings');
|
||||
expect(combinedSkills).toMatch(/Verification/i);
|
||||
expect(combinedSkills).toMatch(/verif(?:y|ication)/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
@@ -281,6 +795,96 @@ describe('Skill Extraction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'memory scratchpad improves repeated-workflow recall versus summary-only index',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 360000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
const baselineRelevantReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.relevantSessionIds,
|
||||
);
|
||||
const enhancedRelevantReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.relevantSessionIds,
|
||||
);
|
||||
const baselineDistractorReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.distractorSessionIds,
|
||||
);
|
||||
const enhancedDistractorReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.distractorSessionIds,
|
||||
);
|
||||
const baselineSignalScore =
|
||||
baselineRelevantReads - baselineDistractorReads;
|
||||
const enhancedSignalScore =
|
||||
enhancedRelevantReads - enhancedDistractorReads;
|
||||
|
||||
expect(enhancedRun.candidateSessions).toHaveLength(
|
||||
enhancedScenario.sessions.length,
|
||||
);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(2);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(
|
||||
baselineRelevantReads,
|
||||
);
|
||||
expect(enhancedDistractorReads).toBeLessThanOrEqual(
|
||||
baselineDistractorReads,
|
||||
);
|
||||
expect(enhancedSignalScore).toBeGreaterThan(baselineSignalScore);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['RUN_SCRATCHPAD_STATS'] === '1') {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'reports memory scratchpad retrieval statistics',
|
||||
timeout: Math.max(360000, SCRATCHPAD_STATS_TRIALS * 150000),
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const report = await runScratchpadStatsReport(SCRATCHPAD_STATS_TRIALS);
|
||||
const outputPath = await writeScratchpadStatsReport(report);
|
||||
|
||||
console.info(
|
||||
`Wrote scratchpad stats report to ${outputPath}\n${JSON.stringify(
|
||||
report.aggregate,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(report.results).toHaveLength(SCRATCHPAD_STATS_TRIALS);
|
||||
expect(report.aggregate.baseline.recallAvg).toBeGreaterThan(0);
|
||||
expect(report.aggregate.enhanced.recallAvg).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
it.skip('reports memory scratchpad retrieval statistics', () => {});
|
||||
}
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
@@ -330,15 +934,24 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
DB_MIGRATION_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run db:check');
|
||||
expect(combinedSkills).toContain('npm run db:migrate');
|
||||
expect(combinedSkills).toContain('npm run db:validate');
|
||||
expect(combinedSkills).toMatch(/rollback/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
+50
-8
@@ -62,11 +62,13 @@ describe('tracker_mode', () => {
|
||||
'Expected tracker_update_task tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const updateCall = toolLogs.find(
|
||||
const updateCalls = toolLogs.filter(
|
||||
(log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
const updateArgs = JSON.parse(updateCall!.toolRequest.args);
|
||||
expect(updateCalls.length).toBeGreaterThan(0);
|
||||
const updateArgs = JSON.parse(
|
||||
updateCalls[updateCalls.length - 1].toolRequest.args,
|
||||
);
|
||||
expect(updateArgs.status).toBe('closed');
|
||||
|
||||
const loginContent = fs.readFileSync(
|
||||
@@ -128,12 +130,52 @@ describe('tracker_mode', () => {
|
||||
prompt:
|
||||
'Where is my task tracker storage located? Please provide the absolute path in your response.',
|
||||
assert: async (rig, result) => {
|
||||
// The rig sets GEMINI_CLI_HOME to rig.homeDir
|
||||
const homeDir = rig.homeDir!;
|
||||
// The response should contain the dynamic path which includes the home directory
|
||||
// and follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toContain(homeDir);
|
||||
// The response should contain the dynamic path which follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should update the tracker in the same turn as the task completion to save turns',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
},
|
||||
files: FILES,
|
||||
prompt:
|
||||
'We have a bug in src/login.js: the password check is missing. Fix this bug. Then, create a new file src/auth.js that exports a simple verifyToken function. Please organize this into tasks and execute them.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
|
||||
await rig.waitForToolCall(TRACKER_UPDATE_TASK_TOOL_NAME);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Get the prompt ID of the fix for login.js
|
||||
const loginEditCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
(log.toolRequest.name === 'replace' ||
|
||||
log.toolRequest.name === 'write_file') &&
|
||||
log.toolRequest.args.includes('login.js'),
|
||||
);
|
||||
|
||||
expect(loginEditCalls.length).toBeGreaterThan(0);
|
||||
const loginEditPromptId =
|
||||
loginEditCalls[loginEditCalls.length - 1].toolRequest.prompt_id;
|
||||
|
||||
// Verify there is an update to the tracker in the exact same turn
|
||||
const parallelTrackerUpdates = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME &&
|
||||
log.toolRequest.prompt_id === loginEditPromptId,
|
||||
);
|
||||
|
||||
expect(
|
||||
parallelTrackerUpdates.length,
|
||||
'Expected tracker_update_task to be called in the same turn as the login.js fix',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import {
|
||||
WhisperModelManager,
|
||||
WhisperTranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import commandExists from 'command-exists';
|
||||
|
||||
describe('Voice Mode Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to download tiny whisper model', async () => {
|
||||
// This test doesn't require the binary, only network access.
|
||||
// However, it's slow and downloads 75MB. We'll keep it for now but
|
||||
// wrap it in a try-catch to avoid failing on network flakiness in CI.
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
|
||||
try {
|
||||
// Cleanup if already exists to ensure we actually test download
|
||||
const modelPath = manager.getModelPath(modelName);
|
||||
if (fs.existsSync(modelPath)) {
|
||||
fs.unlinkSync(modelPath);
|
||||
}
|
||||
|
||||
await manager.downloadModel(modelName);
|
||||
expect(fs.existsSync(modelPath)).toBe(true);
|
||||
expect(fs.statSync(modelPath).size).toBeGreaterThan(70 * 1024 * 1024); // ~75MB
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Skipping whisper model download test due to error (possibly network):',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, 300000); // 5 min timeout for download
|
||||
|
||||
it('should initialize WhisperTranscriptionProvider and handle process', async () => {
|
||||
// Skip this test if whisper-stream is not installed (typical for CI)
|
||||
try {
|
||||
await commandExists('whisper-stream');
|
||||
} catch {
|
||||
console.log(
|
||||
'Skipping Whisper transcription test: whisper-stream not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
if (!manager.isModelInstalled(modelName)) {
|
||||
await manager.downloadModel(modelName);
|
||||
}
|
||||
|
||||
const provider = new WhisperTranscriptionProvider({
|
||||
modelPath: manager.getModelPath(modelName),
|
||||
});
|
||||
|
||||
// Since we can't easily provide real mic input in CI,
|
||||
// we just verify it can start and be disconnected.
|
||||
await provider.connect();
|
||||
provider.disconnect();
|
||||
});
|
||||
});
|
||||
Generated
+10
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -18077,7 +18077,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18206,7 +18206,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18354,7 +18354,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18390,6 +18390,7 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
@@ -18664,7 +18665,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18679,7 +18680,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18710,7 +18711,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18742,7 +18743,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+2
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -63,7 +63,6 @@
|
||||
"lint:all": "node scripts/lint.js",
|
||||
"format": "prettier --experimental-cli --write .",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present && tsc -b evals/tsconfig.json integration-tests/tsconfig.json memory-tests/tsconfig.json",
|
||||
"metrics": "tsx tools/gemini-cli-bot/metrics/index.ts",
|
||||
"preflight": "npm run clean && npm ci && npm run format && npm run build && npm run lint:ci && npm run typecheck && npm run test:ci",
|
||||
"prepare": "husky && npm run bundle",
|
||||
"prepare:package": "node scripts/prepare-package.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
+8
-11
@@ -9,6 +9,11 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import v8 from 'node:v8';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
getSpawnConfig,
|
||||
getScriptArgs,
|
||||
} from './src/utils/processUtils.js';
|
||||
|
||||
// --- Global Entry Point ---
|
||||
|
||||
@@ -74,18 +79,10 @@ async function run() {
|
||||
// --- Lightweight Parent Process / Daemon ---
|
||||
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
|
||||
|
||||
const nodeArgs: string[] = [...process.execArgv];
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
|
||||
const scriptArgs = getScriptArgs();
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
nodeArgs.push(...memoryArgs);
|
||||
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
|
||||
|
||||
const script = process.argv[1];
|
||||
nodeArgs.push(script);
|
||||
nodeArgs.push(...scriptArgs);
|
||||
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
const RELAUNCH_EXIT_CODE = 199;
|
||||
let latestAdminSettings: unknown = undefined;
|
||||
|
||||
// Prevent the parent process from exiting prematurely on signals.
|
||||
@@ -97,7 +94,7 @@ async function run() {
|
||||
const runner = () => {
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.41.0-nightly.20260423.gaa05b4583",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.41.0-nightly.20260423.gaa05b4583"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Agent Client Protocol (ACP) Implementation
|
||||
|
||||
This directory contains the implementation of the Agent Client Protocol (ACP)
|
||||
for the Gemini CLI. The ACP allows external clients (like IDE extensions) to
|
||||
communicate with the Gemini CLI agent over a structured JSON-RPC based protocol.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Following Phase 1 of the modularization refactor, the ACP client is organized
|
||||
into the following specialized modules, all sharing the `acp` prefix for
|
||||
consistency:
|
||||
|
||||
- **[acpStdioTransport.ts](./acpStdioTransport.ts)**: Handles raw I/O. It sets
|
||||
up the Web streams for standard input/output and creates the
|
||||
`AgentSideConnection` using line-delimited JSON (ndjson).
|
||||
- **[acpRpcDispatcher.ts](./acpRpcDispatcher.ts)**: Contains the `GeminiAgent`
|
||||
class. This is the main entry point for incoming JSON-RPC messages. It
|
||||
implements the protocol methods and delegates session-specific work to the
|
||||
manager and individual sessions.
|
||||
- **[acpSessionManager.ts](./acpSessionManager.ts)**: Manages multi-session
|
||||
state. It handles session creation (`newSession`), loading (`loadSession`),
|
||||
and configuration, isolating session state from the RPC routing.
|
||||
- **[acpSession.ts](./acpSession.ts)**: Manages individual active chat sessions.
|
||||
It handles prompt execution, `@path` file resolution, tool execution, command
|
||||
interception, and streaming updates back to the client.
|
||||
- **[acpUtils.ts](./acpUtils.ts)**: Contains shared helper functions, type
|
||||
mappers (e.g., mapping internal tool kinds to ACP kinds), and Zod schemas used
|
||||
across the modules.
|
||||
- **[acpErrors.ts](./acpErrors.ts)**: Centralized error handling and mapping to
|
||||
ACP-compliant error codes.
|
||||
- **[acpCommandHandler.ts](./acpCommandHandler.ts)**: Handles interception and
|
||||
execution of slash commands (e.g., `/memory`, `/init`) sent via ACP prompts.
|
||||
- **[acpFileSystemService.ts](./acpFileSystemService.ts)**: Provides access to
|
||||
the file system restricted by the workspace boundaries and permissions.
|
||||
|
||||
## Development Instructions
|
||||
|
||||
### Running Tests
|
||||
|
||||
Tests are co-located with the source files:
|
||||
|
||||
- `acpRpcDispatcher.test.ts`: Tests for initialization, authentication, and
|
||||
handler delegation.
|
||||
- `acpSessionManager.test.ts`: Tests for session lifecycle and configuration.
|
||||
- `acpSession.test.ts`: Tests for prompt loops, tool execution, and @path
|
||||
resolution.
|
||||
- `acpResume.test.ts`: Integration tests for loading/resuming sessions.
|
||||
|
||||
To run specific tests, use Vitest with the workspace filter:
|
||||
|
||||
```bash
|
||||
# General pattern
|
||||
npm test -w @google/gemini-cli -- src/acp/<test-file-name>.ts
|
||||
|
||||
# Example
|
||||
npm test -w @google/gemini-cli -- src/acp/acpRpcDispatcher.test.ts
|
||||
```
|
||||
|
||||
Note: You may need to ensure your environment has Node available. If running in
|
||||
a restricted environment, try sourcing NVM first:
|
||||
|
||||
```bash
|
||||
source ~/.nvm/nvm.sh && nvm use default && npm test -w @google/gemini-cli -- src/acp/acpSession.test.ts
|
||||
```
|
||||
|
||||
### Adding New Features
|
||||
|
||||
- **New RPC Method**: Add the method to `GeminiAgent` in `acpRpcDispatcher.ts`
|
||||
and register it in the `AgentSideConnection` setup if necessary.
|
||||
- **Session State**: If a feature requires storing state across turns within a
|
||||
session, add it to the `Session` class in `acpSession.ts`.
|
||||
- **Protocol Helpers**: Add any new mapping or serialization logic to
|
||||
`acpUtils.ts`.
|
||||
|
||||
### Coding Conventions
|
||||
|
||||
- **Imports**: Use specific imports and do not import across package boundaries
|
||||
using relative paths.
|
||||
- **License Headers**: All new files must include the Apache-2.0 license header.
|
||||
- **Type Safety**: Avoid using `any` assertions. Use Zod schemas to validate
|
||||
untrusted input from the protocol.
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
|
||||
import type { FileSystemService } from '@google/gemini-cli-core';
|
||||
import os from 'node:os';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpClient.js';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { waitFor } from '../test-utils/async.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
@@ -106,6 +107,9 @@ describe('GeminiAgent Session Resume', () => {
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
toolRegistry: {
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
},
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
@@ -170,11 +174,6 @@ describe('GeminiAgent Session Resume', () => {
|
||||
],
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mockConfig as any).toolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue({ kind: 'read' }),
|
||||
};
|
||||
|
||||
(SessionSelector as unknown as Mock).mockImplementation(() => ({
|
||||
resolveSession: vi.fn().mockResolvedValue({
|
||||
sessionData,
|
||||
@@ -240,7 +239,7 @@ describe('GeminiAgent Session Resume', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
await waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings, SettingScope } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent - RPC Dispatcher', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let agent: GeminiAgent;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
agent = new GeminiAgent(mockConfig, mockSettings, mockArgv, mockConnection);
|
||||
});
|
||||
|
||||
it('should initialize correctly', async () => {
|
||||
const response = await agent.initialize({
|
||||
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
|
||||
protocolVersion: 1,
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
|
||||
expect(response.authMethods).toHaveLength(4);
|
||||
const gatewayAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.GATEWAY,
|
||||
);
|
||||
expect(gatewayAuth?._meta).toEqual({
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
});
|
||||
const geminiAuth = response.authMethods?.find(
|
||||
(m) => m.id === AuthType.USE_GEMINI,
|
||||
);
|
||||
expect(geminiAuth?._meta).toEqual({
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
});
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
});
|
||||
|
||||
it('should authenticate correctly', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with api-key in _meta', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.USE_GEMINI,
|
||||
_meta: {
|
||||
'api-key': 'test-api-key',
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
'test-api-key',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.USE_GEMINI,
|
||||
);
|
||||
});
|
||||
|
||||
it('should authenticate correctly with gateway method', async () => {
|
||||
await agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 'https://example.com',
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest);
|
||||
|
||||
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
|
||||
AuthType.GATEWAY,
|
||||
undefined,
|
||||
'https://example.com',
|
||||
{ Authorization: 'Bearer token' },
|
||||
);
|
||||
expect(mockSettings.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
AuthType.GATEWAY,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw acp.RequestError when gateway payload is malformed', async () => {
|
||||
await expect(
|
||||
agent.authenticate({
|
||||
methodId: AuthType.GATEWAY,
|
||||
_meta: {
|
||||
gateway: {
|
||||
baseUrl: 123,
|
||||
headers: { Authorization: 'Bearer token' },
|
||||
},
|
||||
},
|
||||
} as unknown as acp.AuthenticateRequest),
|
||||
).rejects.toThrow(/Malformed gateway payload/);
|
||||
});
|
||||
|
||||
it('should cancel a session', async () => {
|
||||
const mockSession = {
|
||||
cancelPendingPrompt: vi.fn(),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
await agent.cancel({ sessionId: 'test-session-id' });
|
||||
|
||||
expect(mockSession.cancelPendingPrompt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when cancelling non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(agent.cancel({ sessionId: 'unknown' })).rejects.toThrow(
|
||||
'Session not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should delegate prompt to session', async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.prompt({
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [],
|
||||
});
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should delegate setMode to session', async () => {
|
||||
const mockSession = {
|
||||
setMode: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.setSessionMode({
|
||||
sessionId: 'test-session-id',
|
||||
modeId: 'plan',
|
||||
});
|
||||
|
||||
expect(mockSession.setMode).toHaveBeenCalledWith('plan');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting mode on non-existent session', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.setSessionMode({
|
||||
sessionId: 'unknown',
|
||||
modeId: 'plan',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
|
||||
it('should delegate setModel to session (unstable)', async () => {
|
||||
const mockSession = {
|
||||
setModel: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(mockSession),
|
||||
};
|
||||
|
||||
const result = await agent.unstable_setSessionModel({
|
||||
sessionId: 'test-session-id',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
});
|
||||
|
||||
expect(mockSession.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should throw error when setting model on non-existent session (unstable)', async () => {
|
||||
(
|
||||
agent as unknown as { sessionManager: { getSession: Mock } }
|
||||
).sessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
await expect(
|
||||
agent.unstable_setSessionModel({
|
||||
sessionId: 'unknown',
|
||||
modelId: 'gemini-2.0-pro-exp',
|
||||
}),
|
||||
).rejects.toThrow('Session not found: unknown');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type AgentLoopContext,
|
||||
AuthType,
|
||||
clearCachedCredentialFile,
|
||||
getVersion,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import { SettingScope, type LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { AcpSessionManager, type AuthDetails } from './acpSessionManager.js';
|
||||
import { hasMeta } from './acpUtils.js';
|
||||
|
||||
export class GeminiAgent {
|
||||
private apiKey: string | undefined;
|
||||
private baseUrl: string | undefined;
|
||||
private customHeaders: Record<string, string> | undefined;
|
||||
private sessionManager: AcpSessionManager;
|
||||
|
||||
constructor(
|
||||
private context: AgentLoopContext,
|
||||
private settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
connection: acp.AgentSideConnection,
|
||||
) {
|
||||
this.sessionManager = new AcpSessionManager(settings, argv, connection);
|
||||
}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
if (args.clientCapabilities) {
|
||||
this.sessionManager.setClientCapabilities(args.clientCapabilities);
|
||||
}
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
name: 'Log in with Google',
|
||||
description: 'Log in with your Google account',
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_GEMINI,
|
||||
name: 'Gemini API key',
|
||||
description: 'Use an API key with Gemini Developer API',
|
||||
_meta: {
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_VERTEX_AI,
|
||||
name: 'Vertex AI',
|
||||
description: 'Use an API key with Vertex AI GenAI API',
|
||||
},
|
||||
{
|
||||
id: AuthType.GATEWAY,
|
||||
name: 'AI API Gateway',
|
||||
description: 'Use a custom AI API Gateway',
|
||||
_meta: {
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await this.context.config.initialize();
|
||||
const version = await getVersion();
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentInfo: {
|
||||
name: 'gemini-cli',
|
||||
title: 'Gemini CLI',
|
||||
version,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
|
||||
const { methodId } = req;
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
|
||||
// Only clear credentials when switching to a different auth method
|
||||
if (selectedAuthType && selectedAuthType !== method) {
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
// Check for api-key in _meta
|
||||
const meta = hasMeta(req) ? req._meta : undefined;
|
||||
const apiKey =
|
||||
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
|
||||
|
||||
// Refresh auth with the requested method
|
||||
// This will reuse existing credentials if they're valid,
|
||||
// or perform new authentication if needed
|
||||
try {
|
||||
if (apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
// Extract gateway details if present
|
||||
const gatewaySchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
let baseUrl: string | undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
|
||||
if (meta?.['gateway']) {
|
||||
const result = gatewaySchema.safeParse(meta['gateway']);
|
||||
if (result.success) {
|
||||
baseUrl = result.data.baseUrl;
|
||||
headers = result.data.headers;
|
||||
} else {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Malformed gateway payload: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.customHeaders = headers;
|
||||
|
||||
await this.context.config.refreshAuth(
|
||||
method,
|
||||
apiKey ?? this.apiKey,
|
||||
baseUrl,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
|
||||
}
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
private getAuthDetails(): AuthDetails {
|
||||
return {
|
||||
apiKey: this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
customHeaders: this.customHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
async newSession(
|
||||
params: acp.NewSessionRequest,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
return this.sessionManager.newSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
params: acp.LoadSessionRequest,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
return this.sessionManager.loadSession(params, this.getAuthDetails());
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: acp.SetSessionModeRequest,
|
||||
): Promise<acp.SetSessionModeResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: acp.SetSessionModelRequest,
|
||||
): Promise<acp.SetSessionModelResponse> {
|
||||
const session = this.sessionManager.getSession(params.sessionId);
|
||||
if (!session) {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Session not found: ${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
return session.setModel(params.modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { Session } from './acpSession.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
StreamEventType,
|
||||
ReadManyFilesTool,
|
||||
type GeminiChat,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
LlmRole,
|
||||
type GitService,
|
||||
type ModelRouterService,
|
||||
InvalidStreamError,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { CommandHandler } from './acpCommandHandler.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('node:path', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:path')>();
|
||||
return {
|
||||
...actual,
|
||||
resolve: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
'@google/gemini-cli-core',
|
||||
async (
|
||||
importOriginal: () => Promise<typeof import('@google/gemini-cli-core')>,
|
||||
) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
updatePolicy: vi.fn(),
|
||||
ReadManyFilesTool: vi.fn(),
|
||||
logToolCall: vi.fn(),
|
||||
processSingleFileContent: vi.fn(),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function* createMockStream(items: any[]) {
|
||||
for (const item of items) {
|
||||
yield item;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Session', () => {
|
||||
let mockChat: Mocked<GeminiChat>;
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let session: Session;
|
||||
let mockToolRegistry: { getTool: Mock };
|
||||
let mockTool: { kind: string; build: Mock };
|
||||
let mockMessageBus: Mocked<MessageBus>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockChat = {
|
||||
sendMessageStream: vi.fn(),
|
||||
addHistory: vi.fn(),
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
} as unknown as Mocked<GeminiChat>;
|
||||
mockTool = {
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(null),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
}),
|
||||
};
|
||||
mockToolRegistry = {
|
||||
getTool: vi.fn().mockReturnValue(mockTool),
|
||||
};
|
||||
mockMessageBus = {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as Mocked<MessageBus>;
|
||||
mockConfig = {
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModelRouterService: vi.fn().mockReturnValue({
|
||||
route: vi.fn().mockResolvedValue({ model: 'resolved-model' }),
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getFileService: vi.fn().mockReturnValue({
|
||||
shouldIgnoreFile: vi.fn().mockReturnValue(false),
|
||||
}),
|
||||
getFileFilteringOptions: vi.fn().mockReturnValue({}),
|
||||
getFileSystemService: vi.fn().mockReturnValue({}),
|
||||
getTargetDir: vi.fn().mockReturnValue('/tmp'),
|
||||
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
|
||||
setApprovalMode: vi.fn(),
|
||||
setModel: vi.fn(),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
get toolRegistry() {
|
||||
return mockToolRegistry;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
|
||||
merged: {
|
||||
security: { enablePermanentToolApproval: true },
|
||||
mcpServers: {},
|
||||
},
|
||||
errors: [],
|
||||
} as unknown as LoadedSettings);
|
||||
|
||||
(ReadManyFilesTool as unknown as Mock).mockImplementation(() => ({
|
||||
name: 'read_many_files',
|
||||
kind: 'read',
|
||||
build: vi.fn().mockReturnValue({
|
||||
getDescription: () => 'Read files',
|
||||
toolLocations: () => [],
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
llmContent: ['--- file.txt ---\n\nFile content\n\n'],
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should send available commands', async () => {
|
||||
await session.sendAvailableCommands();
|
||||
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should await MCP initialization before processing a prompt', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'test' }],
|
||||
});
|
||||
|
||||
expect(mockConfig.waitForMcpInit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should handle prompt with text response', async () => {
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalled();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith({
|
||||
sessionId: 'session-1',
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: 'Hello' },
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should use model router to determine model', async () => {
|
||||
const mockRouter = {
|
||||
route: vi.fn().mockResolvedValue({ model: 'routed-model' }),
|
||||
} as unknown as ModelRouterService;
|
||||
mockConfig.getModelRouterService.mockReturnValue(mockRouter);
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(mockRouter.route).toHaveBeenCalled();
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ model: 'routed-model' }),
|
||||
expect.any(Array),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle prompt with empty response (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle prompt with no finish reason (InvalidStreamError)', async () => {
|
||||
mockChat.sendMessageStream.mockRejectedValue(
|
||||
new InvalidStreamError('No finish reason', 'NO_FINISH_REASON'),
|
||||
);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle /memory command', async () => {
|
||||
const handleCommandSpy = vi
|
||||
.spyOn(
|
||||
(session as unknown as { commandHandler: CommandHandler })
|
||||
.commandHandler,
|
||||
'handleCommand',
|
||||
)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: '/memory view' }],
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
expect(handleCommandSpy).toHaveBeenCalledWith(
|
||||
'/memory view',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls', async () => {
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: { foo: 'bar' } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
candidates: [{ content: { parts: [{ text: 'Result' }] } }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
const result = await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('test_tool');
|
||||
expect(result).toMatchObject({ stopReason: 'end_turn' });
|
||||
});
|
||||
|
||||
it('should handle tool call permission request', async () => {
|
||||
const confirmationDetails = {
|
||||
type: 'info',
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
mockTool.build.mockReturnValue({
|
||||
getDescription: () => 'Test Tool',
|
||||
toolLocations: () => [],
|
||||
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
|
||||
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
|
||||
});
|
||||
|
||||
mockConnection.requestPermission.mockResolvedValue({
|
||||
outcome: {
|
||||
outcome: 'selected',
|
||||
optionId: 'proceed_once',
|
||||
},
|
||||
});
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'test_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockConnection.requestPermission).toHaveBeenCalled();
|
||||
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle @path resolution', async () => {
|
||||
(path.resolve as unknown as Mock).mockReturnValue('/tmp/file.txt');
|
||||
(fs.stat as unknown as Mock).mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
});
|
||||
|
||||
const stream = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
mockChat.sendMessageStream.mockResolvedValue(stream);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [
|
||||
{ type: 'text', text: 'Read' },
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file://file.txt',
|
||||
mimeType: 'text/plain',
|
||||
name: 'file.txt',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(path.resolve).toHaveBeenCalled();
|
||||
expect(fs.stat).toHaveBeenCalled();
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('Content from @file.txt'),
|
||||
}),
|
||||
]),
|
||||
expect.anything(),
|
||||
expect.any(AbortSignal),
|
||||
LlmRole.MAIN,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle rate limit error', async () => {
|
||||
const error = new Error('Rate limit');
|
||||
(error as unknown as { status: number }).status = 429;
|
||||
mockChat.sendMessageStream.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Hi' }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 429,
|
||||
message: 'Rate limit exceeded. Try again later.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing tool', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(undefined);
|
||||
|
||||
const stream1 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: {
|
||||
functionCalls: [{ name: 'unknown_tool', args: {} }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const stream2 = createMockStream([
|
||||
{
|
||||
type: StreamEventType.CHUNK,
|
||||
value: { candidates: [] },
|
||||
},
|
||||
]);
|
||||
|
||||
mockChat.sendMessageStream
|
||||
.mockResolvedValueOnce(stream1)
|
||||
.mockResolvedValueOnce(stream2);
|
||||
|
||||
await session.prompt({
|
||||
sessionId: 'session-1',
|
||||
prompt: [{ type: 'text', text: 'Call tool' }],
|
||||
});
|
||||
|
||||
expect(mockChat.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type ApprovalMode,
|
||||
type GeminiChat,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type FilterFilesOptions,
|
||||
type ConversationRecord,
|
||||
CoreToolCallStatus,
|
||||
AuthType,
|
||||
logToolCall,
|
||||
convertToFunctionResponse,
|
||||
ToolConfirmationOutcome,
|
||||
clearCachedCredentialFile,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
getErrorStatus,
|
||||
MCPServerConfig,
|
||||
DiscoveredMCPTool,
|
||||
StreamEventType,
|
||||
ToolCallEvent,
|
||||
@@ -29,547 +22,42 @@ import {
|
||||
ReadManyFilesTool,
|
||||
REFERENCE_CONTENT_START,
|
||||
type RoutingContext,
|
||||
createWorkingStdio,
|
||||
startupProfiler,
|
||||
Kind,
|
||||
partListUnionToString,
|
||||
LlmRole,
|
||||
ApprovalMode,
|
||||
getVersion,
|
||||
convertSessionToClientHistory,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
getDisplayString,
|
||||
processSingleFileContent,
|
||||
InvalidStreamError,
|
||||
type AgentLoopContext,
|
||||
updatePolicy,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
type FilterFilesOptions,
|
||||
isTextPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
function hasMeta(obj: unknown): obj is { _meta?: Record<string, unknown> } {
|
||||
return typeof obj === 'object' && obj !== null && '_meta' in obj;
|
||||
}
|
||||
import type { Content, Part, FunctionCall } from '@google/genai';
|
||||
import {
|
||||
SettingScope,
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
|
||||
const RequestPermissionResponseSchema = z.object({
|
||||
outcome: z.discriminatedUnion('outcome', [
|
||||
z.object({ outcome: z.literal('cancelled') }),
|
||||
z.object({
|
||||
outcome: z.literal('selected'),
|
||||
optionId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export async function runAcpClient(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
// ... (skip unchanged lines) ...
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(stdout, stdin);
|
||||
const connection = new acp.AgentSideConnection(
|
||||
(connection) => new GeminiAgent(config, settings, argv, connection),
|
||||
stream,
|
||||
);
|
||||
|
||||
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
|
||||
// We must explicitly await the connection close to flush telemetry.
|
||||
// Use finally() to ensure cleanup runs even on stream errors.
|
||||
await connection.closed.finally(runExitCleanup);
|
||||
}
|
||||
|
||||
export class GeminiAgent {
|
||||
private static callIdCounter = 0;
|
||||
|
||||
static generateCallId(name: string): string {
|
||||
return `${name}-${Date.now()}-${++GeminiAgent.callIdCounter}`;
|
||||
}
|
||||
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
private apiKey: string | undefined;
|
||||
private baseUrl: string | undefined;
|
||||
private customHeaders: Record<string, string> | undefined;
|
||||
|
||||
constructor(
|
||||
private context: AgentLoopContext,
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private connection: acp.AgentSideConnection,
|
||||
) {}
|
||||
|
||||
async initialize(
|
||||
args: acp.InitializeRequest,
|
||||
): Promise<acp.InitializeResponse> {
|
||||
this.clientCapabilities = args.clientCapabilities;
|
||||
|
||||
const authMethods = [
|
||||
{
|
||||
id: AuthType.LOGIN_WITH_GOOGLE,
|
||||
name: 'Log in with Google',
|
||||
description: 'Log in with your Google account',
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_GEMINI,
|
||||
name: 'Gemini API key',
|
||||
description: 'Use an API key with Gemini Developer API',
|
||||
_meta: {
|
||||
'api-key': {
|
||||
provider: 'google',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: AuthType.USE_VERTEX_AI,
|
||||
name: 'Vertex AI',
|
||||
description: 'Use an API key with Vertex AI GenAI API',
|
||||
},
|
||||
{
|
||||
id: AuthType.GATEWAY,
|
||||
name: 'AI API Gateway',
|
||||
description: 'Use a custom AI API Gateway',
|
||||
_meta: {
|
||||
gateway: {
|
||||
protocol: 'google',
|
||||
restartRequired: 'false',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await this.context.config.initialize();
|
||||
const version = await getVersion();
|
||||
return {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
authMethods,
|
||||
agentInfo: {
|
||||
name: 'gemini-cli',
|
||||
title: 'Gemini CLI',
|
||||
version,
|
||||
},
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
promptCapabilities: {
|
||||
image: true,
|
||||
audio: true,
|
||||
embeddedContext: true,
|
||||
},
|
||||
mcpCapabilities: {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
|
||||
const { methodId } = req;
|
||||
const method = z.nativeEnum(AuthType).parse(methodId);
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
|
||||
// Only clear credentials when switching to a different auth method
|
||||
if (selectedAuthType && selectedAuthType !== method) {
|
||||
await clearCachedCredentialFile();
|
||||
}
|
||||
// Check for api-key in _meta
|
||||
const meta = hasMeta(req) ? req._meta : undefined;
|
||||
const apiKey =
|
||||
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
|
||||
|
||||
// Refresh auth with the requested method
|
||||
// This will reuse existing credentials if they're valid,
|
||||
// or perform new authentication if needed
|
||||
try {
|
||||
if (apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
// Extract gateway details if present
|
||||
const gatewaySchema = z.object({
|
||||
baseUrl: z.string().optional(),
|
||||
headers: z.record(z.string()).optional(),
|
||||
});
|
||||
|
||||
let baseUrl: string | undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
|
||||
if (meta?.['gateway']) {
|
||||
const result = gatewaySchema.safeParse(meta['gateway']);
|
||||
if (result.success) {
|
||||
baseUrl = result.data.baseUrl;
|
||||
headers = result.data.headers;
|
||||
} else {
|
||||
throw new acp.RequestError(
|
||||
-32602,
|
||||
`Malformed gateway payload: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl;
|
||||
this.customHeaders = headers;
|
||||
|
||||
await this.context.config.refreshAuth(
|
||||
method,
|
||||
apiKey ?? this.apiKey,
|
||||
baseUrl,
|
||||
headers,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
|
||||
}
|
||||
this.settings.setValue(
|
||||
SettingScope.User,
|
||||
'security.auth.selectedType',
|
||||
method,
|
||||
);
|
||||
}
|
||||
|
||||
async newSession({
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
const config = await this.newSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const authType =
|
||||
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
authType,
|
||||
this.apiKey,
|
||||
this.baseUrl,
|
||||
this.customHeaders,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
|
||||
// Extra validation for Gemini API key
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI &&
|
||||
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
|
||||
) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = 'Gemini API key is missing or not configured.';
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = getAcpErrorMessage(e);
|
||||
debugLogger.error(
|
||||
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new acp.RequestError(
|
||||
-32000,
|
||||
authErrorMessage || 'Authentication required.',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession({
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
}: acp.LoadSessionRequest): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config.storage);
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
): Promise<Config> {
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
selectedAuthType,
|
||||
this.apiKey,
|
||||
this.baseUrl,
|
||||
this.customHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const currentSettings = loadedSettings || this.settings;
|
||||
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
if (
|
||||
'type' in server &&
|
||||
(server.type === 'sse' || server.type === 'http')
|
||||
) {
|
||||
// HTTP or SSE MCP server
|
||||
const headers = Object.fromEntries(
|
||||
server.headers.map(({ name, value }) => [name, value]),
|
||||
);
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
server.type === 'sse' ? server.url : undefined, // url (sse)
|
||||
server.type === 'http' ? server.url : undefined, // httpUrl
|
||||
headers,
|
||||
);
|
||||
} else if ('command' in server) {
|
||||
// Stdio MCP server
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of server.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
server.command,
|
||||
server.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...currentSettings.merged,
|
||||
mcpServers: mergedMcpServers,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async cancel(params: acp.CancelNotification): Promise<void> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
await session.cancelPendingPrompt();
|
||||
}
|
||||
|
||||
async prompt(params: acp.PromptRequest): Promise<acp.PromptResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.prompt(params);
|
||||
}
|
||||
|
||||
async setSessionMode(
|
||||
params: acp.SetSessionModeRequest,
|
||||
): Promise<acp.SetSessionModeResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setMode(params.modeId);
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: acp.SetSessionModelRequest,
|
||||
): Promise<acp.SetSessionModelResponse> {
|
||||
const session = this.sessions.get(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session not found: ${params.sessionId}`);
|
||||
}
|
||||
return session.setModel(params.modelId);
|
||||
}
|
||||
}
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import {
|
||||
toToolCallContent,
|
||||
toPermissionOptions,
|
||||
toAcpToolKind,
|
||||
buildAvailableModes,
|
||||
RequestPermissionResponseSchema,
|
||||
} from './acpUtils.js';
|
||||
import { z } from 'zod';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
|
||||
export class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private commandHandler = new CommandHandler();
|
||||
private callIdCounter = 0;
|
||||
|
||||
private generateCallId(name: string): string {
|
||||
return `${name}-${Date.now()}-${++this.callIdCounter}`;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly id: string,
|
||||
@@ -709,13 +197,10 @@ export class Session {
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
if ('text' in part) {
|
||||
if (isTextPart(part)) {
|
||||
// It is a text part
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
|
||||
const text = (part as any).text;
|
||||
if (typeof text === 'string') {
|
||||
commandText += text;
|
||||
}
|
||||
const text = part.text;
|
||||
commandText += text;
|
||||
} else {
|
||||
// Non-text part (image, embedded resource)
|
||||
// Stop looking for command
|
||||
@@ -972,7 +457,7 @@ export class Session {
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<Part[]> {
|
||||
const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
|
||||
const callId = fc.id ?? this.generateCallId(fc.name || 'unknown');
|
||||
const args = fc.args ?? {};
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -1661,7 +1146,7 @@ export class Session {
|
||||
include: pathSpecsToRead,
|
||||
};
|
||||
|
||||
const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
|
||||
const callId = this.generateCallId(readManyFilesTool.name);
|
||||
|
||||
try {
|
||||
const invocation = readManyFilesTool.build(toolArgs);
|
||||
@@ -1799,333 +1284,3 @@ export class Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
}
|
||||
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else {
|
||||
if ('fileName' in toolResult.returnDisplay) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path:
|
||||
toolResult.returnDisplay.filePath ??
|
||||
toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
_meta: {
|
||||
kind: !toolResult.returnDisplay.originalContent
|
||||
? 'add'
|
||||
: toolResult.returnDisplay.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const basicPermissionOptions = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
|
||||
if (!disableAlwaysAllow) {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
// askuser and exit_plan_mode don't need "always allow" options
|
||||
break;
|
||||
default:
|
||||
// No "always allow" options for other types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
options.push(...basicPermissionOptions);
|
||||
|
||||
// Exhaustive check
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
case 'exec':
|
||||
case 'mcp':
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps our internal tool kind to the ACP ToolKind.
|
||||
* Fallback to 'other' for kinds that are not supported by the ACP protocol.
|
||||
*/
|
||||
function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
switch (kind) {
|
||||
case Kind.Read:
|
||||
case Kind.Edit:
|
||||
case Kind.Execute:
|
||||
case Kind.Search:
|
||||
case Kind.Delete:
|
||||
case Kind.Move:
|
||||
case Kind.Think:
|
||||
case Kind.Fetch:
|
||||
case Kind.SwitchMode:
|
||||
case Kind.Other:
|
||||
return kind as acp.ToolKind;
|
||||
case Kind.Agent:
|
||||
return 'think';
|
||||
case Kind.Plan:
|
||||
case Kind.Communicate:
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
const modes: acp.SessionMode[] = [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
];
|
||||
|
||||
if (isPlanEnabled) {
|
||||
modes.push({
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
function buildAvailableModels(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
} {
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
mainOptions.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
});
|
||||
}
|
||||
|
||||
const manualOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
options: Array<{ value: string; title: string; description?: string }>,
|
||||
) =>
|
||||
options.map((o) => ({
|
||||
modelId: o.value,
|
||||
name: o.title,
|
||||
description: o.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels: [
|
||||
...scaleOptions(mainOptions),
|
||||
...scaleOptions(manualOptions),
|
||||
],
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
AuthType,
|
||||
type Config,
|
||||
type MessageBus,
|
||||
type Storage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { loadSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
loadCliConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/settings.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../config/settings.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSettings: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const startAutoMemoryIfEnabledMock = vi.fn();
|
||||
vi.mock('../utils/autoMemory.js', () => ({
|
||||
startAutoMemoryIfEnabled: (config: Config) =>
|
||||
startAutoMemoryIfEnabledMock(config),
|
||||
}));
|
||||
|
||||
describe('AcpSessionManager', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
let mockArgv: CliArgs;
|
||||
let mockConnection: Mocked<acp.AgentSideConnection>;
|
||||
let manager: AcpSessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig = {
|
||||
refreshAuth: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
waitForMcpInit: vi.fn(),
|
||||
getFileSystemService: vi.fn(),
|
||||
setFileSystemService: vi.fn(),
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getModel: vi.fn().mockReturnValue('gemini-pro'),
|
||||
getGeminiClient: vi.fn().mockReturnValue({
|
||||
startChat: vi.fn().mockResolvedValue({}),
|
||||
}),
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
}),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
addRule: vi.fn(),
|
||||
}),
|
||||
messageBus: {
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
} as unknown as MessageBus,
|
||||
storage: {
|
||||
getWorkspaceAutoSavedPolicyPath: vi.fn(),
|
||||
getAutoSavedPolicyPath: vi.fn(),
|
||||
} as unknown as Storage,
|
||||
|
||||
get config() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Mocked<Config>;
|
||||
mockSettings = {
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'login_with_google' } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
} as unknown as Mocked<LoadedSettings>;
|
||||
mockArgv = {} as unknown as CliArgs;
|
||||
mockConnection = {
|
||||
sessionUpdate: vi.fn(),
|
||||
requestPermission: vi.fn(),
|
||||
} as unknown as Mocked<acp.AgentSideConnection>;
|
||||
|
||||
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
|
||||
enablePermanentToolApproval: true,
|
||||
},
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
|
||||
manager = new AcpSessionManager(mockSettings, mockArgv, mockConnection);
|
||||
vi.mock('node:crypto', () => ({
|
||||
randomUUID: () => 'test-session-id',
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should create a new session', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.sessionId).toBe('test-session-id');
|
||||
expect(loadCliConfig).toHaveBeenCalled();
|
||||
expect(mockConfig.initialize).toHaveBeenCalled();
|
||||
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
|
||||
|
||||
// Verify deferred call (sendAvailableCommands)
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return modes without plan mode when plan is disabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(false);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('default');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include preview models when user has access', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'auto-gemini-3',
|
||||
name: expect.stringContaining('Auto'),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.models?.availableModels).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
modelId: 'gemini-3.1-flash-lite-preview',
|
||||
name: 'gemini-3.1-flash-lite-preview',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return modes with plan mode when plan is enabled', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
mockConfig.isPlanEnabled = vi.fn().mockReturnValue(true);
|
||||
mockConfig.getApprovalMode = vi.fn().mockReturnValue('plan');
|
||||
|
||||
const response = await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(response.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'Default', description: 'Prompts for approval' },
|
||||
{
|
||||
id: 'autoEdit',
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{ id: 'yolo', name: 'YOLO', description: 'Auto-approves all tools' },
|
||||
{ id: 'plan', name: 'Plan', description: 'Read-only mode' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail session creation if Gemini API key is missing', async () => {
|
||||
(loadSettings as unknown as Mock).mockImplementation(() => ({
|
||||
merged: {
|
||||
security: { auth: { selectedType: AuthType.USE_GEMINI } },
|
||||
mcpServers: {},
|
||||
},
|
||||
setValue: vi.fn(),
|
||||
}));
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Gemini API key is missing or not configured.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new session with mcp servers', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
const mcpServers = [
|
||||
{
|
||||
name: 'test-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: [{ name: 'KEY', value: 'VALUE' }],
|
||||
},
|
||||
];
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(loadCliConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mcpServers: expect.objectContaining({
|
||||
'test-server': expect.objectContaining({
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: { KEY: 'VALUE' },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
'test-session-id',
|
||||
mockArgv,
|
||||
{ cwd: '/tmp' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle authentication failure gracefully', async () => {
|
||||
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
await expect(
|
||||
manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Auth failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize file system service if client supports it', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
manager.setClientCapabilities({
|
||||
fs: { readTextFile: true, writeTextFile: true },
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mockConfig.setFileSystemService).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should start auto memory for new ACP sessions', async () => {
|
||||
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
await manager.newSession(
|
||||
{
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(startAutoMemoryIfEnabledMock).toHaveBeenCalledWith(mockConfig);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
AuthType,
|
||||
MCPServerConfig,
|
||||
debugLogger,
|
||||
startupProfiler,
|
||||
convertSessionToClientHistory,
|
||||
createPolicyUpdater,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { loadSettings, type LoadedSettings } from '../config/settings.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
import { Session } from './acpSession.js';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
import { buildAvailableModels, buildAvailableModes } from './acpUtils.js';
|
||||
import { loadCliConfig, type CliArgs } from '../config/config.js';
|
||||
import { startAutoMemoryIfEnabled } from '../utils/autoMemory.js';
|
||||
|
||||
export interface AuthDetails {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
customHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class AcpSessionManager {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private clientCapabilities: acp.ClientCapabilities | undefined;
|
||||
|
||||
constructor(
|
||||
private settings: LoadedSettings,
|
||||
private argv: CliArgs,
|
||||
private connection: acp.AgentSideConnection,
|
||||
) {}
|
||||
|
||||
setClientCapabilities(capabilities: acp.ClientCapabilities) {
|
||||
this.clientCapabilities = capabilities;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): Session | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
async newSession(
|
||||
{ cwd, mcpServers }: acp.NewSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.NewSessionResponse> {
|
||||
const sessionId = randomUUID();
|
||||
const loadedSettings = loadSettings(cwd);
|
||||
const config = await this.newSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const authType =
|
||||
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
authType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
isAuthenticated = true;
|
||||
|
||||
// Extra validation for Gemini API key
|
||||
const contentGeneratorConfig = config.getContentGeneratorConfig();
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI &&
|
||||
(!contentGeneratorConfig || !contentGeneratorConfig.apiKey)
|
||||
) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = 'Gemini API key is missing or not configured.';
|
||||
}
|
||||
} catch (e) {
|
||||
isAuthenticated = false;
|
||||
authErrorMessage = getAcpErrorMessage(e);
|
||||
debugLogger.error(
|
||||
`Authentication failed: ${e instanceof Error ? e.stack : e}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
throw new acp.RequestError(
|
||||
-32000,
|
||||
authErrorMessage || 'Authentication required.',
|
||||
);
|
||||
}
|
||||
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
|
||||
const chat = await geminiClient.startChat();
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
chat,
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
loadedSettings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
sessionId,
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession(
|
||||
{ sessionId, cwd, mcpServers }: acp.LoadSessionRequest,
|
||||
authDetails: AuthDetails,
|
||||
): Promise<acp.LoadSessionResponse> {
|
||||
const config = await this.initializeSessionConfig(
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers,
|
||||
authDetails,
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config.storage);
|
||||
|
||||
const { sessionData, sessionPath } =
|
||||
await sessionSelector.resolveSession(sessionId);
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
await geminiClient.resumeChat(clientHistory, {
|
||||
conversation: sessionData,
|
||||
filePath: sessionPath,
|
||||
});
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
geminiClient.getChat(),
|
||||
config,
|
||||
this.connection,
|
||||
this.settings,
|
||||
);
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Stream history back to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.streamHistory(sessionData.messages);
|
||||
|
||||
setTimeout(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
session.sendAvailableCommands();
|
||||
}, 0);
|
||||
|
||||
const { availableModels, currentModelId } = buildAvailableModels(
|
||||
config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
const response = {
|
||||
modes: {
|
||||
availableModes: buildAvailableModes(config.isPlanEnabled()),
|
||||
currentModeId: config.getApprovalMode(),
|
||||
},
|
||||
models: {
|
||||
availableModels,
|
||||
currentModelId,
|
||||
},
|
||||
};
|
||||
return response;
|
||||
}
|
||||
|
||||
private async initializeSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
authDetails: AuthDetails,
|
||||
): Promise<Config> {
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 1. Create config WITHOUT initializing it (no MCP servers started yet)
|
||||
const config = await this.newSessionConfig(sessionId, cwd, mcpServers);
|
||||
|
||||
// 2. Authenticate BEFORE initializing configuration or starting MCP servers.
|
||||
// This satisfies the security requirement to verify the user before executing
|
||||
// potentially unsafe server definitions.
|
||||
try {
|
||||
await config.refreshAuth(
|
||||
selectedAuthType,
|
||||
authDetails.apiKey,
|
||||
authDetails.baseUrl,
|
||||
authDetails.customHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(`Authentication failed: ${e}`);
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
// 3. Set the ACP FileSystemService (if supported) before config initialization
|
||||
if (this.clientCapabilities?.fs) {
|
||||
const acpFileSystemService = new AcpFileSystemService(
|
||||
this.connection,
|
||||
sessionId,
|
||||
this.clientCapabilities.fs,
|
||||
config.getFileSystemService(),
|
||||
cwd,
|
||||
);
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
// 4. Now that we are authenticated, it is safe to initialize the config
|
||||
// which starts the MCP servers and other heavy resources.
|
||||
await config.initialize();
|
||||
startupProfiler.flush(config);
|
||||
startAutoMemoryIfEnabled(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async newSessionConfig(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
mcpServers: acp.McpServer[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const currentSettings = loadedSettings || this.settings;
|
||||
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
|
||||
|
||||
for (const server of mcpServers) {
|
||||
if (
|
||||
'type' in server &&
|
||||
(server.type === 'sse' || server.type === 'http')
|
||||
) {
|
||||
// HTTP or SSE MCP server
|
||||
const headers = Object.fromEntries(
|
||||
server.headers.map(({ name, value }) => [name, value]),
|
||||
);
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
undefined, // command
|
||||
undefined, // args
|
||||
undefined, // env
|
||||
undefined, // cwd
|
||||
server.type === 'sse' ? server.url : undefined, // url (sse)
|
||||
server.type === 'http' ? server.url : undefined, // httpUrl
|
||||
headers,
|
||||
);
|
||||
} else if ('command' in server) {
|
||||
// Stdio MCP server
|
||||
const env: Record<string, string> = {};
|
||||
for (const { name: envName, value } of server.env) {
|
||||
env[envName] = value;
|
||||
}
|
||||
mergedMcpServers[server.name] = new MCPServerConfig(
|
||||
server.command,
|
||||
server.args,
|
||||
env,
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const settings = {
|
||||
...currentSettings.merged,
|
||||
mcpServers: mergedMcpServers,
|
||||
};
|
||||
|
||||
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
|
||||
|
||||
createPolicyUpdater(
|
||||
config.getPolicyEngine(),
|
||||
config.messageBus,
|
||||
config.storage,
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Config, createWorkingStdio } from '@google/gemini-cli-core';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
|
||||
export async function runAcpClient(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
argv: CliArgs,
|
||||
) {
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
const stdout = Writable.toWeb(workingStdout) as WritableStream;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const stdin = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
|
||||
|
||||
const stream = acp.ndJsonStream(stdout, stdin);
|
||||
const connection = new acp.AgentSideConnection(
|
||||
(connection) => new GeminiAgent(config, settings, argv, connection),
|
||||
stream,
|
||||
);
|
||||
|
||||
// SIGTERM/SIGINT handlers (in sdk.ts) don't fire when stdin closes.
|
||||
// We must explicitly await the connection close to flush telemetry.
|
||||
// Use finally() to ensure cleanup runs even on stream errors.
|
||||
await connection.closed.finally(runExitCleanup);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type Config,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
Kind,
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_3_1_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
PREVIEW_GEMINI_FLASH_MODEL,
|
||||
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
import { z } from 'zod';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
export function hasMeta(
|
||||
obj: unknown,
|
||||
): obj is { _meta?: Record<string, unknown> } {
|
||||
return typeof obj === 'object' && obj !== null && '_meta' in obj;
|
||||
}
|
||||
|
||||
export const RequestPermissionResponseSchema = z.object({
|
||||
outcome: z.discriminatedUnion('outcome', [
|
||||
z.object({ outcome: z.literal('cancelled') }),
|
||||
z.object({
|
||||
outcome: z.literal('selected'),
|
||||
optionId: z.string(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
export function toToolCallContent(
|
||||
toolResult: ToolResult,
|
||||
): acp.ToolCallContent | null {
|
||||
if (toolResult.error?.message) {
|
||||
throw new Error(toolResult.error.message);
|
||||
}
|
||||
|
||||
if (toolResult.returnDisplay) {
|
||||
if (typeof toolResult.returnDisplay === 'string') {
|
||||
return {
|
||||
type: 'content',
|
||||
content: { type: 'text', text: toolResult.returnDisplay },
|
||||
};
|
||||
} else {
|
||||
if ('fileName' in toolResult.returnDisplay) {
|
||||
return {
|
||||
type: 'diff',
|
||||
path:
|
||||
toolResult.returnDisplay.filePath ??
|
||||
toolResult.returnDisplay.fileName,
|
||||
oldText: toolResult.returnDisplay.originalContent,
|
||||
newText: toolResult.returnDisplay.newContent,
|
||||
_meta: {
|
||||
kind: !toolResult.returnDisplay.originalContent
|
||||
? 'add'
|
||||
: toolResult.returnDisplay.newContent === ''
|
||||
? 'delete'
|
||||
: 'modify',
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const basicPermissionOptions = [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedOnce,
|
||||
name: 'Allow',
|
||||
kind: 'allow_once',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.Cancel,
|
||||
name: 'Reject',
|
||||
kind: 'reject_once',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
config: Config,
|
||||
enablePermanentToolApproval: boolean = false,
|
||||
): acp.PermissionOption[] {
|
||||
const disableAlwaysAllow = config.getDisableAlwaysAllow();
|
||||
const options: acp.PermissionOption[] = [];
|
||||
|
||||
if (!disableAlwaysAllow) {
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for this file in all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'exec':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow this command for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'mcp':
|
||||
options.push(
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: 'Allow all server tools for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: 'Allow tool for this session',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
);
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow tool for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'info':
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Allow for this session',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
if (enablePermanentToolApproval) {
|
||||
options.push({
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
|
||||
name: 'Allow for all future sessions',
|
||||
kind: 'allow_always',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
// askuser and exit_plan_mode don't need "always allow" options
|
||||
break;
|
||||
default:
|
||||
// No "always allow" options for other types
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
options.push(...basicPermissionOptions);
|
||||
|
||||
// Exhaustive check
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
case 'exec':
|
||||
case 'mcp':
|
||||
case 'info':
|
||||
case 'ask_user':
|
||||
case 'exit_plan_mode':
|
||||
case 'sandbox_expansion':
|
||||
break;
|
||||
default: {
|
||||
const unreachable: never = confirmation;
|
||||
throw new Error(`Unexpected: ${unreachable}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function toAcpToolKind(kind: Kind): acp.ToolKind {
|
||||
switch (kind) {
|
||||
case Kind.Read:
|
||||
case Kind.Edit:
|
||||
case Kind.Execute:
|
||||
case Kind.Search:
|
||||
case Kind.Delete:
|
||||
case Kind.Move:
|
||||
case Kind.Think:
|
||||
case Kind.Fetch:
|
||||
case Kind.SwitchMode:
|
||||
case Kind.Other:
|
||||
return kind as acp.ToolKind;
|
||||
case Kind.Agent:
|
||||
return 'think';
|
||||
case Kind.Plan:
|
||||
case Kind.Communicate:
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
|
||||
const modes: acp.SessionMode[] = [
|
||||
{
|
||||
id: ApprovalMode.DEFAULT,
|
||||
name: 'Default',
|
||||
description: 'Prompts for approval',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.AUTO_EDIT,
|
||||
name: 'Auto Edit',
|
||||
description: 'Auto-approves edit tools',
|
||||
},
|
||||
{
|
||||
id: ApprovalMode.YOLO,
|
||||
name: 'YOLO',
|
||||
description: 'Auto-approves all tools',
|
||||
},
|
||||
];
|
||||
|
||||
if (isPlanEnabled) {
|
||||
modes.push({
|
||||
id: ApprovalMode.PLAN,
|
||||
name: 'Plan',
|
||||
description: 'Read-only mode',
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
export function buildAvailableModels(
|
||||
config: Config,
|
||||
settings: LoadedSettings,
|
||||
): {
|
||||
availableModels: Array<{
|
||||
modelId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}>;
|
||||
currentModelId: string;
|
||||
} {
|
||||
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
const selectedAuthType = settings.merged.security.auth.selectedType;
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
config.getModelConfigService
|
||||
) {
|
||||
const options = config.getModelConfigService().getAvailableModelOptions({
|
||||
useGemini3_1: useGemini31,
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
});
|
||||
|
||||
return {
|
||||
availableModels: options,
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const mainOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
|
||||
description:
|
||||
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
mainOptions.unshift({
|
||||
value: PREVIEW_GEMINI_MODEL_AUTO,
|
||||
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
|
||||
description: useGemini31
|
||||
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
|
||||
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
|
||||
});
|
||||
}
|
||||
|
||||
const manualOptions = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
{
|
||||
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
: PREVIEW_GEMINI_MODEL;
|
||||
|
||||
const previewProValue = useCustomToolModel
|
||||
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
|
||||
: previewProModel;
|
||||
|
||||
const previewOptions = [
|
||||
{
|
||||
value: previewProValue,
|
||||
title: getDisplayString(previewProModel),
|
||||
},
|
||||
{
|
||||
value: PREVIEW_GEMINI_FLASH_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
|
||||
},
|
||||
];
|
||||
|
||||
if (useGemini31FlashLite) {
|
||||
previewOptions.push({
|
||||
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
|
||||
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
|
||||
});
|
||||
}
|
||||
|
||||
manualOptions.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
const scaleOptions = (
|
||||
options: Array<{ value: string; title: string; description?: string }>,
|
||||
) =>
|
||||
options.map((o) => ({
|
||||
modelId: o.value,
|
||||
name: o.title,
|
||||
description: o.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels: [
|
||||
...scaleOptions(mainOptions),
|
||||
...scaleOptions(manualOptions),
|
||||
],
|
||||
currentModelId: preferredModel,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<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;
|
||||
let mockReaddir: Mock<(path: string) => Promise<string[]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
listCommand = new ListCheckpointsCommand();
|
||||
mockReaddir = vi.mocked(fs.readdir) as unknown as Mock<
|
||||
(path: string) => Promise<string[]>
|
||||
>;
|
||||
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns "No checkpoints found." when no .json checkpoints exist', async () => {
|
||||
mockReaddir.mockResolvedValue(['not-a-checkpoint.txt']);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
});
|
||||
|
||||
it('ignores error when mkdir fails', async () => {
|
||||
vi.mocked(fs.mkdir).mockRejectedValue(new Error('mkdir fail'));
|
||||
mockReaddir.mockResolvedValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('No checkpoints found.');
|
||||
expect(fs.mkdir).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('formats checkpoint summary output from checkpoint metadata', async () => {
|
||||
mockReaddir.mockResolvedValue(['cp1.json', 'cp2.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([
|
||||
{ messageId: 'id1', checkpoint: 'cp1' },
|
||||
{ messageId: 'id2', checkpoint: 'cp2' },
|
||||
]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toContain('Available Checkpoints:');
|
||||
// Note: The current implementation of ListCheckpointsCommand incorrectly accesses
|
||||
// fileName, toolName, etc. which don't exist on CheckpointInfo, resulting in 'Unknown'.
|
||||
expect(response.data).toContain('- **Unknown**: Unknown (Status: Unknown)');
|
||||
});
|
||||
|
||||
it('handles empty checkpoint info list', async () => {
|
||||
mockReaddir.mockResolvedValue(['some.json']);
|
||||
vi.mocked(getCheckpointInfoList).mockReturnValue([]);
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe('Available Checkpoints:\n');
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message on failures', async () => {
|
||||
mockReaddir.mockRejectedValue(new Error('Readdir fail'));
|
||||
|
||||
const response = await listCommand.execute(context);
|
||||
|
||||
expect(response.data).toBe(
|
||||
'An unexpected error occurred while listing checkpoints.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -217,6 +217,78 @@ describe('mcp list command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display connected status even if ping fails', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockRejectedValue(new Error('Ping failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Connected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use configured timeout for connection', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server', timeout: 12345 },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout for connection when not configured', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
|
||||
@@ -67,6 +67,8 @@ export async function getMcpServersFromConfig(
|
||||
return filteredResult;
|
||||
}
|
||||
|
||||
const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000;
|
||||
|
||||
async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
@@ -127,11 +129,22 @@ async function testMCPConnection(
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt actual MCP connection with short timeout
|
||||
await client.connect(transport, { timeout: 5000 }); // 5s timeout
|
||||
// Attempt actual MCP connection with timeout from config or default to 5s.
|
||||
// We use a short default for the list command to keep it responsive.
|
||||
const timeout = config.timeout ?? MCP_LIST_DEFAULT_TIMEOUT_MSEC;
|
||||
await client.connect(transport, { timeout });
|
||||
|
||||
// Test basic MCP protocol by pinging the server
|
||||
await client.ping();
|
||||
// Test basic MCP protocol by pinging the server.
|
||||
// Ping is optional per MCP spec - some servers (e.g. Google first-party)
|
||||
// don't implement it. A successful connect() is sufficient proof of connectivity.
|
||||
try {
|
||||
await client.ping({ timeout });
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`MCP ping failed for ${serverName}, but connect succeeded:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
await client.close();
|
||||
return MCPServerStatus.CONNECTED;
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
generalistProfile,
|
||||
type ContextManagementConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -233,6 +231,45 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('should fail if both --resume and --session-id are provided', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--resume',
|
||||
'--session-id',
|
||||
'test-uuid-1234',
|
||||
];
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --resume (-r) and --session-id together',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse --session-id option correctly', async () => {
|
||||
process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234'];
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const parsedArgs = await parseArguments(createTestMergedSettings());
|
||||
expect(parsedArgs.sessionId).toBe('test-uuid-1234');
|
||||
});
|
||||
|
||||
describe('worktree', () => {
|
||||
it('should parse --worktree flag when provided with a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||
@@ -257,7 +294,7 @@ describe('parseArguments', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = false;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockConsoleError = vi
|
||||
@@ -272,9 +309,6 @@ describe('parseArguments', () => {
|
||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -306,7 +340,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -323,9 +357,6 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -562,7 +593,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -579,9 +610,6 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -606,7 +634,7 @@ describe('parseArguments', () => {
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -625,10 +653,6 @@ describe('parseArguments', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
|
||||
@@ -780,6 +804,100 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Model resolution', () => {
|
||||
it('should handle multiple --model flags by taking the last one', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: ['gemini-1.5-pro', 'gemini-2.0-flash'] as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.0-flash');
|
||||
});
|
||||
|
||||
it('should handle non-string model flags by coercing to string', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: true as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy configuration', () => {
|
||||
const originalProxyEnv: { [key: string]: string | undefined } = {};
|
||||
const proxyEnvVars = [
|
||||
@@ -872,16 +990,14 @@ describe('loadCliConfig', () => {
|
||||
});
|
||||
|
||||
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
|
||||
const resolveToRealPathSpy = vi
|
||||
.spyOn(ServerConfig, 'resolveToRealPath')
|
||||
.mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.stubEnv(
|
||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
||||
@@ -895,8 +1011,6 @@ describe('loadCliConfig', () => {
|
||||
const dirs = config.getPendingIncludeDirectories();
|
||||
expect(dirs).toContain('/project/folderA');
|
||||
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
||||
|
||||
resolveToRealPathSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use default fileFilter options when unconfigured', async () => {
|
||||
@@ -2217,51 +2331,6 @@ describe('loadCliConfig context management', () => {
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual(
|
||||
generalistProfile,
|
||||
);
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true when contextManagement is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const contextManagementConfig: Partial<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);
|
||||
});
|
||||
});
|
||||
@@ -3225,7 +3294,7 @@ describe('Output format', () => {
|
||||
it('should error on invalid --output-format argument', async () => {
|
||||
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -3243,10 +3312,6 @@ describe('Output format', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3277,13 +3342,11 @@ describe('parseArguments with positional prompt', () => {
|
||||
'test prompt',
|
||||
];
|
||||
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -3297,10 +3360,6 @@ describe('parseArguments with positional prompt', () => {
|
||||
'Cannot use both a positional prompt and the --prompt (-p) flag together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse a positional prompt to query field', async () => {
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
detectIdeFromEnv,
|
||||
generalistProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -97,6 +96,7 @@ export interface CliArgs {
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | typeof RESUME_LATEST | undefined;
|
||||
sessionId: string | undefined;
|
||||
listSessions: boolean | undefined;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
@@ -238,6 +238,10 @@ export async function parseArguments(
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
|
||||
return 'Cannot use both --resume (-r) and --session-id together';
|
||||
}
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
@@ -407,6 +411,25 @@ export async function parseArguments(
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('session-id', {
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description: 'Start a new session with a manually provided UUID.',
|
||||
coerce: (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('The --session-id option cannot be empty.');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
'Invalid session ID "' +
|
||||
trimmed +
|
||||
'": Only alphanumeric characters, dashes, and underscores are allowed.',
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
@@ -818,9 +841,16 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
const rawModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
// Ensure specifiedModel is a string (e.g. if yargs parsed multiple --model as an array)
|
||||
const specifiedModel = Array.isArray(rawModel)
|
||||
? String(rawModel.at(-1) ?? '').trim() || ''
|
||||
: rawModel === undefined
|
||||
? undefined
|
||||
: String(rawModel ?? '').trim() || '';
|
||||
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
? defaultModel
|
||||
@@ -904,14 +934,19 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
// 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 contextManagement = {
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
enabled: !!profileSelector,
|
||||
};
|
||||
|
||||
return new Config({
|
||||
@@ -935,6 +970,7 @@ export async function loadCliConfig(
|
||||
worktreeSettings,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
experimentalContextManagementConfig: profileSelector,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
@@ -1000,6 +1036,7 @@ 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
|
||||
|
||||
@@ -42,10 +42,12 @@ describe('ExtensionManager agents loading', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
|
||||
@@ -48,11 +48,13 @@ describe('ExtensionManager hydration', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
|
||||
vi.spyOn(coreEvents, 'emitFeedback');
|
||||
vi.spyOn(debugLogger, 'debug').mockImplementation(() => {});
|
||||
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
mockHomedir.mockReturnValue(tempDir);
|
||||
vi.stubEnv('GEMINI_CLI_HOME', tempDir);
|
||||
|
||||
// Create the extensions directory that ExtensionManager expects
|
||||
extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
settingsZodSchema,
|
||||
} from './settings-validation.js';
|
||||
import { z } from 'zod';
|
||||
import { type Settings } from './settingsSchema.js';
|
||||
|
||||
describe('settings-validation', () => {
|
||||
describe('validateSettings', () => {
|
||||
@@ -298,6 +299,117 @@ describe('settings-validation', () => {
|
||||
expect(issue).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept customThemes with text.response color override', () => {
|
||||
// Regression test for #25610: `response` is a documented and
|
||||
// implemented color override for model responses (see
|
||||
// packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts),
|
||||
// but was missing from the CustomTheme validation schema.
|
||||
const validSettings = {
|
||||
ui: {
|
||||
theme: 'LimeWhite',
|
||||
customThemes: {
|
||||
LimeWhite: {
|
||||
type: 'custom',
|
||||
name: 'LimeWhite',
|
||||
text: {
|
||||
primary: '#00FF00',
|
||||
response: '#FFFFFF',
|
||||
secondary: '#a0a0a0',
|
||||
accent: '#00FF00',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
describe('type casting', () => {
|
||||
it('should cast "true" and "false" strings to booleans', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'true',
|
||||
hideWindowTitle: 'false',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.ui?.autoThemeSwitching).toBe(true);
|
||||
expect(data.ui?.hideWindowTitle).toBe(false);
|
||||
});
|
||||
|
||||
it('should cast boolean strings case-insensitively', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'TRUE',
|
||||
hideWindowTitle: 'fAlSe',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.ui?.autoThemeSwitching).toBe(true);
|
||||
expect(data.ui?.hideWindowTitle).toBe(false);
|
||||
});
|
||||
|
||||
it('should cast numeric strings to numbers', () => {
|
||||
const settings = {
|
||||
model: {
|
||||
maxSessionTurns: '42',
|
||||
compressionThreshold: '0.5',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.model?.maxSessionTurns).toBe(42);
|
||||
expect(data.model?.compressionThreshold).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should reject invalid castable strings', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'not-a-boolean',
|
||||
},
|
||||
model: {
|
||||
maxSessionTurns: 'not-a-number',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues).toHaveLength(2);
|
||||
expect(result.error?.issues[0].message).toContain(
|
||||
'Expected boolean, received string',
|
||||
);
|
||||
expect(result.error?.issues[1].message).toContain(
|
||||
'Expected number, received string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should cast strings to booleans/numbers in shared definitions (refs)', () => {
|
||||
const settings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
trust: 'true', // from boolean ref
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.mcpServers?.['test-server'].trust).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValidationError', () => {
|
||||
|
||||
@@ -25,10 +25,10 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
if (def.type === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
|
||||
return z.string();
|
||||
return buildPrimitiveSchema('string');
|
||||
}
|
||||
if (def.type === 'number') return z.number();
|
||||
if (def.type === 'boolean') return z.boolean();
|
||||
if (def.type === 'number') return buildPrimitiveSchema('number');
|
||||
if (def.type === 'boolean') return buildPrimitiveSchema('boolean');
|
||||
|
||||
if (def.type === 'array') {
|
||||
if (def.items) {
|
||||
@@ -133,9 +133,22 @@ function buildPrimitiveSchema(
|
||||
case 'string':
|
||||
return z.string();
|
||||
case 'number':
|
||||
return z.number();
|
||||
return z.preprocess((val) => {
|
||||
if (typeof val === 'string' && val.trim() !== '') {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
return val;
|
||||
}, z.number());
|
||||
case 'boolean':
|
||||
return z.boolean();
|
||||
return z.preprocess((val) => {
|
||||
if (typeof val === 'string') {
|
||||
const lower = val.toLowerCase();
|
||||
if (lower === 'true') return true;
|
||||
if (lower === 'false') return false;
|
||||
}
|
||||
return val;
|
||||
}, z.boolean());
|
||||
default:
|
||||
return z.unknown();
|
||||
}
|
||||
@@ -160,7 +173,9 @@ function buildZodSchemaFromDefinition(
|
||||
if (definition.ref === 'TelemetrySettings') {
|
||||
const objectSchema = REF_SCHEMAS['TelemetrySettings'];
|
||||
if (objectSchema) {
|
||||
return z.union([z.boolean(), objectSchema]).optional();
|
||||
return z
|
||||
.union([buildPrimitiveSchema('boolean'), objectSchema])
|
||||
.optional();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -479,6 +479,137 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
|
||||
});
|
||||
|
||||
it('should resolve environment variables and cast them to correct types before validation', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', 'false');
|
||||
vi.stubEnv('TEST_MAX_TURNS', '15');
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: '$TEST_AUTO_THEME' },
|
||||
model: { maxSessionTurns: '$TEST_MAX_TURNS' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.ui.autoThemeSwitching).toBe(false);
|
||||
expect(settings.merged.model.maxSessionTurns).toBe(15);
|
||||
expect(settings.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use default values from environment variable placeholders', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', ''); // Should trigger default
|
||||
delete process.env['TEST_AUTO_THEME'];
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: '${TEST_AUTO_THEME:-true}' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.ui.autoThemeSwitching).toBe(true);
|
||||
expect(settings.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should record validation errors if expansion result is invalid', () => {
|
||||
vi.stubEnv('TEST_MAX_TURNS', 'not-a-number');
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
model: { maxSessionTurns: '$TEST_MAX_TURNS' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.errors.length).toBeGreaterThan(0);
|
||||
expect(settings.errors[0].message).toContain(
|
||||
'Expected number, received string',
|
||||
);
|
||||
// Should fall back to the expanded string value
|
||||
expect(settings.merged.model.maxSessionTurns).toBe('not-a-number');
|
||||
});
|
||||
|
||||
it('should preserve environment variable placeholders on save', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', 'true');
|
||||
const placeholder = '${TEST_AUTO_THEME:-false}';
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: placeholder },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Load settings - this will expand the placeholder for runtime use
|
||||
const loaded = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(loaded.merged.ui.autoThemeSwitching).toBe(true);
|
||||
|
||||
// Verify that the original settings for the user scope still have the placeholder
|
||||
const userFile = loaded.forScope(SettingScope.User);
|
||||
expect(userFile.originalSettings.ui?.autoThemeSwitching).toBe(
|
||||
placeholder,
|
||||
);
|
||||
|
||||
// Save settings - this should use the originalSettings (with placeholders)
|
||||
const mockUpdate = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
saveSettings(userFile);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
USER_SETTINGS_PATH,
|
||||
expect.objectContaining({
|
||||
ui: expect.objectContaining({
|
||||
autoThemeSwitching: placeholder,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use system folderTrust over user setting', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
|
||||
@@ -673,7 +673,9 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
const load = (
|
||||
filePath: string,
|
||||
): { settings: Settings; rawSettings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -689,14 +691,19 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
path: filePath,
|
||||
severity: 'error',
|
||||
});
|
||||
return { settings: {} };
|
||||
return { settings: {}, rawSettings: {} };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const settingsObject = rawSettings as Record<string, unknown>;
|
||||
|
||||
// Validate settings structure with Zod
|
||||
const validationResult = validateSettings(settingsObject);
|
||||
// Expand environment variables
|
||||
const expandedSettings = resolveEnvVarsInObject(
|
||||
settingsObject as Settings,
|
||||
);
|
||||
|
||||
// Validate settings structure with Zod after environment variable expansion
|
||||
const validationResult = validateSettings(expandedSettings);
|
||||
if (!validationResult.success && validationResult.error) {
|
||||
const errorMessage = formatValidationError(
|
||||
validationResult.error,
|
||||
@@ -707,9 +714,22 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
path: filePath,
|
||||
severity: 'warning',
|
||||
});
|
||||
return {
|
||||
settings: expandedSettings,
|
||||
rawSettings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
};
|
||||
}
|
||||
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
// Return the successfully cast and validated data
|
||||
return {
|
||||
// Since we've successfully validated expandedSettings against settingsZodSchema,
|
||||
// it's safe to cast the resulting data to the Settings type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings: (validationResult.data as Settings) ?? expandedSettings,
|
||||
rawSettings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
};
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
@@ -718,33 +738,40 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
return { settings: {} };
|
||||
return { settings: {}, rawSettings: {} };
|
||||
};
|
||||
|
||||
const systemResult = load(systemSettingsPath);
|
||||
const systemDefaultsResult = load(systemDefaultsPath);
|
||||
const userResult = load(USER_SETTINGS_PATH);
|
||||
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
let workspaceResult: {
|
||||
settings: Settings;
|
||||
rawSettings: Settings;
|
||||
rawJson?: string;
|
||||
} = {
|
||||
settings: {} as Settings,
|
||||
rawSettings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
const systemOriginalSettings = structuredClone(systemResult.settings);
|
||||
const systemOriginalSettings = structuredClone(systemResult.rawSettings);
|
||||
const systemDefaultsOriginalSettings = structuredClone(
|
||||
systemDefaultsResult.settings,
|
||||
systemDefaultsResult.rawSettings,
|
||||
);
|
||||
const userOriginalSettings = structuredClone(userResult.rawSettings);
|
||||
const workspaceOriginalSettings = structuredClone(
|
||||
workspaceResult.rawSettings,
|
||||
);
|
||||
const userOriginalSettings = structuredClone(userResult.settings);
|
||||
const workspaceOriginalSettings = structuredClone(workspaceResult.settings);
|
||||
|
||||
// Environment variables for runtime use
|
||||
systemSettings = resolveEnvVarsInObject(systemResult.settings);
|
||||
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
|
||||
userSettings = resolveEnvVarsInObject(userResult.settings);
|
||||
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
||||
// Environment variables for runtime use are already resolved and validated in load()
|
||||
systemSettings = systemResult.settings;
|
||||
systemDefaultSettings = systemDefaultsResult.settings;
|
||||
userSettings = userResult.settings;
|
||||
workspaceSettings = workspaceResult.settings;
|
||||
|
||||
// Support legacy theme names
|
||||
if (userSettings.ui?.theme === 'VS') {
|
||||
|
||||
@@ -2061,6 +2061,87 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voiceMode: {
|
||||
type: 'boolean',
|
||||
label: 'Voice Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental voice dictation and commands (/voice, /voice model).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voice: {
|
||||
type: 'object',
|
||||
label: 'Voice',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for voice mode and transcription.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
activationMode: {
|
||||
type: 'enum',
|
||||
label: 'Voice Activation Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'push-to-talk',
|
||||
description: 'How to trigger voice recording with the Space key.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'push-to-talk', label: 'Push-To-Talk (Hold Space)' },
|
||||
{ value: 'toggle', label: 'Toggle (Press Space to start/stop)' },
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
type: 'enum',
|
||||
label: 'Voice Transcription Backend',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
{ value: 'whisper', label: 'Whisper (Local)' },
|
||||
],
|
||||
},
|
||||
whisperModel: {
|
||||
type: 'enum',
|
||||
label: 'Whisper Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'ggml-base.en.bin',
|
||||
description: 'The Whisper model to use for local transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'ggml-tiny.en.bin', label: 'Tiny (EN) - Fast (~75MB)' },
|
||||
{
|
||||
value: 'ggml-base.en.bin',
|
||||
label: 'Base (EN) - Balanced (~142MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q5_0.bin',
|
||||
label: 'Large v3 Turbo (Q5_0) - High Accuracy (~547MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q8_0.bin',
|
||||
label: 'Large v3 Turbo (Q8_0) - Max Accuracy (~834MB)',
|
||||
},
|
||||
],
|
||||
},
|
||||
stopGracePeriodMs: {
|
||||
type: 'number',
|
||||
label: 'Voice Stop Grace Period (ms)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 1000,
|
||||
description:
|
||||
'How long to wait for final transcription after stopping recording.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
adk: {
|
||||
type: 'object',
|
||||
label: 'ADK',
|
||||
@@ -2307,6 +2388,17 @@ const SETTINGS_SCHEMA = {
|
||||
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
|
||||
showInDialog: true,
|
||||
},
|
||||
stressTestProfile: {
|
||||
type: 'boolean',
|
||||
label:
|
||||
'Use the stress test profile to aggressively trigger context management.',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Significantly lowers token limits to force early garbage collection and distillation for testing purposes.',
|
||||
showInDialog: false,
|
||||
},
|
||||
autoMemory: {
|
||||
type: 'boolean',
|
||||
label: 'Auto Memory',
|
||||
@@ -3197,6 +3289,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
secondary: { type: 'string' },
|
||||
link: { type: 'string' },
|
||||
accent: { type: 'string' },
|
||||
response: { type: 'string' },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
validateDnsResolutionOrder,
|
||||
startInteractiveUI,
|
||||
getNodeMemoryArgs,
|
||||
resolveSessionId,
|
||||
} from './gemini.js';
|
||||
import {
|
||||
loadCliConfig,
|
||||
@@ -47,10 +48,13 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
ExitCodes,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { SessionSelector, SessionError } from './utils/sessionUtils.js';
|
||||
|
||||
// Hoisted constants and mocks
|
||||
const performance = vi.hoisted(() => ({
|
||||
now: vi.fn(),
|
||||
@@ -548,6 +552,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
@@ -607,6 +612,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
@@ -822,7 +828,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should handle session selector error', async () => {
|
||||
const { SessionSelector } = await import('./utils/sessionUtils.js');
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@@ -879,9 +884,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should start normally with a warning when no sessions found for resume', async () => {
|
||||
const { SessionSelector, SessionError } = await import(
|
||||
'./utils/sessionUtils.js'
|
||||
);
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@@ -1056,6 +1058,63 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSessionId', () => {
|
||||
it('should return a new session ID when neither resume nor sessionId is provided', async () => {
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(resumedSessionData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sessionExists: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as InstanceType<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;
|
||||
|
||||
+40
-19
@@ -76,7 +76,7 @@ import {
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runAcpClient } from './acp/acpClient.js';
|
||||
import { runAcpClient } from './acp/acpStdioTransport.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
@@ -85,7 +85,6 @@ import { relaunchOnExitCode } from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
@@ -191,21 +190,38 @@ ${reason.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveSessionId(resumeArg: string | undefined): Promise<{
|
||||
export async function resolveSessionId(
|
||||
resumeArg: string | undefined,
|
||||
sessionIdArg?: string | undefined,
|
||||
): Promise<{
|
||||
sessionId: string;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}> {
|
||||
if (!resumeArg) {
|
||||
if (!resumeArg && !sessionIdArg) {
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
|
||||
const sessionSelector = new SessionSelector(storage);
|
||||
|
||||
if (sessionIdArg) {
|
||||
if (await sessionSelector.sessionExists(sessionIdArg)) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`,
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_INPUT_ERROR);
|
||||
}
|
||||
return { sessionId: sessionIdArg };
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionData, sessionPath } = await new SessionSelector(
|
||||
storage,
|
||||
).resolveSession(resumeArg);
|
||||
const { sessionData, sessionPath } = await sessionSelector.resolveSession(
|
||||
resumeArg!,
|
||||
);
|
||||
return {
|
||||
sessionId: sessionData.sessionId,
|
||||
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
|
||||
@@ -319,7 +335,10 @@ export async function main() {
|
||||
|
||||
const argv = await argvPromise;
|
||||
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||
argv.resume,
|
||||
argv.sessionId,
|
||||
);
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
@@ -358,15 +377,14 @@ export async function main() {
|
||||
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: true,
|
||||
interactive: isHeadlessMode() ? false : true,
|
||||
stderr: argv.isCommand ? false : true,
|
||||
interactive: isHeadlessMode() && !argv.isCommand ? false : true,
|
||||
debugMode: isDebugMode,
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
|
||||
@@ -392,6 +410,7 @@ 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
|
||||
@@ -549,6 +568,12 @@ export async function main() {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
// Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
|
||||
// are correctly redirected to stderr (especially for non-interactive JSON output).
|
||||
if (!config.getAcpMode()) {
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
}
|
||||
|
||||
// Launch cleanup expired sessions as a background task
|
||||
cleanupExpiredSessions(config, settings.merged).catch((e) => {
|
||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||
@@ -644,7 +669,7 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getUseAlternateBuffer(),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await rawStartupWarningsPromise;
|
||||
@@ -786,20 +811,16 @@ 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);
|
||||
writeToStderr(payload.content + '\n');
|
||||
} else {
|
||||
writeToStdout(payload.content);
|
||||
writeToStderr(payload.content + '\n');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
if (payload.severity === 'error' || payload.severity === 'warning') {
|
||||
writeToStderr(payload.message);
|
||||
} else {
|
||||
writeToStdout(payload.message);
|
||||
}
|
||||
writeToStderr(payload.message + '\n');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,13 @@ vi.mock('./config/settings.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./ui/utils/ConsolePatcher.js', () => ({
|
||||
ConsolePatcher: vi.fn().mockImplementation(() => ({
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./config/config.js', () => ({
|
||||
loadCliConfig: vi.fn().mockResolvedValue({
|
||||
getSandbox: vi.fn(() => false),
|
||||
@@ -150,6 +157,10 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./acp/acpStdioTransport.js', () => ({
|
||||
runAcpClient: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./zed-integration/zedIntegration.js', () => ({
|
||||
runZedIntegration: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -296,6 +307,120 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not register ConsolePatcher cleanup in ACP mode', async () => {
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
acp: true,
|
||||
startupMessages: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
tools: { allowed: [], exclude: [] },
|
||||
advanced: { dnsResolutionOrder: 'ipv4first' },
|
||||
security: { auth: { selectedType: 'google' } },
|
||||
ui: { theme: 'default' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getAcpMode: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedCleanup: () => void;
|
||||
vi.mocked(ConsolePatcher).mockImplementation(() => {
|
||||
const instance = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
capturedCleanup = instance.cleanup;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return instance as any;
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
const registeredFunctions = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map((call) => call[0]);
|
||||
expect(registeredFunctions).not.toContain(capturedCleanup!);
|
||||
});
|
||||
|
||||
it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
acp: false,
|
||||
query: 'test',
|
||||
startupMessages: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
tools: { allowed: [], exclude: [] },
|
||||
advanced: { dnsResolutionOrder: 'ipv4first' },
|
||||
security: { auth: { selectedType: 'google' } },
|
||||
ui: { theme: 'default' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getAcpMode: () => false,
|
||||
getQuestion: () => 'test',
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedCleanup: () => void;
|
||||
vi.mocked(ConsolePatcher).mockImplementation(() => {
|
||||
const instance = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
capturedCleanup = instance.cleanup;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return instance as any;
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch {
|
||||
// Ignore errors from incomplete mocks in full main() execution
|
||||
}
|
||||
|
||||
const registeredFunctions = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map((call) => call[0]);
|
||||
expect(registeredFunctions).toContain(capturedCleanup!);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
@@ -319,7 +444,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
|
||||
@@ -170,6 +170,7 @@ describe('BuiltinCommandLoader', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
@@ -396,6 +397,7 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
|
||||
@@ -62,6 +62,7 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
|
||||
import { voiceCommand } from '../ui/commands/voiceCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -227,6 +228,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []),
|
||||
...(this.config?.getContentGeneratorConfig()?.authType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE
|
||||
? [upgradeCommand]
|
||||
|
||||
@@ -552,6 +552,8 @@ const mockUIActions: UIActions = {
|
||||
exitPrivacyNotice: vi.fn(),
|
||||
closeSettingsDialog: vi.fn(),
|
||||
closeModelDialog: vi.fn(),
|
||||
openVoiceModelDialog: vi.fn(),
|
||||
closeVoiceModelDialog: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
openPermissionsDialog: vi.fn(),
|
||||
@@ -598,6 +600,7 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
setVoiceModeEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
|
||||
@@ -103,6 +103,7 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
@@ -312,6 +313,7 @@ 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);
|
||||
@@ -946,6 +948,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isVoiceModelDialogOpen,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
} = useVoiceModelCommand();
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
@@ -969,6 +977,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
@@ -981,6 +990,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev),
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
@@ -1006,6 +1016,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
@@ -2191,6 +2202,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isVoiceModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthenticating ||
|
||||
@@ -2448,6 +2460,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2468,6 +2481,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
@@ -2559,6 +2573,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2579,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
@@ -2671,6 +2687,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2751,6 +2769,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setAccountSuspensionInfo(null);
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
setVoiceModeEnabled: (value: boolean) => {
|
||||
setVoiceModeEnabled(value);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -2764,6 +2785,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2807,6 +2830,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
setVoiceModeEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface CommandContext {
|
||||
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVoiceMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
reloadCommands: () => void;
|
||||
@@ -125,6 +126,7 @@ export interface OpenDialogActionReturn {
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'voice-model'
|
||||
| 'agentConfig'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const voiceCommand: SlashCommand = {
|
||||
name: 'voice',
|
||||
altNames: [],
|
||||
description: 'Toggle voice dictation mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleVoiceMode();
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Manage voice transcription models',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'voice-model',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,6 +25,7 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { VoiceModelDialog } from './VoiceModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
@@ -238,6 +239,9 @@ 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,12 +9,41 @@ import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act, useState, useMemo } from 'react';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
|
||||
const { fakeTranscriptionProvider } = vi.hoisted(() => {
|
||||
// Use require within hoisted block for immediate synchronous access
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
|
||||
const { EventEmitter } = require('node:events');
|
||||
class FakeTranscriptionProvider extends EventEmitter {
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
disconnect = vi.fn();
|
||||
sendAudioChunk = vi.fn();
|
||||
getTranscription = vi.fn().mockReturnValue('');
|
||||
}
|
||||
return {
|
||||
fakeTranscriptionProvider: new FakeTranscriptionProvider(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
TranscriptionFactory: {
|
||||
createProvider: vi.fn(() => fakeTranscriptionProvider),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
InputPrompt,
|
||||
tryTogglePasteExpansion,
|
||||
type InputPromptProps,
|
||||
} from './InputPrompt.js';
|
||||
import { InputContext } from '../contexts/InputContext.js';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
calculateTransformationsForLine,
|
||||
calculateTransformedLine,
|
||||
@@ -417,6 +446,7 @@ describe('InputPrompt', () => {
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => ['/test/project/src'],
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
|
||||
} as unknown as Config,
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
@@ -4925,6 +4955,383 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Voice Mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
fakeTranscriptionProvider as unknown as EventEmitter
|
||||
).removeAllListeners();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should start recording when space is pressed and voice mode is enabled (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<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 {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
debugLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useVoiceMode } from '../hooks/useVoiceMode.js';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
parseSegmentsFromTokens,
|
||||
@@ -159,7 +160,6 @@ export function isLargePaste(text: string): boolean {
|
||||
}
|
||||
|
||||
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
|
||||
|
||||
/**
|
||||
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||||
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||||
@@ -238,6 +238,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setEmbeddedShellFocused,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
setVoiceModeEnabled,
|
||||
} = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
@@ -246,6 +247,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
shortcutsHelpVisible,
|
||||
isVoiceModeEnabled,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -263,6 +265,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetTurnBaseline();
|
||||
resetCompletionState();
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
@@ -281,6 +284,16 @@ export const InputPrompt: React.FC<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('');
|
||||
@@ -387,6 +400,7 @@ 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();
|
||||
@@ -398,6 +412,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
resetTurnBaseline,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -647,6 +662,8 @@ 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 &&
|
||||
@@ -873,9 +890,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,
|
||||
@@ -1360,6 +1377,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTaskHeight,
|
||||
streamingState,
|
||||
handleEscPress,
|
||||
resetTurnBaseline,
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
toggleCleanUiDetailsVisible,
|
||||
@@ -1369,9 +1387,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
handleVoiceInput,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
|
||||
priority: true,
|
||||
@@ -1792,20 +1810,39 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{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>
|
||||
{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}>
|
||||
> 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>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</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>
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : null
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
|
||||
@@ -44,7 +44,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\x7f',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_P = '\u0010',
|
||||
CTRL_N = '\u000E',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @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>
|
||||
);
|
||||
}
|
||||
@@ -168,13 +168,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
|
||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> [Pasted Text: 10 lines]
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
|
||||
"────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
> hello
|
||||
|
||||
@@ -24,7 +24,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\x7f',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_L = '\u000C',
|
||||
}
|
||||
|
||||
|
||||
@@ -9,17 +9,7 @@ import { act } from 'react';
|
||||
import { renderHookWithProviders } from '../../test-utils/render.js';
|
||||
import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import type { Mock } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
afterAll,
|
||||
beforeAll,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from 'vitest';
|
||||
import { vi, afterAll, beforeAll, type Mock } from 'vitest';
|
||||
import {
|
||||
useKeypressContext,
|
||||
ESC_TIMEOUT,
|
||||
@@ -441,80 +431,6 @@ describe('KeypressContext', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Windows Terminal Backspace handling', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should NOT treat \\b as ctrl when WT_SESSION is NOT present and OS is not Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when WT_SESSION IS present (even if not Windows_NT)', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Linux');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\b as ctrl when OS is Windows_NT', async () => {
|
||||
vi.stubEnv('WT_SESSION', '');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\b');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should treat \\x7f as regular backspace regardless of WT_SESSION or OS', async () => {
|
||||
vi.stubEnv('WT_SESSION', 'some-id');
|
||||
vi.stubEnv('OS', 'Windows_NT');
|
||||
const { keyHandler } = await setupKeypressTest();
|
||||
|
||||
act(() => {
|
||||
stdin.write('\x7f');
|
||||
});
|
||||
|
||||
expect(keyHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'backspace',
|
||||
ctrl: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('paste mode', () => {
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -41,6 +41,7 @@ const KEY_INFO_MAP: Record<
|
||||
string,
|
||||
{ name: string; shift?: boolean; ctrl?: boolean }
|
||||
> = {
|
||||
OM: { name: 'enter' },
|
||||
'[200~': { name: 'paste-start' },
|
||||
'[201~': { name: 'paste-end' },
|
||||
'[[A': { name: 'f1' },
|
||||
@@ -651,20 +652,8 @@ function* emitKeys(
|
||||
// tab
|
||||
name = 'tab';
|
||||
alt = escaped;
|
||||
} else if (ch === '\b') {
|
||||
// ctrl+h / ctrl+backspace (windows terminals send \x08 for ctrl+backspace)
|
||||
name = 'backspace';
|
||||
// In Windows environments, \b is sent for Ctrl+Backspace (standard backspace is translated to \x7f).
|
||||
// We scope this to Windows/WT_SESSION to avoid breaking other unixes where \b is a plain backspace.
|
||||
if (
|
||||
typeof process !== 'undefined' &&
|
||||
(process.env?.['OS'] === 'Windows_NT' || !!process.env?.['WT_SESSION'])
|
||||
) {
|
||||
ctrl = true;
|
||||
}
|
||||
alt = escaped;
|
||||
} else if (ch === '\x7f') {
|
||||
// backspace
|
||||
} else if (ch === '\b' || ch === '\x7f') {
|
||||
// backspace or ctrl+h
|
||||
name = 'backspace';
|
||||
alt = escaped;
|
||||
} else if (ch === ESC) {
|
||||
|
||||
@@ -41,6 +41,8 @@ export interface UIActions {
|
||||
exitPrivacyNotice: () => void;
|
||||
closeSettingsDialog: () => void;
|
||||
closeModelDialog: () => void;
|
||||
openVoiceModelDialog: () => void;
|
||||
closeVoiceModelDialog: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
@@ -93,6 +95,7 @@ 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,6 +112,7 @@ export interface UIState {
|
||||
isSettingsDialogOpen: boolean;
|
||||
isSessionBrowserOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isVoiceModelDialogOpen: boolean;
|
||||
isAgentConfigDialogOpen: boolean;
|
||||
selectedAgentName?: string;
|
||||
selectedAgentDisplayName?: string;
|
||||
@@ -132,6 +133,7 @@ export interface UIState {
|
||||
pendingGeminiHistoryItems: HistoryItemWithoutId[];
|
||||
thought: ThoughtSummary | null;
|
||||
isInputActive: boolean;
|
||||
isVoiceModeEnabled: boolean;
|
||||
isResuming: boolean;
|
||||
shouldShowIdePrompt: boolean;
|
||||
isFolderTrustDialogOpen: boolean;
|
||||
|
||||
@@ -205,11 +205,13 @@ describe('useSlashCommandProcessor', () => {
|
||||
openSettingsDialog: vi.fn(),
|
||||
openSessionBrowser: vi.fn(),
|
||||
openModelDialog: mockOpenModelDialog,
|
||||
openVoiceModelDialog: vi.fn(),
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog: vi.fn(),
|
||||
quit: mockSetQuittingMessages,
|
||||
setDebugMessage: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleVoiceMode: vi.fn(),
|
||||
toggleDebugProfiler: vi.fn(),
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
addConfirmUpdateExtensionRequest: vi.fn(),
|
||||
|
||||
@@ -72,6 +72,7 @@ interface SlashCommandProcessorActions {
|
||||
openSettingsDialog: () => void;
|
||||
openSessionBrowser: () => void;
|
||||
openModelDialog: () => void;
|
||||
openVoiceModelDialog: () => void;
|
||||
openAgentConfigDialog: (
|
||||
name: string,
|
||||
displayName: string,
|
||||
@@ -81,6 +82,7 @@ interface SlashCommandProcessorActions {
|
||||
quit: (messages: HistoryItem[]) => void;
|
||||
setDebugMessage: (message: string) => void;
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVoiceMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
|
||||
@@ -232,6 +234,7 @@ export const useSlashCommandProcessor = (
|
||||
pendingItem,
|
||||
setPendingItem,
|
||||
toggleCorgiMode: actions.toggleCorgiMode,
|
||||
toggleVoiceMode: actions.toggleVoiceMode,
|
||||
toggleDebugProfiler: actions.toggleDebugProfiler,
|
||||
toggleVimEnabled,
|
||||
reloadCommands,
|
||||
@@ -503,6 +506,9 @@ export const useSlashCommandProcessor = (
|
||||
case 'model':
|
||||
actions.openModelDialog();
|
||||
return { type: 'handled' };
|
||||
case 'voice-model':
|
||||
actions.openVoiceModelDialog();
|
||||
return { type: 'handled' };
|
||||
case 'agentConfig': {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const props = result.props as Record<string, unknown>;
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
AudioRecorder,
|
||||
TranscriptionFactory,
|
||||
debugLogger,
|
||||
type Config,
|
||||
type TranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { TextBuffer } from '../components/shared/text-buffer.js';
|
||||
import type { MergedSettings } from '../../config/settingsSchema.js';
|
||||
import type { Key } from './useKeypress.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
|
||||
interface UseVoiceModeProps {
|
||||
buffer: TextBuffer;
|
||||
config: Config;
|
||||
settings: MergedSettings;
|
||||
setQueueErrorMessage: (message: string | null) => void;
|
||||
isVoiceModeEnabled: boolean;
|
||||
setVoiceModeEnabled: (enabled: boolean) => void;
|
||||
keyMatchers: Record<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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseVoiceModelCommandReturn {
|
||||
isVoiceModelDialogOpen: boolean;
|
||||
openVoiceModelDialog: () => void;
|
||||
closeVoiceModelDialog: () => void;
|
||||
}
|
||||
|
||||
export const useVoiceModelCommand = (): UseVoiceModelCommandReturn => {
|
||||
const [isVoiceModelDialogOpen, setIsVoiceModelDialogOpen] = useState(false);
|
||||
|
||||
const openVoiceModelDialog = useCallback(() => {
|
||||
setIsVoiceModelDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeVoiceModelDialog = useCallback(() => {
|
||||
setIsVoiceModelDialogOpen(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isVoiceModelDialogOpen,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
};
|
||||
};
|
||||
@@ -97,6 +97,7 @@ export enum Command {
|
||||
RESTART_APP = 'app.restart',
|
||||
SUSPEND_APP = 'app.suspend',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
|
||||
VOICE_MODE_PTT = 'app.voiceModePTT',
|
||||
|
||||
// Background Shell Controls
|
||||
BACKGROUND_SHELL_ESCAPE = 'background.escape',
|
||||
@@ -407,9 +408,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
|
||||
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
|
||||
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
|
||||
[Command.START_RECORDING, [new KeyBinding('f6')]],
|
||||
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
|
||||
[Command.VOICE_MODE_PTT, [new KeyBinding('space')]],
|
||||
|
||||
// Background Shell Controls
|
||||
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
|
||||
@@ -424,6 +423,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
|
||||
// Extension Controls
|
||||
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
|
||||
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
|
||||
|
||||
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
|
||||
[Command.START_RECORDING, [new KeyBinding('f6')]],
|
||||
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
|
||||
]);
|
||||
|
||||
interface CommandCategory {
|
||||
@@ -538,6 +541,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.RESTART_APP,
|
||||
Command.SUSPEND_APP,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.VOICE_MODE_PTT,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -658,6 +662,7 @@ 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,5 +43,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundTasks: () => {},
|
||||
toggleShortcutsHelp: () => {},
|
||||
toggleVoiceMode: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,25 @@ const debugLogger = vi.hoisted(() => ({
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
getPackageJson,
|
||||
debugLogger,
|
||||
ReleaseChannel: {
|
||||
NIGHTLY: 'nightly',
|
||||
PREVIEW: 'preview',
|
||||
STABLE: 'stable',
|
||||
},
|
||||
getChannelFromVersion: (version: string) => {
|
||||
if (!version || version.includes('nightly')) {
|
||||
return 'nightly';
|
||||
}
|
||||
if (version.includes('preview')) {
|
||||
return 'preview';
|
||||
}
|
||||
return 'stable';
|
||||
},
|
||||
RELEASE_CHANNEL_STABILITY: {
|
||||
nightly: 0,
|
||||
preview: 1,
|
||||
stable: 2,
|
||||
},
|
||||
}));
|
||||
|
||||
const latestVersion = vi.hoisted(() => vi.fn());
|
||||
@@ -152,4 +171,68 @@ describe('checkForUpdates', () => {
|
||||
expect(result?.update.latest).toBe('1.2.3-nightly.2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel stability', () => {
|
||||
it('should NOT offer nightly update to a stable user even if tagged as latest', async () => {
|
||||
getPackageJson.mockResolvedValue({
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
});
|
||||
// latest points to a nightly that is semver-greater
|
||||
latestVersion.mockResolvedValue('1.1.0-nightly.1');
|
||||
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should NOT offer preview update to a stable user even if tagged as latest', async () => {
|
||||
getPackageJson.mockResolvedValue({
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
});
|
||||
// latest points to a preview that is semver-greater
|
||||
latestVersion.mockResolvedValue('1.1.0-preview.1');
|
||||
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should offer stable update to a stable user', async () => {
|
||||
getPackageJson.mockResolvedValue({
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
});
|
||||
latestVersion.mockResolvedValue('1.1.0');
|
||||
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result?.update.latest).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should offer stable update to a nightly user', async () => {
|
||||
getPackageJson.mockResolvedValue({
|
||||
name: 'test-package',
|
||||
version: '1.0.0-nightly.1',
|
||||
});
|
||||
latestVersion.mockImplementation(async (name, options) => {
|
||||
if (options?.version === 'nightly') {
|
||||
return '1.0.0-nightly.1'; // No nightly update
|
||||
}
|
||||
return '1.1.0'; // Stable update available
|
||||
});
|
||||
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result?.update.latest).toBe('1.1.0');
|
||||
});
|
||||
|
||||
it('should offer stable update to a preview user', async () => {
|
||||
getPackageJson.mockResolvedValue({
|
||||
name: 'test-package',
|
||||
version: '1.0.0-preview.1',
|
||||
});
|
||||
latestVersion.mockResolvedValue('1.1.0');
|
||||
|
||||
const result = await checkForUpdates(mockSettings);
|
||||
expect(result?.update.latest).toBe('1.1.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
|
||||
import latestVersion from 'latest-version';
|
||||
import semver from 'semver';
|
||||
import { getPackageJson, debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
getPackageJson,
|
||||
debugLogger,
|
||||
getChannelFromVersion,
|
||||
RELEASE_CHANNEL_STABILITY,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
@@ -65,6 +70,7 @@ export async function checkForUpdates(
|
||||
}
|
||||
|
||||
const { name, version: currentVersion } = packageJson;
|
||||
const currentChannel = getChannelFromVersion(currentVersion);
|
||||
const isNightly = currentVersion.includes('nightly');
|
||||
|
||||
if (isNightly) {
|
||||
@@ -90,8 +96,21 @@ export async function checkForUpdates(
|
||||
}
|
||||
} else {
|
||||
const latestUpdate = await latestVersion(name);
|
||||
if (!latestUpdate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (latestUpdate && semver.gt(latestUpdate, currentVersion)) {
|
||||
const targetChannel = getChannelFromVersion(latestUpdate);
|
||||
|
||||
// Only offer updates that are as stable or more stable than the current version
|
||||
if (
|
||||
RELEASE_CHANNEL_STABILITY[targetChannel] <
|
||||
RELEASE_CHANNEL_STABILITY[currentChannel]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (semver.gt(latestUpdate, currentVersion)) {
|
||||
const message = `Gemini CLI update available! ${currentVersion} → ${latestUpdate}`;
|
||||
const type = semver.diff(latestUpdate, currentVersion) || undefined;
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { ActivityLogger, type NetworkLog } from './activityLogger.js';
|
||||
import type { ConsoleLogPayload } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -132,4 +132,95 @@ describe('ActivityLogger', () => {
|
||||
expect(after.console.length).toBe(0);
|
||||
expect(after.network.length).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves headers and method from Request object when intercepting fetch', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
const mockFetch = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
clone: () => ({
|
||||
body: null,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: async () => 'ok',
|
||||
json: async () => ({}),
|
||||
}),
|
||||
} as unknown as Response),
|
||||
);
|
||||
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
logger.isInterceptionEnabled = false;
|
||||
logger.enable();
|
||||
|
||||
const request = new Request('https://api.example.com/data', {
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
await global.fetch(request);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
const [, calledInit] = mockFetch.mock.calls[0];
|
||||
|
||||
expect(calledInit?.headers).toBeDefined();
|
||||
const headers = new Headers(calledInit?.headers as HeadersInit);
|
||||
expect(headers.get('Authorization')).toBe('Bearer test-token');
|
||||
expect(headers.has('x-activity-request-id')).toBe(true);
|
||||
expect(calledInit?.method).toBe('POST');
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
// @ts-expect-error - reset private property
|
||||
logger.isInterceptionEnabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('replaces Request headers with init headers (Fetch spec compliance)', async () => {
|
||||
const originalFetch = global.fetch;
|
||||
const mockFetch = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
clone: () => ({
|
||||
body: null,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
text: async () => 'ok',
|
||||
}),
|
||||
} as unknown as Response),
|
||||
);
|
||||
global.fetch = mockFetch;
|
||||
|
||||
try {
|
||||
// @ts-expect-error - accessing private property for testing
|
||||
logger.isInterceptionEnabled = false;
|
||||
logger.enable();
|
||||
|
||||
const request = new Request('https://api.example.com/data', {
|
||||
headers: { 'X-Old': 'old-value', 'X-Shared': 'old-shared' },
|
||||
});
|
||||
|
||||
await global.fetch(request, {
|
||||
headers: { 'X-New': 'new-value', 'X-Shared': 'new-shared' },
|
||||
});
|
||||
|
||||
const [, calledInit] = mockFetch.mock.calls[0];
|
||||
const headers = new Headers(calledInit?.headers as HeadersInit);
|
||||
|
||||
expect(headers.get('X-New')).toBe('new-value');
|
||||
expect(headers.get('X-Shared')).toBe('new-shared');
|
||||
expect(headers.has('X-Old')).toBe(false);
|
||||
expect(headers.has('x-activity-request-id')).toBe(true);
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
// @ts-expect-error - reset private property
|
||||
logger.isInterceptionEnabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,18 +302,31 @@ export class ActivityLogger extends EventEmitter {
|
||||
return originalFetch(input, init);
|
||||
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
const method = (init?.method || 'GET').toUpperCase();
|
||||
|
||||
const newInit = { ...init };
|
||||
const headers = new Headers(init?.headers || {});
|
||||
const inputMethod =
|
||||
typeof input === 'object' && 'method' in input
|
||||
? input.method
|
||||
: undefined;
|
||||
const inputHeaders =
|
||||
typeof input === 'object' && 'headers' in input
|
||||
? input.headers
|
||||
: undefined;
|
||||
|
||||
const method = (init?.method ?? inputMethod ?? 'GET').toUpperCase();
|
||||
const headers = new Headers(init?.headers ?? inputHeaders ?? {});
|
||||
headers.set(ACTIVITY_ID_HEADER, id);
|
||||
newInit.headers = headers;
|
||||
|
||||
const newInit = {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
let reqBody = '';
|
||||
if (init?.body) {
|
||||
if (typeof init.body === 'string') reqBody = init.body;
|
||||
else if (init.body instanceof URLSearchParams)
|
||||
reqBody = init.body.toString();
|
||||
const body = newInit.body;
|
||||
if (body) {
|
||||
if (typeof body === 'string') reqBody = body;
|
||||
else if (body instanceof URLSearchParams) reqBody = body.toString();
|
||||
}
|
||||
|
||||
this.requestStartTimes.set(id, Date.now());
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually. (command: npm i -g @google/gemini-cli@2.0.0)',
|
||||
'Automatic update failed. Please try updating manually:\n\nnpm i -g @google/gemini-cli@2.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('handleAutoUpdate', () => {
|
||||
|
||||
expect(updateEventEmitter.emit).toHaveBeenCalledWith('update-failed', {
|
||||
message:
|
||||
'Automatic update failed. Please try updating manually. (error: Spawn error)',
|
||||
'Automatic update failed. Please try updating manually. (error: Spawn error)\n\nnpm i -g @google/gemini-cli@2.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,7 +334,8 @@ describe('handleAutoUpdate', () => {
|
||||
...mockUpdateInfo,
|
||||
update: {
|
||||
...mockUpdateInfo.update,
|
||||
latest: '2.0.0-nightly',
|
||||
current: '1.0.0-nightly.0',
|
||||
latest: '2.0.0-nightly.1',
|
||||
},
|
||||
};
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
@@ -356,6 +357,26 @@ describe('handleAutoUpdate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT update if target is less stable than current (defense-in-depth)', async () => {
|
||||
mockUpdateInfo = {
|
||||
...mockUpdateInfo,
|
||||
update: {
|
||||
...mockUpdateInfo.update,
|
||||
current: '1.0.0',
|
||||
latest: '1.1.0-nightly.1',
|
||||
},
|
||||
};
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
updateCommand: 'npm i -g @google/gemini-cli@latest',
|
||||
isGlobal: false,
|
||||
packageManager: PackageManager.NPM,
|
||||
});
|
||||
|
||||
handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn);
|
||||
|
||||
expect(mockSpawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit "update-success" when the update process succeeds', async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
mockGetInstallationInfo.mockReturnValue({
|
||||
@@ -435,13 +456,15 @@ describe('setUpdateHandler', () => {
|
||||
});
|
||||
|
||||
it('should handle update-failed event', () => {
|
||||
updateEventEmitter.emit('update-failed', { message: 'Failed' });
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: 'Failed message with command',
|
||||
});
|
||||
|
||||
expect(setUpdateInfo).toHaveBeenCalledWith(null);
|
||||
expect(addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Automatic update failed. Please try updating manually',
|
||||
text: 'Failed message with command',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,11 @@ import { updateEventEmitter } from './updateEventEmitter.js';
|
||||
import { MessageType, type HistoryItem } from '../ui/types.js';
|
||||
import { spawnWrapper } from './spawnWrapper.js';
|
||||
import type { spawn } from 'node:child_process';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
getChannelFromVersion,
|
||||
RELEASE_CHANNEL_STABILITY,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
let _updateInProgress = false;
|
||||
|
||||
@@ -122,6 +126,25 @@ export function handleAutoUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = info.update.current;
|
||||
if (!currentVersion) {
|
||||
debugLogger.warn(
|
||||
'Update check: current version is missing. Skipping automatic update for safety.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentChannel = getChannelFromVersion(currentVersion);
|
||||
const targetChannel = getChannelFromVersion(info.update.latest);
|
||||
|
||||
// Defense-in-depth: prevent updates to a less stable channel
|
||||
if (
|
||||
RELEASE_CHANNEL_STABILITY[targetChannel] <
|
||||
RELEASE_CHANNEL_STABILITY[currentChannel]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNightly = info.update.latest.includes('nightly');
|
||||
|
||||
const updateCommand = installationInfo.updateCommand.replace(
|
||||
@@ -148,7 +171,7 @@ export function handleAutoUpdate(
|
||||
});
|
||||
} else {
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed. Please try updating manually. (command: ${updateCommand})`,
|
||||
message: `Automatic update failed. Please try updating manually:\n\n${updateCommand}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -156,7 +179,7 @@ export function handleAutoUpdate(
|
||||
updateProcess.on('error', (err) => {
|
||||
_updateInProgress = false;
|
||||
updateEventEmitter.emit('update-failed', {
|
||||
message: `Automatic update failed. Please try updating manually. (error: ${err.message})`,
|
||||
message: `Automatic update failed. Please try updating manually. (error: ${err.message})\n\n${updateCommand}`,
|
||||
});
|
||||
});
|
||||
return updateProcess;
|
||||
@@ -184,12 +207,14 @@ export function setUpdateHandler(
|
||||
}, 60000);
|
||||
};
|
||||
|
||||
const handleUpdateFailed = () => {
|
||||
const handleUpdateFailed = (data?: { message: string }) => {
|
||||
setUpdateInfo(null);
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Automatic update failed. Please try updating manually`,
|
||||
text:
|
||||
data?.message ||
|
||||
`Automatic update failed. Please try updating manually`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
relaunchApp,
|
||||
_resetRelaunchStateForTesting,
|
||||
isStandardSea,
|
||||
getScriptArgs,
|
||||
isSeaEnvironment,
|
||||
getSpawnConfig,
|
||||
type ProcessWithSea,
|
||||
} from './processUtils.js';
|
||||
import * as cleanup from './cleanup.js';
|
||||
import * as handleAutoUpdate from './handleAutoUpdate.js';
|
||||
@@ -36,3 +41,156 @@ describe('processUtils', () => {
|
||||
expect(processExit).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEA handling utilities', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalExecArgv = process.execArgv;
|
||||
const originalExecPath = process.execPath;
|
||||
const originalIsSea = (process as ProcessWithSea).isSea;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.stubEnv('NODE_OPTIONS', '');
|
||||
process.argv = [...originalArgv];
|
||||
process.execArgv = [...originalExecArgv];
|
||||
process.execPath = '/fake/exec/path';
|
||||
delete (process as ProcessWithSea).isSea;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.argv = originalArgv;
|
||||
process.execArgv = originalExecArgv;
|
||||
process.execPath = originalExecPath;
|
||||
if (originalIsSea) {
|
||||
(process as ProcessWithSea).isSea = originalIsSea;
|
||||
} else {
|
||||
delete (process as ProcessWithSea).isSea;
|
||||
}
|
||||
});
|
||||
|
||||
describe('isStandardSea', () => {
|
||||
it('returns false if argv[0] === argv[1]', () => {
|
||||
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command'];
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
expect(isStandardSea()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if IS_BINARY is true and argv[0] !== argv[1]', () => {
|
||||
process.argv = ['/bin/gemini', 'my-command'];
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
expect(isStandardSea()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if process.isSea() is true and argv[0] !== argv[1]', () => {
|
||||
process.argv = ['/bin/gemini', 'my-command'];
|
||||
(process as ProcessWithSea).isSea = () => true;
|
||||
expect(isStandardSea()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in standard node environment', () => {
|
||||
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
|
||||
expect(isStandardSea()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getScriptArgs', () => {
|
||||
it('slices from index 1 if isStandardSea is true', () => {
|
||||
process.argv = ['/bin/gemini', 'my-command', '--flag'];
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
|
||||
});
|
||||
|
||||
it('slices from index 2 if isStandardSea is false (relaunch SEA or standard node)', () => {
|
||||
// Relaunch SEA
|
||||
process.argv = ['/bin/gemini', '/bin/gemini', 'my-command', '--flag'];
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
expect(getScriptArgs()).toEqual(['my-command', '--flag']);
|
||||
|
||||
// Standard node
|
||||
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
|
||||
vi.stubEnv('IS_BINARY', '');
|
||||
expect(getScriptArgs()).toEqual(['my-command']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSeaEnvironment', () => {
|
||||
it('returns true if IS_BINARY is true', () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
expect(isSeaEnvironment()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if process.isSea() is true', () => {
|
||||
(process as ProcessWithSea).isSea = () => true;
|
||||
expect(isSeaEnvironment()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if argv[0] === argv[1]', () => {
|
||||
process.argv = ['/bin/gemini', '/bin/gemini'];
|
||||
expect(isSeaEnvironment()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false otherwise', () => {
|
||||
process.argv = ['/bin/node', '/path/to/script.js'];
|
||||
expect(isSeaEnvironment()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSpawnConfig', () => {
|
||||
it('handles standard node mode', () => {
|
||||
process.argv = ['/bin/node', '/path/to/script.js', 'my-command'];
|
||||
process.execArgv = ['--inspect'];
|
||||
process.execPath = '/bin/node';
|
||||
|
||||
const config = getSpawnConfig(
|
||||
['--max-old-space-size=8192'],
|
||||
['my-command'],
|
||||
);
|
||||
|
||||
expect(config.spawnArgs).toEqual([
|
||||
'--inspect',
|
||||
'--max-old-space-size=8192',
|
||||
'/path/to/script.js',
|
||||
'my-command',
|
||||
]);
|
||||
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
|
||||
expect(config.env['NODE_OPTIONS']).toBeFalsy();
|
||||
});
|
||||
|
||||
it('handles SEA binary mode with new nodeArgs', () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
|
||||
process.argv = ['/bin/gemini', 'my-command'];
|
||||
process.execArgv = ['--inspect']; // Should not be duplicated in NODE_OPTIONS
|
||||
process.execPath = '/bin/gemini';
|
||||
|
||||
const config = getSpawnConfig(
|
||||
['--max-old-space-size=8192'],
|
||||
['my-command'],
|
||||
);
|
||||
|
||||
expect(config.spawnArgs).toEqual([
|
||||
'/bin/gemini', // explicitly uses execPath as placeholder
|
||||
'my-command',
|
||||
]);
|
||||
expect(config.env['NODE_OPTIONS']).toBe(
|
||||
'--existing-flag --max-old-space-size=8192',
|
||||
);
|
||||
expect(config.env['GEMINI_CLI_NO_RELAUNCH']).toBe('true');
|
||||
});
|
||||
|
||||
it('throws error for complex nodeArgs in SEA mode', () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
|
||||
expect(() => {
|
||||
getSpawnConfig(['--title "My App"'], []);
|
||||
}).toThrow(
|
||||
'Unsupported node argument for SEA relaunch: --title "My App". Complex escaping is not supported.',
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
getSpawnConfig(['--title=A\\B'], []);
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,3 +29,101 @@ export async function relaunchApp(): Promise<void> {
|
||||
await runExitCleanup();
|
||||
process.exit(RELAUNCH_EXIT_CODE);
|
||||
}
|
||||
|
||||
export interface ProcessWithSea extends NodeJS.Process {
|
||||
isSea?: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the current process is a "standard" SEA (Single Executable Application)
|
||||
* where the user arguments start at index 1 instead of index 2.
|
||||
* A relaunched SEA child will have process.argv[0] === process.argv[1] (because we inject execPath),
|
||||
* so it will return false here and correctly slice from index 2.
|
||||
*/
|
||||
export function isStandardSea(): boolean {
|
||||
return (
|
||||
process.argv[0] !== process.argv[1] &&
|
||||
(process.env['IS_BINARY'] === 'true' ||
|
||||
(process as ProcessWithSea).isSea?.() === true)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the user-provided script arguments from process.argv,
|
||||
* accounting for the differences in SEA execution modes.
|
||||
*/
|
||||
export function getScriptArgs(): string[] {
|
||||
return process.argv.slice(isStandardSea() ? 1 : 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current process is running in any SEA environment
|
||||
* (either the initial launch or a relaunched child).
|
||||
*/
|
||||
export function isSeaEnvironment(): boolean {
|
||||
return (
|
||||
process.env['IS_BINARY'] === 'true' ||
|
||||
(process as ProcessWithSea).isSea?.() === true ||
|
||||
process.argv[0] === process.argv[1]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the arguments and environment for spawning a child process during relaunch.
|
||||
* Handles differences between standard Node and SEA binary modes.
|
||||
*/
|
||||
export function getSpawnConfig(
|
||||
nodeArgs: string[],
|
||||
scriptArgs: string[],
|
||||
): {
|
||||
spawnArgs: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
} {
|
||||
const isBinary = isSeaEnvironment();
|
||||
const newEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GEMINI_CLI_NO_RELAUNCH: 'true',
|
||||
};
|
||||
|
||||
const finalSpawnArgs: string[] = [];
|
||||
|
||||
if (isBinary) {
|
||||
// In SEA mode, Node flags must be passed via NODE_OPTIONS, as the binary
|
||||
// passes all CLI arguments directly to the application.
|
||||
// We only need to append the *new* nodeArgs (e.g., memory flags).
|
||||
// Existing execArgv are inherited via the environment or baked into the binary.
|
||||
if (nodeArgs.length > 0) {
|
||||
for (const arg of nodeArgs) {
|
||||
if (/[\s"'\\]/.test(arg)) {
|
||||
throw new Error(
|
||||
`Unsupported node argument for SEA relaunch: ${arg}. Complex escaping is not supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const existingNodeOptions = process.env['NODE_OPTIONS'] || '';
|
||||
// nodeArgs in our codebase are simple flags like --max-old-space-size=X
|
||||
// that do not contain spaces and do not require complex escaping.
|
||||
newEnv['NODE_OPTIONS'] =
|
||||
`${existingNodeOptions} ${nodeArgs.join(' ')}`.trim();
|
||||
}
|
||||
// Binary is its own entry point. To maintain the [node, script, ...args]
|
||||
// structure expected by the application (which uses slice(2)),
|
||||
// we must provide a placeholder for the script path.
|
||||
// We explicitly use process.execPath to break the cycle and prevent
|
||||
// compounding argument duplication on subsequent relaunches.
|
||||
finalSpawnArgs.push(process.execPath, ...scriptArgs);
|
||||
} else {
|
||||
// Standard Node mode: pass all flags via command line.
|
||||
finalSpawnArgs.push(
|
||||
...process.execArgv,
|
||||
...nodeArgs,
|
||||
process.argv[1],
|
||||
...scriptArgs,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
spawnArgs: finalSpawnArgs,
|
||||
env: newEnv,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('relaunchOnExitCode', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
processExitSpy.mockRestore();
|
||||
stdinResumeSpy.mockRestore();
|
||||
});
|
||||
@@ -116,7 +117,6 @@ describe('relaunchAppInChildProcess', () => {
|
||||
let stdinResumeSpy: MockInstance;
|
||||
|
||||
// Store original values to restore later
|
||||
const originalEnv = { ...process.env };
|
||||
const originalExecArgv = [...process.execArgv];
|
||||
const originalArgv = [...process.argv];
|
||||
const originalExecPath = process.execPath;
|
||||
@@ -125,8 +125,9 @@ describe('relaunchAppInChildProcess', () => {
|
||||
vi.clearAllMocks();
|
||||
mocks.writeToStderr.mockClear();
|
||||
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
|
||||
vi.stubEnv('IS_BINARY', '');
|
||||
vi.stubEnv('NODE_OPTIONS', '');
|
||||
|
||||
process.execArgv = [...originalExecArgv];
|
||||
process.argv = [...originalArgv];
|
||||
@@ -144,7 +145,7 @@ describe('relaunchAppInChildProcess', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.unstubAllEnvs();
|
||||
process.execArgv = [...originalExecArgv];
|
||||
process.argv = [...originalArgv];
|
||||
process.execPath = originalExecPath;
|
||||
@@ -156,7 +157,7 @@ describe('relaunchAppInChildProcess', () => {
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is set', () => {
|
||||
it('should return early without spawning a child process', async () => {
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', 'true');
|
||||
|
||||
await relaunchAppInChildProcess(['--test'], ['--verbose']);
|
||||
|
||||
@@ -167,132 +168,141 @@ describe('relaunchAppInChildProcess', () => {
|
||||
|
||||
describe('when GEMINI_CLI_NO_RELAUNCH is not set', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
|
||||
vi.stubEnv('GEMINI_CLI_NO_RELAUNCH', '');
|
||||
});
|
||||
|
||||
it('should construct correct node arguments from execArgv, additionalNodeArgs, script, additionalScriptArgs, and argv', () => {
|
||||
// Test the argument construction logic directly by extracting it into a testable function
|
||||
// This tests the same logic that's used in relaunchAppInChildProcess
|
||||
|
||||
// Setup test data to verify argument ordering
|
||||
const mockExecArgv = ['--inspect=9229', '--trace-warnings'];
|
||||
const mockArgv = [
|
||||
it('should construct correct spawn arguments and use command line for node arguments in standard Node mode', async () => {
|
||||
process.execArgv = ['--inspect=9229', '--trace-warnings'];
|
||||
process.argv = [
|
||||
'/usr/bin/node',
|
||||
'/path/to/cli.js',
|
||||
'command',
|
||||
'--flag=value',
|
||||
'--verbose',
|
||||
];
|
||||
|
||||
const additionalNodeArgs = [
|
||||
'--max-old-space-size=4096',
|
||||
'--experimental-modules',
|
||||
];
|
||||
const additionalScriptArgs = ['--model', 'gemini-1.5-pro', '--debug'];
|
||||
|
||||
// Extract the argument construction logic from relaunchAppInChildProcess
|
||||
const script = mockArgv[1];
|
||||
const scriptArgs = mockArgv.slice(2);
|
||||
const mockChild = createMockChildProcess(0, true);
|
||||
mockedSpawn.mockReturnValue(mockChild);
|
||||
|
||||
const nodeArgs = [
|
||||
...mockExecArgv,
|
||||
...additionalNodeArgs,
|
||||
script,
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
await expect(
|
||||
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
|
||||
).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
[
|
||||
'--inspect=9229',
|
||||
'--trace-warnings',
|
||||
'--max-old-space-size=4096',
|
||||
'--experimental-modules',
|
||||
'/path/to/cli.js',
|
||||
'--model',
|
||||
'gemini-1.5-pro',
|
||||
'--debug',
|
||||
'command',
|
||||
'--flag=value',
|
||||
'--verbose',
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
GEMINI_CLI_NO_RELAUNCH: 'true',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const lastCall = mockedSpawn.mock.calls[0] as unknown as [
|
||||
string,
|
||||
string[],
|
||||
{ env: NodeJS.ProcessEnv },
|
||||
];
|
||||
const env = lastCall[2].env;
|
||||
expect(env['NODE_OPTIONS']).toBeFalsy();
|
||||
});
|
||||
|
||||
// Verify the argument construction follows the expected pattern:
|
||||
// [...process.execArgv, ...additionalNodeArgs, script, ...additionalScriptArgs, ...scriptArgs]
|
||||
const expectedArgs = [
|
||||
// Original node execution arguments
|
||||
'--inspect=9229',
|
||||
'--trace-warnings',
|
||||
// Additional node arguments passed to function
|
||||
'--max-old-space-size=4096',
|
||||
'--experimental-modules',
|
||||
// The script path
|
||||
'/path/to/cli.js',
|
||||
// Additional script arguments passed to function
|
||||
'--model',
|
||||
'gemini-1.5-pro',
|
||||
'--debug',
|
||||
// Original script arguments (everything after the script in process.argv)
|
||||
it('should handle SEA binary mode (IS_BINARY=true) correctly using NODE_OPTIONS', async () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
// execArgv should be inherited, not duplicated in NODE_OPTIONS
|
||||
process.execArgv = ['--inspect=9229'];
|
||||
process.argv = [
|
||||
'/usr/bin/gemini',
|
||||
'/usr/bin/gemini',
|
||||
'command',
|
||||
'--flag=value',
|
||||
'--verbose',
|
||||
];
|
||||
|
||||
expect(nodeArgs).toEqual(expectedArgs);
|
||||
});
|
||||
|
||||
it('should handle empty additional arguments correctly', () => {
|
||||
// Test edge cases with empty arrays
|
||||
const mockExecArgv = ['--trace-warnings'];
|
||||
const mockArgv = ['/usr/bin/node', '/app/cli.js', 'start'];
|
||||
const additionalNodeArgs: string[] = [];
|
||||
const additionalNodeArgs = ['--max-old-space-size=8192'];
|
||||
const additionalScriptArgs: string[] = [];
|
||||
|
||||
// Extract the argument construction logic
|
||||
const script = mockArgv[1];
|
||||
const scriptArgs = mockArgv.slice(2);
|
||||
const mockChild = createMockChildProcess(0, true);
|
||||
mockedSpawn.mockReturnValue(mockChild);
|
||||
|
||||
const nodeArgs = [
|
||||
...mockExecArgv,
|
||||
...additionalNodeArgs,
|
||||
script,
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
];
|
||||
await expect(
|
||||
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
|
||||
).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||
|
||||
const expectedArgs = ['--trace-warnings', '/app/cli.js', 'start'];
|
||||
|
||||
expect(nodeArgs).toEqual(expectedArgs);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/usr/bin/node', 'command', '--verbose'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
GEMINI_CLI_NO_RELAUNCH: 'true',
|
||||
NODE_OPTIONS: '--max-old-space-size=8192',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex argument patterns', () => {
|
||||
// Test with various argument types including flags with values, boolean flags, etc.
|
||||
const mockExecArgv = ['--max-old-space-size=8192'];
|
||||
const mockArgv = [
|
||||
'/usr/bin/node',
|
||||
'/cli.js',
|
||||
'--config=/path/to/config.json',
|
||||
'--verbose',
|
||||
'subcommand',
|
||||
'--output',
|
||||
'file.txt',
|
||||
];
|
||||
const additionalNodeArgs = ['--inspect-brk=9230'];
|
||||
const additionalScriptArgs = ['--model=gpt-4', '--temperature=0.7'];
|
||||
it('should append new nodeArgs to NODE_OPTIONS in SEA mode without escaping', async () => {
|
||||
vi.stubEnv('IS_BINARY', 'true');
|
||||
vi.stubEnv('NODE_OPTIONS', '--existing-flag');
|
||||
process.execArgv = ['--inspect']; // inherited from env/binary, should not be duplicated
|
||||
process.argv = ['/usr/bin/gemini', '/usr/bin/gemini', 'command'];
|
||||
|
||||
const script = mockArgv[1];
|
||||
const scriptArgs = mockArgv.slice(2);
|
||||
// In our use case, these are simple flags like --max-old-space-size=X
|
||||
const additionalNodeArgs = ['--max-old-space-size=8192'];
|
||||
const additionalScriptArgs: string[] = [];
|
||||
|
||||
const nodeArgs = [
|
||||
...mockExecArgv,
|
||||
...additionalNodeArgs,
|
||||
script,
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
];
|
||||
const mockChild = createMockChildProcess(0, true);
|
||||
mockedSpawn.mockReturnValue(mockChild);
|
||||
|
||||
const expectedArgs = [
|
||||
'--max-old-space-size=8192',
|
||||
'--inspect-brk=9230',
|
||||
'/cli.js',
|
||||
'--model=gpt-4',
|
||||
'--temperature=0.7',
|
||||
'--config=/path/to/config.json',
|
||||
'--verbose',
|
||||
'subcommand',
|
||||
'--output',
|
||||
'file.txt',
|
||||
];
|
||||
await expect(
|
||||
relaunchAppInChildProcess(additionalNodeArgs, additionalScriptArgs),
|
||||
).rejects.toThrow('PROCESS_EXIT_CALLED');
|
||||
|
||||
expect(nodeArgs).toEqual(expectedArgs);
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['/usr/bin/node', 'command'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
NODE_OPTIONS: '--existing-flag --max-old-space-size=8192',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Note: Additional integration tests for spawn behavior are complex due to module mocking
|
||||
// limitations with ES modules. The core logic is tested in relaunchOnExitCode tests.
|
||||
it('should handle empty additional arguments correctly in Node mode', async () => {
|
||||
process.execArgv = ['--trace-warnings'];
|
||||
process.argv = ['/usr/bin/node', '/app/cli.js', 'start'];
|
||||
|
||||
const mockChild = createMockChildProcess(0, true);
|
||||
mockedSpawn.mockReturnValue(mockChild);
|
||||
|
||||
await expect(relaunchAppInChildProcess([], [])).rejects.toThrow(
|
||||
'PROCESS_EXIT_CALLED',
|
||||
);
|
||||
|
||||
expect(mockedSpawn).toHaveBeenCalledWith(
|
||||
process.execPath,
|
||||
['--trace-warnings', '/app/cli.js', 'start'],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle null exit code from child process', async () => {
|
||||
process.argv = ['/usr/bin/node', '/app/cli.js'];
|
||||
@@ -342,6 +352,8 @@ function createMockChildProcess(
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn(),
|
||||
ref: vi.fn(),
|
||||
on: mockChild.on.bind(mockChild),
|
||||
emit: mockChild.emit.bind(mockChild),
|
||||
});
|
||||
|
||||
if (autoClose) {
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
|
||||
import {
|
||||
RELAUNCH_EXIT_CODE,
|
||||
getSpawnConfig,
|
||||
getScriptArgs,
|
||||
} from './processUtils.js';
|
||||
import {
|
||||
writeToStderr,
|
||||
type AdminControlsSettings,
|
||||
@@ -43,24 +47,16 @@ export async function relaunchAppInChildProcess(
|
||||
let latestAdminSettings = remoteAdminSettings;
|
||||
|
||||
const runner = () => {
|
||||
// process.argv is [node, script, ...args]
|
||||
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
|
||||
const script = process.argv[1];
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
|
||||
const nodeArgs = [
|
||||
...process.execArgv,
|
||||
...additionalNodeArgs,
|
||||
script,
|
||||
const scriptArgs = getScriptArgs();
|
||||
const { spawnArgs, env: newEnv } = getSpawnConfig(additionalNodeArgs, [
|
||||
...additionalScriptArgs,
|
||||
...scriptArgs,
|
||||
];
|
||||
const newEnv = { ...process.env, GEMINI_CLI_NO_RELAUNCH: 'true' };
|
||||
]);
|
||||
|
||||
// The parent process should not be reading from stdin while the child is running.
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
@@ -719,6 +719,65 @@ describe('sandbox', () => {
|
||||
expect(entrypointCmd).toContain('su -p gemini');
|
||||
});
|
||||
|
||||
it('should register and unregister proxy exit handlers', async () => {
|
||||
vi.stubEnv('GEMINI_SANDBOX_PROXY_COMMAND', 'some-proxy-cmd');
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
});
|
||||
|
||||
const onSpy = vi.spyOn(process, 'on');
|
||||
const offSpy = vi.spyOn(process, 'off');
|
||||
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
|
||||
vi.mocked(spawn).mockImplementation((cmd, args) => {
|
||||
const a = args as string[];
|
||||
if (cmd === 'docker' && a && a[0] === 'images') {
|
||||
const mockImageCheckProcess =
|
||||
new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<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' },
|
||||
|
||||
@@ -55,6 +55,8 @@ export async function start_sandbox(
|
||||
});
|
||||
patcher.patch();
|
||||
|
||||
let stopProxy: (() => void) | undefined = undefined;
|
||||
|
||||
try {
|
||||
if (config.command === 'sandbox-exec') {
|
||||
// disallow BUILD_SANDBOX
|
||||
@@ -188,17 +190,18 @@ export async function start_sandbox(
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
const stopProxy = () => {
|
||||
stopProxy = () => {
|
||||
debugLogger.log('stopping proxy ...');
|
||||
if (proxyProcess?.pid) {
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
try {
|
||||
process.kill(-proxyProcess.pid, 'SIGTERM');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
@@ -746,15 +749,18 @@ export async function start_sandbox(
|
||||
detached: true,
|
||||
});
|
||||
// install handlers to stop proxy on exit/signal
|
||||
const stopProxy = () => {
|
||||
stopProxy = () => {
|
||||
debugLogger.log('stopping proxy container ...');
|
||||
execSync(`${command} rm -f ${SANDBOX_PROXY_NAME}`);
|
||||
try {
|
||||
spawnSync(command, ['rm', '-f', SANDBOX_PROXY_NAME], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
process.off('exit', stopProxy);
|
||||
process.on('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
@@ -806,6 +812,12 @@ export async function start_sandbox(
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (stopProxy) {
|
||||
stopProxy();
|
||||
process.off('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
}
|
||||
patcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,47 @@ describe('SessionSelector', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('sessionExists', () => {
|
||||
it('should return true if a session file with the exact UUID exists', async () => {
|
||||
const sessionId = randomUUID();
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`session-20240101T000000-${sessionId.slice(0, 8)}.jsonl`,
|
||||
),
|
||||
JSON.stringify({ sessionId }),
|
||||
);
|
||||
|
||||
const selector = new SessionSelector(storage);
|
||||
const exists = await selector.sessionExists(sessionId);
|
||||
expect(exists).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if no session file matches the UUID', async () => {
|
||||
const sessionId = randomUUID();
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(chatsDir, `session-different-uuid-20240101.jsonl`),
|
||||
'{}',
|
||||
);
|
||||
|
||||
const selector = new SessionSelector(storage);
|
||||
const exists = await selector.sessionExists(sessionId);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if the chats directory does not exist', async () => {
|
||||
const sessionId = randomUUID();
|
||||
// Notice we do NOT create chatsDir here.
|
||||
const selector = new SessionSelector(storage);
|
||||
const exists = await selector.sessionExists(sessionId);
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve session by UUID', async () => {
|
||||
const sessionId1 = randomUUID();
|
||||
const sessionId2 = randomUUID();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user