mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| affe6856f1 | |||
| f727139c72 |
@@ -2,11 +2,7 @@
|
||||
"experimental": {
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"gemma": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true
|
||||
"memoryManager": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -28,7 +28,6 @@ runs:
|
||||
- name: 'Run Tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
run: |-
|
||||
echo "::group::Build"
|
||||
|
||||
@@ -98,7 +98,6 @@ runs:
|
||||
working-directory: '${{ inputs.working-directory }}'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true'
|
||||
# We must diable CI mode here because it interferes with interactive tests.
|
||||
# See https://github.com/google-gemini/gemini-cli/issues/10517
|
||||
|
||||
@@ -167,7 +167,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
VERBOSE: 'true'
|
||||
BUILD_SANDBOX_FLAGS: '--cache-from type=gha --cache-to type=gha,mode=max'
|
||||
@@ -213,7 +212,6 @@ jobs:
|
||||
if: "${{runner.os != 'Windows'}}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
@@ -290,7 +288,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
@@ -337,7 +334,6 @@ jobs:
|
||||
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: 'gemini-3-pro-preview'
|
||||
# Only run always passes behavioral tests.
|
||||
EVAL_SUITE_TYPE: 'behavioral'
|
||||
|
||||
@@ -179,7 +179,6 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli"
|
||||
@@ -268,7 +267,6 @@ jobs:
|
||||
- name: 'Run tests and generate reports'
|
||||
env:
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
if [[ "${{ matrix.shard }}" == "cli" ]]; then
|
||||
npm run test:ci --workspace "@google/gemini-cli" -- --coverage.enabled=false
|
||||
@@ -432,7 +430,6 @@ jobs:
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
NO_COLOR: true
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
|
||||
UV_THREADPOOL_SIZE: '32'
|
||||
NODE_ENV: 'test'
|
||||
|
||||
@@ -62,7 +62,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
IS_DOCKER: "${{ matrix.sandbox == 'sandbox:docker' }}"
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
@@ -106,7 +105,6 @@ jobs:
|
||||
if: "runner.os != 'Windows'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
RUNS: '${{ github.event.inputs.runs }}'
|
||||
SANDBOX: 'sandbox:none'
|
||||
@@ -161,7 +159,6 @@ jobs:
|
||||
- name: 'Run E2E tests'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
KEEP_OUTPUT: 'true'
|
||||
SANDBOX: 'sandbox:none'
|
||||
VERBOSE: 'true'
|
||||
|
||||
@@ -28,8 +28,6 @@ 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,7 +66,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
GEMINI_MODEL: '${{ matrix.model }}'
|
||||
RUN_EVALS: 'true'
|
||||
EVAL_SUITE_TYPE: "${{ github.event.inputs.suite_type || 'behavioral' }}"
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
name: '🧠 Gemini CLI Bot: Brain'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Every 24 hours
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_memory:
|
||||
description: 'Clear memory (drops learnings from previous runs)'
|
||||
type: 'boolean'
|
||||
default: false
|
||||
enable_prs:
|
||||
description: 'Enable PRs (automatically promote changes to PRs)'
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
reasoning:
|
||||
name: 'Brain (Reasoning Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
# The reasoning phase is strictly readonly.
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'read'
|
||||
pull-requests: 'read'
|
||||
actions: 'read'
|
||||
env:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Build Gemini CLI'
|
||||
run: 'npm run bundle'
|
||||
|
||||
- name: 'Download Previous State'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ "${{ github.event.inputs.clear_memory }}" = "true" ]; then
|
||||
echo "Memory clear requested. Skipping previous state download."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the last successful run of this workflow
|
||||
LAST_RUN_ID=$(gh run list --workflow "${{ github.workflow }}" --status success --limit 1 --json databaseId --jq '.[0].databaseId')
|
||||
|
||||
if [ -n "$LAST_RUN_ID" ]; then
|
||||
echo "Found previous successful run: $LAST_RUN_ID"
|
||||
|
||||
# Download brain memory (all state in one artifact)
|
||||
gh run download "$LAST_RUN_ID" -n brain-data -D . || echo "brain-data not found"
|
||||
else
|
||||
echo "No previous successful run found."
|
||||
fi
|
||||
|
||||
- name: 'Collect Current Metrics'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: 'npx tsx tools/gemini-cli-bot/metrics/index.ts'
|
||||
|
||||
- name: 'Run Brain Phases'
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
ENABLE_PRS: "${{ github.event.inputs.enable_prs || 'false' }}"
|
||||
run: 'node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/metrics.md)"'
|
||||
|
||||
- name: 'Run Critique Phase'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' }}"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
# This token is strictly readonly as enforced by the job-level permissions.
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
run: |
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes staged. Skipping critique."
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
else
|
||||
node bundle/gemini.js --policy tools/gemini-cli-bot/ci-policy.toml -p "$(cat tools/gemini-cli-bot/brain/critique.md)" 2>&1 | tee critique_output.log
|
||||
|
||||
# PIPESTATUS[0] captures the exit code of the node command before the pipe
|
||||
if [ "${PIPESTATUS[0]}" -ne 0 ] || grep -q "\[REJECTED\]" critique_output.log; then
|
||||
echo "Critique failed or rejected changes. Skipping PR creation."
|
||||
echo "[REJECTED]" > critique_result.txt
|
||||
else
|
||||
echo "[APPROVED]" > critique_result.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 'Generate Patch'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' }}"
|
||||
run: |
|
||||
touch bot-changes.patch
|
||||
touch pr-description.md
|
||||
if [ -f critique_result.txt ] && grep -q "\[REJECTED\]" critique_result.txt; then
|
||||
echo "Critique rejected. Skipping patch generation."
|
||||
else
|
||||
git diff --staged > bot-changes.patch
|
||||
fi
|
||||
|
||||
- name: 'Archive Brain Data'
|
||||
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
|
||||
with:
|
||||
name: 'brain-data'
|
||||
path: |
|
||||
tools/gemini-cli-bot/lessons-learned.md
|
||||
tools/gemini-cli-bot/history/*.csv
|
||||
bot-changes.patch
|
||||
pr-description.md
|
||||
branch-name.txt
|
||||
pr-comment.md
|
||||
pr-number.txt
|
||||
retention-days: 90
|
||||
|
||||
publish:
|
||||
name: 'Publish Artifacts (Archive Layer)'
|
||||
needs: 'reasoning'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
# The publish phase is for archiving artifacts and optionally creating PRs.
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
actions: 'write'
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: 'main'
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Download Brain Data'
|
||||
uses: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' # ratchet:actions/download-artifact@v4
|
||||
with:
|
||||
name: 'brain-data'
|
||||
path: '${{ runner.temp }}/brain-data/'
|
||||
|
||||
- name: 'Create or Update PR'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
run: |
|
||||
if [ -s "${{ runner.temp }}/brain-data/bot-changes.patch" ]; then
|
||||
git config user.name "gemini-cli-robot"
|
||||
git config user.email "gemini-cli-robot@google.com"
|
||||
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
|
||||
BRANCH_NAME="bot/productivity-updates-$(date +'%Y%m%d%H%M%S')-${{ github.run_id }}"
|
||||
if [ -f "${{ runner.temp }}/brain-data/branch-name.txt" ]; then
|
||||
BRANCH_NAME=$(cat "${{ runner.temp }}/brain-data/branch-name.txt")
|
||||
fi
|
||||
|
||||
# SECURITY: Only allow pushing to branches starting with 'bot/'
|
||||
if [[ ! "$BRANCH_NAME" =~ ^bot/ ]]; then
|
||||
echo "Error: Branch name '$BRANCH_NAME' does not start with 'bot/'. Safety abort."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git apply "${{ runner.temp }}/brain-data/bot-changes.patch"
|
||||
git add .
|
||||
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
git commit -F "${{ runner.temp }}/brain-data/pr-description.md"
|
||||
else
|
||||
git commit -m "🤖 Gemini Bot Productivity Optimizations"
|
||||
fi
|
||||
|
||||
# Use force to update existing PR branches
|
||||
git push origin "$BRANCH_NAME" --force
|
||||
|
||||
PR_TITLE="🤖 Gemini Bot Productivity Optimizations"
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-description.md" ]; then
|
||||
PR_TITLE=$(head -n 1 "${{ runner.temp }}/brain-data/pr-description.md")
|
||||
fi
|
||||
|
||||
# Create PR if it doesn't exist
|
||||
if ! gh pr view "$BRANCH_NAME" > /dev/null 2>&1; then
|
||||
gh pr create --draft --title "$PR_TITLE" --body-file "${{ runner.temp }}/brain-data/pr-description.md" --head "$BRANCH_NAME" --base main || \
|
||||
gh pr create --draft --title "🤖 Gemini Bot Productivity Optimizations" --body "Automated changes generated by Gemini CLI Bot." --head "$BRANCH_NAME" --base main
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: 'Post PR Comment'
|
||||
if: "${{ github.event.inputs.enable_prs == 'true' }}"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
run: |
|
||||
if [ -s "${{ runner.temp }}/brain-data/pr-comment.md" ] && [ -f "${{ runner.temp }}/brain-data/pr-number.txt" ]; then
|
||||
PR_NUM=$(cat "${{ runner.temp }}/brain-data/pr-number.txt")
|
||||
|
||||
# SECURITY: Only allow commenting on PRs authored by the bot
|
||||
PR_AUTHOR=$(gh pr view "$PR_NUM" --json author --jq '.author.login')
|
||||
if [ "$PR_AUTHOR" != "gemini-cli-robot" ]; then
|
||||
echo "Error: PR #$PR_NUM is authored by '$PR_AUTHOR', not 'gemini-cli-robot'. Safety abort."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh pr comment "$PR_NUM" -F "${{ runner.temp }}/brain-data/pr-comment.md"
|
||||
fi
|
||||
@@ -1,48 +0,0 @@
|
||||
name: '🔄 Gemini CLI Bot: Pulse'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *' # Every 30 minutes
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}-${{ github.ref }}'
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: 'write'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
jobs:
|
||||
pulse:
|
||||
name: 'Pulse (Reflex Layer)'
|
||||
runs-on: 'ubuntu-latest'
|
||||
if: "github.repository == 'google-gemini/gemini-cli'"
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: 'Install dependencies'
|
||||
run: 'npm ci'
|
||||
|
||||
- name: 'Run Reflex Processes'
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: |
|
||||
if [ -d "tools/gemini-cli-bot/reflexes/scripts" ] && [ "$(ls -A tools/gemini-cli-bot/reflexes/scripts)" ]; then
|
||||
for script in tools/gemini-cli-bot/reflexes/scripts/*.ts; do
|
||||
echo "Running reflex script: $script"
|
||||
npx tsx "$script"
|
||||
done
|
||||
else
|
||||
echo "No reflex scripts found."
|
||||
fi
|
||||
@@ -70,8 +70,6 @@ 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: |
|
||||
|
||||
@@ -141,7 +141,6 @@ jobs:
|
||||
if: "github.event_name != 'pull_request'"
|
||||
env:
|
||||
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: true
|
||||
run: |
|
||||
echo "Running integration tests with binary..."
|
||||
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
|
||||
|
||||
+2
-2
@@ -40,8 +40,8 @@ ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
USER node
|
||||
|
||||
# install gemini-cli and clean up
|
||||
COPY --chown=node:node packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY --chown=node:node packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
COPY packages/cli/dist/google-gemini-cli-*.tgz /tmp/gemini-cli.tgz
|
||||
COPY packages/core/dist/google-gemini-cli-core-*.tgz /tmp/gemini-core.tgz
|
||||
RUN npm install -g /tmp/gemini-core.tgz \
|
||||
&& npm install -g /tmp/gemini-cli.tgz \
|
||||
&& node -e "const fs=require('node:fs'); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json','utf8')); JSON.parse(fs.readFileSync('/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json','utf8'));" \
|
||||
|
||||
@@ -371,8 +371,6 @@ for planned features and priorities.
|
||||
|
||||
## 📖 Resources
|
||||
|
||||
- **[Free Course](https://learn.deeplearning.ai/courses/gemini-cli-code-and-create-with-an-open-source-agent/information)** -
|
||||
Learn the basics.
|
||||
- **[Official Roadmap](./ROADMAP.md)** - See what's coming next.
|
||||
- **[Changelog](https://www.geminicli.com/docs/changelogs)** - See recent
|
||||
notable updates.
|
||||
|
||||
@@ -18,24 +18,6 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.39.0 - 2026-04-23
|
||||
|
||||
- **Skill Management:** Added a new `/memory` inbox command for reviewing and
|
||||
patching skills extracted during sessions
|
||||
([#24544](https://github.com/google-gemini/gemini-cli/pull/24544) by
|
||||
@SandyTao520, [#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
by @SandyTao520).
|
||||
- **Improved Transparency:** Plan Mode now requires confirmation for skill
|
||||
activation and allows plan inspection
|
||||
([#24946](https://github.com/google-gemini/gemini-cli/pull/24946),
|
||||
[#25058](https://github.com/google-gemini/gemini-cli/pull/25058) by
|
||||
@ruomengz).
|
||||
- **Architecture & Reliability:** Introduced a decoupled `ContextManager`
|
||||
architecture and resolved several critical memory leaks and PTY exhaustion
|
||||
issues ([#24752](https://github.com/google-gemini/gemini-cli/pull/24752) by
|
||||
@joshualitt, [#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
by @spencer426).
|
||||
|
||||
## Announcements: v0.38.0 - 2026-04-14
|
||||
|
||||
- **Chapters Narrative Flow:** Group agent interactions into "Chapters" based on
|
||||
|
||||
+255
-243
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.39.0
|
||||
# Latest stable release: v0.38.2
|
||||
|
||||
Released: April 23, 2026
|
||||
Released: April 17, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,252 +11,264 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Skill Extractor & Memory Inbox:** Introduced the `/memory` command to review
|
||||
and patch skills extracted during agent sessions, streamlining the continuous
|
||||
learning workflow.
|
||||
- **Enhanced Plan Mode Security:** Increased transparency in Plan Mode by
|
||||
requiring user confirmation for skill activation and allowing users to view
|
||||
the full content of generated plans.
|
||||
- **Advanced Display Protocol:** Implemented a tool-controlled display protocol,
|
||||
enabling agents to provide richer, more structured visual feedback during
|
||||
execution.
|
||||
- **Core Architecture Refactor:** Introduced a decoupled `ContextManager` and
|
||||
`Sidecar` architecture to improve state management and session resilience.
|
||||
- **Streamlined Agent Feedback:** Restored the display of model thoughts and raw
|
||||
text in responses, ensuring full visibility into the agent's reasoning
|
||||
process.
|
||||
- **Chapters Narrative Flow:** Introduced tool-based topic grouping ("Chapters")
|
||||
to provide better session structure and narrative continuity in long-running
|
||||
tasks.
|
||||
- **Context Compression Service:** Implemented a dedicated service for advanced
|
||||
context management, efficiently distilling conversation history to preserve
|
||||
focus and tokens.
|
||||
- **Enhanced UI Stability & UX:** Introduced a new "Terminal Buffer" mode to
|
||||
solve rendering flicker, along with selective topic expansion and improved
|
||||
tool confirmation layouts.
|
||||
- **Context-Aware Policy Approvals:** Users can now grant persistent,
|
||||
context-aware approvals for tools, significantly reducing manual confirmation
|
||||
overhead for trusted workflows.
|
||||
- **Background Process Monitoring:** New tools for monitoring and inspecting
|
||||
background shell processes, providing better visibility into asynchronous
|
||||
tasks.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
[#24876](https://github.com/google-gemini/gemini-cli/pull/24876)
|
||||
- feat(memory): add /memory inbox command for reviewing extracted skills by
|
||||
- fix(patch): cherry-pick 14b2f35 to release/v0.38.1-pr-24974 to patch version
|
||||
v0.38.1 and create version 0.38.2 by @gemini-cli-robot in
|
||||
[#25585](https://github.com/google-gemini/gemini-cli/pull/25585)
|
||||
- fix(patch): cherry-pick 050c303 to release/v0.38.0-pr-25317 to patch version
|
||||
v0.38.0 and create version 0.38.1 by @gemini-cli-robot in
|
||||
[#25466](https://github.com/google-gemini/gemini-cli/pull/25466)
|
||||
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
|
||||
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
|
||||
- Update README.md for links. by @g-samroberts in
|
||||
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
|
||||
- fix(core): ensure complete_task tool calls are recorded in chat history by
|
||||
@abhipatel12 in
|
||||
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
|
||||
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
|
||||
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
|
||||
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
|
||||
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
|
||||
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
|
||||
by @adamfweidman in
|
||||
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
|
||||
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
|
||||
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
|
||||
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
|
||||
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
|
||||
- fix(cli): ensure agent stops when all declinable tools are cancelled by
|
||||
@NTaylorMullen in
|
||||
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
|
||||
- fix(core): enhance sandbox usability and fix build error by @galz10 in
|
||||
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
|
||||
- Terminal Serializer Optimization by @jacob314 in
|
||||
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
|
||||
- Auto configure memory. by @jacob314 in
|
||||
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
|
||||
- Unused error variables in catch block are not allowed by @alisa-alisa in
|
||||
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
|
||||
- feat(core): add background memory service for skill extraction by @SandyTao520
|
||||
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
|
||||
- feat: implement high-signal PR regression check for evaluations by
|
||||
@alisa-alisa in
|
||||
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
|
||||
- Fix shell output display by @jacob314 in
|
||||
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
|
||||
- fix(ui): resolve unwanted vertical spacing around various tool output
|
||||
treatments by @jwhelangoog in
|
||||
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
|
||||
- revert(cli): bring back input box and footer visibility in copy mode by
|
||||
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
|
||||
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
|
||||
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
|
||||
- feat(cli): support default values for environment variables by @ruomengz in
|
||||
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
|
||||
- Implement background process monitoring and inspection tools by @cocosheng-g
|
||||
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
|
||||
- docs(browser-agent): update stale browser agent documentation by @gsquared94
|
||||
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
|
||||
- fix: enable browser_agent in integration tests and add localhost fixture tests
|
||||
by @gsquared94 in
|
||||
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
|
||||
- fix(browser): handle computer-use model detection for analyze_screenshot by
|
||||
@gsquared94 in
|
||||
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
|
||||
- feat(core): Land ContextCompressionService by @joshualitt in
|
||||
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
|
||||
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
|
||||
@SandyTao520 in
|
||||
[#24544](https://github.com/google-gemini/gemini-cli/pull/24544)
|
||||
- chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 by
|
||||
@gemini-cli-robot in
|
||||
[#24939](https://github.com/google-gemini/gemini-cli/pull/24939)
|
||||
- fix(core): ensure robust sandbox cleanup in all process execution paths by
|
||||
@ehedlund in [#24763](https://github.com/google-gemini/gemini-cli/pull/24763)
|
||||
- chore: update ink version to 6.6.8 by @jacob314 in
|
||||
[#24934](https://github.com/google-gemini/gemini-cli/pull/24934)
|
||||
- Changelog for v0.38.0-preview.0 by @gemini-cli-robot in
|
||||
[#24938](https://github.com/google-gemini/gemini-cli/pull/24938)
|
||||
- chore: ignore conductor directory by @JayadityaGit in
|
||||
[#22128](https://github.com/google-gemini/gemini-cli/pull/22128)
|
||||
- Changelog for v0.37.0 by @gemini-cli-robot in
|
||||
[#24940](https://github.com/google-gemini/gemini-cli/pull/24940)
|
||||
- feat(plan): require user confirmation for activate_skill in Plan Mode by
|
||||
@ruomengz in [#24946](https://github.com/google-gemini/gemini-cli/pull/24946)
|
||||
- feat(test-utils): add CPU performance integration test harness by @sripasg in
|
||||
[#24951](https://github.com/google-gemini/gemini-cli/pull/24951)
|
||||
- fix(cli-ui): enable Ctrl+Backspace for word deletion in Windows Terminal by
|
||||
@dogukanozen in
|
||||
[#21447](https://github.com/google-gemini/gemini-cli/pull/21447)
|
||||
- test(sdk): add unit tests for GeminiCliSession by @AdamyaSingh7 in
|
||||
[#21897](https://github.com/google-gemini/gemini-cli/pull/21897)
|
||||
- fix(core): resolve windows symlink bypass and stabilize sandbox integration
|
||||
tests by @ehedlund in
|
||||
[#24834](https://github.com/google-gemini/gemini-cli/pull/24834)
|
||||
- fix(cli): restore file path display in edit and write tool confirmations by
|
||||
@jwhelangoog in
|
||||
[#24974](https://github.com/google-gemini/gemini-cli/pull/24974)
|
||||
- feat(core): refine shell tool description display logic by @jwhelangoog in
|
||||
[#24903](https://github.com/google-gemini/gemini-cli/pull/24903)
|
||||
- fix(core): dynamic session ID injection to resolve resume bugs by @scidomino
|
||||
in [#24972](https://github.com/google-gemini/gemini-cli/pull/24972)
|
||||
- Update ink version to 6.6.9 by @jacob314 in
|
||||
[#24980](https://github.com/google-gemini/gemini-cli/pull/24980)
|
||||
- Generalize evals infra to support more types of evals, organization and
|
||||
queuing of named suites by @gundermanc in
|
||||
[#24941](https://github.com/google-gemini/gemini-cli/pull/24941)
|
||||
- fix(cli): optimize startup with lightweight parent process by @sehoon38 in
|
||||
[#24667](https://github.com/google-gemini/gemini-cli/pull/24667)
|
||||
- refactor(sandbox): use centralized sandbox paths in macOS Seatbelt
|
||||
implementation by @ehedlund in
|
||||
[#24984](https://github.com/google-gemini/gemini-cli/pull/24984)
|
||||
- feat(cli): refine tool output formatting for compact mode by @jwhelangoog in
|
||||
[#24677](https://github.com/google-gemini/gemini-cli/pull/24677)
|
||||
- fix(sdk): skip broken sendStream tests to unblock nightly by @SandyTao520 in
|
||||
[#25000](https://github.com/google-gemini/gemini-cli/pull/25000)
|
||||
- refactor(core): use centralized path resolution for Linux sandbox by @ehedlund
|
||||
in [#24985](https://github.com/google-gemini/gemini-cli/pull/24985)
|
||||
- Support ctrl+shift+g by @jacob314 in
|
||||
[#25035](https://github.com/google-gemini/gemini-cli/pull/25035)
|
||||
- feat(core): refactor subagent tool to unified invoke_subagent tool by
|
||||
@abhipatel12 in
|
||||
[#24489](https://github.com/google-gemini/gemini-cli/pull/24489)
|
||||
- fix(core): add explicit git identity env vars to prevent sandbox checkpointing
|
||||
error by @mrpmohiburrahman in
|
||||
[#19775](https://github.com/google-gemini/gemini-cli/pull/19775)
|
||||
- fix: respect hideContextPercentage when FooterConfigDialog is closed without
|
||||
changes by @chernistry in
|
||||
[#24773](https://github.com/google-gemini/gemini-cli/pull/24773)
|
||||
- fix(cli): suppress unhandled AbortError logs during request cancellation by
|
||||
@euxaristia in
|
||||
[#22621](https://github.com/google-gemini/gemini-cli/pull/22621)
|
||||
- Automated documentation audit by @g-samroberts in
|
||||
[#24567](https://github.com/google-gemini/gemini-cli/pull/24567)
|
||||
- feat(cli): implement useAgentStream hook by @mbleigh in
|
||||
[#24292](https://github.com/google-gemini/gemini-cli/pull/24292)
|
||||
- refactor(plan) Clean default plan toml by @ruomengz in
|
||||
[#25037](https://github.com/google-gemini/gemini-cli/pull/25037)
|
||||
- refactor(core): remove legacy subagent wrapping tools by @abhipatel12 in
|
||||
[#25053](https://github.com/google-gemini/gemini-cli/pull/25053)
|
||||
- fix(core): honor retryDelay in RetryInfo for 503 errors by @yunaseoul in
|
||||
[#25057](https://github.com/google-gemini/gemini-cli/pull/25057)
|
||||
- fix(core): remediate subagent memory leaks using AbortSignal in MessageBus by
|
||||
@abhipatel12 in
|
||||
[#25048](https://github.com/google-gemini/gemini-cli/pull/25048)
|
||||
- feat(cli): wire up useAgentStream in AppContainer by @mbleigh in
|
||||
[#24297](https://github.com/google-gemini/gemini-cli/pull/24297)
|
||||
- feat(core): migrate chat recording to JSONL streaming by @spencer426 in
|
||||
[#23749](https://github.com/google-gemini/gemini-cli/pull/23749)
|
||||
- fix(core): clear 5-minute timeouts in oauth flow to prevent memory leaks by
|
||||
@spencer426 in
|
||||
[#24968](https://github.com/google-gemini/gemini-cli/pull/24968)
|
||||
- fix(sandbox): centralize async git worktree resolution and enforce read-only
|
||||
security by @ehedlund in
|
||||
[#25040](https://github.com/google-gemini/gemini-cli/pull/25040)
|
||||
- feat(test): add high-volume shell test and refine perf harness by @sripasg in
|
||||
[#24983](https://github.com/google-gemini/gemini-cli/pull/24983)
|
||||
- fix(core): silently handle EPERM when listing dir structure by @scidomino in
|
||||
[#25066](https://github.com/google-gemini/gemini-cli/pull/25066)
|
||||
- Changelog for v0.37.1 by @gemini-cli-robot in
|
||||
[#25055](https://github.com/google-gemini/gemini-cli/pull/25055)
|
||||
- fix: decode Uint8Array and multi-byte UTF-8 in API error messages by
|
||||
@kimjune01 in [#23341](https://github.com/google-gemini/gemini-cli/pull/23341)
|
||||
- Automated documentation audit results by @g-samroberts in
|
||||
[#22755](https://github.com/google-gemini/gemini-cli/pull/22755)
|
||||
- debugging(ui): add optional debugRainbow setting by @jacob314 in
|
||||
[#25088](https://github.com/google-gemini/gemini-cli/pull/25088)
|
||||
- fix: resolve lifecycle memory leaks by cleaning up listeners and root closures
|
||||
by @spencer426 in
|
||||
[#25049](https://github.com/google-gemini/gemini-cli/pull/25049)
|
||||
- docs(cli): updates f12 description to be more precise by @JayadityaGit in
|
||||
[#15816](https://github.com/google-gemini/gemini-cli/pull/15816)
|
||||
- fix(cli): mark /settings as unsafe to run concurrently by @jacob314 in
|
||||
[#25061](https://github.com/google-gemini/gemini-cli/pull/25061)
|
||||
- fix(core): remove buffer slice to prevent OOM on large output streams by
|
||||
@spencer426 in
|
||||
[#25094](https://github.com/google-gemini/gemini-cli/pull/25094)
|
||||
- feat(core): persist subagent agentId in tool call records by @abhipatel12 in
|
||||
[#25092](https://github.com/google-gemini/gemini-cli/pull/25092)
|
||||
- chore(core): increase codebase investigator turn limits to 50 by @abhipatel12
|
||||
in [#25125](https://github.com/google-gemini/gemini-cli/pull/25125)
|
||||
- refactor(core): consolidate execute() arguments into ExecuteOptions by
|
||||
@mbleigh in [#25101](https://github.com/google-gemini/gemini-cli/pull/25101)
|
||||
- feat(core): add Strategic Re-evaluation guidance to system prompt by
|
||||
@aishaneeshah in
|
||||
[#25062](https://github.com/google-gemini/gemini-cli/pull/25062)
|
||||
- fix(core): preserve shell execution config fields on update by
|
||||
@jasonmatthewsuhari in
|
||||
[#25113](https://github.com/google-gemini/gemini-cli/pull/25113)
|
||||
- docs: add vi shortcuts and clarify MCP sandbox setup by @chrisjcthomas in
|
||||
[#21679](https://github.com/google-gemini/gemini-cli/pull/21679)
|
||||
- fix(cli): pass session id to interactive shell executions by
|
||||
@jasonmatthewsuhari in
|
||||
[#25114](https://github.com/google-gemini/gemini-cli/pull/25114)
|
||||
- fix(cli): resolve text sanitization data loss due to C1 control characters by
|
||||
@euxaristia in
|
||||
[#22624](https://github.com/google-gemini/gemini-cli/pull/22624)
|
||||
- feat(core): add large memory regression test by @cynthialong0-0 in
|
||||
[#25059](https://github.com/google-gemini/gemini-cli/pull/25059)
|
||||
- fix(core): resolve PTY exhaustion and orphan MCP subprocess leaks by
|
||||
@spencer426 in
|
||||
[#25079](https://github.com/google-gemini/gemini-cli/pull/25079)
|
||||
- chore(deps): update vulnerable dependencies via npm audit fix by @scidomino in
|
||||
[#25140](https://github.com/google-gemini/gemini-cli/pull/25140)
|
||||
- perf(sandbox): optimize Windows sandbox initialization via native ACL
|
||||
application by @ehedlund in
|
||||
[#25077](https://github.com/google-gemini/gemini-cli/pull/25077)
|
||||
- chore: switch from keytar to @github/keytar by @cocosheng-g in
|
||||
[#25143](https://github.com/google-gemini/gemini-cli/pull/25143)
|
||||
- fix: improve audio MIME normalization and validation in file reads by
|
||||
@junaiddshaukat in
|
||||
[#21636](https://github.com/google-gemini/gemini-cli/pull/21636)
|
||||
- docs: Update docs-audit to include changes in PR body by @g-samroberts in
|
||||
[#25153](https://github.com/google-gemini/gemini-cli/pull/25153)
|
||||
- docs: correct documentation for enforced authentication type by @cocosheng-g
|
||||
in [#25142](https://github.com/google-gemini/gemini-cli/pull/25142)
|
||||
- fix(cli): exclude update_topic from confirmation queue count by @Abhijit-2592
|
||||
in [#24945](https://github.com/google-gemini/gemini-cli/pull/24945)
|
||||
- Memory fix for trace's streamWrapper. by @anthraxmilkshake in
|
||||
[#25089](https://github.com/google-gemini/gemini-cli/pull/25089)
|
||||
- fix(core): fix quota footer for non-auto models and improve display by
|
||||
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
|
||||
- Update ink version to 6.6.7 by @jacob314 in
|
||||
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
|
||||
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
|
||||
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
|
||||
- Fix crash when vim editor is not found in PATH on Windows by
|
||||
@Nagajyothi-tammisetti in
|
||||
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
|
||||
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
|
||||
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
|
||||
- Enable 'Other' option for yesno question type by @ruomengz in
|
||||
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
|
||||
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
|
||||
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
|
||||
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
|
||||
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
|
||||
- feat(core): implement context-aware persistent policy approvals by @jerop in
|
||||
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
|
||||
- docs: move agent disabling instructions and update remote agent status by
|
||||
@jackwotherspoon in
|
||||
[#25121](https://github.com/google-gemini/gemini-cli/pull/25121)
|
||||
- docs(contributing): clarify self-assignment policy for issues by @jmr in
|
||||
[#23087](https://github.com/google-gemini/gemini-cli/pull/23087)
|
||||
- feat(core): add skill patching support with /memory inbox integration by
|
||||
@SandyTao520 in
|
||||
[#25148](https://github.com/google-gemini/gemini-cli/pull/25148)
|
||||
- Stop suppressing thoughts and text in model response by @gundermanc in
|
||||
[#25073](https://github.com/google-gemini/gemini-cli/pull/25073)
|
||||
- fix(release): prefix git hash in nightly versions to prevent semver
|
||||
normalization by @SandyTao520 in
|
||||
[#25304](https://github.com/google-gemini/gemini-cli/pull/25304)
|
||||
- feat(cli): extract QuotaContext and resolve infinite render loop by @Adib234
|
||||
in [#24959](https://github.com/google-gemini/gemini-cli/pull/24959)
|
||||
- refactor(core): extract and centralize sandbox path utilities by @ehedlund in
|
||||
[#25305](https://github.com/google-gemini/gemini-cli/pull/25305)
|
||||
- feat(ui): added enhancements to scroll momentum by @devr0306 in
|
||||
[#24447](https://github.com/google-gemini/gemini-cli/pull/24447)
|
||||
- fix(core): replace custom binary detection with isbinaryfile to correctly
|
||||
handle UTF-8 (U+FFFD) by @Anjaligarhwal in
|
||||
[#25297](https://github.com/google-gemini/gemini-cli/pull/25297)
|
||||
- feat(agent): implement tool-controlled display protocol (Steps 2-3) by
|
||||
@mbleigh in [#25134](https://github.com/google-gemini/gemini-cli/pull/25134)
|
||||
- Stop showing scrollbar unless we are in terminalBuffer mode by @jacob314 in
|
||||
[#25320](https://github.com/google-gemini/gemini-cli/pull/25320)
|
||||
- feat: support auth block in MCP servers config in agents by @TanmayVartak in
|
||||
[#24770](https://github.com/google-gemini/gemini-cli/pull/24770)
|
||||
- fix(core): expose GEMINI_PLANS_DIR to hook environment by @Adib234 in
|
||||
[#25296](https://github.com/google-gemini/gemini-cli/pull/25296)
|
||||
- feat(core): implement silent fallback for Plan Mode model routing by @jerop in
|
||||
[#25317](https://github.com/google-gemini/gemini-cli/pull/25317)
|
||||
- fix: correct redirect count increment in fetchJson by @KevinZhao in
|
||||
[#24896](https://github.com/google-gemini/gemini-cli/pull/24896)
|
||||
- fix(core): prevent secondary crash in ModelRouterService finally block by
|
||||
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
|
||||
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
|
||||
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
|
||||
- fix(core): unsafe type assertions in Core File System #19712 by
|
||||
@aniketsaurav18 in
|
||||
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
|
||||
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
|
||||
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
|
||||
- Changelog for v0.36.0 by @gemini-cli-robot in
|
||||
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
|
||||
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
|
||||
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
|
||||
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
|
||||
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
|
||||
- fix(ui): fixed table styling by @devr0306 in
|
||||
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
|
||||
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
|
||||
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
|
||||
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
|
||||
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
|
||||
- docs: clarify release coordination by @scidomino in
|
||||
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
|
||||
- fix(core): remove broken PowerShell translation and fix native \_\_write in
|
||||
Windows sandbox by @scidomino in
|
||||
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
|
||||
- Add instructions for how to start react in prod and force react to prod mode
|
||||
by @jacob314 in
|
||||
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
|
||||
- feat(cli): minimalist sandbox status labels by @galz10 in
|
||||
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
|
||||
- Feat/browser agent metrics by @kunal-10-cloud in
|
||||
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
|
||||
- test: fix Windows CI execution and resolve exposed platform failures by
|
||||
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
|
||||
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
|
||||
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
|
||||
- show color by @jacob314 in
|
||||
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
|
||||
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
|
||||
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
|
||||
- fix(core): inject skill system instructions into subagent prompts if activated
|
||||
by @abhipatel12 in
|
||||
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
|
||||
- fix(core): improve windows sandbox reliability and fix integration tests by
|
||||
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
|
||||
- fix(core): ensure sandbox approvals are correctly persisted and matched for
|
||||
proactive expansions by @galz10 in
|
||||
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
|
||||
- feat(cli) Scrollbar for input prompt by @jacob314 in
|
||||
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
|
||||
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
|
||||
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
|
||||
- Fix restoration of topic headers. by @gundermanc in
|
||||
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
|
||||
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
|
||||
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
|
||||
- fix(core): ensure global temp directory is always in sandbox allowed paths by
|
||||
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
|
||||
- fix(core): detect uninitialized lines by @jacob314 in
|
||||
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
|
||||
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
|
||||
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
|
||||
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
|
||||
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
|
||||
- feat(acp): add support for `/about` command by @sripasg in
|
||||
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
|
||||
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
|
||||
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
|
||||
- split context by @jacob314 in
|
||||
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
|
||||
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
|
||||
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
|
||||
- Fix issue where topic headers can be posted back to back by @gundermanc in
|
||||
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
|
||||
- fix(core): handle partial llm_request in BeforeModel hook override by
|
||||
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
|
||||
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
|
||||
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
|
||||
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
|
||||
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
|
||||
- fix(browser): remove premature browser cleanup after subagent invocation by
|
||||
@gsquared94 in
|
||||
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
|
||||
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
|
||||
@Abhijit-2592 in
|
||||
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
|
||||
- relax tool sandboxing overrides for plan mode to match defaults. by
|
||||
@DavidAPierce in
|
||||
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
|
||||
- fix(cli): respect global environment variable allowlist by @scidomino in
|
||||
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
|
||||
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
|
||||
by @spencer426 in
|
||||
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
|
||||
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
|
||||
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
|
||||
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
|
||||
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
|
||||
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
|
||||
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
|
||||
- feat(cli): support selective topic expansion and click-to-expand by
|
||||
@Abhijit-2592 in
|
||||
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
|
||||
- temporarily disable sandbox integration test on windows by @ehedlund in
|
||||
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
|
||||
- Remove flakey test by @scidomino in
|
||||
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
|
||||
- Alisa/approve button by @alisa-alisa in
|
||||
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
|
||||
- feat(hooks): display hook system messages in UI by @mbleigh in
|
||||
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
|
||||
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
|
||||
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
|
||||
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
|
||||
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
|
||||
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
|
||||
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
|
||||
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
|
||||
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
|
||||
- feat(acp): add /help command by @sripasg in
|
||||
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
|
||||
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
|
||||
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
|
||||
- Improve sandbox error matching and caching by @DavidAPierce in
|
||||
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
|
||||
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
|
||||
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
|
||||
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
|
||||
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
|
||||
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
|
||||
@gundermanc in
|
||||
[#25333](https://github.com/google-gemini/gemini-cli/pull/25333)
|
||||
- feat(core): introduce decoupled ContextManager and Sidecar architecture by
|
||||
@joshualitt in
|
||||
[#24752](https://github.com/google-gemini/gemini-cli/pull/24752)
|
||||
- docs(core): update generalist agent documentation by @abhipatel12 in
|
||||
[#25325](https://github.com/google-gemini/gemini-cli/pull/25325)
|
||||
- chore(mcp): check MCP error code over brittle string match by @jackwotherspoon
|
||||
in [#25381](https://github.com/google-gemini/gemini-cli/pull/25381)
|
||||
- feat(plan): update plan mode prompt to allow showing plan content by @ruomengz
|
||||
in [#25058](https://github.com/google-gemini/gemini-cli/pull/25058)
|
||||
- test(core): improve sandbox integration test coverage and fix OS-specific
|
||||
failures by @ehedlund in
|
||||
[#25307](https://github.com/google-gemini/gemini-cli/pull/25307)
|
||||
- fix(core): use debug level for keychain fallback logging by @ehedlund in
|
||||
[#25398](https://github.com/google-gemini/gemini-cli/pull/25398)
|
||||
- feat(test): add a performance test in asian language by @cynthialong0-0 in
|
||||
[#25392](https://github.com/google-gemini/gemini-cli/pull/25392)
|
||||
- feat(cli): enable mouse clicking for cursor positioning in AskUser multi-line
|
||||
answers by @Adib234 in
|
||||
[#24630](https://github.com/google-gemini/gemini-cli/pull/24630)
|
||||
- fix(core): detect kmscon terminal as supporting true color by @claygeo in
|
||||
[#25282](https://github.com/google-gemini/gemini-cli/pull/25282)
|
||||
- ci: add agent session drift check workflow by @adamfweidman in
|
||||
[#25389](https://github.com/google-gemini/gemini-cli/pull/25389)
|
||||
- use macos-latest-large runner where applicable. by @scidomino in
|
||||
[#25413](https://github.com/google-gemini/gemini-cli/pull/25413)
|
||||
- Changelog for v0.37.2 by @gemini-cli-robot in
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
- fix(patch): cherry-pick a4e98c0 to release/v0.39.0-preview.0-pr-25138 to patch
|
||||
version v0.39.0-preview.0 and create version 0.39.0-preview.1 by
|
||||
@gemini-cli-robot in
|
||||
[#25766](https://github.com/google-gemini/gemini-cli/pull/25766)
|
||||
- fix(patch): cherry-pick d6f88f8 to release/v0.39.0-preview.1-pr-25670 to patch
|
||||
version v0.39.0-preview.1 and create version 0.39.0-preview.2 by
|
||||
@gemini-cli-robot in
|
||||
[#25776](https://github.com/google-gemini/gemini-cli/pull/25776)
|
||||
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
|
||||
- refactor(cli): remove duplication in interactive shell awaiting input hint by
|
||||
@JayadityaGit in
|
||||
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
|
||||
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
|
||||
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
|
||||
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
|
||||
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
|
||||
- fix(cli): always show shell command description or actual command by @jacob314
|
||||
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
|
||||
- Added flag for ept size and increased default size by @devr0306 in
|
||||
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
|
||||
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
|
||||
@Anjaligarhwal in
|
||||
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
|
||||
- fix(cli): switch default back to terminalBuffer=false and fix regressions
|
||||
introduced for that mode by @jacob314 in
|
||||
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
|
||||
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
|
||||
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
|
||||
- fix: isolate concurrent browser agent instances by @gsquared94 in
|
||||
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
|
||||
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
|
||||
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.2...v0.39.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0...v0.38.2
|
||||
|
||||
+18
-168
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.40.0-preview.3
|
||||
# Preview release: v0.39.0-preview.0
|
||||
|
||||
Released: April 24, 2026
|
||||
Released: April 14, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,174 +13,24 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Ripgrep Binary Bundling:** Ripgrep binaries are now bundled into the Single
|
||||
Executable Application (SEA), enabling grep functionality in offline
|
||||
environments.
|
||||
- **MCP Resource Tools:** New core tools added to list and read MCP (Model
|
||||
Context Protocol) resources, expanding the agent's ability to interact with
|
||||
MCP servers.
|
||||
- **Local Model Setup:** Introduced a streamlined `gemini gemma` command for
|
||||
easier local model setup and integration.
|
||||
- **Prompt-Driven Memory Management:** Refactored memory management into a
|
||||
prompt-driven, four-tier system and integrated `skill-creator` for robust
|
||||
skill extraction.
|
||||
- **Enhanced UI and Accessibility:** Added support for OSC 777 terminal
|
||||
notifications and GitHub colorblind themes for better user feedback and
|
||||
accessibility.
|
||||
- **Refactored Subagents and Unified Tooling:** Consolidate subagent tools into
|
||||
a single `invoke_subagent` tool, removed legacy wrapping tools, and improved
|
||||
turn limits for codebase investigator.
|
||||
- **Advanced Memory and Skill Management:** Introduced `/memory` inbox for
|
||||
reviewing extracted skills and added skill patching support, enhancing agent
|
||||
learning and persistence.
|
||||
- **Expanded Test and Evaluation Infrastructure:** Added memory and CPU
|
||||
performance integration test harnesses and generalized evaluation
|
||||
infrastructure for better suite organization.
|
||||
- **Sandbox and Security Hardening:** Centralized sandbox paths for Linux and
|
||||
macOS, enforced read-only security for async git worktree resolution, and
|
||||
optimized Windows sandbox initialization.
|
||||
- **Enhanced CLI UX and UI Stability:** Improved scroll momentum, added a
|
||||
`debugRainbow` setting, and resolved various memory leaks and PTY exhaustion
|
||||
issues for a smoother terminal experience.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
|
||||
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
|
||||
- feat(core): enhance shell command validation and add core tools allowlist by
|
||||
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
|
||||
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
|
||||
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
|
||||
- chore(release): bump version to 0.40.0-nightly.20260414.g5b1f7375a by
|
||||
@gemini-cli-robot in
|
||||
[#25420](https://github.com/google-gemini/gemini-cli/pull/25420)
|
||||
- Fix(core): retry additional OpenSSL 3.x SSL errors during streaming (#16075)
|
||||
by @rcleveng in
|
||||
[#25187](https://github.com/google-gemini/gemini-cli/pull/25187)
|
||||
- fix(core): prevent YOLO mode from being downgraded by @galz10 in
|
||||
[#25341](https://github.com/google-gemini/gemini-cli/pull/25341)
|
||||
- feat: bundle ripgrep binaries into SEA for offline support by @scidomino in
|
||||
[#25342](https://github.com/google-gemini/gemini-cli/pull/25342)
|
||||
- Changelog for v0.39.0-preview.0 by @gemini-cli-robot in
|
||||
[#25417](https://github.com/google-gemini/gemini-cli/pull/25417)
|
||||
- feat(test): add large conversation scenario for performance test by
|
||||
@cynthialong0-0 in
|
||||
[#25331](https://github.com/google-gemini/gemini-cli/pull/25331)
|
||||
- improve(core): require recurrence evidence before extracting skills by
|
||||
@SandyTao520 in
|
||||
[#25147](https://github.com/google-gemini/gemini-cli/pull/25147)
|
||||
- test(evals): add subagent delegation evaluation tests by @anj-s in
|
||||
[#24619](https://github.com/google-gemini/gemini-cli/pull/24619)
|
||||
- feat: add github colorblind themes by @Z1xus in
|
||||
[#15504](https://github.com/google-gemini/gemini-cli/pull/15504)
|
||||
- fix(core): honor GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL by
|
||||
@chrisjcthomas in
|
||||
[#25357](https://github.com/google-gemini/gemini-cli/pull/25357)
|
||||
- fix(cli): clean up slash command IDE listeners by @jasonmatthewsuhari in
|
||||
[#24397](https://github.com/google-gemini/gemini-cli/pull/24397)
|
||||
- Changelog for v0.38.0 by @gemini-cli-robot in
|
||||
[#25470](https://github.com/google-gemini/gemini-cli/pull/25470)
|
||||
- fix(evals): update eval tests for invoke_agent telemetry and project-scoped
|
||||
memory by @SandyTao520 in
|
||||
[#25502](https://github.com/google-gemini/gemini-cli/pull/25502)
|
||||
- Changelog for v0.38.1 by @gemini-cli-robot in
|
||||
[#25476](https://github.com/google-gemini/gemini-cli/pull/25476)
|
||||
- feat(core): integrate skill-creator into skill extraction agent by
|
||||
@SandyTao520 in
|
||||
[#25421](https://github.com/google-gemini/gemini-cli/pull/25421)
|
||||
- feat(cli): provide default post-submit prompt for skill command by @ruomengz
|
||||
in [#25327](https://github.com/google-gemini/gemini-cli/pull/25327)
|
||||
- feat(core): add tools to list and read MCP resources by @ruomengz in
|
||||
[#25395](https://github.com/google-gemini/gemini-cli/pull/25395)
|
||||
- fix(evals): add typecheck coverage for evals, integration-tests, and
|
||||
memory-tests by @SandyTao520 in
|
||||
[#25480](https://github.com/google-gemini/gemini-cli/pull/25480)
|
||||
- Use OSC 777 for terminal notifications by @jackyliuxx in
|
||||
[#25300](https://github.com/google-gemini/gemini-cli/pull/25300)
|
||||
- fix(extensions): fix bundling for examples by @abhipatel12 in
|
||||
[#25542](https://github.com/google-gemini/gemini-cli/pull/25542)
|
||||
- fix(cli): reset plan session state on /clear by @jasonmatthewsuhari in
|
||||
[#25515](https://github.com/google-gemini/gemini-cli/pull/25515)
|
||||
- feat(core): add .mdx support to get-internal-docs tool by @g-samroberts in
|
||||
[#25090](https://github.com/google-gemini/gemini-cli/pull/25090)
|
||||
- docs(policy): mention that workspace policies are broken by @6112 in
|
||||
[#24367](https://github.com/google-gemini/gemini-cli/pull/24367)
|
||||
- fix(core): allow explicit write permissions to override governance file
|
||||
protections in sandboxes by @galz10 in
|
||||
[#25338](https://github.com/google-gemini/gemini-cli/pull/25338)
|
||||
- feat(sandbox): resolve custom seatbelt profiles from $HOME/.gemini first by
|
||||
@mvanhorn in [#25427](https://github.com/google-gemini/gemini-cli/pull/25427)
|
||||
- Reduce blank lines. by @gundermanc in
|
||||
[#25563](https://github.com/google-gemini/gemini-cli/pull/25563)
|
||||
- fix(ui): revert preview theme on dialog unmount by @JayadityaGit in
|
||||
[#22542](https://github.com/google-gemini/gemini-cli/pull/22542)
|
||||
- fix(core): fix ShellExecutionConfig spread and add ProjectRegistry save
|
||||
backoff by @mahimashanware in
|
||||
[#25382](https://github.com/google-gemini/gemini-cli/pull/25382)
|
||||
- feat(core): Disable topic updates for subagents by @gundermanc in
|
||||
[#25567](https://github.com/google-gemini/gemini-cli/pull/25567)
|
||||
- feat(core): enable topic update narration by default and promote to general by
|
||||
@gundermanc in
|
||||
[#25586](https://github.com/google-gemini/gemini-cli/pull/25586)
|
||||
- docs: migrate installation and authentication to mdx with tabbed layouts by
|
||||
@g-samroberts in
|
||||
[#25155](https://github.com/google-gemini/gemini-cli/pull/25155)
|
||||
- feat(config): split memoryManager flag into autoMemory by @SandyTao520 in
|
||||
[#25601](https://github.com/google-gemini/gemini-cli/pull/25601)
|
||||
- fix(core): allow Cloud Shell users to use PRO_MODEL_NO_ACCESS experiment by
|
||||
@sehoon38 in [#25702](https://github.com/google-gemini/gemini-cli/pull/25702)
|
||||
- fix(cli): round slow render latency to avoid opentelemetry float warning by
|
||||
@scidomino in [#25709](https://github.com/google-gemini/gemini-cli/pull/25709)
|
||||
- docs(tracker): introduce experimental task tracker feature by @anj-s in
|
||||
[#24556](https://github.com/google-gemini/gemini-cli/pull/24556)
|
||||
- docs(cli): fix inconsistent system.md casing in system prompt docs by @Bodlux
|
||||
in [#25414](https://github.com/google-gemini/gemini-cli/pull/25414)
|
||||
- feat(cli): add streamlined `gemini gemma` local model setup by @Samee24 in
|
||||
[#25498](https://github.com/google-gemini/gemini-cli/pull/25498)
|
||||
- Changelog for v0.38.2 by @gemini-cli-robot in
|
||||
[#25593](https://github.com/google-gemini/gemini-cli/pull/25593)
|
||||
- Fix: Disallow overriding IDE stdio via workspace .env (RCE) by @M0nd0R in
|
||||
[#25022](https://github.com/google-gemini/gemini-cli/pull/25022)
|
||||
- feat(test): refactor the memory usage test to use metrics from CLI process
|
||||
instead of test runner by @cynthialong0-0 in
|
||||
[#25708](https://github.com/google-gemini/gemini-cli/pull/25708)
|
||||
- feat(vertex): add settings for Vertex AI request routing by @gordonhwc in
|
||||
[#25513](https://github.com/google-gemini/gemini-cli/pull/25513)
|
||||
- Fix/allow for session persistence by @ahsanfarooq210 in
|
||||
[#25176](https://github.com/google-gemini/gemini-cli/pull/25176)
|
||||
- Allow dots on GEMINI_API_KEY by @DKbyo in
|
||||
[#25497](https://github.com/google-gemini/gemini-cli/pull/25497)
|
||||
- feat(telemetry): add flag for enabling traces specifically by @spencer426 in
|
||||
[#25343](https://github.com/google-gemini/gemini-cli/pull/25343)
|
||||
- fix(core): resolve nested plan directory duplication and relative path
|
||||
policies by @mahimashanware in
|
||||
[#25138](https://github.com/google-gemini/gemini-cli/pull/25138)
|
||||
- feat: detect new files in @ recommendations with watcher based updates by
|
||||
@prassamin in [#25256](https://github.com/google-gemini/gemini-cli/pull/25256)
|
||||
- fix(cli): use newline in shell command wrapping to avoid breaking heredocs by
|
||||
@cocosheng-g in
|
||||
[#25537](https://github.com/google-gemini/gemini-cli/pull/25537)
|
||||
- fix(cli): ensure theme dialog labels are rendered for all themes by
|
||||
@JayadityaGit in
|
||||
[#24599](https://github.com/google-gemini/gemini-cli/pull/24599)
|
||||
- fix(core): disable detached mode in Bun to prevent immediate SIGHUP of child
|
||||
processes by @euxaristia in
|
||||
[#22620](https://github.com/google-gemini/gemini-cli/pull/22620)
|
||||
- feat: add /new as alias for /clear and refine command description by @ved015
|
||||
in [#17865](https://github.com/google-gemini/gemini-cli/pull/17865)
|
||||
- fix(cli): start auto memory in ACP sessions by @jasonmatthewsuhari in
|
||||
[#25626](https://github.com/google-gemini/gemini-cli/pull/25626)
|
||||
- fix(core): remove duplicate initialize call on agents refreshed by
|
||||
@adamfweidman in
|
||||
[#25670](https://github.com/google-gemini/gemini-cli/pull/25670)
|
||||
- test(e2e): default integration tests to Flash Preview by @SandyTao520 in
|
||||
[#25753](https://github.com/google-gemini/gemini-cli/pull/25753)
|
||||
- refactor(memory): replace MemoryManagerAgent with prompt-driven memory editing
|
||||
across four tiers by @SandyTao520 in
|
||||
[#25716](https://github.com/google-gemini/gemini-cli/pull/25716)
|
||||
- fix(cli): fix "/clear (new)" command by @mini2s in
|
||||
[#25801](https://github.com/google-gemini/gemini-cli/pull/25801)
|
||||
- fix(core): use dynamic CLI version for IDE client instead of hardcoded '1.0.0'
|
||||
by @thekishandev in
|
||||
[#24414](https://github.com/google-gemini/gemini-cli/pull/24414)
|
||||
- fix(core): handle line endings in ignore file parsing by @xoma-zver in
|
||||
[#23895](https://github.com/google-gemini/gemini-cli/pull/23895)
|
||||
- Fix/command injection shell by @Famous077 in
|
||||
[#24170](https://github.com/google-gemini/gemini-cli/pull/24170)
|
||||
- fix(ui): removed background color for input by @devr0306 in
|
||||
[#25339](https://github.com/google-gemini/gemini-cli/pull/25339)
|
||||
- fix(devtools): reduce memory usage and defer connection by @SandyTao520 in
|
||||
[#24496](https://github.com/google-gemini/gemini-cli/pull/24496)
|
||||
- fix(core): support jsonl session logs in memory and summary services by
|
||||
@SandyTao520 in
|
||||
[#25816](https://github.com/google-gemini/gemini-cli/pull/25816)
|
||||
- fix(release): exclude ripgrep binaries from npm tarballs by @SandyTao520 in
|
||||
[#25841](https://github.com/google-gemini/gemini-cli/pull/25841)
|
||||
- refactor(plan): simplify policy priorities and consolidate read-only rules by
|
||||
@ruomengz in [#24849](https://github.com/google-gemini/gemini-cli/pull/24849)
|
||||
- feat(test-utils): add memory usage integration test harness by @sripasg in
|
||||
@@ -404,4 +254,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#25336](https://github.com/google-gemini/gemini-cli/pull/25336)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.40.0-preview.3
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.38.0-preview.0...v0.39.0-preview.0
|
||||
|
||||
@@ -52,7 +52,6 @@ These commands are available within the interactive REPL.
|
||||
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
|
||||
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
|
||||
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
|
||||
| `--skip-trust` | - | boolean | `false` | Trust the current workspace for this session, skipping the folder trust check. |
|
||||
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
|
||||
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
|
||||
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
|
||||
|
||||
@@ -470,8 +470,7 @@ 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 **Enable Session Cleanup** or **Keep chat history**) or in your
|
||||
`settings.json` file. See
|
||||
(search for **Session Retention**) or in your `settings.json` file. See
|
||||
[session retention](../cli/session-management.md#session-retention) for more
|
||||
details.
|
||||
|
||||
|
||||
+48
-165
@@ -31,53 +31,6 @@ The benefits of sandboxing include:
|
||||
- **Safety**: Reduce risk when working with untrusted code or experimental
|
||||
commands.
|
||||
|
||||
## Quickstart
|
||||
|
||||
You can enable sandboxing using a command flag, environment variable, or
|
||||
configuration file.
|
||||
|
||||
### Using the command flag
|
||||
|
||||
```bash
|
||||
gemini -s -p "analyze the code structure"
|
||||
```
|
||||
|
||||
### Using an environment variable
|
||||
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
### Configuring via settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable sandboxing using one of the following methods (in order of precedence):
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
## Sandboxing methods
|
||||
|
||||
Your ideal method of sandboxing may differ depending on your platform and your
|
||||
@@ -90,92 +43,12 @@ Lightweight, built-in sandboxing using `sandbox-exec`.
|
||||
**Default profile**: `permissive-open` - restricts writes outside project
|
||||
directory but allows most other operations.
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
|
||||
### 2. Container-based (Docker/Podman)
|
||||
|
||||
Cross-platform sandboxing with complete process isolation using container
|
||||
technology. By default, it uses the `ghcr.io/google/gemini-cli:latest` image.
|
||||
Cross-platform sandboxing with complete process isolation.
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Docker or Podman must be installed and running on your system.
|
||||
|
||||
**How it works (Workspace directory):**
|
||||
|
||||
Inside the sandbox container, your current working directory is mounted at the
|
||||
**exact same absolute path** as it is on your host machine. For example, if you
|
||||
run the CLI from `/Users/you/project` on your host machine, the sandbox will
|
||||
mount your local project folder and operate within `/Users/you/project` inside
|
||||
the container. This allows the AI to seamlessly read and modify your project
|
||||
files while remaining isolated from the rest of your system.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
To enable Docker sandboxing, run Gemini CLI with the sandbox flag and specify
|
||||
Docker as the provider:
|
||||
|
||||
```bash
|
||||
# Using the environment variable (Recommended)
|
||||
export GEMINI_SANDBOX=docker
|
||||
gemini -p "build the project"
|
||||
|
||||
# Or configure it permanently in your settings.json
|
||||
# {"tools": {"sandbox": "docker"}}
|
||||
```
|
||||
|
||||
**Customizing the Sandbox Image:**
|
||||
|
||||
If your project requires specific dependencies, you can specify a custom image
|
||||
name or have Gemini CLI build one for you automatically. You can use any Docker
|
||||
or Podman image as your sandbox, provided it has standard shell utilities (like
|
||||
`bash`) available.
|
||||
|
||||
**Option A: Using an existing custom image (e.g., Artifact Registry)**
|
||||
|
||||
To configure a custom image that is hosted on a registry (or built locally),
|
||||
update your `settings.json` to use an object for the sandbox configuration, or
|
||||
set the `GEMINI_SANDBOX_IMAGE` environment variable.
|
||||
|
||||
_Example: Configuring via `settings.json`_
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": {
|
||||
"command": "docker",
|
||||
"image": "us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
_Example: Configuring via environment variable_
|
||||
|
||||
```bash
|
||||
export GEMINI_SANDBOX_IMAGE="us-central1-docker.pkg.dev/my-project/my-repo/my-custom-sandbox:latest"
|
||||
```
|
||||
|
||||
**Option B: Building a local custom image automatically**
|
||||
|
||||
If you prefer to define your environment as code, you can provide a Dockerfile
|
||||
and Gemini CLI will build the image automatically.
|
||||
|
||||
1. Create a `.gemini/sandbox.Dockerfile` in your project root.
|
||||
2. Ensure you have the `gh` CLI installed and authenticated (if you are using
|
||||
the default `ghcr.io/google/gemini-cli` image as a base).
|
||||
3. Run your command with the `BUILD_SANDBOX` environment variable set:
|
||||
|
||||
```bash
|
||||
BUILD_SANDBOX=1 GEMINI_SANDBOX=docker gemini -p "run my custom build"
|
||||
```
|
||||
**Note**: Requires building the sandbox image locally or using a published image
|
||||
from your organization's registry.
|
||||
|
||||
### 3. Windows Native Sandbox (Windows only)
|
||||
|
||||
@@ -315,49 +188,59 @@ This mechanism ensures you don't have to manually re-run commands with more
|
||||
permissive sandbox settings, while still maintaining control over what the AI
|
||||
can access.
|
||||
|
||||
### Including files outside the workspace
|
||||
|
||||
By default, the sandbox only has access to the current project workspace. If you
|
||||
need the sandbox to have permission to operate on certain files or directories
|
||||
from the local file system outside of the project workspace, you can mount them
|
||||
using the `SANDBOX_MOUNTS` environment variable.
|
||||
|
||||
Provide a comma-separated list of mount definitions in the format
|
||||
`from:to:opts`. If `to` is omitted, it defaults to the same path as `from`. If
|
||||
`opts` is omitted, it defaults to `ro` (read-only). Note that the `from` path
|
||||
must be an absolute path.
|
||||
|
||||
**Example**:
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
export SANDBOX_MOUNTS="/path/on/host:/path/in/container:rw,/another/path:ro"
|
||||
# Enable sandboxing with command flag
|
||||
gemini -s -p "analyze the code structure"
|
||||
```
|
||||
|
||||
## Running inside a Docker container
|
||||
**Use environment variable**
|
||||
|
||||
If you are running Gemini CLI itself from within an official or custom Docker
|
||||
container and want to enable sandboxing, you must share the host's Docker socket
|
||||
and ensure your workspace paths align.
|
||||
|
||||
1. **Mount the Docker socket**: Map `/var/run/docker.sock` so the CLI can spawn
|
||||
sibling sandbox containers via the host's Docker daemon.
|
||||
2. **Align workspace paths**: The path to your workspace inside the container
|
||||
must exactly match the absolute path on the host. Because the sandbox
|
||||
container is spawned by the host's Docker daemon, it resolves volume mounts
|
||||
against the host file system.
|
||||
|
||||
**Example**:
|
||||
**macOS/Linux**
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /absolute/path/on/host/project:/absolute/path/on/host/project \
|
||||
-w /absolute/path/on/host/project \
|
||||
-e GEMINI_SANDBOX=docker \
|
||||
ghcr.io/google/gemini-cli:latest
|
||||
export GEMINI_SANDBOX=true
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
## Advanced settings
|
||||
**Windows (PowerShell)**
|
||||
|
||||
```powershell
|
||||
$env:GEMINI_SANDBOX="true"
|
||||
gemini -p "run the test suite"
|
||||
```
|
||||
|
||||
**Configure in settings.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"sandbox": "docker"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable sandboxing (in order of precedence)
|
||||
|
||||
1. **Command flag**: `-s` or `--sandbox`
|
||||
2. **Environment variable**:
|
||||
`GEMINI_SANDBOX=true|docker|podman|sandbox-exec|runsc|lxc`
|
||||
3. **Settings file**: `"sandbox": true` in the `tools` object of your
|
||||
`settings.json` file (for example, `{"tools": {"sandbox": true}}`).
|
||||
|
||||
### macOS Seatbelt profiles
|
||||
|
||||
Built-in profiles (set via `SEATBELT_PROFILE` env var):
|
||||
|
||||
- `permissive-open` (default): Write restrictions, network allowed
|
||||
- `permissive-proxied`: Write restrictions, network via proxy
|
||||
- `restrictive-open`: Strict restrictions, network allowed
|
||||
- `restrictive-proxied`: Strict restrictions, network via proxy
|
||||
- `strict-open`: Read and write restrictions, network allowed
|
||||
- `strict-proxied`: Read and write restrictions, network via proxy
|
||||
|
||||
### Custom sandbox flags
|
||||
|
||||
@@ -396,7 +279,7 @@ export SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
$env:SANDBOX_FLAGS="--flag1 --flag2=value"
|
||||
```
|
||||
|
||||
### Linux UID/GID handling
|
||||
## Linux UID/GID handling
|
||||
|
||||
The sandbox automatically handles user permissions on Linux. Override these
|
||||
permissions with:
|
||||
|
||||
+13
-19
@@ -161,25 +161,19 @@ they appear in the UI.
|
||||
|
||||
### Experimental
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models (experimental). | `false` |
|
||||
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
|
||||
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
|
||||
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. | `"gemini-live"` |
|
||||
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
|
||||
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `1000` |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
|
||||
| Auto Memory | `experimental.autoMemory` | Automatically extract reusable skills from past sessions in the background. Review results with /memory inbox. | `false` |
|
||||
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
|
||||
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ---------------------------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
|
||||
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
|
||||
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
|
||||
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
|
||||
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
|
||||
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
|
||||
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
|
||||
| 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
|
||||
|
||||
|
||||
@@ -100,34 +100,6 @@ protect you. In this mode, the following features are disabled:
|
||||
Granting trust to a folder unlocks the full functionality of Gemini CLI for that
|
||||
workspace.
|
||||
|
||||
## Headless and automated environments
|
||||
|
||||
When running Gemini CLI in a headless environment (for example, a CI/CD
|
||||
pipeline) where interactive prompts are not possible, the trust dialog cannot be
|
||||
displayed. If the folder is untrusted and the Folder Trust feature is enabled,
|
||||
the CLI will throw a `FatalUntrustedWorkspaceError` and exit.
|
||||
|
||||
To proceed in these environments, you can bypass the trust check using one of
|
||||
the following methods:
|
||||
|
||||
- **Command-line flag:** Run the CLI with the `--skip-trust` flag.
|
||||
- **Environment variable:** Set the `GEMINI_CLI_TRUST_WORKSPACE=true`
|
||||
environment variable.
|
||||
|
||||
These methods will trust the current workspace for the duration of the session
|
||||
without prompting.
|
||||
|
||||
For detailed instructions on managing folder trust within CI/CD workflows,
|
||||
review the
|
||||
[Gemini CLI trust guidance for GitHub Actions](https://github.com/google-github-actions/run-gemini-cli/blob/main/docs/trust-guidance.md).
|
||||
|
||||
## Overriding the trust file location
|
||||
|
||||
By default, trust settings are saved to `~/.gemini/trustedFolders.json`. If you
|
||||
need to store this file in a different location, you can set the
|
||||
`GEMINI_CLI_TRUSTED_FOLDERS_PATH` environment variable to the desired absolute
|
||||
file path.
|
||||
|
||||
## Managing your trust settings
|
||||
|
||||
If you need to change a decision or see all your settings, you have a couple of
|
||||
|
||||
+13
-112
@@ -563,18 +563,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-2.5-flash-lite"
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-31b-it"
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemma-4-26b-a4b-it"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-flash-base": {
|
||||
"extends": "base",
|
||||
"modelConfig": {
|
||||
@@ -846,28 +834,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-31b-it": {
|
||||
"displayName": "gemma-4-31b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"displayName": "gemma-4-26b-a4b-it",
|
||||
"tier": "custom",
|
||||
"family": "gemma-4",
|
||||
"isPreview": false,
|
||||
"isVisible": true,
|
||||
"features": {
|
||||
"thinking": true,
|
||||
"multimodalToolUse": false
|
||||
}
|
||||
},
|
||||
"auto": {
|
||||
"tier": "auto",
|
||||
"isPreview": true,
|
||||
@@ -938,12 +904,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
```json
|
||||
{
|
||||
"gemma-4-31b-it": {
|
||||
"default": "gemma-4-31b-it"
|
||||
},
|
||||
"gemma-4-26b-a4b-it": {
|
||||
"default": "gemma-4-26b-a4b-it"
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
"default": "gemini-3.1-pro-preview",
|
||||
"contexts": [
|
||||
@@ -1191,7 +1151,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1207,7 +1167,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1224,7 +1184,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1240,7 +1200,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1257,7 +1217,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1272,7 +1232,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1288,7 +1248,7 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
},
|
||||
"stateTransitions": {
|
||||
"terminal": "terminal",
|
||||
"transient": "sticky_retry",
|
||||
"transient": "terminal",
|
||||
"not_found": "terminal",
|
||||
"unknown": "terminal"
|
||||
}
|
||||
@@ -1507,12 +1467,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.confirmationRequired`** (array):
|
||||
- **Description:** Tool names that always require user confirmation. Takes
|
||||
precedence over allowed tools and core tool allowlists.
|
||||
- **Default:** `undefined`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.exclude`** (array):
|
||||
- **Description:** Tool names to exclude from discovery.
|
||||
- **Default:** `undefined`
|
||||
@@ -1686,37 +1640,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `experimental`
|
||||
|
||||
- **`experimental.gemma`** (boolean):
|
||||
- **Description:** Enable access to Gemma 4 models (experimental).
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.voiceMode`** (boolean):
|
||||
- **Description:** Enable experimental voice dictation and commands (/voice,
|
||||
/voice model).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`experimental.voice.activationMode`** (enum):
|
||||
- **Description:** How to trigger voice recording with the Space key.
|
||||
- **Default:** `"push-to-talk"`
|
||||
- **Values:** `"push-to-talk"`, `"toggle"`
|
||||
|
||||
- **`experimental.voice.backend`** (enum):
|
||||
- **Description:** The backend to use for voice transcription.
|
||||
- **Default:** `"gemini-live"`
|
||||
- **Values:** `"gemini-live"`, `"whisper"`
|
||||
|
||||
- **`experimental.voice.whisperModel`** (enum):
|
||||
- **Description:** The Whisper model to use for local transcription.
|
||||
- **Default:** `"ggml-base.en.bin"`
|
||||
- **Values:** `"ggml-tiny.en.bin"`, `"ggml-base.en.bin"`,
|
||||
`"ggml-large-v3-turbo-q5_0.bin"`, `"ggml-large-v3-turbo-q8_0.bin"`
|
||||
|
||||
- **`experimental.voice.stopGracePeriodMs`** (number):
|
||||
- **Description:** How long to wait for final transcription after stopping
|
||||
recording.
|
||||
- **Default:** `1000`
|
||||
|
||||
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
|
||||
- **Description:** Enable non-interactive agent sessions.
|
||||
- **Default:** `false`
|
||||
@@ -1765,10 +1688,8 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.jitContext`** (boolean):
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading. Defaults to
|
||||
true; set to false to opt out and load all GEMINI.md files into the system
|
||||
instruction up-front.
|
||||
- **Default:** `true`
|
||||
- **Description:** Enable Just-In-Time (JIT) context loading.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.useOSC52Paste`** (boolean):
|
||||
@@ -1833,22 +1754,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
- **Default:** `"gemma3-1b-gpu-custom"`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.memoryV2`** (boolean):
|
||||
- **Description:** 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.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.stressTestProfile`** (boolean):
|
||||
- **Description:** Significantly lowers token limits to force early garbage
|
||||
collection and distillation for testing purposes.
|
||||
- **`experimental.memoryManager`** (boolean):
|
||||
- **Description:** Replace the built-in save_memory tool with a memory manager
|
||||
subagent that supports adding, removing, de-duplicating, and organizing
|
||||
memories.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
@@ -2239,14 +2148,6 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file.
|
||||
- Overrides the hardcoded default
|
||||
- Example: `export GEMINI_MODEL="gemini-3-flash-preview"` (Windows PowerShell:
|
||||
`$env:GEMINI_MODEL="gemini-3-flash-preview"`)
|
||||
- **`GEMINI_CLI_TRUST_WORKSPACE`**:
|
||||
- If set to `"true"`, trusts the current workspace for the duration of the
|
||||
session, bypassing the folder trust check.
|
||||
- Useful for headless environments (for example, CI/CD pipelines).
|
||||
- **`GEMINI_CLI_TRUSTED_FOLDERS_PATH`**:
|
||||
- Overrides the default location for the `trustedFolders.json` file.
|
||||
- Useful if you want to store this configuration in a custom location instead
|
||||
of the default `~/.gemini/`.
|
||||
- **`GEMINI_CLI_IDE_PID`**:
|
||||
- Manually specifies the PID of the IDE process to use for integration. This
|
||||
is useful when running Gemini CLI in a standalone terminal while still
|
||||
|
||||
@@ -115,7 +115,6 @@ available combinations.
|
||||
| `app.restart` | Restart the application. | `R`<br />`Shift+R` |
|
||||
| `app.suspend` | Suspend the CLI and move it to the background. | `Ctrl+Z` |
|
||||
| `app.showShellUnfocusWarning` | Show warning when trying to move focus away from shell input. | `Tab` |
|
||||
| `app.voiceModePTT` | Hold to speak in Voice Mode. | `Space` |
|
||||
|
||||
#### Background Shell Controls
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('Hierarchical Memory', () => {
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -55,7 +55,7 @@ What is my favorite fruit? Tell me just the name of the fruit.`,
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ Provide the answer as an XML block like this:
|
||||
params: {
|
||||
settings: {
|
||||
security: {
|
||||
folderTrust: { enabled: false },
|
||||
folderTrust: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+20
-508
@@ -5,78 +5,12 @@
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
loadConversationRecord,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
function findDir(base: string, name: string): string | null {
|
||||
if (!fs.existsSync(base)) return null;
|
||||
const files = fs.readdirSync(base);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(base, file);
|
||||
if (fs.statSync(fullPath).isDirectory()) {
|
||||
if (file === name) return fullPath;
|
||||
const found = findDir(fullPath, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadLatestSessionRecord(homeDir: string, sessionId: string) {
|
||||
const chatsDir = findDir(path.join(homeDir, '.gemini'), 'chats');
|
||||
if (!chatsDir) {
|
||||
throw new Error('Could not find chats directory for eval session logs');
|
||||
}
|
||||
|
||||
const candidates = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter(
|
||||
(file) =>
|
||||
file.startsWith(SESSION_FILE_PREFIX) &&
|
||||
(file.endsWith('.json') || file.endsWith('.jsonl')),
|
||||
);
|
||||
|
||||
const matchingRecords = [];
|
||||
for (const file of candidates) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
const record = await loadConversationRecord(filePath);
|
||||
if (record?.sessionId === sessionId) {
|
||||
matchingRecords.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
matchingRecords.sort(
|
||||
(a, b) => Date.parse(b.lastUpdated) - Date.parse(a.lastUpdated),
|
||||
);
|
||||
return matchingRecords[0] ?? null;
|
||||
}
|
||||
|
||||
async function waitForSessionScratchpad(
|
||||
homeDir: string,
|
||||
sessionId: string,
|
||||
timeoutMs = 30000,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const record = await loadLatestSessionRecord(homeDir, sessionId);
|
||||
if (record?.memoryScratchpad) {
|
||||
return record;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return loadLatestSessionRecord(homeDir, sessionId);
|
||||
}
|
||||
|
||||
describe('save_memory', () => {
|
||||
const TEST_PREFIX = 'Save memory test: ';
|
||||
const rememberingFavoriteColor = "Agent remembers user's favorite color";
|
||||
@@ -84,11 +18,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingFavoriteColor,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `remember that my favorite color is blue.
|
||||
|
||||
@@ -111,11 +40,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandRestrictions,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I don't want you to ever run npm commands.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -137,11 +61,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingWorkflow,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I want you to always lint after building.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -164,11 +83,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: ignoringTemporaryInformation,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I'm going to get a coffee.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -194,11 +108,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingPetName,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `Please remember that my dog's name is Buddy.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -220,11 +129,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCommandAlias,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `When I say 'start server', you should run 'npm run dev'.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -247,11 +151,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingDbSchemaLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -281,11 +180,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingCodingStyle,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `I prefer to use tabs instead of spaces for indentation.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -308,11 +202,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingBuildArtifactLocationAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -342,11 +231,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: savingMainEntryPointAsProjectMemory,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
@@ -375,11 +259,6 @@ describe('save_memory', () => {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: rememberingBirthday,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: false },
|
||||
},
|
||||
},
|
||||
|
||||
prompt: `My birthday is on June 15th.`,
|
||||
assert: async (rig, result) => {
|
||||
@@ -404,7 +283,7 @@ describe('save_memory', () => {
|
||||
name: proactiveMemoryFromLongSession,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -462,75 +341,29 @@ describe('save_memory', () => {
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2, the agent persists memories by
|
||||
// editing markdown files directly with write_file or replace — not via
|
||||
// a save_memory subagent. The user said "I always prefer Vitest over
|
||||
// Jest for testing in all my projects" — that matches the new
|
||||
// cross-project cue phrase ("across all my projects"), so under the
|
||||
// 4-tier model the correct destination is the global personal memory
|
||||
// file (~/.gemini/GEMINI.md). It must NOT land in a committed project
|
||||
// GEMINI.md (that tier is for team conventions) or the per-project
|
||||
// private memory folder (that tier is for project-specific personal
|
||||
// notes). The chat history mixes this durable preference with
|
||||
// transient debugging chatter, so the eval also verifies the agent
|
||||
// picks out the persistent fact among the noise.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteVitestToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
/vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args) && /vitest/i.test(args),
|
||||
);
|
||||
expect(
|
||||
wroteVitestToGlobal,
|
||||
'Expected the cross-project Vitest preference to be written to the global personal memory file (~/.gemini/GEMINI.md) via write_file or replace',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent and the Vitest preference from the conversation history',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
/vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Cross-project Vitest preference must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToPrivateProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) && /vitest/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateProject,
|
||||
'Cross-project Vitest preference must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesTeamConventionsToProjectGemini =
|
||||
'Agent routes team-shared project conventions to ./GEMINI.md';
|
||||
const memoryManagerRoutingPreferences =
|
||||
'Agent routes global and project preferences to memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesTeamConventionsToProjectGemini,
|
||||
name: memoryManagerRoutingPreferences,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
experimental: { memoryManager: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
@@ -539,7 +372,7 @@ describe('save_memory', () => {
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
text: 'For this project, the team always runs tests with `npm run test` — please remember that as our project convention.',
|
||||
text: 'I always use dark mode in all my editors and terminals.',
|
||||
},
|
||||
],
|
||||
timestamp: '2026-01-01T00:00:00Z',
|
||||
@@ -547,9 +380,7 @@ describe('save_memory', () => {
|
||||
{
|
||||
id: 'msg-2',
|
||||
type: 'gemini',
|
||||
content: [
|
||||
{ text: 'Got it, I will keep `npm run test` in mind for tests.' },
|
||||
],
|
||||
content: [{ text: 'Got it, I will keep that in mind!' }],
|
||||
timestamp: '2026-01-01T00:00:05Z',
|
||||
},
|
||||
{
|
||||
@@ -573,334 +404,15 @@ describe('save_memory', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2, the prompt enforces an explicit
|
||||
// one-tier-per-fact rule: team-shared project conventions (the team's
|
||||
// test command, project-wide indentation rules) belong in the
|
||||
// committed project-root ./GEMINI.md and must NOT be mirrored or
|
||||
// cross-referenced into the private project memory folder
|
||||
// (~/.gemini/tmp/<hash>/memory/). The global ~/.gemini/GEMINI.md must
|
||||
// never be touched in this mode either.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToProjectRoot = (factPattern: RegExp) =>
|
||||
writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
factPattern.test(args)
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wroteToProjectRoot(/npm run test/i),
|
||||
'Expected the team test-command convention to be written to the project-root ./GEMINI.md',
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wroteToProjectRoot(/2[- ]space/i),
|
||||
'Expected the project-wide "2-space indentation" convention to be written to the project-root ./GEMINI.md',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToPrivateMemory = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
|
||||
(/npm run test/i.test(args) || /2[- ]space/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateMemory,
|
||||
'Team-shared project conventions must NOT be mirrored into the private project memory folder (~/.gemini/tmp/<hash>/memory/) — each fact lives in exactly one tier.',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToGlobal,
|
||||
'Project preferences must NOT be written to the global ~/.gemini/GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
const wasToolCalled = await rig.waitForToolCall(
|
||||
'invoke_agent',
|
||||
undefined,
|
||||
(args) => /save_memory/i.test(args),
|
||||
);
|
||||
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',
|
||||
wasToolCalled,
|
||||
'Expected invoke_agent to be called with save_memory agent',
|
||||
).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', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesUserProject,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
prompt: `Please remember my personal local dev setup for THIS project's Postgres database. This is private to my machine — do NOT commit it to the repo.
|
||||
|
||||
Connection details:
|
||||
- Host: localhost
|
||||
- Port: 6543 (non-standard, I run multiple Postgres instances)
|
||||
- Database: myproj_dev
|
||||
- User: sandy_local
|
||||
- Password: read from the SANDY_PG_LOCAL_PASS env var in my shell
|
||||
|
||||
How I start it locally:
|
||||
1. Run \`brew services start postgresql@15\` to bring the server up.
|
||||
2. Run \`./scripts/seed-local-db.sh\` from the repo root to load my personal seed data.
|
||||
3. Verify with \`psql -h localhost -p 6543 -U sandy_local myproj_dev -c '\\dt'\`.
|
||||
|
||||
Quirks to remember:
|
||||
- The migrations runner sometimes hangs on my machine if I forget step 1; kill it with Ctrl+C and rerun.
|
||||
- I keep an extra \`scratch\` schema for ad-hoc experiments — never reference it from project code.`,
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2 with the Private Project Memory bullet
|
||||
// surfaced in the prompt, a fact that is project-specific AND
|
||||
// personal-to-the-user (must not be committed) should land in the
|
||||
// private project memory folder under ~/.gemini/tmp/<hash>/memory/. The
|
||||
// detailed note should be written to a sibling markdown file, with
|
||||
// MEMORY.md updated as the index. It must NOT go to committed
|
||||
// ./GEMINI.md or the global ~/.gemini/GEMINI.md.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteUserProjectDetail = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\/(?!MEMORY\.md)[^"]+\.md/i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
wroteUserProjectDetail,
|
||||
'Expected the personal-to-user project note to be written to a private project memory detail file (~/.gemini/tmp/<hash>/memory/*.md)',
|
||||
).toBe(true);
|
||||
|
||||
const wroteUserProjectIndex = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return /\.gemini\/tmp\/[^/]+\/memory\/MEMORY\.md/i.test(args);
|
||||
});
|
||||
expect(
|
||||
wroteUserProjectIndex,
|
||||
'Expected the personal-to-user project note to update the private project memory index (~/.gemini/tmp/<hash>/memory/MEMORY.md)',
|
||||
).toBe(true);
|
||||
|
||||
// Defensive: should NOT have written this private note to the
|
||||
// committed project GEMINI.md or the global GEMINI.md.
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Personal-to-user note must NOT be written to the committed project GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToGlobal = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
/6543/.test(args)
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToGlobal,
|
||||
'Personal-to-user project note must NOT be written to the global ~/.gemini/GEMINI.md',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
const memoryV2RoutesCrossProjectToGlobal =
|
||||
'Agent routes cross-project personal preferences to ~/.gemini/GEMINI.md';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryV2RoutesCrossProjectToGlobal,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
prompt:
|
||||
'Please remember this about me in general: across all my projects I always prefer Prettier with single quotes and trailing commas, and I always prefer tabs over spaces for indentation. These are my personal coding-style defaults that follow me into every workspace.',
|
||||
assert: async (rig, result) => {
|
||||
// Under experimental.memoryV2 with the Global Personal Memory
|
||||
// tier surfaced in the prompt, a fact that explicitly applies to the
|
||||
// user "across all my projects" / "in every workspace" must land in
|
||||
// the global ~/.gemini/GEMINI.md (the cross-project tier). It must
|
||||
// NOT be mirrored into a committed project-root ./GEMINI.md (that
|
||||
// tier is for team-shared conventions) or into the per-project
|
||||
// private memory folder (that tier is for project-specific personal
|
||||
// notes). Each fact lives in exactly one tier across all four tiers.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
const writeCalls = rig
|
||||
.readToolLogs()
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
);
|
||||
|
||||
const wroteToGlobal = (factPattern: RegExp) =>
|
||||
writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/GEMINI\.md/i.test(args) &&
|
||||
!/tmp\/[^/]+\/memory/i.test(args) &&
|
||||
factPattern.test(args)
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
wroteToGlobal(/Prettier/i),
|
||||
'Expected the cross-project Prettier preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
wroteToGlobal(/tabs/i),
|
||||
'Expected the cross-project "tabs over spaces" preference to be written to the global personal memory file (~/.gemini/GEMINI.md)',
|
||||
).toBe(true);
|
||||
|
||||
const leakedToCommittedProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/GEMINI\.md/i.test(args) &&
|
||||
!/\.gemini\//i.test(args) &&
|
||||
(/Prettier/i.test(args) || /tabs/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToCommittedProject,
|
||||
'Cross-project personal preferences must NOT be mirrored into a committed project ./GEMINI.md (that tier is for team-shared conventions only)',
|
||||
).toBe(false);
|
||||
|
||||
const leakedToPrivateProject = writeCalls.some((log) => {
|
||||
const args = log.toolRequest.args;
|
||||
return (
|
||||
/\.gemini\/tmp\/[^/]+\/memory\//i.test(args) &&
|
||||
(/Prettier/i.test(args) || /tabs/i.test(args))
|
||||
);
|
||||
});
|
||||
expect(
|
||||
leakedToPrivateProject,
|
||||
'Cross-project personal preferences must NOT be mirrored into the private project memory folder (that tier is for project-specific personal notes only)',
|
||||
).toBe(false);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
|
||||
+17
-630
@@ -6,30 +6,21 @@
|
||||
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect } from 'vitest';
|
||||
import {
|
||||
type Config,
|
||||
ApprovalMode,
|
||||
type MemoryScratchpad,
|
||||
SESSION_FILE_PREFIX,
|
||||
getProjectHash,
|
||||
startMemoryService,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { ComponentRig, componentEvalTest } from './component-test-helper.js';
|
||||
import {
|
||||
average,
|
||||
averageNullable,
|
||||
countMatchingIds,
|
||||
roundStat,
|
||||
} from './statistics-helper.js';
|
||||
import { prepareWorkspace } from './test-helper.js';
|
||||
import { componentEvalTest } from './component-test-helper.js';
|
||||
|
||||
interface SeedSession {
|
||||
sessionId: string;
|
||||
summary: string;
|
||||
userTurns: string[];
|
||||
timestampOffsetMinutes: number;
|
||||
memoryScratchpad?: MemoryScratchpad;
|
||||
}
|
||||
|
||||
interface MessageRecord {
|
||||
@@ -39,81 +30,6 @@ interface MessageRecord {
|
||||
content: Array<{ text: string }>;
|
||||
}
|
||||
|
||||
interface SessionVersion {
|
||||
sessionId: string;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface ExtractionRunSnapshot {
|
||||
sessionIds: string[];
|
||||
skillsCreated: string[];
|
||||
candidateSessions: SessionVersion[];
|
||||
processedSessions: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}
|
||||
|
||||
interface ExtractionOutcome {
|
||||
state: { runs: ExtractionRunSnapshot[] };
|
||||
skillsDir: string;
|
||||
skillBodies: string[];
|
||||
}
|
||||
|
||||
interface SkillQualitySignal {
|
||||
label: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ScratchpadRunMetrics {
|
||||
turnCount: number | null;
|
||||
durationMs: number | null;
|
||||
terminateReason: string | null;
|
||||
skillsCreated: number;
|
||||
candidateSessions: number;
|
||||
processedSessions: number;
|
||||
relevantReads: number;
|
||||
distractorReads: number;
|
||||
totalReads: number;
|
||||
recall: number;
|
||||
precision: number;
|
||||
signalScore: number;
|
||||
skillQualityScore: number;
|
||||
skillQualityMax: number;
|
||||
skillQualityRatio: number;
|
||||
missingQualitySignals: string[];
|
||||
}
|
||||
|
||||
interface ScratchpadStatsTrial {
|
||||
trial: number;
|
||||
baseline: ScratchpadRunMetrics;
|
||||
enhanced: ScratchpadRunMetrics;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsAggregate {
|
||||
turnCountAvg: number | null;
|
||||
durationMsAvg: number | null;
|
||||
recallAvg: number;
|
||||
precisionAvg: number;
|
||||
signalScoreAvg: number;
|
||||
relevantReadsAvg: number;
|
||||
distractorReadsAvg: number;
|
||||
skillsCreatedAvg: number;
|
||||
skillQualityScoreAvg: number;
|
||||
skillQualityRatioAvg: number;
|
||||
}
|
||||
|
||||
interface ScratchpadStatsReport {
|
||||
generatedAt: string;
|
||||
trials: number;
|
||||
aggregate: {
|
||||
baseline: ScratchpadStatsAggregate;
|
||||
enhanced: ScratchpadStatsAggregate;
|
||||
};
|
||||
deltas: ScratchpadStatsAggregate;
|
||||
results: ScratchpadStatsTrial[];
|
||||
}
|
||||
|
||||
const WORKSPACE_FILES = {
|
||||
'package.json': JSON.stringify(
|
||||
{
|
||||
@@ -152,143 +68,6 @@ function buildMessages(userTurns: string[]): MessageRecord[] {
|
||||
]);
|
||||
}
|
||||
|
||||
function padTurns(turns: string[]): string[] {
|
||||
if (turns.length >= 10) {
|
||||
return turns;
|
||||
}
|
||||
|
||||
const padded = [...turns];
|
||||
for (let i = turns.length; i < 10; i++) {
|
||||
padded.push(`${turns[i % turns.length]} (repeat ${i + 1})`);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
|
||||
function createScratchpad(
|
||||
workflowSummary: string,
|
||||
touchedPaths: string[],
|
||||
validationStatus: MemoryScratchpad['validationStatus'] = 'passed',
|
||||
): MemoryScratchpad {
|
||||
return {
|
||||
version: 1,
|
||||
workflowSummary,
|
||||
toolSequence: ['run_shell_command'],
|
||||
touchedPaths,
|
||||
validationStatus,
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkflowComparisonSessions(withScratchpad: boolean): {
|
||||
sessions: SeedSession[];
|
||||
relevantSessionIds: string[];
|
||||
distractorSessionIds: string[];
|
||||
} {
|
||||
const relevantWorkflowSummary =
|
||||
'run_shell_command -> run_shell_command | paths packages/cli/src/config/settings.ts, docs/settings.md | validated';
|
||||
|
||||
const relevantScratchpad = withScratchpad
|
||||
? createScratchpad(relevantWorkflowSummary, [
|
||||
'packages/cli/src/config/settings.ts',
|
||||
'docs/settings.md',
|
||||
])
|
||||
: undefined;
|
||||
|
||||
const sessions: SeedSession[] = [
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-a',
|
||||
summary: 'Prepare release notes for settings launch',
|
||||
timestampOffsetMinutes: 420,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'When we add a new setting, the durable workflow is to regenerate the settings docs instead of editing them by hand.',
|
||||
'The sequence that worked was npm run predocs:settings, npm run schema:settings, then npm run docs:settings.',
|
||||
'Skipping predocs leaves stale defaults in the generated docs.',
|
||||
'We verify the workflow by checking that both the schema output and docs update together.',
|
||||
'This exact command order is the recurring workflow we use for settings changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'hidden-settings-workflow-b',
|
||||
summary: 'Investigate CI drift in generated config reference',
|
||||
timestampOffsetMinutes: 390,
|
||||
memoryScratchpad: relevantScratchpad,
|
||||
userTurns: padTurns([
|
||||
'The config reference drift was fixed by rerunning the standard settings regeneration workflow.',
|
||||
'We again used npm run predocs:settings before npm run schema:settings and npm run docs:settings.',
|
||||
'The recurring rule is never to hand-edit generated settings docs.',
|
||||
'The validation step is to confirm the schema artifact and docs changed together after regeneration.',
|
||||
'This is the same recurring workflow we use every time a setting changes.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-release-notes',
|
||||
summary: 'Prepare release notes for auth launch',
|
||||
timestampOffsetMinutes: 360,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This release-notes task was one-off and just needed manual wording updates.',
|
||||
'I edited CHANGELOG.md and docs/release-notes.md directly.',
|
||||
'There was no reusable command sequence here beyond proofreading the copy.',
|
||||
'This task should not become a standing workflow.',
|
||||
'Once the wording landed, we were done.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-ci-snapshots',
|
||||
summary: 'Investigate CI drift in auth snapshots',
|
||||
timestampOffsetMinutes: 330,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This auth snapshot issue was specific to a flaky test in CI.',
|
||||
'The only commands we ran were npm test -- auth and an isolated snapshot update.',
|
||||
'It was not the recurring settings-doc workflow.',
|
||||
'Once the flaky snapshot passed, there was no broader reusable procedure.',
|
||||
'Treat this as a one-off CI cleanup.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-onboarding-docs',
|
||||
summary: 'Refresh onboarding documentation copy',
|
||||
timestampOffsetMinutes: 300,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was just a docs wording cleanup in docs/onboarding.md.',
|
||||
'No command sequence was involved.',
|
||||
'We manually edited the copy and reviewed it.',
|
||||
'There is no recurring operational workflow to capture here.',
|
||||
'This should stay a one-off docs edit.',
|
||||
]),
|
||||
},
|
||||
{
|
||||
sessionId: 'distractor-deploy-copy',
|
||||
summary: 'Adjust deployment checklist wording',
|
||||
timestampOffsetMinutes: 270,
|
||||
memoryScratchpad: undefined,
|
||||
userTurns: padTurns([
|
||||
'This was a wording-only change to docs/deploy.md.',
|
||||
'We did not run a reusable command sequence.',
|
||||
'It should not become a skill.',
|
||||
'The edit was only for this deploy checklist cleanup.',
|
||||
'After the copy change, the task was complete.',
|
||||
]),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
sessions,
|
||||
relevantSessionIds: [
|
||||
'hidden-settings-workflow-a',
|
||||
'hidden-settings-workflow-b',
|
||||
],
|
||||
distractorSessionIds: [
|
||||
'distractor-release-notes',
|
||||
'distractor-ci-snapshots',
|
||||
'distractor-onboarding-docs',
|
||||
'distractor-deploy-copy',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function seedSessions(
|
||||
config: Config,
|
||||
sessions: SeedSession[],
|
||||
@@ -299,10 +78,9 @@ async function seedSessions(
|
||||
const projectRoot = config.storage.getProjectRoot();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionTimestamp = new Date(
|
||||
const timestamp = new Date(
|
||||
Date.now() - session.timestampOffsetMinutes * 60 * 1000,
|
||||
);
|
||||
const timestamp = sessionTimestamp
|
||||
)
|
||||
.toISOString()
|
||||
.slice(0, 16)
|
||||
.replace(/:/g, '-');
|
||||
@@ -311,9 +89,8 @@ async function seedSessions(
|
||||
sessionId: session.sessionId,
|
||||
projectHash: getProjectHash(projectRoot),
|
||||
summary: session.summary,
|
||||
memoryScratchpad: session.memoryScratchpad,
|
||||
startTime: new Date(Date.now() - 7 * 60 * 60 * 1000).toISOString(),
|
||||
lastUpdated: sessionTimestamp.toISOString(),
|
||||
lastUpdated: new Date(Date.now() - 4 * 60 * 60 * 1000).toISOString(),
|
||||
messages: buildMessages(session.userTurns),
|
||||
};
|
||||
|
||||
@@ -324,9 +101,10 @@ async function seedSessions(
|
||||
}
|
||||
}
|
||||
|
||||
async function runExtractionAndReadState(
|
||||
config: Config,
|
||||
): Promise<ExtractionOutcome> {
|
||||
async function runExtractionAndReadState(config: Config): Promise<{
|
||||
state: { runs: Array<{ sessionIds: string[]; skillsCreated: string[] }> };
|
||||
skillsDir: string;
|
||||
}> {
|
||||
await startMemoryService(config);
|
||||
|
||||
const memoryDir = config.storage.getProjectMemoryTempDir();
|
||||
@@ -335,15 +113,7 @@ async function runExtractionAndReadState(
|
||||
|
||||
const raw = await fsp.readFile(statePath, 'utf-8');
|
||||
const state = JSON.parse(raw) as {
|
||||
runs?: Array<{
|
||||
sessionIds?: string[];
|
||||
skillsCreated?: string[];
|
||||
candidateSessions?: SessionVersion[];
|
||||
processedSessions?: SessionVersion[];
|
||||
turnCount?: number;
|
||||
durationMs?: number;
|
||||
terminateReason?: string;
|
||||
}>;
|
||||
runs?: Array<{ sessionIds?: string[]; skillsCreated?: string[] }>;
|
||||
};
|
||||
if (!Array.isArray(state.runs) || state.runs.length === 0) {
|
||||
throw new Error('Skill extraction finished without writing any run state');
|
||||
@@ -356,292 +126,27 @@ async function runExtractionAndReadState(
|
||||
skillsCreated: Array.isArray(run.skillsCreated)
|
||||
? run.skillsCreated
|
||||
: [],
|
||||
candidateSessions: Array.isArray(run.candidateSessions)
|
||||
? run.candidateSessions
|
||||
: [],
|
||||
processedSessions: Array.isArray(run.processedSessions)
|
||||
? run.processedSessions
|
||||
: [],
|
||||
turnCount:
|
||||
typeof run.turnCount === 'number' ? run.turnCount : undefined,
|
||||
durationMs:
|
||||
typeof run.durationMs === 'number' ? run.durationMs : undefined,
|
||||
terminateReason:
|
||||
typeof run.terminateReason === 'string'
|
||||
? run.terminateReason
|
||||
: undefined,
|
||||
})),
|
||||
},
|
||||
skillsDir,
|
||||
skillBodies: await readSkillBodies(skillsDir),
|
||||
};
|
||||
}
|
||||
|
||||
async function summarizeScratchpadRun(
|
||||
outcome: ExtractionOutcome,
|
||||
run: ExtractionRunSnapshot,
|
||||
scenario: ReturnType<typeof createWorkflowComparisonSessions>,
|
||||
): Promise<ScratchpadRunMetrics> {
|
||||
const relevantReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.relevantSessionIds,
|
||||
);
|
||||
const distractorReads = countMatchingIds(
|
||||
run.processedSessions,
|
||||
scenario.distractorSessionIds,
|
||||
);
|
||||
const totalReads = run.processedSessions.length;
|
||||
const quality = scoreSkillQuality(
|
||||
outcome.skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
return {
|
||||
turnCount: run.turnCount ?? null,
|
||||
durationMs: run.durationMs ?? null,
|
||||
terminateReason: run.terminateReason ?? null,
|
||||
skillsCreated: run.skillsCreated.length,
|
||||
candidateSessions: run.candidateSessions.length,
|
||||
processedSessions: totalReads,
|
||||
relevantReads,
|
||||
distractorReads,
|
||||
totalReads,
|
||||
recall: relevantReads / scenario.relevantSessionIds.length,
|
||||
precision: totalReads === 0 ? 0 : relevantReads / totalReads,
|
||||
signalScore: relevantReads - distractorReads,
|
||||
skillQualityScore: quality.score,
|
||||
skillQualityMax: quality.maxScore,
|
||||
skillQualityRatio:
|
||||
quality.maxScore === 0 ? 0 : quality.score / quality.maxScore,
|
||||
missingQualitySignals: quality.missing,
|
||||
};
|
||||
}
|
||||
|
||||
function averageScratchpadRuns(
|
||||
runs: ScratchpadRunMetrics[],
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg: roundStat(averageNullable(runs.map((run) => run.turnCount))),
|
||||
durationMsAvg: roundStat(
|
||||
averageNullable(runs.map((run) => run.durationMs)),
|
||||
),
|
||||
recallAvg: roundStat(average(runs.map((run) => run.recall))) ?? 0,
|
||||
precisionAvg: roundStat(average(runs.map((run) => run.precision))) ?? 0,
|
||||
signalScoreAvg: roundStat(average(runs.map((run) => run.signalScore))) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.relevantReads))) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(average(runs.map((run) => run.distractorReads))) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(average(runs.map((run) => run.skillsCreated))) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityScore))) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(average(runs.map((run) => run.skillQualityRatio))) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function diffScratchpadAggregates(
|
||||
baseline: ScratchpadStatsAggregate,
|
||||
enhanced: ScratchpadStatsAggregate,
|
||||
): ScratchpadStatsAggregate {
|
||||
return {
|
||||
turnCountAvg:
|
||||
baseline.turnCountAvg === null || enhanced.turnCountAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.turnCountAvg - baseline.turnCountAvg),
|
||||
durationMsAvg:
|
||||
baseline.durationMsAvg === null || enhanced.durationMsAvg === null
|
||||
? null
|
||||
: roundStat(enhanced.durationMsAvg - baseline.durationMsAvg),
|
||||
recallAvg: roundStat(enhanced.recallAvg - baseline.recallAvg) ?? 0,
|
||||
precisionAvg: roundStat(enhanced.precisionAvg - baseline.precisionAvg) ?? 0,
|
||||
signalScoreAvg:
|
||||
roundStat(enhanced.signalScoreAvg - baseline.signalScoreAvg) ?? 0,
|
||||
relevantReadsAvg:
|
||||
roundStat(enhanced.relevantReadsAvg - baseline.relevantReadsAvg) ?? 0,
|
||||
distractorReadsAvg:
|
||||
roundStat(enhanced.distractorReadsAvg - baseline.distractorReadsAvg) ?? 0,
|
||||
skillsCreatedAvg:
|
||||
roundStat(enhanced.skillsCreatedAvg - baseline.skillsCreatedAvg) ?? 0,
|
||||
skillQualityScoreAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityScoreAvg - baseline.skillQualityScoreAvg,
|
||||
) ?? 0,
|
||||
skillQualityRatioAvg:
|
||||
roundStat(
|
||||
enhanced.skillQualityRatioAvg - baseline.skillQualityRatioAvg,
|
||||
) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function runScenarioWithFreshRig(
|
||||
sessions: SeedSession[],
|
||||
): Promise<ExtractionOutcome> {
|
||||
const rig = new ComponentRig({
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
});
|
||||
try {
|
||||
await rig.initialize();
|
||||
await prepareWorkspace(rig.testDir, rig.testDir, WORKSPACE_FILES);
|
||||
await seedSessions(rig.config!, sessions);
|
||||
return await runExtractionAndReadState(rig.config!);
|
||||
} finally {
|
||||
await rig.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runScratchpadStatsTrial(
|
||||
trial: number,
|
||||
): Promise<ScratchpadStatsTrial> {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
return {
|
||||
trial,
|
||||
baseline: await summarizeScratchpadRun(
|
||||
baselineOutcome,
|
||||
baselineRun,
|
||||
baselineScenario,
|
||||
),
|
||||
enhanced: await summarizeScratchpadRun(
|
||||
enhancedOutcome,
|
||||
enhancedRun,
|
||||
enhancedScenario,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function runScratchpadStatsReport(
|
||||
trials: number,
|
||||
): Promise<ScratchpadStatsReport> {
|
||||
const results: ScratchpadStatsTrial[] = [];
|
||||
|
||||
for (let trial = 1; trial <= trials; trial++) {
|
||||
results.push(await runScratchpadStatsTrial(trial));
|
||||
}
|
||||
|
||||
const baseline = averageScratchpadRuns(
|
||||
results.map((result) => result.baseline),
|
||||
);
|
||||
const enhanced = averageScratchpadRuns(
|
||||
results.map((result) => result.enhanced),
|
||||
);
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
trials,
|
||||
aggregate: {
|
||||
baseline,
|
||||
enhanced,
|
||||
},
|
||||
deltas: diffScratchpadAggregates(baseline, enhanced),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeScratchpadStatsReport(
|
||||
report: ScratchpadStatsReport,
|
||||
): Promise<string> {
|
||||
const outputPath = path.resolve(
|
||||
process.cwd(),
|
||||
'evals/logs/skill_extraction_scratchpad_stats.json',
|
||||
);
|
||||
await fsp.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fsp.writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
async function readSkillBodies(skillsDir: string): Promise<string[]> {
|
||||
const bodies: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
bodies.push(
|
||||
await fsp.readFile(
|
||||
path.join(skillsDir, entry.name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// Ignore incomplete skill directories so one bad artifact does not hide
|
||||
// valid skills created in the same eval run.
|
||||
}
|
||||
}
|
||||
const skillDirs = entries.filter((entry) => entry.isDirectory());
|
||||
const bodies = await Promise.all(
|
||||
skillDirs.map((entry) =>
|
||||
fsp.readFile(path.join(skillsDir, entry.name, 'SKILL.md'), 'utf-8'),
|
||||
),
|
||||
);
|
||||
return bodies;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function expectSuccessfulExtractionRun(run: ExtractionRunSnapshot): void {
|
||||
expect(run.turnCount).toBeGreaterThan(0);
|
||||
expect(run.turnCount).toBeLessThanOrEqual(30);
|
||||
expect(run.durationMs).toBeGreaterThan(0);
|
||||
expect(run.terminateReason).toBe('GOAL');
|
||||
}
|
||||
|
||||
function scoreSkillQuality(
|
||||
skillBodies: string[],
|
||||
signals: SkillQualitySignal[],
|
||||
): { score: number; maxScore: number; missing: string[] } {
|
||||
const combined = skillBodies.join('\n\n');
|
||||
const matched = signals.filter((signal) => signal.pattern.test(combined));
|
||||
|
||||
return {
|
||||
score: matched.length,
|
||||
maxScore: signals.length,
|
||||
missing: signals
|
||||
.filter((signal) => !signal.pattern.test(combined))
|
||||
.map((signal) => signal.label),
|
||||
};
|
||||
}
|
||||
|
||||
const SETTINGS_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'predocs command', pattern: /npm run predocs:settings/i },
|
||||
{ label: 'schema command', pattern: /npm run schema:settings/i },
|
||||
{ label: 'docs command', pattern: /npm run docs:settings/i },
|
||||
{ label: 'verification guidance', pattern: /verif(?:y|ication)/i },
|
||||
{
|
||||
label: 'generated docs warning or ordering constraint',
|
||||
pattern:
|
||||
/do not hand-edit|manual edits|exact command order|preserve.*order/i,
|
||||
},
|
||||
];
|
||||
|
||||
const DB_MIGRATION_SKILL_QUALITY_SIGNALS: SkillQualitySignal[] = [
|
||||
{ label: 'db check command', pattern: /npm run db:check/i },
|
||||
{ label: 'db migrate command', pattern: /npm run db:migrate/i },
|
||||
{ label: 'db validate command', pattern: /npm run db:validate/i },
|
||||
{ label: 'rollback guidance', pattern: /npm run db:rollback|rollback/i },
|
||||
{
|
||||
label: 'ordering constraint',
|
||||
pattern: /check.*migrate.*validate|ordering is critical|mandatory/i,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared configOverrides for all skill extraction component evals.
|
||||
* - experimentalAutoMemory: enables the Auto Memory skill extraction pipeline.
|
||||
@@ -653,16 +158,6 @@ const EXTRACTION_CONFIG_OVERRIDES = {
|
||||
approvalMode: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
function parseScratchpadStatsTrials(): number {
|
||||
const configured = Number.parseInt(
|
||||
process.env['SCRATCHPAD_STATS_TRIALS'] ?? '8',
|
||||
10,
|
||||
);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 8;
|
||||
}
|
||||
|
||||
const SCRATCHPAD_STATS_TRIALS = parseScratchpadStatsTrials();
|
||||
|
||||
describe('Skill Extraction', () => {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
@@ -769,24 +264,15 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
SETTINGS_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run predocs:settings');
|
||||
expect(combinedSkills).toContain('npm run schema:settings');
|
||||
expect(combinedSkills).toContain('npm run docs:settings');
|
||||
expect(combinedSkills).toMatch(/verif(?:y|ication)/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
expect(combinedSkills).toMatch(/Verification/i);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
@@ -795,96 +281,6 @@ describe('Skill Extraction', () => {
|
||||
},
|
||||
});
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'memory scratchpad improves repeated-workflow recall versus summary-only index',
|
||||
files: WORKSPACE_FILES,
|
||||
timeout: 360000,
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const baselineScenario = createWorkflowComparisonSessions(false);
|
||||
const enhancedScenario = createWorkflowComparisonSessions(true);
|
||||
|
||||
const baselineOutcome = await runScenarioWithFreshRig(
|
||||
baselineScenario.sessions,
|
||||
);
|
||||
const enhancedOutcome = await runScenarioWithFreshRig(
|
||||
enhancedScenario.sessions,
|
||||
);
|
||||
|
||||
const baselineRun = baselineOutcome.state.runs.at(-1);
|
||||
const enhancedRun = enhancedOutcome.state.runs.at(-1);
|
||||
if (!baselineRun || !enhancedRun) {
|
||||
throw new Error('Expected both baseline and scratchpad runs to exist');
|
||||
}
|
||||
|
||||
expectSuccessfulExtractionRun(baselineRun);
|
||||
expectSuccessfulExtractionRun(enhancedRun);
|
||||
|
||||
const baselineRelevantReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.relevantSessionIds,
|
||||
);
|
||||
const enhancedRelevantReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.relevantSessionIds,
|
||||
);
|
||||
const baselineDistractorReads = countMatchingIds(
|
||||
baselineRun.processedSessions,
|
||||
baselineScenario.distractorSessionIds,
|
||||
);
|
||||
const enhancedDistractorReads = countMatchingIds(
|
||||
enhancedRun.processedSessions,
|
||||
enhancedScenario.distractorSessionIds,
|
||||
);
|
||||
const baselineSignalScore =
|
||||
baselineRelevantReads - baselineDistractorReads;
|
||||
const enhancedSignalScore =
|
||||
enhancedRelevantReads - enhancedDistractorReads;
|
||||
|
||||
expect(enhancedRun.candidateSessions).toHaveLength(
|
||||
enhancedScenario.sessions.length,
|
||||
);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(2);
|
||||
expect(enhancedRelevantReads).toBeGreaterThanOrEqual(
|
||||
baselineRelevantReads,
|
||||
);
|
||||
expect(enhancedDistractorReads).toBeLessThanOrEqual(
|
||||
baselineDistractorReads,
|
||||
);
|
||||
expect(enhancedSignalScore).toBeGreaterThan(baselineSignalScore);
|
||||
},
|
||||
});
|
||||
|
||||
if (process.env['RUN_SCRATCHPAD_STATS'] === '1') {
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
name: 'reports memory scratchpad retrieval statistics',
|
||||
timeout: Math.max(360000, SCRATCHPAD_STATS_TRIALS * 150000),
|
||||
configOverrides: EXTRACTION_CONFIG_OVERRIDES,
|
||||
assert: async () => {
|
||||
const report = await runScratchpadStatsReport(SCRATCHPAD_STATS_TRIALS);
|
||||
const outputPath = await writeScratchpadStatsReport(report);
|
||||
|
||||
console.info(
|
||||
`Wrote scratchpad stats report to ${outputPath}\n${JSON.stringify(
|
||||
report.aggregate,
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
);
|
||||
|
||||
expect(report.results).toHaveLength(SCRATCHPAD_STATS_TRIALS);
|
||||
expect(report.aggregate.baseline.recallAvg).toBeGreaterThan(0);
|
||||
expect(report.aggregate.enhanced.recallAvg).toBeGreaterThan(0);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
it.skip('reports memory scratchpad retrieval statistics', () => {});
|
||||
}
|
||||
|
||||
componentEvalTest('USUALLY_PASSES', {
|
||||
suiteName: 'skill-extraction',
|
||||
suiteType: 'component-level',
|
||||
@@ -934,24 +330,15 @@ describe('Skill Extraction', () => {
|
||||
const { state, skillsDir } = await runExtractionAndReadState(config);
|
||||
const skillBodies = await readSkillBodies(skillsDir);
|
||||
const combinedSkills = skillBodies.join('\n\n');
|
||||
const quality = scoreSkillQuality(
|
||||
skillBodies,
|
||||
DB_MIGRATION_SKILL_QUALITY_SIGNALS,
|
||||
);
|
||||
|
||||
expect(state.runs).toHaveLength(1);
|
||||
expect(state.runs[0].sessionIds).toHaveLength(2);
|
||||
expectSuccessfulExtractionRun(state.runs[0]);
|
||||
expect(state.runs[0].skillsCreated.length).toBeGreaterThanOrEqual(1);
|
||||
expect(skillBodies.length).toBeGreaterThanOrEqual(1);
|
||||
expect(combinedSkills).toContain('npm run db:check');
|
||||
expect(combinedSkills).toContain('npm run db:migrate');
|
||||
expect(combinedSkills).toContain('npm run db:validate');
|
||||
expect(combinedSkills).toMatch(/rollback/i);
|
||||
expect(
|
||||
quality.score,
|
||||
`missing quality signals: ${quality.missing.join(', ')}`,
|
||||
).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Verify the extraction agent activated skill-creator for design guidance.
|
||||
expect(config.getSkillManager().isSkillActive('skill-creator')).toBe(
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export function countMatchingIds<T extends { sessionId: string }>(
|
||||
items: T[],
|
||||
expectedIds: string[],
|
||||
): number {
|
||||
const expected = new Set(expectedIds);
|
||||
return items.filter((item) => expected.has(item.sessionId)).length;
|
||||
}
|
||||
|
||||
export function roundStat(value: number | null): number | null {
|
||||
return value === null ? null : Number(value.toFixed(4));
|
||||
}
|
||||
|
||||
export function average(values: number[]): number {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
export function averageNullable(values: Array<number | null>): number | null {
|
||||
const numericValues = values.filter((value) => value !== null);
|
||||
return numericValues.length === 0 ? null : average(numericValues);
|
||||
}
|
||||
@@ -172,7 +172,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+8
-50
@@ -62,13 +62,11 @@ describe('tracker_mode', () => {
|
||||
'Expected tracker_update_task tool to be called',
|
||||
).toBe(true);
|
||||
|
||||
const updateCalls = toolLogs.filter(
|
||||
const updateCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME,
|
||||
);
|
||||
expect(updateCalls.length).toBeGreaterThan(0);
|
||||
const updateArgs = JSON.parse(
|
||||
updateCalls[updateCalls.length - 1].toolRequest.args,
|
||||
);
|
||||
expect(updateCall).toBeDefined();
|
||||
const updateArgs = JSON.parse(updateCall!.toolRequest.args);
|
||||
expect(updateArgs.status).toBe('closed');
|
||||
|
||||
const loginContent = fs.readFileSync(
|
||||
@@ -130,52 +128,12 @@ describe('tracker_mode', () => {
|
||||
prompt:
|
||||
'Where is my task tracker storage located? Please provide the absolute path in your response.',
|
||||
assert: async (rig, result) => {
|
||||
// The response should contain the dynamic path which follows the .gemini/tmp/.../tracker structure.
|
||||
// The rig sets GEMINI_CLI_HOME to rig.homeDir
|
||||
const homeDir = rig.homeDir!;
|
||||
// The response should contain the dynamic path which includes the home directory
|
||||
// and follows the .gemini/tmp/.../tracker structure.
|
||||
expect(result).toContain(homeDir);
|
||||
expect(result).toMatch(/\.gemini\/tmp\/.*\/tracker/);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: 'should update the tracker in the same turn as the task completion to save turns',
|
||||
params: {
|
||||
settings: { experimental: { taskTracker: true } },
|
||||
},
|
||||
files: FILES,
|
||||
prompt:
|
||||
'We have a bug in src/login.js: the password check is missing. Fix this bug. Then, create a new file src/auth.js that exports a simple verifyToken function. Please organize this into tasks and execute them.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForToolCall(TRACKER_CREATE_TASK_TOOL_NAME);
|
||||
await rig.waitForToolCall(TRACKER_UPDATE_TASK_TOOL_NAME);
|
||||
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
// Get the prompt ID of the fix for login.js
|
||||
const loginEditCalls = toolLogs.filter(
|
||||
(log) =>
|
||||
(log.toolRequest.name === 'replace' ||
|
||||
log.toolRequest.name === 'write_file') &&
|
||||
log.toolRequest.args.includes('login.js'),
|
||||
);
|
||||
|
||||
expect(loginEditCalls.length).toBeGreaterThan(0);
|
||||
const loginEditPromptId =
|
||||
loginEditCalls[loginEditCalls.length - 1].toolRequest.prompt_id;
|
||||
|
||||
// Verify there is an update to the tracker in the exact same turn
|
||||
const parallelTrackerUpdates = toolLogs.filter(
|
||||
(log) =>
|
||||
log.toolRequest.name === TRACKER_UPDATE_TASK_TOOL_NAME &&
|
||||
log.toolRequest.prompt_id === loginEditPromptId,
|
||||
);
|
||||
|
||||
expect(
|
||||
parallelTrackerUpdates.length,
|
||||
'Expected tracker_update_task to be called in the same turn as the login.js fix',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import {
|
||||
WhisperModelManager,
|
||||
WhisperTranscriptionProvider,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as fs from 'node:fs';
|
||||
import commandExists from 'command-exists';
|
||||
|
||||
describe('Voice Mode Integration', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to download tiny whisper model', async () => {
|
||||
// This test doesn't require the binary, only network access.
|
||||
// However, it's slow and downloads 75MB. We'll keep it for now but
|
||||
// wrap it in a try-catch to avoid failing on network flakiness in CI.
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
|
||||
try {
|
||||
// Cleanup if already exists to ensure we actually test download
|
||||
const modelPath = manager.getModelPath(modelName);
|
||||
if (fs.existsSync(modelPath)) {
|
||||
fs.unlinkSync(modelPath);
|
||||
}
|
||||
|
||||
await manager.downloadModel(modelName);
|
||||
expect(fs.existsSync(modelPath)).toBe(true);
|
||||
expect(fs.statSync(modelPath).size).toBeGreaterThan(70 * 1024 * 1024); // ~75MB
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Skipping whisper model download test due to error (possibly network):',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}, 300000); // 5 min timeout for download
|
||||
|
||||
it('should initialize WhisperTranscriptionProvider and handle process', async () => {
|
||||
// Skip this test if whisper-stream is not installed (typical for CI)
|
||||
try {
|
||||
await commandExists('whisper-stream');
|
||||
} catch {
|
||||
console.log(
|
||||
'Skipping Whisper transcription test: whisper-stream not found',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const manager = new WhisperModelManager();
|
||||
const modelName = 'ggml-tiny.en.bin';
|
||||
if (!manager.isModelInstalled(modelName)) {
|
||||
await manager.downloadModel(modelName);
|
||||
}
|
||||
|
||||
const provider = new WhisperTranscriptionProvider({
|
||||
modelPath: manager.getModelPath(modelName),
|
||||
});
|
||||
|
||||
// Since we can't easily provide real mic input in CI,
|
||||
// we just verify it can start and be disconnected.
|
||||
await provider.connect();
|
||||
provider.disconnect();
|
||||
});
|
||||
});
|
||||
Generated
+45
-402
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -449,7 +449,8 @@
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
|
||||
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@bundled-es-modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
@@ -991,37 +992,6 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-helpers": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz",
|
||||
@@ -1069,24 +1039,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
|
||||
@@ -1100,19 +1052,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz",
|
||||
@@ -1535,6 +1474,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
|
||||
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -2212,6 +2152,7 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2392,6 +2333,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2441,6 +2383,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2815,6 +2758,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
|
||||
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2848,6 +2792,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
|
||||
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0"
|
||||
@@ -2902,6 +2847,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
|
||||
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.5.0",
|
||||
"@opentelemetry/resources": "2.5.0",
|
||||
@@ -3795,37 +3741,6 @@
|
||||
"path-browserify": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@@ -4139,6 +4054,7 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4412,6 +4328,7 @@
|
||||
"integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.58.2",
|
||||
"@typescript-eslint/types": "8.58.2",
|
||||
@@ -4986,13 +4903,6 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -5032,30 +4942,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/minimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/vsce/node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@@ -5187,6 +5073,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6553,13 +6440,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/config-chain": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
|
||||
@@ -7075,23 +6955,6 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depcheck/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/camelcase": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
|
||||
@@ -7151,22 +7014,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/minimatch": {
|
||||
"version": "7.4.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.9.tgz",
|
||||
"integrity": "sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/depcheck/node_modules/resolve-from": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||
@@ -7304,7 +7151,8 @@
|
||||
"version": "0.0.1581282",
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
|
||||
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
@@ -7889,6 +7737,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8057,24 +7906,6 @@
|
||||
"eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
@@ -8085,19 +7916,6 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -8154,37 +7972,6 @@
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react/node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -8243,37 +8030,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -8499,6 +8255,7 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -9765,6 +9522,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -10024,6 +9782,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
|
||||
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"ansi-styles": "^6.2.3",
|
||||
@@ -11914,12 +11673,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
@@ -12100,37 +11859,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multimatch/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/multimatch/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mute-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||
@@ -12371,24 +12099,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
@@ -12438,19 +12148,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-run-all/node_modules/normalize-package-data": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
|
||||
@@ -13799,6 +13496,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13809,6 +13507,7 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -15817,39 +15516,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/text-decoder": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
|
||||
@@ -15961,6 +15627,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16183,7 +15850,8 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16191,6 +15859,7 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16356,6 +16025,7 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -16423,6 +16093,7 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -16608,23 +16279,6 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
@@ -16635,22 +16289,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint/node_modules/minimatch": {
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
@@ -16842,6 +16480,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17412,6 +17051,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17424,6 +17064,7 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -18062,6 +17703,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18077,7 +17719,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -18206,7 +17848,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
@@ -18354,7 +17996,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -18390,7 +18032,6 @@
|
||||
"ajv-formats": "^3.0.0",
|
||||
"chardet": "^2.1.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"command-exists": "^1.2.9",
|
||||
"diff": "^8.0.3",
|
||||
"dotenv": "^17.2.4",
|
||||
"dotenv-expand": "^12.0.3",
|
||||
@@ -18498,6 +18139,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
|
||||
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
"@js-sdsl/ordered-map": "^4.4.2"
|
||||
@@ -18616,6 +18258,7 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -18665,7 +18308,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "^8.16.0"
|
||||
@@ -18680,7 +18323,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18711,7 +18354,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -18743,7 +18386,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.23.0",
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"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.42.0-nightly.20260428.g59b2dea0e"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
@@ -81,7 +81,8 @@
|
||||
"glob": "^12.0.0",
|
||||
"node-domexception": "npm:empty@^0.10.1",
|
||||
"prebuild-install": "npm:nop@1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
"cross-spawn": "^7.0.6",
|
||||
"minimatch": "^10.2.2"
|
||||
},
|
||||
"bin": {
|
||||
"gemini": "bundle/gemini.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -112,7 +112,6 @@ export function createMockConfig(
|
||||
}),
|
||||
isContextManagementEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
|
||||
|
||||
+12
-9
@@ -9,11 +9,6 @@
|
||||
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 ---
|
||||
|
||||
@@ -79,10 +74,18 @@ async function run() {
|
||||
// --- Lightweight Parent Process / Daemon ---
|
||||
// We avoid importing heavy dependencies here to save ~1.5s of startup time.
|
||||
|
||||
const scriptArgs = getScriptArgs();
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs);
|
||||
const nodeArgs: string[] = [...process.execArgv];
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
|
||||
const memoryArgs = await getMemoryNodeArgs();
|
||||
nodeArgs.push(...memoryArgs);
|
||||
|
||||
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.
|
||||
@@ -94,7 +97,7 @@ async function run() {
|
||||
const runner = () => {
|
||||
process.stdin.pause();
|
||||
|
||||
const child = spawn(process.execPath, spawnArgs, {
|
||||
const child = spawn(process.execPath, nodeArgs, {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: newEnv,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.42.0-nightly.20260428.g59b2dea0e",
|
||||
"version": "0.40.0-nightly.20260414.g5b1f7375a",
|
||||
"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.42.0-nightly.20260428.g59b2dea0e"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.40.0-nightly.20260414.g5b1f7375a"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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,20 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
type ApprovalMode,
|
||||
type Config,
|
||||
type GeminiChat,
|
||||
type ToolResult,
|
||||
type ToolCallConfirmationDetails,
|
||||
type FilterFilesOptions,
|
||||
type ConversationRecord,
|
||||
CoreToolCallStatus,
|
||||
AuthType,
|
||||
logToolCall,
|
||||
convertToFunctionResponse,
|
||||
ToolConfirmationOutcome,
|
||||
clearCachedCredentialFile,
|
||||
isNodeError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
getErrorStatus,
|
||||
MCPServerConfig,
|
||||
DiscoveredMCPTool,
|
||||
StreamEventType,
|
||||
ToolCallEvent,
|
||||
@@ -22,42 +29,547 @@ 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 type { LoadedSettings } from '../config/settings.js';
|
||||
import {
|
||||
SettingScope,
|
||||
loadSettings,
|
||||
type LoadedSettings,
|
||||
} from '../config/settings.js';
|
||||
import { createPolicyUpdater } from '../config/policy.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import {
|
||||
toToolCallContent,
|
||||
toPermissionOptions,
|
||||
toAcpToolKind,
|
||||
buildAvailableModes,
|
||||
RequestPermissionResponseSchema,
|
||||
} from './acpUtils.js';
|
||||
import { z } from 'zod';
|
||||
import { getAcpErrorMessage } from './acpErrors.js';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -197,10 +709,13 @@ export class Session {
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'object' && part !== null) {
|
||||
if (isTextPart(part)) {
|
||||
if ('text' in part) {
|
||||
// It is a text part
|
||||
const text = part.text;
|
||||
commandText += text;
|
||||
// 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;
|
||||
}
|
||||
} else {
|
||||
// Non-text part (image, embedded resource)
|
||||
// Stop looking for command
|
||||
@@ -457,7 +972,7 @@ export class Session {
|
||||
promptId: string,
|
||||
fc: FunctionCall,
|
||||
): Promise<Part[]> {
|
||||
const callId = fc.id ?? this.generateCallId(fc.name || 'unknown');
|
||||
const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
|
||||
const args = fc.args ?? {};
|
||||
|
||||
const startTime = Date.now();
|
||||
@@ -1146,7 +1661,7 @@ export class Session {
|
||||
include: pathSpecsToRead,
|
||||
};
|
||||
|
||||
const callId = this.generateCallId(readManyFilesTool.name);
|
||||
const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
|
||||
|
||||
try {
|
||||
const invocation = readManyFilesTool.build(toolArgs);
|
||||
@@ -1284,3 +1799,333 @@ 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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Mocked,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { GeminiAgent } from './acpRpcDispatcher.js';
|
||||
import { GeminiAgent } from './acpClient.js';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import {
|
||||
ApprovalMode,
|
||||
@@ -28,7 +28,6 @@ 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(),
|
||||
@@ -107,9 +106,6 @@ 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;
|
||||
},
|
||||
@@ -174,6 +170,11 @@ 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,
|
||||
@@ -239,7 +240,7 @@ describe('GeminiAgent Session Resume', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
await vi.waitFor(() => {
|
||||
// User message
|
||||
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
/**
|
||||
* @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');
|
||||
});
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
/**
|
||||
* @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,386 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
/**
|
||||
* @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
-1
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandHandler } from './acpCommandHandler.js';
|
||||
import { CommandHandler } from './commandHandler.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { RestoreCommand, ListCheckpointsCommand } from './restore.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import {
|
||||
getCheckpointInfoList,
|
||||
getToolCallDataSchema,
|
||||
isNodeError,
|
||||
performRestore,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { CommandContext } from './types.js';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getCheckpointInfoList: vi.fn(),
|
||||
getToolCallDataSchema: vi.fn(),
|
||||
isNodeError: vi.fn(),
|
||||
performRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('RestoreCommand', () => {
|
||||
let context: CommandContext;
|
||||
let restoreCommand: RestoreCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
restoreCommand = new RestoreCommand();
|
||||
context = {
|
||||
agentContext: {
|
||||
config: {
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(true),
|
||||
storage: {
|
||||
getProjectTempCheckpointsDir: vi
|
||||
.fn()
|
||||
.mockReturnValue('/tmp/checkpoints'),
|
||||
},
|
||||
},
|
||||
},
|
||||
git: {},
|
||||
sendMessage: vi.fn(),
|
||||
} as unknown as CommandContext;
|
||||
});
|
||||
|
||||
it('delegates to list behavior when invoked without args', async () => {
|
||||
const listExecuteSpy = vi
|
||||
.spyOn(ListCheckpointsCommand.prototype, 'execute')
|
||||
.mockResolvedValue({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
|
||||
const response = await restoreCommand.execute(context, []);
|
||||
|
||||
expect(listExecuteSpy).toHaveBeenCalledWith(context);
|
||||
expect(response).toEqual({
|
||||
name: 'restore list',
|
||||
data: 'list data',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns checkpointing-disabled message when disabled', async () => {
|
||||
(
|
||||
context.agentContext.config.getCheckpointingEnabled as Mock
|
||||
).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['checkpoint1']);
|
||||
|
||||
expect(response.data).toContain('Checkpointing is not enabled');
|
||||
});
|
||||
|
||||
it('returns file-not-found message for missing checkpoint', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['missing']);
|
||||
|
||||
expect(response.data).toBe('File not found: missing.json');
|
||||
});
|
||||
|
||||
it('handles checkpoint filename already ending in .json', async () => {
|
||||
const error = new Error('ENOENT');
|
||||
(error as Error & { code: string }).code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
vi.mocked(isNodeError).mockReturnValue(true);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['existing.json']);
|
||||
|
||||
expect(response.data).toBe('File not found: existing.json');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('existing.json'),
|
||||
'utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns invalid/corrupt checkpoint message when schema parse fails', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"invalid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi.fn().mockReturnValue({ success: false }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['invalid']);
|
||||
|
||||
expect(response.data).toBe('Checkpoint file is invalid or corrupted.');
|
||||
});
|
||||
|
||||
it('formats streamed restore results correctly', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('{"valid": "data"}');
|
||||
vi.mocked(getToolCallDataSchema).mockReturnValue({
|
||||
safeParse: vi
|
||||
.fn()
|
||||
.mockReturnValue({ success: true, data: { some: 'data' } }),
|
||||
} as unknown as ReturnType<typeof getToolCallDataSchema>);
|
||||
|
||||
async function* mockRestoreGenerator() {
|
||||
yield { type: 'message', messageType: 'info', content: 'Restoring...' };
|
||||
yield { type: 'load_history', clientHistory: [{}, {}] };
|
||||
yield { type: 'other', some: 'other' };
|
||||
}
|
||||
vi.mocked(performRestore).mockReturnValue(
|
||||
mockRestoreGenerator() as unknown as ReturnType<typeof performRestore>,
|
||||
);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['valid']);
|
||||
|
||||
expect(response.data).toContain('[INFO] Restoring...');
|
||||
expect(response.data).toContain('Loaded history with 2 messages.');
|
||||
expect(response.data).toContain(
|
||||
'Restored: {"type":"other","some":"other"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns generic unexpected error message for non-ENOENT failures', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Random error'));
|
||||
vi.mocked(isNodeError).mockReturnValue(false);
|
||||
|
||||
const response = await restoreCommand.execute(context, ['error']);
|
||||
|
||||
expect(response.data).toContain(
|
||||
'An unexpected error occurred during restore: Error: Random error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListCheckpointsCommand', () => {
|
||||
let context: CommandContext;
|
||||
let listCommand: ListCheckpointsCommand;
|
||||
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 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
afterEach,
|
||||
type Mocked,
|
||||
} from 'vitest';
|
||||
import { AcpFileSystemService } from './acpFileSystemService.js';
|
||||
import { AcpFileSystemService } from './fileSystemService.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 2026 Google LLC
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@@ -217,78 +217,6 @@ describe('mcp list command', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display connected status even if ping fails', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockRejectedValue(new Error('Ping failed'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-server: /test/server (stdio) - Connected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use configured timeout for connection', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server', timeout: 12345 },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 12345 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout for connection when not configured', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(mockClient.connect).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
expect(mockClient.ping).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge extension servers with config servers', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
|
||||
@@ -67,8 +67,6 @@ export async function getMcpServersFromConfig(
|
||||
return filteredResult;
|
||||
}
|
||||
|
||||
const MCP_LIST_DEFAULT_TIMEOUT_MSEC = 5000;
|
||||
|
||||
async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
@@ -129,22 +127,11 @@ async function testMCPConnection(
|
||||
}
|
||||
|
||||
try {
|
||||
// Attempt actual MCP connection with timeout from config or default to 5s.
|
||||
// We use a short default for the list command to keep it responsive.
|
||||
const timeout = config.timeout ?? MCP_LIST_DEFAULT_TIMEOUT_MSEC;
|
||||
await client.connect(transport, { timeout });
|
||||
// Attempt actual MCP connection with short timeout
|
||||
await client.connect(transport, { timeout: 5000 }); // 5s timeout
|
||||
|
||||
// Test basic MCP protocol by pinging the server.
|
||||
// Ping is optional per MCP spec - some servers (e.g. Google first-party)
|
||||
// don't implement it. A successful connect() is sufficient proof of connectivity.
|
||||
try {
|
||||
await client.ping({ timeout });
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`MCP ping failed for ${serverName}, but connect succeeded:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
// Test basic MCP protocol by pinging the server
|
||||
await client.ping();
|
||||
|
||||
await client.close();
|
||||
return MCPServerStatus.CONNECTED;
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
type MCPServerConfig,
|
||||
type GeminiCLIExtension,
|
||||
Storage,
|
||||
generalistProfile,
|
||||
type ContextManagementConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import {
|
||||
@@ -231,45 +233,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('parseArguments', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('should fail if both --resume and --session-id are provided', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--resume',
|
||||
'--session-id',
|
||||
'test-uuid-1234',
|
||||
];
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
await expect(parseArguments(createTestMergedSettings())).rejects.toThrow(
|
||||
'process.exit called',
|
||||
);
|
||||
|
||||
expect(mockConsoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Cannot use both --resume (-r) and --session-id together',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse --session-id option correctly', async () => {
|
||||
process.argv = ['node', 'script.js', '--session-id', 'test-uuid-1234'];
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
const parsedArgs = await parseArguments(createTestMergedSettings());
|
||||
expect(parsedArgs.sessionId).toBe('test-uuid-1234');
|
||||
});
|
||||
|
||||
describe('worktree', () => {
|
||||
it('should parse --worktree flag when provided with a name', async () => {
|
||||
process.argv = ['node', 'script.js', '--worktree', 'my-feature'];
|
||||
@@ -294,7 +257,7 @@ describe('parseArguments', () => {
|
||||
const settings = createTestMergedSettings();
|
||||
settings.experimental.worktrees = false;
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
const mockConsoleError = vi
|
||||
@@ -309,6 +272,9 @@ describe('parseArguments', () => {
|
||||
'The --worktree flag is only available when experimental.worktrees is enabled in your settings.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,7 +306,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -357,6 +323,9 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --prompt (-p) and --prompt-interactive (-i) together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -593,7 +562,7 @@ describe('parseArguments', () => {
|
||||
async ({ argv }) => {
|
||||
process.argv = argv;
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -610,6 +579,9 @@ describe('parseArguments', () => {
|
||||
'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -634,7 +606,7 @@ describe('parseArguments', () => {
|
||||
it('should reject invalid --approval-mode values', async () => {
|
||||
process.argv = ['node', 'script.js', '--approval-mode', 'invalid'];
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -653,6 +625,10 @@ describe('parseArguments', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow resuming a session without prompt argument in non-interactive mode (expecting stdin)', async () => {
|
||||
@@ -804,100 +780,6 @@ describe('loadCliConfig', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Model resolution', () => {
|
||||
it('should handle multiple --model flags by taking the last one', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: ['gemini-1.5-pro', 'gemini-2.0-flash'] as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('gemini-2.0-flash');
|
||||
});
|
||||
|
||||
it('should handle non-string model flags by coercing to string', async () => {
|
||||
const argv = {
|
||||
query: undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
model: true as any,
|
||||
sandbox: undefined,
|
||||
debug: false,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
policy: undefined,
|
||||
adminPolicy: undefined,
|
||||
allowedMcpServerNames: undefined,
|
||||
allowedTools: undefined,
|
||||
extensions: undefined,
|
||||
listExtensions: false,
|
||||
listSessions: false,
|
||||
deleteSession: undefined,
|
||||
screenReader: undefined,
|
||||
isCommand: false,
|
||||
rawOutput: false,
|
||||
acceptRawOutputRisk: false,
|
||||
startupMessages: [],
|
||||
resume: undefined,
|
||||
includeDirectories: [],
|
||||
useWriteTodos: false,
|
||||
outputFormat: undefined,
|
||||
fakeResponses: undefined,
|
||||
recordResponses: undefined,
|
||||
skipTrust: false,
|
||||
};
|
||||
|
||||
const settings = createTestMergedSettings();
|
||||
const config = await loadCliConfig(
|
||||
settings,
|
||||
'test-session',
|
||||
argv as unknown as CliArgs,
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy configuration', () => {
|
||||
const originalProxyEnv: { [key: string]: string | undefined } = {};
|
||||
const proxyEnvVars = [
|
||||
@@ -990,14 +872,16 @@ describe('loadCliConfig', () => {
|
||||
});
|
||||
|
||||
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
|
||||
vi.spyOn(ServerConfig, 'resolveToRealPath').mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
const resolveToRealPathSpy = vi
|
||||
.spyOn(ServerConfig, 'resolveToRealPath')
|
||||
.mockImplementation((p) => {
|
||||
if (p.toString().includes('restricted')) {
|
||||
const err = new Error('EACCES: permission denied');
|
||||
(err as NodeJS.ErrnoException).code = 'EACCES';
|
||||
throw err;
|
||||
}
|
||||
return p.toString();
|
||||
});
|
||||
vi.stubEnv(
|
||||
'GEMINI_CLI_IDE_WORKSPACE_PATH',
|
||||
['/project/folderA', '/nonexistent/restricted/folder'].join(
|
||||
@@ -1011,6 +895,8 @@ describe('loadCliConfig', () => {
|
||||
const dirs = config.getPendingIncludeDirectories();
|
||||
expect(dirs).toContain('/project/folderA');
|
||||
expect(dirs).not.toContain('/nonexistent/restricted/folder');
|
||||
|
||||
resolveToRealPathSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should use default fileFilter options when unconfigured', async () => {
|
||||
@@ -2331,6 +2217,51 @@ describe('loadCliConfig context management', () => {
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual(
|
||||
generalistProfile,
|
||||
);
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be true when contextManagement is set to true in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const contextManagementConfig: Partial<ContextManagementConfig> = {
|
||||
historyWindow: {
|
||||
maxTokens: 100_000,
|
||||
retainedTokens: 50_000,
|
||||
},
|
||||
messageLimits: {
|
||||
normalMaxTokens: 1000,
|
||||
retainedMaxTokens: 10_000,
|
||||
normalizationHeadRatio: 0.25,
|
||||
},
|
||||
tools: {
|
||||
distillation: {
|
||||
maxOutputTokens: 10_000,
|
||||
summarizationThresholdTokens: 15_000,
|
||||
},
|
||||
outputMasking: {
|
||||
protectionThresholdTokens: 30_000,
|
||||
minPrunableThresholdTokens: 10_000,
|
||||
protectLatestTurn: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
contextManagement: true,
|
||||
},
|
||||
// The type of numbers is being inferred strangely, and so we have to cast
|
||||
// to `any` here.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
contextManagement: contextManagementConfig as any,
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getContextManagementConfig()).toStrictEqual({
|
||||
enabled: true,
|
||||
...contextManagementConfig,
|
||||
});
|
||||
expect(config.isContextManagementEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3124,18 +3055,6 @@ describe('loadCliConfig gemmaModelRouter', () => {
|
||||
expect(gemmaSettings.classifier?.model).toBe('custom-gemma');
|
||||
});
|
||||
|
||||
it('should load experimental.gemma setting from merged settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
const settings = createTestMergedSettings({
|
||||
experimental: {
|
||||
gemma: true,
|
||||
},
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
expect(config.getExperimentalGemma()).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle partial gemmaModelRouter settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments(createTestMergedSettings());
|
||||
@@ -3294,7 +3213,7 @@ describe('Output format', () => {
|
||||
it('should error on invalid --output-format argument', async () => {
|
||||
process.argv = ['node', 'script.js', '--output-format', 'invalid'];
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
@@ -3312,6 +3231,10 @@ describe('Output format', () => {
|
||||
expect.stringContaining('Invalid values:'),
|
||||
);
|
||||
expect(mockConsoleError).toHaveBeenCalled();
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3342,11 +3265,13 @@ describe('parseArguments with positional prompt', () => {
|
||||
'test prompt',
|
||||
];
|
||||
|
||||
vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mockConsoleError = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => {});
|
||||
const debugErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -3360,6 +3285,10 @@ describe('parseArguments with positional prompt', () => {
|
||||
'Cannot use both a positional prompt and the --prompt (-p) flag together',
|
||||
),
|
||||
);
|
||||
|
||||
mockExit.mockRestore();
|
||||
mockConsoleError.mockRestore();
|
||||
debugErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should correctly parse a positional prompt to query field', async () => {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FileDiscoveryService,
|
||||
resolveTelemetrySettings,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
getPty,
|
||||
debugLogger,
|
||||
loadServerHierarchicalMemory,
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
type HookEventName,
|
||||
type OutputFormat,
|
||||
detectIdeFromEnv,
|
||||
generalistProfile,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
type Settings,
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
|
||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||
import { resolvePath } from '../utils/resolvePath.js';
|
||||
import { isRecord } from '../utils/settingsUtils.js';
|
||||
import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
||||
|
||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||
@@ -96,7 +95,6 @@ export interface CliArgs {
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
resume: string | typeof RESUME_LATEST | undefined;
|
||||
sessionId: string | undefined;
|
||||
listSessions: boolean | undefined;
|
||||
deleteSession: string | undefined;
|
||||
includeDirectories: string[] | undefined;
|
||||
@@ -108,7 +106,6 @@ export interface CliArgs {
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
acceptRawOutputRisk: boolean | undefined;
|
||||
skipTrust: boolean | undefined;
|
||||
isCommand: boolean | undefined;
|
||||
}
|
||||
|
||||
@@ -238,10 +235,6 @@ export async function parseArguments(
|
||||
? query.length > 0
|
||||
: !!query;
|
||||
|
||||
if (argv['resume'] !== undefined && argv['session-id'] !== undefined) {
|
||||
return 'Cannot use both --resume (-r) and --session-id together';
|
||||
}
|
||||
|
||||
if (argv['prompt'] && hasPositionalQuery) {
|
||||
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
|
||||
}
|
||||
@@ -298,11 +291,6 @@ export async function parseArguments(
|
||||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('skip-trust', {
|
||||
type: 'boolean',
|
||||
description: 'Trust the current workspace for this session.',
|
||||
default: false,
|
||||
})
|
||||
.option('worktree', {
|
||||
alias: 'w',
|
||||
type: 'string',
|
||||
@@ -411,25 +399,6 @@ export async function parseArguments(
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('session-id', {
|
||||
type: 'string',
|
||||
nargs: 1,
|
||||
description: 'Start a new session with a manually provided UUID.',
|
||||
coerce: (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('The --session-id option cannot be empty.');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9-_]+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
'Invalid session ID "' +
|
||||
trimmed +
|
||||
'": Only alphanumeric characters, dashes, and underscores are allowed.',
|
||||
);
|
||||
}
|
||||
return trimmed;
|
||||
},
|
||||
})
|
||||
.option('list-sessions', {
|
||||
type: 'boolean',
|
||||
description:
|
||||
@@ -490,16 +459,9 @@ export async function parseArguments(
|
||||
yargsInstance.wrap(yargsInstance.terminalWidth());
|
||||
let result;
|
||||
try {
|
||||
const parsed = await yargsInstance.parse();
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error('Failed to parse arguments');
|
||||
}
|
||||
result = parsed;
|
||||
if (result['skip-trust']) {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
}
|
||||
result = await yargsInstance.parse();
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e);
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
debugLogger.error(msg);
|
||||
yargsInstance.showHelp();
|
||||
await runExitCleanup();
|
||||
@@ -513,13 +475,11 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Normalize query args: handle both quoted "@path file" and unquoted @path file
|
||||
const queryArg = result['query'];
|
||||
let q: string | undefined;
|
||||
if (Array.isArray(queryArg)) {
|
||||
q = queryArg.join(' ');
|
||||
} else if (typeof queryArg === 'string') {
|
||||
q = queryArg;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const queryArg = (result as { query?: string | string[] | undefined }).query;
|
||||
const q: string | undefined = Array.isArray(queryArg)
|
||||
? queryArg.join(' ')
|
||||
: queryArg;
|
||||
|
||||
// -p/--prompt forces non-interactive mode; positional args default to interactive in TTY
|
||||
if (q && !result['prompt']) {
|
||||
@@ -534,8 +494,8 @@ export async function parseArguments(
|
||||
}
|
||||
|
||||
// Keep CliArgs.query as a string for downstream typing
|
||||
result['query'] = q || undefined;
|
||||
result['startupMessages'] = startupMessages;
|
||||
(result as Record<string, unknown>)['query'] = q || undefined;
|
||||
(result as Record<string, unknown>)['startupMessages'] = startupMessages;
|
||||
|
||||
// The import format is now only controlled by settings.memoryImportFormat
|
||||
// We no longer accept it as a CLI argument
|
||||
@@ -587,7 +547,7 @@ export async function loadCliConfig(
|
||||
? false
|
||||
: (settings.security?.folderTrust?.enabled ?? false);
|
||||
const trustedFolder =
|
||||
isWorkspaceTrusted(settings, cwd, {
|
||||
isWorkspaceTrusted(settings, cwd, undefined, {
|
||||
prompt: argv.prompt,
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
@@ -633,7 +593,7 @@ export async function loadCliConfig(
|
||||
return resolveToRealPath(trimmedPath) !== realCwd;
|
||||
} catch (e) {
|
||||
debugLogger.debug(
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${getErrorMessage(e)})`,
|
||||
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -657,7 +617,7 @@ export async function loadCliConfig(
|
||||
.getExtensions()
|
||||
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
|
||||
|
||||
const experimentalJitContext = settings.experimental.jitContext ?? true;
|
||||
const experimentalJitContext = settings.experimental.jitContext;
|
||||
|
||||
let extensionRegistryURI =
|
||||
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
|
||||
@@ -841,16 +801,9 @@ export async function loadCliConfig(
|
||||
);
|
||||
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const rawModel =
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
// Ensure specifiedModel is a string (e.g. if yargs parsed multiple --model as an array)
|
||||
const specifiedModel = Array.isArray(rawModel)
|
||||
? String(rawModel.at(-1) ?? '').trim() || ''
|
||||
: rawModel === undefined
|
||||
? undefined
|
||||
: String(rawModel ?? '').trim() || '';
|
||||
|
||||
const resolvedModel =
|
||||
specifiedModel === GEMINI_MODEL_ALIAS_AUTO
|
||||
? defaultModel
|
||||
@@ -934,19 +887,14 @@ export async function loadCliConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(joshualitt): Clean this up alongside removal of the legacy config.
|
||||
let profileSelector: string | undefined = undefined;
|
||||
if (settings.experimental?.stressTestProfile) {
|
||||
profileSelector = 'stressTestProfile';
|
||||
} else if (
|
||||
settings.experimental?.generalistProfile ||
|
||||
settings.experimental?.contextManagement
|
||||
) {
|
||||
profileSelector = 'generalistProfile';
|
||||
}
|
||||
|
||||
const useGeneralistProfile =
|
||||
settings.experimental?.generalistProfile ?? false;
|
||||
const useContextManagement =
|
||||
settings.experimental?.contextManagement ?? false;
|
||||
const contextManagement = {
|
||||
enabled: !!profileSelector,
|
||||
...(useGeneralistProfile ? generalistProfile : {}),
|
||||
...(useContextManagement ? settings?.contextManagement : {}),
|
||||
enabled: useContextManagement || useGeneralistProfile,
|
||||
};
|
||||
|
||||
return new Config({
|
||||
@@ -970,7 +918,6 @@ export async function loadCliConfig(
|
||||
worktreeSettings,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
experimentalContextManagementConfig: profileSelector,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
policyEngineConfig,
|
||||
policyUpdateConfirmationRequest,
|
||||
@@ -1036,7 +983,6 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.general?.plan?.enabled ?? true,
|
||||
voiceMode: settings.experimental?.voiceMode,
|
||||
tracker: settings.experimental?.taskTracker,
|
||||
directWebFetch: settings.experimental?.directWebFetch,
|
||||
planSettings: settings.general?.plan?.directory
|
||||
@@ -1045,10 +991,9 @@ export async function loadCliConfig(
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext,
|
||||
experimentalMemoryV2: settings.experimental?.memoryV2,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
experimentalMemoryManager: settings.experimental?.memoryManager,
|
||||
experimentalAutoMemory: settings.experimental?.autoMemory,
|
||||
experimentalGemma: settings.experimental?.gemma,
|
||||
contextManagement,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
topicUpdateNarration:
|
||||
@@ -1154,7 +1099,7 @@ async function resolveWorktreeSettings(
|
||||
worktreeBaseSha = stdout.trim();
|
||||
} catch (e: unknown) {
|
||||
debugLogger.debug(
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${getErrorMessage(e)}`,
|
||||
`Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,10 @@ 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,13 +48,11 @@ 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);
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
loadAgentsFromDirectory,
|
||||
loadSkillsFromDir,
|
||||
getRealPath,
|
||||
normalizePath,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadSettings,
|
||||
@@ -1421,7 +1420,6 @@ name = "yolo-checker"
|
||||
'.gemini',
|
||||
'trustedFolders.json',
|
||||
);
|
||||
vi.stubEnv('GEMINI_CLI_TRUSTED_FOLDERS_PATH', trustedFoldersPath);
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue({
|
||||
isTrusted: false,
|
||||
source: undefined,
|
||||
@@ -1440,9 +1438,7 @@ name = "yolo-checker"
|
||||
const trustedFolders = JSON.parse(
|
||||
fs.readFileSync(trustedFoldersPath, 'utf-8'),
|
||||
);
|
||||
expect(trustedFolders[normalizePath(tempWorkspaceDir)]).toBe(
|
||||
'TRUST_FOLDER',
|
||||
);
|
||||
expect(trustedFolders[tempWorkspaceDir]).toBe('TRUST_FOLDER');
|
||||
});
|
||||
|
||||
describe.each([true, false])(
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
settingsZodSchema,
|
||||
} from './settings-validation.js';
|
||||
import { z } from 'zod';
|
||||
import { type Settings } from './settingsSchema.js';
|
||||
|
||||
describe('settings-validation', () => {
|
||||
describe('validateSettings', () => {
|
||||
@@ -299,117 +298,6 @@ describe('settings-validation', () => {
|
||||
expect(issue).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should accept customThemes with text.response color override', () => {
|
||||
// Regression test for #25610: `response` is a documented and
|
||||
// implemented color override for model responses (see
|
||||
// packages/cli/src/ui/themes/theme.ts and semantic-tokens.ts),
|
||||
// but was missing from the CustomTheme validation schema.
|
||||
const validSettings = {
|
||||
ui: {
|
||||
theme: 'LimeWhite',
|
||||
customThemes: {
|
||||
LimeWhite: {
|
||||
type: 'custom',
|
||||
name: 'LimeWhite',
|
||||
text: {
|
||||
primary: '#00FF00',
|
||||
response: '#FFFFFF',
|
||||
secondary: '#a0a0a0',
|
||||
accent: '#00FF00',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(validSettings);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
describe('type casting', () => {
|
||||
it('should cast "true" and "false" strings to booleans', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'true',
|
||||
hideWindowTitle: 'false',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.ui?.autoThemeSwitching).toBe(true);
|
||||
expect(data.ui?.hideWindowTitle).toBe(false);
|
||||
});
|
||||
|
||||
it('should cast boolean strings case-insensitively', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'TRUE',
|
||||
hideWindowTitle: 'fAlSe',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.ui?.autoThemeSwitching).toBe(true);
|
||||
expect(data.ui?.hideWindowTitle).toBe(false);
|
||||
});
|
||||
|
||||
it('should cast numeric strings to numbers', () => {
|
||||
const settings = {
|
||||
model: {
|
||||
maxSessionTurns: '42',
|
||||
compressionThreshold: '0.5',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.model?.maxSessionTurns).toBe(42);
|
||||
expect(data.model?.compressionThreshold).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should reject invalid castable strings', () => {
|
||||
const settings = {
|
||||
ui: {
|
||||
autoThemeSwitching: 'not-a-boolean',
|
||||
},
|
||||
model: {
|
||||
maxSessionTurns: 'not-a-number',
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.issues).toHaveLength(2);
|
||||
expect(result.error?.issues[0].message).toContain(
|
||||
'Expected boolean, received string',
|
||||
);
|
||||
expect(result.error?.issues[1].message).toContain(
|
||||
'Expected number, received string',
|
||||
);
|
||||
});
|
||||
|
||||
it('should cast strings to booleans/numbers in shared definitions (refs)', () => {
|
||||
const settings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
trust: 'true', // from boolean ref
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateSettings(settings);
|
||||
expect(result.success).toBe(true);
|
||||
const data = result.data as Settings;
|
||||
expect(data.mcpServers?.['test-server'].trust).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatValidationError', () => {
|
||||
|
||||
@@ -25,10 +25,10 @@ function buildZodSchemaFromJsonSchema(def: any): z.ZodTypeAny {
|
||||
if (def.type === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
if (def.enum) return z.enum(def.enum as [string, ...string[]]);
|
||||
return buildPrimitiveSchema('string');
|
||||
return z.string();
|
||||
}
|
||||
if (def.type === 'number') return buildPrimitiveSchema('number');
|
||||
if (def.type === 'boolean') return buildPrimitiveSchema('boolean');
|
||||
if (def.type === 'number') return z.number();
|
||||
if (def.type === 'boolean') return z.boolean();
|
||||
|
||||
if (def.type === 'array') {
|
||||
if (def.items) {
|
||||
@@ -133,22 +133,9 @@ function buildPrimitiveSchema(
|
||||
case 'string':
|
||||
return z.string();
|
||||
case 'number':
|
||||
return z.preprocess((val) => {
|
||||
if (typeof val === 'string' && val.trim() !== '') {
|
||||
const num = Number(val);
|
||||
if (!isNaN(num)) return num;
|
||||
}
|
||||
return val;
|
||||
}, z.number());
|
||||
return z.number();
|
||||
case 'boolean':
|
||||
return z.preprocess((val) => {
|
||||
if (typeof val === 'string') {
|
||||
const lower = val.toLowerCase();
|
||||
if (lower === 'true') return true;
|
||||
if (lower === 'false') return false;
|
||||
}
|
||||
return val;
|
||||
}, z.boolean());
|
||||
return z.boolean();
|
||||
default:
|
||||
return z.unknown();
|
||||
}
|
||||
@@ -173,9 +160,7 @@ function buildZodSchemaFromDefinition(
|
||||
if (definition.ref === 'TelemetrySettings') {
|
||||
const objectSchema = REF_SCHEMAS['TelemetrySettings'];
|
||||
if (objectSchema) {
|
||||
return z
|
||||
.union([buildPrimitiveSchema('boolean'), objectSchema])
|
||||
.optional();
|
||||
return z.union([z.boolean(), objectSchema]).optional();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -479,137 +479,6 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.security?.folderTrust?.enabled).toBe(false); // Workspace setting should be used
|
||||
});
|
||||
|
||||
it('should resolve environment variables and cast them to correct types before validation', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', 'false');
|
||||
vi.stubEnv('TEST_MAX_TURNS', '15');
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: '$TEST_AUTO_THEME' },
|
||||
model: { maxSessionTurns: '$TEST_MAX_TURNS' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.ui.autoThemeSwitching).toBe(false);
|
||||
expect(settings.merged.model.maxSessionTurns).toBe(15);
|
||||
expect(settings.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should use default values from environment variable placeholders', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', ''); // Should trigger default
|
||||
delete process.env['TEST_AUTO_THEME'];
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: '${TEST_AUTO_THEME:-true}' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.ui.autoThemeSwitching).toBe(true);
|
||||
expect(settings.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should record validation errors if expansion result is invalid', () => {
|
||||
vi.stubEnv('TEST_MAX_TURNS', 'not-a-number');
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
model: { maxSessionTurns: '$TEST_MAX_TURNS' },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.errors.length).toBeGreaterThan(0);
|
||||
expect(settings.errors[0].message).toContain(
|
||||
'Expected number, received string',
|
||||
);
|
||||
// Should fall back to the expanded string value
|
||||
expect(settings.merged.model.maxSessionTurns).toBe('not-a-number');
|
||||
});
|
||||
|
||||
it('should preserve environment variable placeholders on save', () => {
|
||||
vi.stubEnv('TEST_AUTO_THEME', 'true');
|
||||
const placeholder = '${TEST_AUTO_THEME:-false}';
|
||||
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) =>
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH),
|
||||
);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (
|
||||
path.normalize(p.toString()) === path.normalize(USER_SETTINGS_PATH)
|
||||
) {
|
||||
return JSON.stringify({
|
||||
ui: { autoThemeSwitching: placeholder },
|
||||
});
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
// Load settings - this will expand the placeholder for runtime use
|
||||
const loaded = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(loaded.merged.ui.autoThemeSwitching).toBe(true);
|
||||
|
||||
// Verify that the original settings for the user scope still have the placeholder
|
||||
const userFile = loaded.forScope(SettingScope.User);
|
||||
expect(userFile.originalSettings.ui?.autoThemeSwitching).toBe(
|
||||
placeholder,
|
||||
);
|
||||
|
||||
// Save settings - this should use the originalSettings (with placeholders)
|
||||
const mockUpdate = vi.mocked(updateSettingsFilePreservingFormat);
|
||||
saveSettings(userFile);
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
USER_SETTINGS_PATH,
|
||||
expect.objectContaining({
|
||||
ui: expect.objectContaining({
|
||||
autoThemeSwitching: placeholder,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use system folderTrust over user setting', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
@@ -2043,9 +1912,6 @@ describe('Settings Loading and Merging', () => {
|
||||
const geminiEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, GEMINI_DIR, '.env'),
|
||||
);
|
||||
const workspaceEnvPath = path.resolve(
|
||||
path.join(MOCK_WORKSPACE_DIR, '.env'),
|
||||
);
|
||||
|
||||
vi.spyOn(trustedFolders, 'isWorkspaceTrusted').mockReturnValue({
|
||||
isTrusted: isWorkspaceTrustedValue,
|
||||
@@ -2053,11 +1919,9 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
(mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
return [
|
||||
path.resolve(USER_SETTINGS_PATH),
|
||||
geminiEnvPath,
|
||||
workspaceEnvPath,
|
||||
].includes(normalizedP);
|
||||
return [path.resolve(USER_SETTINGS_PATH), geminiEnvPath].includes(
|
||||
normalizedP,
|
||||
);
|
||||
});
|
||||
const userSettingsContent: Settings = {
|
||||
ui: {
|
||||
@@ -2077,7 +1941,7 @@ describe('Settings Loading and Merging', () => {
|
||||
const normalizedP = path.resolve(p.toString());
|
||||
if (normalizedP === path.resolve(USER_SETTINGS_PATH))
|
||||
return JSON.stringify(userSettingsContent);
|
||||
if (normalizedP === geminiEnvPath || normalizedP === workspaceEnvPath)
|
||||
if (normalizedP === geminiEnvPath)
|
||||
return 'TESTTEST=1234\nGEMINI_API_KEY=test-key';
|
||||
return '{}';
|
||||
},
|
||||
@@ -2106,7 +1970,7 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
});
|
||||
|
||||
it('does NOT load non-whitelisted env files from untrusted spaces even when NOT sandboxed', () => {
|
||||
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||
const settings = {
|
||||
security: { folderTrust: { enabled: true } },
|
||||
@@ -2114,8 +1978,7 @@ describe('Settings Loading and Merging', () => {
|
||||
} as Settings;
|
||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||
|
||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||
expect(process.env['TESTTEST']).toEqual('1234');
|
||||
});
|
||||
|
||||
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||
|
||||
@@ -499,15 +499,13 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
function findEnvFile(startDir: string): string | null {
|
||||
let currentDir = path.resolve(startDir);
|
||||
while (true) {
|
||||
// prefer gemini-specific .env under GEMINI_DIR
|
||||
if (isTrusted) {
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(geminiEnvPath)) {
|
||||
return geminiEnvPath;
|
||||
}
|
||||
const envPath = path.join(currentDir, '.env');
|
||||
if (fs.existsSync(envPath)) {
|
||||
@@ -516,11 +514,9 @@ function findEnvFile(startDir: string, isTrusted: boolean): string | null {
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir || !parentDir) {
|
||||
// check .env under home as fallback, again preferring gemini-specific .env
|
||||
if (isTrusted) {
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env');
|
||||
if (fs.existsSync(homeGeminiEnvPath)) {
|
||||
return homeGeminiEnvPath;
|
||||
}
|
||||
const homeEnvPath = path.join(homedir(), '.env');
|
||||
if (fs.existsSync(homeEnvPath)) {
|
||||
@@ -563,10 +559,10 @@ export function loadEnvironment(
|
||||
workspaceDir: string,
|
||||
isWorkspaceTrustedFn = isWorkspaceTrusted,
|
||||
): void {
|
||||
const envFilePath = findEnvFile(workspaceDir);
|
||||
const trustResult = isWorkspaceTrustedFn(settings, workspaceDir);
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
const envFilePath = findEnvFile(workspaceDir, isTrusted);
|
||||
|
||||
const isTrusted = trustResult.isTrusted ?? false;
|
||||
// Check settings OR check process.argv directly since this might be called
|
||||
// before arguments are fully parsed. This is a best-effort sniffing approach
|
||||
// that happens early in the CLI lifecycle. It is designed to detect the
|
||||
@@ -601,8 +597,8 @@ export function loadEnvironment(
|
||||
for (const key in parsedEnv) {
|
||||
if (Object.hasOwn(parsedEnv, key)) {
|
||||
let value = parsedEnv[key];
|
||||
// If the workspace is untrusted, only allow whitelisted variables.
|
||||
if (!isTrusted) {
|
||||
// If the workspace is untrusted but we are sandboxed, only allow whitelisted variables.
|
||||
if (!isTrusted && isSandboxed) {
|
||||
if (!AUTH_ENV_VAR_WHITELIST.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
@@ -673,9 +669,7 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
const storage = new Storage(workspaceDir);
|
||||
const workspaceSettingsPath = storage.getWorkspaceSettingsPath();
|
||||
|
||||
const load = (
|
||||
filePath: string,
|
||||
): { settings: Settings; rawSettings: Settings; rawJson?: string } => {
|
||||
const load = (filePath: string): { settings: Settings; rawJson?: string } => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
@@ -691,19 +685,14 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
path: filePath,
|
||||
severity: 'error',
|
||||
});
|
||||
return { settings: {}, rawSettings: {} };
|
||||
return { settings: {} };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const settingsObject = rawSettings as Record<string, unknown>;
|
||||
|
||||
// Expand environment variables
|
||||
const expandedSettings = resolveEnvVarsInObject(
|
||||
settingsObject as Settings,
|
||||
);
|
||||
|
||||
// Validate settings structure with Zod after environment variable expansion
|
||||
const validationResult = validateSettings(expandedSettings);
|
||||
// Validate settings structure with Zod
|
||||
const validationResult = validateSettings(settingsObject);
|
||||
if (!validationResult.success && validationResult.error) {
|
||||
const errorMessage = formatValidationError(
|
||||
validationResult.error,
|
||||
@@ -714,22 +703,9 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
path: filePath,
|
||||
severity: 'warning',
|
||||
});
|
||||
return {
|
||||
settings: expandedSettings,
|
||||
rawSettings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
};
|
||||
}
|
||||
|
||||
// Return the successfully cast and validated data
|
||||
return {
|
||||
// Since we've successfully validated expandedSettings against settingsZodSchema,
|
||||
// it's safe to cast the resulting data to the Settings type.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings: (validationResult.data as Settings) ?? expandedSettings,
|
||||
rawSettings: settingsObject as Settings,
|
||||
rawJson: content,
|
||||
};
|
||||
return { settings: settingsObject as Settings, rawJson: content };
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
@@ -738,40 +714,33 @@ function _doLoadSettings(workspaceDir: string): LoadedSettings {
|
||||
severity: 'error',
|
||||
});
|
||||
}
|
||||
return { settings: {}, rawSettings: {} };
|
||||
return { settings: {} };
|
||||
};
|
||||
|
||||
const systemResult = load(systemSettingsPath);
|
||||
const systemDefaultsResult = load(systemDefaultsPath);
|
||||
const userResult = load(USER_SETTINGS_PATH);
|
||||
|
||||
let workspaceResult: {
|
||||
settings: Settings;
|
||||
rawSettings: Settings;
|
||||
rawJson?: string;
|
||||
} = {
|
||||
let workspaceResult: { settings: Settings; rawJson?: string } = {
|
||||
settings: {} as Settings,
|
||||
rawSettings: {} as Settings,
|
||||
rawJson: undefined,
|
||||
};
|
||||
if (!storage.isWorkspaceHomeDir()) {
|
||||
workspaceResult = load(workspaceSettingsPath);
|
||||
}
|
||||
|
||||
const systemOriginalSettings = structuredClone(systemResult.rawSettings);
|
||||
const systemOriginalSettings = structuredClone(systemResult.settings);
|
||||
const systemDefaultsOriginalSettings = structuredClone(
|
||||
systemDefaultsResult.rawSettings,
|
||||
);
|
||||
const userOriginalSettings = structuredClone(userResult.rawSettings);
|
||||
const workspaceOriginalSettings = structuredClone(
|
||||
workspaceResult.rawSettings,
|
||||
systemDefaultsResult.settings,
|
||||
);
|
||||
const userOriginalSettings = structuredClone(userResult.settings);
|
||||
const workspaceOriginalSettings = structuredClone(workspaceResult.settings);
|
||||
|
||||
// Environment variables for runtime use are already resolved and validated in load()
|
||||
systemSettings = systemResult.settings;
|
||||
systemDefaultSettings = systemDefaultsResult.settings;
|
||||
userSettings = userResult.settings;
|
||||
workspaceSettings = workspaceResult.settings;
|
||||
// Environment variables for runtime use
|
||||
systemSettings = resolveEnvVarsInObject(systemResult.settings);
|
||||
systemDefaultSettings = resolveEnvVarsInObject(systemDefaultsResult.settings);
|
||||
userSettings = resolveEnvVarsInObject(userResult.settings);
|
||||
workspaceSettings = resolveEnvVarsInObject(workspaceResult.settings);
|
||||
|
||||
// Support legacy theme names
|
||||
if (userSettings.ui?.theme === 'VS') {
|
||||
|
||||
@@ -1667,19 +1667,6 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
confirmationRequired: {
|
||||
type: 'array',
|
||||
label: 'Confirmation Required',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: oneLine`
|
||||
Tool names that always require user confirmation.
|
||||
Takes precedence over allowed tools and core tool allowlists.
|
||||
`,
|
||||
showInDialog: false,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
@@ -2052,96 +2039,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Setting to enable experimental features',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
gemma: {
|
||||
type: 'boolean',
|
||||
label: 'Gemma Models',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable access to Gemma 4 models (experimental).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voiceMode: {
|
||||
type: 'boolean',
|
||||
label: 'Voice Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Enable experimental voice dictation and commands (/voice, /voice model).',
|
||||
showInDialog: true,
|
||||
},
|
||||
voice: {
|
||||
type: 'object',
|
||||
label: 'Voice',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: {},
|
||||
description: 'Settings for voice mode and transcription.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
activationMode: {
|
||||
type: 'enum',
|
||||
label: 'Voice Activation Mode',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'push-to-talk',
|
||||
description: 'How to trigger voice recording with the Space key.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'push-to-talk', label: 'Push-To-Talk (Hold Space)' },
|
||||
{ value: 'toggle', label: 'Toggle (Press Space to start/stop)' },
|
||||
],
|
||||
},
|
||||
backend: {
|
||||
type: 'enum',
|
||||
label: 'Voice Transcription Backend',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'gemini-live',
|
||||
description: 'The backend to use for voice transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'gemini-live', label: 'Gemini Live API (Cloud)' },
|
||||
{ value: 'whisper', label: 'Whisper (Local)' },
|
||||
],
|
||||
},
|
||||
whisperModel: {
|
||||
type: 'enum',
|
||||
label: 'Whisper Model',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 'ggml-base.en.bin',
|
||||
description: 'The Whisper model to use for local transcription.',
|
||||
showInDialog: true,
|
||||
options: [
|
||||
{ value: 'ggml-tiny.en.bin', label: 'Tiny (EN) - Fast (~75MB)' },
|
||||
{
|
||||
value: 'ggml-base.en.bin',
|
||||
label: 'Base (EN) - Balanced (~142MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q5_0.bin',
|
||||
label: 'Large v3 Turbo (Q5_0) - High Accuracy (~547MB)',
|
||||
},
|
||||
{
|
||||
value: 'ggml-large-v3-turbo-q8_0.bin',
|
||||
label: 'Large v3 Turbo (Q8_0) - Max Accuracy (~834MB)',
|
||||
},
|
||||
],
|
||||
},
|
||||
stopGracePeriodMs: {
|
||||
type: 'number',
|
||||
label: 'Voice Stop Grace Period (ms)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: false,
|
||||
default: 1000,
|
||||
description:
|
||||
'How long to wait for final transcription after stopping recording.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
adk: {
|
||||
type: 'object',
|
||||
label: 'ADK',
|
||||
@@ -2243,9 +2140,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
|
||||
default: false,
|
||||
description: 'Enable Just-In-Time (JIT) context loading.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
@@ -2378,26 +2274,15 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
memoryV2: {
|
||||
memoryManager: {
|
||||
type: 'boolean',
|
||||
label: 'Memory v2',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'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.',
|
||||
label: 'Memory Manager Agent',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Significantly lowers token limits to force early garbage collection and distillation for testing purposes.',
|
||||
showInDialog: false,
|
||||
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
|
||||
showInDialog: true,
|
||||
},
|
||||
autoMemory: {
|
||||
type: 'boolean',
|
||||
@@ -3289,7 +3174,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
|
||||
secondary: { type: 'string' },
|
||||
link: { type: 'string' },
|
||||
accent: { type: 'string' },
|
||||
response: { type: 'string' },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as os from 'node:os';
|
||||
import {
|
||||
FatalConfigError,
|
||||
ideContextStore,
|
||||
normalizePath,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
loadTrustedFolders,
|
||||
@@ -32,14 +32,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
...actual,
|
||||
homedir: () => '/mock/home/user',
|
||||
isHeadlessMode: vi.fn(() => false),
|
||||
coreEvents: Object.assign(
|
||||
Object.create(Object.getPrototypeOf(actual.coreEvents)),
|
||||
actual.coreEvents,
|
||||
{
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
),
|
||||
FatalConfigError: actual.FatalConfigError,
|
||||
coreEvents: {
|
||||
emitFeedback: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -58,7 +53,6 @@ describe('Trusted Folders', () => {
|
||||
// Reset the internal state
|
||||
resetTrustedFoldersForTesting();
|
||||
vi.clearAllMocks();
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -76,14 +70,8 @@ describe('Trusted Folders', () => {
|
||||
|
||||
// Start two concurrent calls
|
||||
// These will race to acquire the lock on the real file system
|
||||
const p1 = loadedFolders.setValue(
|
||||
path.resolve('/path1'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p2 = loadedFolders.setValue(
|
||||
path.resolve('/path2'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
const p1 = loadedFolders.setValue('/path1', TrustLevel.TRUST_FOLDER);
|
||||
const p2 = loadedFolders.setValue('/path2', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
@@ -92,8 +80,8 @@ describe('Trusted Folders', () => {
|
||||
const config = JSON.parse(content);
|
||||
|
||||
expect(config).toEqual({
|
||||
[normalizePath('/path1')]: TrustLevel.TRUST_FOLDER,
|
||||
[normalizePath('/path2')]: TrustLevel.TRUST_FOLDER,
|
||||
'/path1': TrustLevel.TRUST_FOLDER,
|
||||
'/path2': TrustLevel.TRUST_FOLDER,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,16 +95,13 @@ describe('Trusted Folders', () => {
|
||||
|
||||
it('should load rules from the configuration file', () => {
|
||||
const config = {
|
||||
[normalizePath('/user/folder')]: TrustLevel.TRUST_FOLDER,
|
||||
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||
};
|
||||
fs.writeFileSync(trustedFoldersPath, JSON.stringify(config), 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{
|
||||
path: normalizePath('/user/folder'),
|
||||
trustLevel: TrustLevel.TRUST_FOLDER,
|
||||
},
|
||||
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -158,14 +143,14 @@ describe('Trusted Folders', () => {
|
||||
const content = `
|
||||
{
|
||||
// This is a comment
|
||||
"${normalizePath('/path').replaceAll('\\', '\\\\')}": "TRUST_FOLDER"
|
||||
"/path": "TRUST_FOLDER"
|
||||
}
|
||||
`;
|
||||
fs.writeFileSync(trustedFoldersPath, content, 'utf-8');
|
||||
|
||||
const { rules, errors } = loadTrustedFolders();
|
||||
expect(rules).toEqual([
|
||||
{ path: normalizePath('/path'), trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
{ path: '/path', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -231,18 +216,15 @@ describe('Trusted Folders', () => {
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
await loadedFolders.setValue(
|
||||
normalizePath('/new/path'),
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(loadedFolders.user.config[normalizePath('/new/path')]).toBe(
|
||||
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||
TrustLevel.TRUST_FOLDER,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config[normalizePath('/new/path')]).toBe(TrustLevel.TRUST_FOLDER);
|
||||
expect(config['/new/path']).toBe(TrustLevel.TRUST_FOLDER);
|
||||
});
|
||||
|
||||
it('should throw FatalConfigError if there were load errors', async () => {
|
||||
@@ -255,6 +237,28 @@ describe('Trusted Folders', () => {
|
||||
loadedFolders.setValue('/some/path', TrustLevel.TRUST_FOLDER),
|
||||
).rejects.toThrow(FatalConfigError);
|
||||
});
|
||||
|
||||
it('should report corrupted config via coreEvents.emitFeedback and still succeed', async () => {
|
||||
// Initialize with valid JSON
|
||||
fs.writeFileSync(trustedFoldersPath, '{}', 'utf-8');
|
||||
const loadedFolders = loadTrustedFolders();
|
||||
|
||||
// Corrupt the file after initial load
|
||||
fs.writeFileSync(trustedFoldersPath, 'invalid json', 'utf-8');
|
||||
|
||||
await loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||
|
||||
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('may be corrupted'),
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
// Should have overwritten the corrupted file with new valid config
|
||||
const content = fs.readFileSync(trustedFoldersPath, 'utf-8');
|
||||
const config = JSON.parse(content);
|
||||
expect(config).toEqual({ '/new/path': TrustLevel.TRUST_FOLDER });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWorkspaceTrusted Integration', () => {
|
||||
@@ -423,28 +427,16 @@ describe('Trusted Folders', () => {
|
||||
},
|
||||
};
|
||||
|
||||
it('should NOT return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
it('should return true when isHeadlessMode is true, ignoring config', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: undefined,
|
||||
isTrusted: true,
|
||||
source: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return true when GEMINI_CLI_TRUST_WORKSPACE is true', async () => {
|
||||
process.env['GEMINI_CLI_TRUST_WORKSPACE'] = 'true';
|
||||
try {
|
||||
expect(isWorkspaceTrusted(mockSettings)).toEqual({
|
||||
isTrusted: true,
|
||||
source: 'env',
|
||||
});
|
||||
} finally {
|
||||
delete process.env['GEMINI_CLI_TRUST_WORKSPACE'];
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to config when isHeadlessMode is false', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(false);
|
||||
@@ -457,12 +449,12 @@ describe('Trusted Folders', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
it('should return true for isPathTrusted when isHeadlessMode is true', async () => {
|
||||
const geminiCore = await import('@google/gemini-cli-core');
|
||||
vi.spyOn(geminiCore, 'isHeadlessMode').mockReturnValue(true);
|
||||
|
||||
const folders = loadTrustedFolders();
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(undefined);
|
||||
expect(folders.isPathTrusted('/any-untrusted-path')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,29 +4,330 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { lock } from 'proper-lockfile';
|
||||
import {
|
||||
type HeadlessModeOptions,
|
||||
checkPathTrust,
|
||||
FatalConfigError,
|
||||
getErrorMessage,
|
||||
isWithinRoot,
|
||||
ideContextStore,
|
||||
GEMINI_DIR,
|
||||
homedir,
|
||||
isHeadlessMode,
|
||||
loadTrustedFolders as loadCoreTrustedFolders,
|
||||
type LoadedTrustedFolders,
|
||||
coreEvents,
|
||||
type HeadlessModeOptions,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Settings } from './settings.js';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
export {
|
||||
TrustLevel,
|
||||
isTrustLevel,
|
||||
resetTrustedFoldersForTesting,
|
||||
saveTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
const { promises: fsPromises } = fs;
|
||||
|
||||
export type {
|
||||
TrustRule,
|
||||
TrustedFoldersError,
|
||||
TrustedFoldersFile,
|
||||
TrustResult,
|
||||
LoadedTrustedFolders,
|
||||
} from '@google/gemini-cli-core';
|
||||
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||
|
||||
export function getUserSettingsDir(): string {
|
||||
return path.join(homedir(), GEMINI_DIR);
|
||||
}
|
||||
|
||||
export function getTrustedFoldersPath(): string {
|
||||
if (process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH']) {
|
||||
return process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'];
|
||||
}
|
||||
return path.join(getUserSettingsDir(), TRUSTED_FOLDERS_FILENAME);
|
||||
}
|
||||
|
||||
export enum TrustLevel {
|
||||
TRUST_FOLDER = 'TRUST_FOLDER',
|
||||
TRUST_PARENT = 'TRUST_PARENT',
|
||||
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||
}
|
||||
|
||||
export function isTrustLevel(
|
||||
value: string | number | boolean | object | null | undefined,
|
||||
): value is TrustLevel {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
Object.values(TrustLevel).includes(value as TrustLevel)
|
||||
);
|
||||
}
|
||||
|
||||
export interface TrustRule {
|
||||
path: string;
|
||||
trustLevel: TrustLevel;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersError {
|
||||
message: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrustedFoldersFile {
|
||||
config: Record<string, TrustLevel>;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface TrustResult {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | undefined;
|
||||
}
|
||||
|
||||
const realPathCache = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Parses the trusted folders JSON content, stripping comments.
|
||||
*/
|
||||
function parseTrustedFoldersJson(content: string): unknown {
|
||||
return JSON.parse(stripJsonComments(content));
|
||||
}
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Clears the real path cache.
|
||||
*/
|
||||
export function clearRealPathCacheForTesting(): void {
|
||||
realPathCache.clear();
|
||||
}
|
||||
|
||||
function getRealPath(location: string): string {
|
||||
let realPath = realPathCache.get(location);
|
||||
if (realPath !== undefined) {
|
||||
return realPath;
|
||||
}
|
||||
|
||||
try {
|
||||
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
|
||||
} catch {
|
||||
realPath = location;
|
||||
}
|
||||
|
||||
realPathCache.set(location, realPath);
|
||||
return realPath;
|
||||
}
|
||||
|
||||
export class LoadedTrustedFolders {
|
||||
constructor(
|
||||
readonly user: TrustedFoldersFile,
|
||||
readonly errors: TrustedFoldersError[],
|
||||
) {}
|
||||
|
||||
get rules(): TrustRule[] {
|
||||
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
|
||||
path,
|
||||
trustLevel,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true or false if the path should be "trusted". This function
|
||||
* should only be invoked when the folder trust setting is active.
|
||||
*
|
||||
* @param location path
|
||||
* @returns
|
||||
*/
|
||||
isPathTrusted(
|
||||
location: string,
|
||||
config?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): boolean | undefined {
|
||||
if (isHeadlessMode(headlessOptions)) {
|
||||
return true;
|
||||
}
|
||||
const configToUse = config ?? this.user.config;
|
||||
|
||||
// Resolve location to its realpath for canonical comparison
|
||||
const realLocation = getRealPath(location);
|
||||
|
||||
let longestMatchLen = -1;
|
||||
let longestMatchTrust: TrustLevel | undefined = undefined;
|
||||
|
||||
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
|
||||
const effectivePath =
|
||||
trustLevel === TrustLevel.TRUST_PARENT
|
||||
? path.dirname(rulePath)
|
||||
: rulePath;
|
||||
|
||||
// Resolve effectivePath to its realpath for canonical comparison
|
||||
const realEffectivePath = getRealPath(effectivePath);
|
||||
|
||||
if (isWithinRoot(realLocation, realEffectivePath)) {
|
||||
if (rulePath.length > longestMatchLen) {
|
||||
longestMatchLen = rulePath.length;
|
||||
longestMatchTrust = trustLevel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
|
||||
if (
|
||||
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
|
||||
longestMatchTrust === TrustLevel.TRUST_PARENT
|
||||
)
|
||||
return true;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async setValue(folderPath: string, trustLevel: TrustLevel): Promise<void> {
|
||||
if (this.errors.length > 0) {
|
||||
const errorMessages = this.errors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
`Cannot update trusted folders because the configuration file is invalid:\n${errorMessages.join('\n')}\nPlease fix the file manually before trying to update it.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dirPath = path.dirname(this.user.path);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
await fsPromises.mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// lockfile requires the file to exist
|
||||
if (!fs.existsSync(this.user.path)) {
|
||||
await fsPromises.writeFile(this.user.path, JSON.stringify({}, null, 2), {
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
const release = await lock(this.user.path, {
|
||||
retries: {
|
||||
retries: 10,
|
||||
minTimeout: 100,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
// Re-read the file to handle concurrent updates
|
||||
const content = await fsPromises.readFile(this.user.path, 'utf-8');
|
||||
let config: Record<string, TrustLevel>;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
config = parseTrustedFoldersJson(content) as Record<string, TrustLevel>;
|
||||
} catch (error) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Failed to parse trusted folders file at ${this.user.path}. The file may be corrupted.`,
|
||||
error,
|
||||
);
|
||||
config = {};
|
||||
}
|
||||
|
||||
const originalTrustLevel = config[folderPath];
|
||||
config[folderPath] = trustLevel;
|
||||
this.user.config[folderPath] = trustLevel;
|
||||
|
||||
try {
|
||||
saveTrustedFolders({ ...this.user, config });
|
||||
} catch (e) {
|
||||
// Revert the in-memory change if the save failed.
|
||||
if (originalTrustLevel === undefined) {
|
||||
delete this.user.config[folderPath];
|
||||
} else {
|
||||
this.user.config[folderPath] = originalTrustLevel;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let loadedTrustedFolders: LoadedTrustedFolders | undefined;
|
||||
|
||||
/**
|
||||
* FOR TESTING PURPOSES ONLY.
|
||||
* Resets the in-memory cache of the trusted folders configuration.
|
||||
*/
|
||||
export function resetTrustedFoldersForTesting(): void {
|
||||
loadedTrustedFolders = undefined;
|
||||
clearRealPathCacheForTesting();
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
if (loadedTrustedFolders) {
|
||||
return loadedTrustedFolders;
|
||||
}
|
||||
|
||||
const errors: TrustedFoldersError[] = [];
|
||||
const userConfig: Record<string, TrustLevel> = {};
|
||||
|
||||
const userPath = getTrustedFoldersPath();
|
||||
try {
|
||||
if (fs.existsSync(userPath)) {
|
||||
const content = fs.readFileSync(userPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsed = parseTrustedFoldersJson(content) as Record<string, string>;
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
errors.push({
|
||||
message: 'Trusted folders file is not a valid JSON object.',
|
||||
path: userPath,
|
||||
});
|
||||
} else {
|
||||
for (const [path, trustLevel] of Object.entries(parsed)) {
|
||||
if (isTrustLevel(trustLevel)) {
|
||||
userConfig[path] = trustLevel;
|
||||
} else {
|
||||
const possibleValues = Object.values(TrustLevel).join(', ');
|
||||
errors.push({
|
||||
message: `Invalid trust level "${trustLevel}" for path "${path}". Possible values are: ${possibleValues}.`,
|
||||
path: userPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: userPath,
|
||||
});
|
||||
}
|
||||
|
||||
loadedTrustedFolders = new LoadedTrustedFolders(
|
||||
{ path: userPath, config: userConfig },
|
||||
errors,
|
||||
);
|
||||
return loadedTrustedFolders;
|
||||
}
|
||||
|
||||
export function saveTrustedFolders(
|
||||
trustedFoldersFile: TrustedFoldersFile,
|
||||
): void {
|
||||
// Ensure the directory exists
|
||||
const dirPath = path.dirname(trustedFoldersFile.path);
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
const content = JSON.stringify(trustedFoldersFile.config, null, 2);
|
||||
const tempPath = `${trustedFoldersFile.path}.tmp.${crypto.randomUUID()}`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tempPath, content, {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(tempPath, trustedFoldersFile.path);
|
||||
} catch (error) {
|
||||
// Clean up temp file if it was created but rename failed
|
||||
if (fs.existsSync(tempPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempPath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Is folder trust feature enabled per the current applied settings */
|
||||
export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
@@ -34,24 +335,57 @@ export function isFolderTrustEnabled(settings: Settings): boolean {
|
||||
return folderTrustSetting;
|
||||
}
|
||||
|
||||
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||
return loadCoreTrustedFolders();
|
||||
function getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir: string,
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): TrustResult {
|
||||
const folders = loadTrustedFolders();
|
||||
const configToUse = trustConfig ?? folders.user.config;
|
||||
|
||||
if (folders.errors.length > 0) {
|
||||
const errorMessages = folders.errors.map(
|
||||
(error) => `Error in ${error.path}: ${error.message}`,
|
||||
);
|
||||
throw new FatalConfigError(
|
||||
`${errorMessages.join('\n')}\nPlease fix the configuration file and try again.`,
|
||||
);
|
||||
}
|
||||
|
||||
const isTrusted = folders.isPathTrusted(
|
||||
workspaceDir,
|
||||
configToUse,
|
||||
headlessOptions,
|
||||
);
|
||||
return {
|
||||
isTrusted,
|
||||
source: isTrusted !== undefined ? 'file' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true or false if the workspace is considered "trusted".
|
||||
*/
|
||||
export function isWorkspaceTrusted(
|
||||
settings: Settings,
|
||||
workspaceDir: string = process.cwd(),
|
||||
trustConfig?: Record<string, TrustLevel>,
|
||||
headlessOptions?: HeadlessModeOptions,
|
||||
): {
|
||||
isTrusted: boolean | undefined;
|
||||
source: 'ide' | 'file' | 'env' | undefined;
|
||||
} {
|
||||
return checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: isFolderTrustEnabled(settings),
|
||||
isHeadless: isHeadlessMode(headlessOptions),
|
||||
});
|
||||
): TrustResult {
|
||||
if (isHeadlessMode(headlessOptions)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
if (!isFolderTrustEnabled(settings)) {
|
||||
return { isTrusted: true, source: undefined };
|
||||
}
|
||||
|
||||
const ideTrust = ideContextStore.get()?.workspaceState?.isTrusted;
|
||||
if (ideTrust !== undefined) {
|
||||
return { isTrusted: ideTrust, source: 'ide' };
|
||||
}
|
||||
|
||||
// Fall back to the local user configuration
|
||||
return getWorkspaceTrustFromLocalConfig(
|
||||
workspaceDir,
|
||||
trustConfig,
|
||||
headlessOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
validateDnsResolutionOrder,
|
||||
startInteractiveUI,
|
||||
getNodeMemoryArgs,
|
||||
resolveSessionId,
|
||||
} from './gemini.js';
|
||||
import {
|
||||
loadCliConfig,
|
||||
@@ -48,13 +47,10 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
AuthType,
|
||||
ExitCodes,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { act } from 'react';
|
||||
import { type InitializationResult } from './core/initializer.js';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
import { SessionSelector, SessionError } from './utils/sessionUtils.js';
|
||||
|
||||
// Hoisted constants and mocks
|
||||
const performance = vi.hoisted(() => ({
|
||||
now: vi.fn(),
|
||||
@@ -284,7 +280,6 @@ describe('gemini.tsx main function', () => {
|
||||
vi.stubEnv('GEMINI_SANDBOX', '');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.stubEnv('SHPOOL_SESSION_NAME', '');
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
|
||||
initialUnhandledRejectionListeners =
|
||||
process.listeners('unhandledRejection');
|
||||
@@ -552,7 +547,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
@@ -561,7 +555,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -612,7 +605,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
screenReader: undefined,
|
||||
useWriteTodos: undefined,
|
||||
resume: undefined,
|
||||
sessionId: undefined,
|
||||
listSessions: undefined,
|
||||
deleteSession: undefined,
|
||||
outputFormat: undefined,
|
||||
@@ -621,7 +613,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
skipTrust: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -828,6 +819,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should handle session selector error', async () => {
|
||||
const { SessionSelector } = await import('./utils/sessionUtils.js');
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@@ -884,6 +876,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
|
||||
it('should start normally with a warning when no sessions found for resume', async () => {
|
||||
const { SessionSelector, SessionError } = await import(
|
||||
'./utils/sessionUtils.js'
|
||||
);
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
@@ -1058,63 +1053,6 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSessionId', () => {
|
||||
it('should return a new session ID when neither resume nor sessionId is provided', async () => {
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(resumedSessionData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => {
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sessionExists: vi.fn().mockResolvedValue(true),
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
);
|
||||
|
||||
const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback');
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((code) => {
|
||||
throw new MockProcessExitError(code);
|
||||
});
|
||||
|
||||
try {
|
||||
await resolveSessionId(undefined, 'existing-id');
|
||||
} catch (e) {
|
||||
if (!(e instanceof MockProcessExitError)) throw e;
|
||||
}
|
||||
|
||||
expect(emitFeedbackSpy).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.stringContaining('Session ID "existing-id" already exists'),
|
||||
);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR);
|
||||
|
||||
emitFeedbackSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return provided sessionId when it does not exist', async () => {
|
||||
vi.mocked(SessionSelector).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sessionExists: vi.fn().mockResolvedValue(false),
|
||||
}) as unknown as InstanceType<typeof SessionSelector>,
|
||||
);
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||
undefined,
|
||||
'new-id',
|
||||
);
|
||||
expect(sessionId).toBe('new-id');
|
||||
expect(resumedSessionData).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gemini.tsx main function exit codes', () => {
|
||||
let originalEnvNoRelaunch: string | undefined;
|
||||
let originalIsTTY: boolean | undefined;
|
||||
|
||||
+20
-47
@@ -76,7 +76,7 @@ import {
|
||||
type InitializationResult,
|
||||
} from './core/initializer.js';
|
||||
import { validateAuthMethod } from './config/auth.js';
|
||||
import { runAcpClient } from './acp/acpStdioTransport.js';
|
||||
import { runAcpClient } from './acp/acpClient.js';
|
||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||
import { appEvents, AppEvent } from './utils/events.js';
|
||||
import { SessionError, SessionSelector } from './utils/sessionUtils.js';
|
||||
@@ -85,6 +85,7 @@ import { relaunchOnExitCode } from './utils/relaunch.js';
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import { deleteSession, listSessions } from './utils/sessions.js';
|
||||
import { createPolicyUpdater } from './config/policy.js';
|
||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||
|
||||
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||
import { runDeferredCommand } from './deferred.js';
|
||||
@@ -190,38 +191,21 @@ ${reason.stack}`
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveSessionId(
|
||||
resumeArg: string | undefined,
|
||||
sessionIdArg?: string | undefined,
|
||||
): Promise<{
|
||||
export async function resolveSessionId(resumeArg: string | undefined): Promise<{
|
||||
sessionId: string;
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}> {
|
||||
if (!resumeArg && !sessionIdArg) {
|
||||
if (!resumeArg) {
|
||||
return { sessionId: createSessionId() };
|
||||
}
|
||||
|
||||
const storage = new Storage(process.cwd());
|
||||
await storage.initialize();
|
||||
|
||||
const sessionSelector = new SessionSelector(storage);
|
||||
|
||||
if (sessionIdArg) {
|
||||
if (await sessionSelector.sessionExists(sessionIdArg)) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`,
|
||||
);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.FATAL_INPUT_ERROR);
|
||||
}
|
||||
return { sessionId: sessionIdArg };
|
||||
}
|
||||
|
||||
try {
|
||||
const { sessionData, sessionPath } = await sessionSelector.resolveSession(
|
||||
resumeArg!,
|
||||
);
|
||||
const { sessionData, sessionPath } = await new SessionSelector(
|
||||
storage,
|
||||
).resolveSession(resumeArg);
|
||||
return {
|
||||
sessionId: sessionData.sessionId,
|
||||
resumedSessionData: { conversation: sessionData, filePath: sessionPath },
|
||||
@@ -335,10 +319,7 @@ export async function main() {
|
||||
|
||||
const argv = await argvPromise;
|
||||
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(
|
||||
argv.resume,
|
||||
argv.sessionId,
|
||||
);
|
||||
const { sessionId, resumedSessionData } = await resolveSessionId(argv.resume);
|
||||
|
||||
if (
|
||||
(argv.allowedTools && argv.allowedTools.length > 0) ||
|
||||
@@ -377,14 +358,15 @@ export async function main() {
|
||||
|
||||
const isDebugMode = cliConfig.isDebugMode(argv);
|
||||
const consolePatcher = new ConsolePatcher({
|
||||
stderr: argv.isCommand ? false : true,
|
||||
interactive: isHeadlessMode() && !argv.isCommand ? false : true,
|
||||
stderr: true,
|
||||
interactive: isHeadlessMode() ? false : true,
|
||||
debugMode: isDebugMode,
|
||||
onNewMessage: (msg) => {
|
||||
coreEvents.emitConsoleLog(msg.type, msg.content);
|
||||
},
|
||||
});
|
||||
consolePatcher.patch();
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
|
||||
dns.setDefaultResultOrder(
|
||||
validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder),
|
||||
@@ -410,7 +392,6 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
// Refresh auth to fetch remote admin settings from CCPA and before entering
|
||||
@@ -551,7 +532,7 @@ export async function main() {
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
// Register config for telemetry shutdown
|
||||
@@ -568,12 +549,6 @@ export async function main() {
|
||||
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
|
||||
});
|
||||
|
||||
// Register ConsolePatcher cleanup last to ensure logs from shutdown hooks
|
||||
// are correctly redirected to stderr (especially for non-interactive JSON output).
|
||||
if (!config.getAcpMode()) {
|
||||
registerCleanup(consolePatcher.cleanup);
|
||||
}
|
||||
|
||||
// Launch cleanup expired sessions as a background task
|
||||
cleanupExpiredSessions(config, settings.merged).catch((e) => {
|
||||
debugLogger.error('Failed to cleanup expired sessions:', e);
|
||||
@@ -669,7 +644,7 @@ export async function main() {
|
||||
|
||||
let input = config.getQuestion();
|
||||
const useAlternateBuffer = shouldEnterAlternateScreen(
|
||||
config.getUseAlternateBuffer(),
|
||||
isAlternateBufferEnabled(config),
|
||||
config.getScreenReader(),
|
||||
);
|
||||
const rawStartupWarnings = await rawStartupWarningsPromise;
|
||||
@@ -686,12 +661,6 @@ export async function main() {
|
||||
|
||||
cliStartupHandle?.end();
|
||||
|
||||
if (!config.isInteractive()) {
|
||||
for (const warning of startupWarnings) {
|
||||
writeToStderr(warning.message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Render UI, passing necessary config values. Check that there is no command line question.
|
||||
if (config.isInteractive()) {
|
||||
// Earlier initialization phases (like TerminalCapabilityManager resolving
|
||||
@@ -811,16 +780,20 @@ export function initializeOutputListenersAndFlush() {
|
||||
if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) {
|
||||
coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => {
|
||||
if (payload.type === 'error' || payload.type === 'warn') {
|
||||
writeToStderr(payload.content + '\n');
|
||||
writeToStderr(payload.content);
|
||||
} else {
|
||||
writeToStderr(payload.content + '\n');
|
||||
writeToStdout(payload.content);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) {
|
||||
coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => {
|
||||
writeToStderr(payload.message + '\n');
|
||||
if (payload.severity === 'error' || payload.severity === 'warning') {
|
||||
writeToStderr(payload.message);
|
||||
} else {
|
||||
writeToStdout(payload.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,6 @@ vi.mock('./config/settings.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./ui/utils/ConsolePatcher.js', () => ({
|
||||
ConsolePatcher: vi.fn().mockImplementation(() => ({
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('./config/config.js', () => ({
|
||||
loadCliConfig: vi.fn().mockResolvedValue({
|
||||
getSandbox: vi.fn(() => false),
|
||||
@@ -157,10 +150,6 @@ vi.mock('./utils/cleanup.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./acp/acpStdioTransport.js', () => ({
|
||||
runAcpClient: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./zed-integration/zedIntegration.js', () => ({
|
||||
runZedIntegration: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -192,7 +181,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true';
|
||||
vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -307,120 +295,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not register ConsolePatcher cleanup in ACP mode', async () => {
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
acp: true,
|
||||
startupMessages: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
tools: { allowed: [], exclude: [] },
|
||||
advanced: { dnsResolutionOrder: 'ipv4first' },
|
||||
security: { auth: { selectedType: 'google' } },
|
||||
ui: { theme: 'default' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getAcpMode: () => true,
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedCleanup: () => void;
|
||||
vi.mocked(ConsolePatcher).mockImplementation(() => {
|
||||
const instance = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
capturedCleanup = instance.cleanup;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return instance as any;
|
||||
});
|
||||
|
||||
await main();
|
||||
|
||||
const registeredFunctions = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map((call) => call[0]);
|
||||
expect(registeredFunctions).not.toContain(capturedCleanup!);
|
||||
});
|
||||
|
||||
it('should register ConsolePatcher cleanup in non-ACP mode', async () => {
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js');
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
acp: false,
|
||||
query: 'test',
|
||||
startupMessages: [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadSettings).mockReturnValue({
|
||||
merged: {
|
||||
tools: { allowed: [], exclude: [] },
|
||||
advanced: { dnsResolutionOrder: 'ipv4first' },
|
||||
security: { auth: { selectedType: 'google' } },
|
||||
ui: { theme: 'default' },
|
||||
},
|
||||
workspace: { settings: {} },
|
||||
errors: [],
|
||||
subscribe: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
setValue: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
buildMockConfig({
|
||||
getAcpMode: () => false,
|
||||
getQuestion: () => 'test',
|
||||
}),
|
||||
);
|
||||
|
||||
let capturedCleanup: () => void;
|
||||
vi.mocked(ConsolePatcher).mockImplementation(() => {
|
||||
const instance = {
|
||||
patch: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
capturedCleanup = instance.cleanup;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return instance as any;
|
||||
});
|
||||
|
||||
try {
|
||||
await main();
|
||||
} catch {
|
||||
// Ignore errors from incomplete mocks in full main() execution
|
||||
}
|
||||
|
||||
const registeredFunctions = vi
|
||||
.mocked(registerCleanup)
|
||||
.mock.calls.map((call) => call[0]);
|
||||
expect(registeredFunctions).toContain(capturedCleanup!);
|
||||
});
|
||||
|
||||
function buildMockConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
isInteractive: vi.fn(() => false),
|
||||
@@ -431,7 +305,6 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getMessageBus: () => ({ subscribe: vi.fn() }),
|
||||
getEnableHooks: vi.fn(() => true),
|
||||
getHookSystem: vi.fn(() => undefined),
|
||||
getExperimentalGemma: vi.fn(() => false),
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
@@ -444,6 +317,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getListSessions: vi.fn(() => false),
|
||||
getDeleteSession: vi.fn(() => undefined),
|
||||
getToolRegistry: vi.fn(),
|
||||
getExtensions: vi.fn(() => []),
|
||||
getModel: vi.fn(() => 'gemini-pro'),
|
||||
getEmbeddingModel: vi.fn(() => 'embedding-001'),
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive(
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function runNonInteractive({
|
||||
const { setupInitialActivityLogger } = await import(
|
||||
'./utils/devtoolsService.js'
|
||||
);
|
||||
setupInitialActivityLogger(config);
|
||||
await setupInitialActivityLogger(config);
|
||||
}
|
||||
|
||||
const { stdout: workingStdout } = createWorkingStdio();
|
||||
|
||||
@@ -170,7 +170,6 @@ describe('BuiltinCommandLoader', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
@@ -397,7 +396,6 @@ describe('BuiltinCommandLoader profile', () => {
|
||||
getAllSkills: vi.fn().mockReturnValue([]),
|
||||
isAdminEnabled: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
isVoiceModeEnabled: vi.fn().mockReturnValue(true),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
authType: 'other',
|
||||
}),
|
||||
|
||||
@@ -42,6 +42,7 @@ import { initCommand } from '../ui/commands/initCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { noteCommand } from '../ui/commands/noteCommand.js';
|
||||
import { oncallCommand } from '../ui/commands/oncallCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { planCommand } from '../ui/commands/planCommand.js';
|
||||
@@ -62,7 +63,6 @@ import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
|
||||
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
|
||||
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
|
||||
import { gemmaStatusCommand } from '../ui/commands/gemmaStatusCommand.js';
|
||||
import { voiceCommand } from '../ui/commands/voiceCommand.js';
|
||||
|
||||
/**
|
||||
* Loads the core, hard-coded slash commands that are an integral part
|
||||
@@ -185,6 +185,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
: [mcpCommand]),
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
noteCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
...(this.config?.isPlanEnabled() ? [planCommand] : []),
|
||||
policiesCommand,
|
||||
@@ -228,7 +229,6 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
vimCommand,
|
||||
setupGithubCommand,
|
||||
terminalSetupCommand,
|
||||
...(this.config?.isVoiceModeEnabled() ? [voiceCommand] : []),
|
||||
...(this.config?.getContentGeneratorConfig()?.authType ===
|
||||
AuthType.LOGIN_WITH_GOOGLE
|
||||
? [upgradeCommand]
|
||||
|
||||
@@ -89,7 +89,6 @@ describe('ShellProcessor', () => {
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: mockPolicyEngineCheck,
|
||||
}),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
get config() {
|
||||
return this as unknown as Config;
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
|
||||
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
isMemoryV2Enabled: vi.fn(() => false),
|
||||
isMemoryManagerEnabled: vi.fn(() => false),
|
||||
isAutoMemoryEnabled: vi.fn(() => false),
|
||||
getListExtensions: vi.fn(() => false),
|
||||
getExtensions: vi.fn(() => []),
|
||||
@@ -168,7 +168,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
getExperimentalJitContext: vi.fn().mockReturnValue(false),
|
||||
getExperimentalGemma: vi.fn().mockReturnValue(false),
|
||||
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
|
||||
getTerminalBackground: vi.fn().mockReturnValue(undefined),
|
||||
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
|
||||
|
||||
@@ -552,8 +552,6 @@ const mockUIActions: UIActions = {
|
||||
exitPrivacyNotice: vi.fn(),
|
||||
closeSettingsDialog: vi.fn(),
|
||||
closeModelDialog: vi.fn(),
|
||||
openVoiceModelDialog: vi.fn(),
|
||||
closeVoiceModelDialog: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
openPermissionsDialog: vi.fn(),
|
||||
@@ -600,7 +598,6 @@ const mockUIActions: UIActions = {
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
getPreferredEditor: vi.fn(),
|
||||
clearAccountSuspension: vi.fn(),
|
||||
setVoiceModeEnabled: vi.fn(),
|
||||
};
|
||||
|
||||
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
|
||||
|
||||
@@ -103,7 +103,6 @@ import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
import { useVoiceModelCommand } from './hooks/useVoiceModelCommand.js';
|
||||
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
|
||||
import { useVimMode } from './contexts/VimModeContext.js';
|
||||
import {
|
||||
@@ -313,7 +312,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
|
||||
const [shellModeActive, setShellModeActive] = useState(false);
|
||||
const [isVoiceModeEnabled, setVoiceModeEnabled] = useState(false);
|
||||
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
|
||||
useState<boolean>(false);
|
||||
const [historyRemountKey, setHistoryRemountKey] = useState(0);
|
||||
@@ -948,12 +946,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
|
||||
const {
|
||||
isVoiceModelDialogOpen,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
} = useVoiceModelCommand();
|
||||
|
||||
const { toggleVimEnabled } = useVimMode();
|
||||
|
||||
const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>(
|
||||
@@ -977,7 +969,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
@@ -990,7 +981,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
},
|
||||
setDebugMessage,
|
||||
toggleCorgiMode: () => setCorgiMode((prev) => !prev),
|
||||
toggleVoiceMode: () => setVoiceModeEnabled((prev) => !prev),
|
||||
toggleDebugProfiler,
|
||||
dispatchExtensionStateUpdate,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
@@ -1016,7 +1006,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openSettingsDialog,
|
||||
openSessionBrowser,
|
||||
openModelDialog,
|
||||
openVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
setQuittingMessages,
|
||||
setDebugMessage,
|
||||
@@ -2202,7 +2191,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isVoiceModelDialogOpen ||
|
||||
isAgentConfigDialogOpen ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthenticating ||
|
||||
@@ -2460,7 +2448,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2481,7 +2468,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false,
|
||||
@@ -2573,7 +2559,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
isSettingsDialogOpen,
|
||||
isSessionBrowserOpen,
|
||||
isModelDialogOpen,
|
||||
isVoiceModelDialogOpen,
|
||||
isAgentConfigDialogOpen,
|
||||
selectedAgentName,
|
||||
selectedAgentDisplayName,
|
||||
@@ -2594,7 +2579,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
pendingGeminiHistoryItems,
|
||||
thought,
|
||||
isInputActive,
|
||||
isVoiceModeEnabled,
|
||||
isResuming,
|
||||
shouldShowIdePrompt,
|
||||
isFolderTrustDialogOpen,
|
||||
@@ -2687,8 +2671,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2769,9 +2751,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setAccountSuspensionInfo(null);
|
||||
setAuthState(AuthState.Updating);
|
||||
},
|
||||
setVoiceModeEnabled: (value: boolean) => {
|
||||
setVoiceModeEnabled(value);
|
||||
},
|
||||
}),
|
||||
[
|
||||
handleThemeSelect,
|
||||
@@ -2785,8 +2764,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
exitPrivacyNotice,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openVoiceModelDialog,
|
||||
closeVoiceModelDialog,
|
||||
openAgentConfigDialog,
|
||||
closeAgentConfigDialog,
|
||||
openPermissionsDialog,
|
||||
@@ -2830,7 +2807,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
config,
|
||||
historyManager,
|
||||
getPreferredEditor,
|
||||
setVoiceModeEnabled,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
<rect x="27" y="0" width="324" height="17" fill="#141414" />
|
||||
<text x="27" y="2" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
|
||||
<rect x="351" y="0" width="549" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#141414" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
|
||||
<rect x="0" y="17" width="900" height="17" fill="#141414" />
|
||||
<text x="0" y="19" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
|
||||
<text x="0" y="53" fill="#333333" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
|
||||
<text x="0" y="70" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs">│</text>
|
||||
<text x="18" y="70" fill="#ffffaf" textLength="63" lengthAdjust="spacingAndGlyphs" font-weight="bold">? Edit </text>
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -2,7 +2,7 @@
|
||||
|
||||
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
|
||||
" > Can you edit InputPrompt.tsx for me?
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
|
||||
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
|
||||
|
||||
@@ -16,7 +16,7 @@ import { MessageType } from '../types.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
name: 'clear (new)',
|
||||
altNames: ['new'],
|
||||
description: 'Clear the screen and start a new session',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { noteCommand } from './noteCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
|
||||
vi.mock('node:fs/promises');
|
||||
|
||||
describe('noteCommand', () => {
|
||||
const mockContext = {} as CommandContext;
|
||||
const notesPath = path.join(process.cwd(), 'notes.md');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return notes content when no args provided and file exists', async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue('existing note\n');
|
||||
|
||||
const result = await noteCommand.action!(mockContext, '');
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(notesPath, 'utf8');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('existing note'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message when no args provided and file does not exist (ENOENT)', async () => {
|
||||
const error = new Error('File not found') as NodeJS.ErrnoException;
|
||||
error.code = 'ENOENT';
|
||||
vi.mocked(fs.readFile).mockRejectedValue(error);
|
||||
|
||||
const result = await noteCommand.action!(mockContext, ' ');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No notes found. Use "/note <text>" to add one.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error message when readFile fails with other error', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await noteCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining(
|
||||
'Failed to read notes: Permission denied',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it('should append trimmed note to file when args are provided', async () => {
|
||||
const note = ' this is a new note ';
|
||||
vi.mocked(fs.appendFile).mockResolvedValue(undefined);
|
||||
|
||||
const result = await noteCommand.action!(mockContext, note);
|
||||
|
||||
expect(fs.appendFile).toHaveBeenCalledWith(
|
||||
notesPath,
|
||||
`this is a new note\n`,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Note added'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error message when append fails', async () => {
|
||||
vi.mocked(fs.appendFile).mockRejectedValue(new Error('Permission denied'));
|
||||
|
||||
const result = await noteCommand.action!(mockContext, 'some note');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: expect.stringContaining(
|
||||
'Failed to save note: Permission denied',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { isNodeError } from '@google/gemini-cli-core';
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const noteCommand: SlashCommand = {
|
||||
name: 'note',
|
||||
description: 'Append a note to notes.md or view current notes',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async (_context, args) => {
|
||||
const notesPath = path.join(process.cwd(), 'notes.md');
|
||||
|
||||
if (!args || args.trim().length === 0) {
|
||||
try {
|
||||
const content = await fs.readFile(notesPath, 'utf8');
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Current notes in ${notesPath}:\n\n${content}`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No notes found. Use "/note <text>" to add one.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to read notes: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmedNote = args.trim();
|
||||
await fs.appendFile(notesPath, `${trimmedNote}\n`);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Note added to ${notesPath}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Failed to save note: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -72,7 +72,6 @@ export interface CommandContext {
|
||||
loadHistory: (history: HistoryItem[], postLoadInput?: string) => void;
|
||||
/** Toggles a special display mode. */
|
||||
toggleCorgiMode: () => void;
|
||||
toggleVoiceMode: () => void;
|
||||
toggleDebugProfiler: () => void;
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
reloadCommands: () => void;
|
||||
@@ -126,7 +125,6 @@ export interface OpenDialogActionReturn {
|
||||
| 'settings'
|
||||
| 'sessionBrowser'
|
||||
| 'model'
|
||||
| 'voice-model'
|
||||
| 'agentConfig'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { CommandKind, type SlashCommand } from './types.js';
|
||||
|
||||
export const voiceCommand: SlashCommand = {
|
||||
name: 'voice',
|
||||
altNames: [],
|
||||
description: 'Toggle voice dictation mode',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleVoiceMode();
|
||||
},
|
||||
subCommands: [
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Manage voice transcription models',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
autoExecute: true,
|
||||
action: async () => ({
|
||||
type: 'dialog',
|
||||
dialog: 'voice-model',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -25,7 +25,6 @@ import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { SessionBrowser } from './SessionBrowser.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { ModelDialog } from './ModelDialog.js';
|
||||
import { VoiceModelDialog } from './VoiceModelDialog.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useQuotaState } from '../contexts/QuotaContext.js';
|
||||
@@ -239,9 +238,6 @@ export const DialogManager = ({
|
||||
if (uiState.isModelDialogOpen) {
|
||||
return <ModelDialog onClose={uiActions.closeModelDialog} />;
|
||||
}
|
||||
if (uiState.isVoiceModelDialogOpen) {
|
||||
return <VoiceModelDialog onClose={uiActions.closeVoiceModelDialog} />;
|
||||
}
|
||||
if (
|
||||
uiState.isAgentConfigDialogOpen &&
|
||||
uiState.selectedAgentName &&
|
||||
|
||||
@@ -9,41 +9,12 @@ import { createMockSettings } from '../../test-utils/settings.js';
|
||||
import { makeFakeConfig } from '@google/gemini-cli-core';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { act, useState, useMemo } from 'react';
|
||||
import type { EventEmitter } from 'node:events';
|
||||
|
||||
const { fakeTranscriptionProvider } = vi.hoisted(() => {
|
||||
// Use require within hoisted block for immediate synchronous access
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, no-restricted-syntax
|
||||
const { EventEmitter } = require('node:events');
|
||||
class FakeTranscriptionProvider extends EventEmitter {
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
disconnect = vi.fn();
|
||||
sendAudioChunk = vi.fn();
|
||||
getTranscription = vi.fn().mockReturnValue('');
|
||||
}
|
||||
return {
|
||||
fakeTranscriptionProvider: new FakeTranscriptionProvider(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actual = (await importOriginal()) as any;
|
||||
return {
|
||||
...actual,
|
||||
TranscriptionFactory: {
|
||||
createProvider: vi.fn(() => fakeTranscriptionProvider),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
InputPrompt,
|
||||
tryTogglePasteExpansion,
|
||||
type InputPromptProps,
|
||||
} from './InputPrompt.js';
|
||||
import { InputContext } from '../contexts/InputContext.js';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
calculateTransformationsForLine,
|
||||
calculateTransformedLine,
|
||||
@@ -85,8 +56,11 @@ import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import chalk from 'chalk';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
@@ -104,6 +78,9 @@ vi.mock('../hooks/useReverseSearchCompletion.js');
|
||||
vi.mock('clipboardy');
|
||||
vi.mock('../utils/clipboardUtils.js');
|
||||
vi.mock('../hooks/useKittyKeyboardProtocol.js');
|
||||
vi.mock('../utils/terminalUtils.js', () => ({
|
||||
isLowColorDepth: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock ink BEFORE importing components that use it to intercept terminalCursorPosition
|
||||
vi.mock('ink', async (importOriginal) => {
|
||||
@@ -446,7 +423,6 @@ describe('InputPrompt', () => {
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => ['/test/project/src'],
|
||||
}),
|
||||
getContentGeneratorConfig: () => ({ apiKey: 'test-api-key' }),
|
||||
} as unknown as Config,
|
||||
slashCommands: mockSlashCommands,
|
||||
commandContext: mockCommandContext,
|
||||
@@ -1938,6 +1914,172 @@ describe('InputPrompt', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('Background Color Styles', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should render with background color by default', async () => {
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ color: 'black', name: 'black' },
|
||||
{ color: '#000000', name: '#000000' },
|
||||
{ color: '#000', name: '#000' },
|
||||
{ color: 'white', name: 'white' },
|
||||
{ color: '#ffffff', name: '#ffffff' },
|
||||
{ color: '#fff', name: '#fff' },
|
||||
])(
|
||||
'should render with safe grey background but NO side borders in 8-bit mode when background is $name',
|
||||
async ({ color }) => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: color,
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
const isWhite =
|
||||
color === 'white' || color === '#ffffff' || color === '#fff';
|
||||
const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c';
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
// Use chalk to get the expected background color escape sequence
|
||||
const bgCheck = chalk.bgHex(expectedBgColor)(' ');
|
||||
const bgCode = bgCheck.substring(0, bgCheck.indexOf(' '));
|
||||
|
||||
// Background color code should be present
|
||||
expect(frame).toContain(bgCode);
|
||||
// Background characters should be rendered
|
||||
expect(frame).toContain('▀');
|
||||
expect(frame).toContain('▄');
|
||||
// Side borders should STILL be removed
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
|
||||
it('should NOT render with background color but SHOULD render horizontal lines when color depth is < 24 and background is NOT black', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: '#333333',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// It SHOULD have horizontal fallback lines
|
||||
expect(frame).toContain('─');
|
||||
// It SHOULD NOT have vertical side borders (standard Box borders have │)
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
it('should handle 4-bit color mode (16 colors) as low color depth', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: 'black',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
expect(frame).toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render horizontal lines (but NO background) in 8-bit mode when background is blue', async () => {
|
||||
vi.mocked(isLowColorDepth).mockReturnValue(true);
|
||||
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
|
||||
{
|
||||
uiState: {
|
||||
terminalBackgroundColor: 'blue',
|
||||
} as Partial<UIState>,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
|
||||
// Should NOT have background characters
|
||||
|
||||
expect(frame).not.toContain('▀');
|
||||
|
||||
expect(frame).not.toContain('▄');
|
||||
|
||||
// Should HAVE horizontal lines from the fallback Box borders
|
||||
|
||||
// Box style "round" uses these for top/bottom
|
||||
|
||||
expect(frame).toContain('─');
|
||||
|
||||
// Should NOT have vertical side borders
|
||||
|
||||
expect(frame).not.toContain('│');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render with plain borders when useBackgroundColor is false', async () => {
|
||||
props.config.getUseBackgroundColor = () => false;
|
||||
const { stdout, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = stdout.lastFrameRaw();
|
||||
expect(frame).not.toContain('▀');
|
||||
expect(frame).not.toContain('▄');
|
||||
// Check for Box borders (round style uses unicode box chars)
|
||||
expect(frame).toMatch(/[─│┐└┘┌]/);
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cursor-based completion trigger', () => {
|
||||
it.each([
|
||||
{
|
||||
@@ -3865,9 +4007,9 @@ describe('InputPrompt', () => {
|
||||
expect(stdout.lastFrame()).toContain('hello world');
|
||||
});
|
||||
|
||||
// With plain borders offset
|
||||
// With plain borders: 1(border) + 1(padding) + 2(prompt) = 4 offset (x=4, col=5)
|
||||
await act(async () => {
|
||||
stdin.write(`\x1b[<0;4;2M`); // Click at col 4, row 2
|
||||
stdin.write(`\x1b[<0;5;2M`); // Click at col 5, row 2
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -4955,383 +5097,6 @@ describe('InputPrompt', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Voice Mode', () => {
|
||||
beforeEach(() => {
|
||||
(
|
||||
fakeTranscriptionProvider as unknown as EventEmitter
|
||||
).removeAllListeners();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should start recording when space is pressed and voice mode is enabled (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Initially not recording
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
|
||||
// Press space to start
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Now should show listening
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should toggle recording off when space is pressed again (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
// Stop recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should resume recording when space is pressed even if buffer is not empty (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('some existing text');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Should show voice mode hint even if buffer is not empty (new behavior)
|
||||
expect(lastFrame()).toContain(
|
||||
'Voice mode: Space to start/stop recording',
|
||||
);
|
||||
expect(lastFrame()).toContain('some existing text');
|
||||
|
||||
// Press space to start recording again
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not start recording if voice mode is disabled (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('');
|
||||
});
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: false } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Press space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Should NOT show listening, instead should call handleInput which handles space
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append transcription correctly across multiple turn updates (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('initial');
|
||||
});
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Emit first transcription
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'hello',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith('initial hello', 'end');
|
||||
});
|
||||
|
||||
// Emit turnComplete (Gemini Live starts over after this)
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'turnComplete',
|
||||
);
|
||||
});
|
||||
|
||||
// Emit second part (Gemini Live sends new turn text starting from empty)
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'world',
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
// Should have appended 'world' to the baseline 'initial hello'
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'initial hello world',
|
||||
'end',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should append transcription correctly when resuming voice mode (toggle)', async () => {
|
||||
await act(async () => {
|
||||
mockBuffer.setText('First turn.');
|
||||
});
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'toggle' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start recording (resumed)
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Emit transcription
|
||||
await act(async () => {
|
||||
(fakeTranscriptionProvider as unknown as EventEmitter).emit(
|
||||
'transcription',
|
||||
'Second turn.',
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockBuffer.setText).toHaveBeenCalledWith(
|
||||
'First turn. Second turn.',
|
||||
'end',
|
||||
);
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
describe('push-to-talk', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should insert a space on a single tap', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Voice mode: Hold Space to record');
|
||||
|
||||
// Press space once
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Should insert space optimistically
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
|
||||
// Advance timer past HOLD_DELAY_MS
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(700);
|
||||
});
|
||||
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should start recording on hold (simulated by repeat spaces)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// First space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
expect(mockBuffer.insert).toHaveBeenCalledWith(' ');
|
||||
|
||||
// Second space (repeat)
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Should have backspaced the optimistic space
|
||||
expect(mockBuffer.backspace).toHaveBeenCalled();
|
||||
// Should show listening
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should stop recording when space heartbeat stops (release)', async () => {
|
||||
const { stdin, unmount, lastFrame } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// Start hold
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Use a short interval in waitFor to prevent advancing fake timers past the 300ms RELEASE_DELAY_MS
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
},
|
||||
{ interval: 10 },
|
||||
);
|
||||
|
||||
// Simulate heartbeat (held key) - send space first to reset timer, then advance
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(lastFrame()).toContain('🎙️ Listening...');
|
||||
|
||||
// Stop heartbeat (release)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400); // Past RELEASE_DELAY_MS
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).not.toContain('🎙️ Listening...');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cancel hold state if non-space key is pressed after first space', async () => {
|
||||
const { stdin, unmount } = await renderWithProviders(
|
||||
<TestInputPrompt {...props} focus={true} buffer={mockBuffer} />,
|
||||
{
|
||||
uiState: { isVoiceModeEnabled: true } as UIState,
|
||||
settings: createMockSettings({
|
||||
experimental: { voice: { activationMode: 'push-to-talk' } },
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// First space
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
// Type 'a'
|
||||
await act(async () => {
|
||||
stdin.write('a');
|
||||
});
|
||||
|
||||
// Should NOT start recording on next space even if fast
|
||||
await act(async () => {
|
||||
stdin.write(' ');
|
||||
});
|
||||
|
||||
expect(mockBuffer.insert).toHaveBeenCalledTimes(2); // Two spaces inserted
|
||||
expect(mockBuffer.handleInput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'a' }),
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
debugLogger,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useVoiceMode } from '../hooks/useVoiceMode.js';
|
||||
import {
|
||||
parseInputForHighlighting,
|
||||
parseSegmentsFromTokens,
|
||||
@@ -74,6 +73,8 @@ import {
|
||||
import { parseSlashCommand } from '../../utils/commands.js';
|
||||
import * as path from 'node:path';
|
||||
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
|
||||
import { getSafeLowColorBackground } from '../themes/color-utils.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useInputState } from '../contexts/InputContext.js';
|
||||
@@ -160,6 +161,7 @@ export function isLargePaste(text: string): boolean {
|
||||
}
|
||||
|
||||
const DOUBLE_TAB_CLEAN_UI_TOGGLE_WINDOW_MS = 350;
|
||||
|
||||
/**
|
||||
* Attempt to toggle expansion of a paste placeholder in the buffer.
|
||||
* Returns true if a toggle action was performed or hint was shown, false otherwise.
|
||||
@@ -238,7 +240,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
setEmbeddedShellFocused,
|
||||
setShortcutsHelpVisible,
|
||||
toggleCleanUiDetailsVisible,
|
||||
setVoiceModeEnabled,
|
||||
} = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
@@ -247,7 +248,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTasks,
|
||||
backgroundTaskHeight,
|
||||
shortcutsHelpVisible,
|
||||
isVoiceModeEnabled,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const { handlePress: registerPlainTabPress, resetCount: resetPlainTabPress } =
|
||||
@@ -265,7 +265,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetEscapeState();
|
||||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetTurnBaseline();
|
||||
resetCompletionState();
|
||||
} else if (history.length > 0) {
|
||||
onSubmit('/rewind');
|
||||
@@ -284,16 +283,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const hasUserNavigatedSuggestions = useRef(false);
|
||||
const listRef = useRef<ScrollableListRef<ScrollableItem>>(null);
|
||||
|
||||
const { isRecording, handleVoiceInput, resetTurnBaseline } = useVoiceMode({
|
||||
buffer,
|
||||
config,
|
||||
settings,
|
||||
setQueueErrorMessage,
|
||||
isVoiceModeEnabled,
|
||||
setVoiceModeEnabled,
|
||||
keyMatchers,
|
||||
});
|
||||
|
||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||
const [textBeforeReverseSearch, setTextBeforeReverseSearch] = useState('');
|
||||
@@ -400,7 +389,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
// Clear the buffer *before* calling onSubmit to prevent potential re-submission
|
||||
// if onSubmit triggers a re-render while the buffer still holds the old value.
|
||||
buffer.setText('');
|
||||
resetTurnBaseline();
|
||||
onSubmit(processedValue);
|
||||
resetCompletionState();
|
||||
resetReverseSearchCompletionState();
|
||||
@@ -412,7 +400,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
shellModeActive,
|
||||
shellHistory,
|
||||
resetReverseSearchCompletionState,
|
||||
resetTurnBaseline,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -662,8 +649,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key) => {
|
||||
if (handleVoiceInput(key)) return true;
|
||||
|
||||
// Determine if this keypress is a history navigation command
|
||||
const isHistoryUp =
|
||||
!shellModeActive &&
|
||||
@@ -890,9 +875,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
) {
|
||||
setShellModeActive(!shellModeActive);
|
||||
buffer.setText(''); // Clear the '!' from input
|
||||
resetTurnBaseline();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.ESCAPE](key)) {
|
||||
const cancelSearch = (
|
||||
setActive: (active: boolean) => void,
|
||||
@@ -1377,7 +1362,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundTaskHeight,
|
||||
streamingState,
|
||||
handleEscPress,
|
||||
resetTurnBaseline,
|
||||
registerPlainTabPress,
|
||||
resetPlainTabPress,
|
||||
toggleCleanUiDetailsVisible,
|
||||
@@ -1387,9 +1371,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
keyMatchers,
|
||||
isHelpDismissKey,
|
||||
settings,
|
||||
handleVoiceInput,
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, {
|
||||
isActive: !isEmbeddedShellFocused && !copyModeEnabled,
|
||||
priority: true,
|
||||
@@ -1661,6 +1645,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
);
|
||||
|
||||
const useBackgroundColor = config.getUseBackgroundColor();
|
||||
const isLowColor = isLowColorDepth();
|
||||
const terminalBg = theme.background.primary || 'black';
|
||||
|
||||
// We should fallback to lines if the background color is disabled OR if it is
|
||||
// enabled but we are in a low color depth terminal where we don't have a safe
|
||||
// background color to use.
|
||||
const useLineFallback = useMemo(() => {
|
||||
if (!useBackgroundColor) {
|
||||
return true;
|
||||
}
|
||||
if (isLowColor) {
|
||||
return !getSafeLowColorBackground(terminalBg);
|
||||
}
|
||||
return false;
|
||||
}, [useBackgroundColor, isLowColor, terminalBg]);
|
||||
|
||||
const prevCursorRef = useRef(buffer.visualCursor);
|
||||
const prevTextRef = useRef(buffer.text);
|
||||
@@ -1699,11 +1698,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
}, [buffer.visualCursor, buffer.text, focus]);
|
||||
|
||||
const listBackgroundColor = !useBackgroundColor
|
||||
? undefined
|
||||
: theme.background.input;
|
||||
|
||||
const useLineFallback = !!process.env['NO_COLOR'];
|
||||
const listBackgroundColor =
|
||||
useLineFallback || !useBackgroundColor ? undefined : theme.background.input;
|
||||
|
||||
useEffect(() => {
|
||||
if (onSuggestionsVisibilityChange) {
|
||||
@@ -1766,7 +1762,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return (
|
||||
<>
|
||||
{suggestionsPosition === 'above' && suggestionsNode}
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={true}
|
||||
@@ -1785,7 +1781,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
backgroundOpacity={1}
|
||||
useBackgroundColor={useBackgroundColor}
|
||||
>
|
||||
<Box flexGrow={1} flexDirection="row" paddingX={1}>
|
||||
<Box
|
||||
flexGrow={1}
|
||||
flexDirection="row"
|
||||
paddingX={1}
|
||||
borderColor={borderColor}
|
||||
borderStyle={useLineFallback ? 'round' : undefined}
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={!useBackgroundColor}
|
||||
borderRight={!useBackgroundColor}
|
||||
>
|
||||
<Text
|
||||
color={statusColor ?? theme.text.accent}
|
||||
aria-label={statusText || undefined}
|
||||
@@ -1810,39 +1816,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
)}{' '}
|
||||
</Text>
|
||||
<Box flexGrow={1} flexDirection="column" ref={innerBoxRef}>
|
||||
{isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.status.success}>🎙️ Listening...</Text>
|
||||
</Box>
|
||||
)}
|
||||
{isVoiceModeEnabled && !isRecording && (
|
||||
<Box flexDirection="row" marginBottom={0}>
|
||||
<Text color={theme.text.secondary}>
|
||||
> Voice mode:{' '}
|
||||
{(settings.experimental.voice?.activationMode ??
|
||||
'push-to-talk') === 'push-to-talk'
|
||||
? 'Hold Space to record'
|
||||
: 'Space to start/stop recording'}{' '}
|
||||
(Esc to exit)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{buffer.text.length === 0 && !isRecording ? (
|
||||
!isVoiceModeEnabled && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
{buffer.text.length === 0 && placeholder ? (
|
||||
showCursor ? (
|
||||
<Text
|
||||
terminalCursorFocus={showCursor}
|
||||
terminalCursorPosition={0}
|
||||
>
|
||||
{chalk.inverse(placeholder.slice(0, 1))}
|
||||
<Text color={theme.text.secondary}>
|
||||
{placeholder.slice(1)}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : null
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{placeholder}</Text>
|
||||
)
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
@@ -1893,7 +1880,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
</Box>
|
||||
</Box>
|
||||
</HalfLinePaddedBox>
|
||||
{useLineFallback || !useBackgroundColor ? (
|
||||
{useLineFallback ? (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderTop={false}
|
||||
|
||||
@@ -65,7 +65,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: () => boolean;
|
||||
getProModelNoAccess: () => Promise<boolean>;
|
||||
getProModelNoAccessSync: () => boolean;
|
||||
getExperimentalGemma: () => boolean;
|
||||
getLastRetrievedQuota: () =>
|
||||
| {
|
||||
buckets: Array<{
|
||||
@@ -86,7 +85,6 @@ describe('<ModelDialog />', () => {
|
||||
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
|
||||
getProModelNoAccess: mockGetProModelNoAccess,
|
||||
getProModelNoAccessSync: mockGetProModelNoAccessSync,
|
||||
getExperimentalGemma: () => false,
|
||||
getLastRetrievedQuota: () => ({ buckets: [] }),
|
||||
getSessionId: () => 'test-session-id',
|
||||
};
|
||||
|
||||
@@ -19,8 +19,6 @@ import {
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
GEMMA_4_31B_IT_MODEL,
|
||||
GEMMA_4_26B_A4B_IT_MODEL,
|
||||
ModelSlashCommandEvent,
|
||||
logModelSlashCommand,
|
||||
getDisplayString,
|
||||
@@ -224,9 +222,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
}
|
||||
|
||||
// --- LEGACY PATH ---
|
||||
const showGemmaModels = config?.getExperimentalGemma() ?? false;
|
||||
|
||||
const options = [
|
||||
const list = [
|
||||
{
|
||||
value: DEFAULT_GEMINI_MODEL,
|
||||
title: getDisplayString(DEFAULT_GEMINI_MODEL),
|
||||
@@ -244,21 +240,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
];
|
||||
|
||||
if (showGemmaModels) {
|
||||
options.push(
|
||||
{
|
||||
value: GEMMA_4_31B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_31B_IT_MODEL),
|
||||
key: GEMMA_4_31B_IT_MODEL,
|
||||
},
|
||||
{
|
||||
value: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
title: getDisplayString(GEMMA_4_26B_A4B_IT_MODEL),
|
||||
key: GEMMA_4_26B_A4B_IT_MODEL,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowPreviewModels) {
|
||||
const previewProModel = useGemini31
|
||||
? PREVIEW_GEMINI_3_1_MODEL
|
||||
@@ -289,15 +270,15 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
options.unshift(...previewOptions);
|
||||
list.unshift(...previewOptions);
|
||||
}
|
||||
|
||||
if (!hasAccessToProModel) {
|
||||
// Filter out all Pro models for free tier
|
||||
return options.filter((option) => !isProModel(option.value));
|
||||
return list.filter((option) => !isProModel(option.value));
|
||||
}
|
||||
|
||||
return options;
|
||||
return list;
|
||||
}, [
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
|
||||
@@ -86,7 +86,6 @@ const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getProjectTempDir: () => '/tmp/test',
|
||||
},
|
||||
getSessionId: () => 'default-session-id',
|
||||
getExperimentalGemma: () => false,
|
||||
...overrides,
|
||||
}) as Config;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ enum TerminalKeys {
|
||||
LEFT_ARROW = '\u001B[D',
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\u0008',
|
||||
BACKSPACE = '\x7f',
|
||||
CTRL_P = '\u0010',
|
||||
CTRL_N = '\u000E',
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user