Compare commits

...

52 Commits

Author SHA1 Message Date
Aishanee Shah a30cc4a8a9 revert: package-lock.json changes 2026-03-28 04:14:09 +00:00
Aishanee Shah 0e169ee968 revert: remove heredoc mandate from legacy system prompt 2026-03-28 04:13:21 +00:00
Aishanee Shah 6c23f0c7e1 feat(core): append write_file suggestion when shell commands with heredocs fail 2026-03-28 04:13:21 +00:00
Aishanee Shah 83792d51c1 feat(core): harden shell tool against heredocs and update system prompt to prefer write_file 2026-03-28 04:13:20 +00:00
Christian Gunderman 07ab16dbbe feat(cli): support 'tab to queue' for messages while generating (#24052) 2026-03-28 01:31:11 +00:00
Abhijit Balaji afc1d50c20 feat(core): implement tool-based topic grouping (Chapters) (#23150)
Co-authored-by: Christian Gunderman <gundermanc@google.com>
2026-03-28 01:28:25 +00:00
Gal Zahavi ae123c547c fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox (#24057) 2026-03-28 00:14:35 +00:00
Keith Guerin c2705e8332 fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) 2026-03-28 00:06:07 +00:00
Sam Roberts 9574855435 Re-word intro to Gemini 3 page. (#24069) 2026-03-28 00:01:22 +00:00
krishdef7 bf6dae4690 fix(hooks): prevent SessionEnd from firing twice in non-interactive mode (#22139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-27 23:39:48 +00:00
Ivan Porty b5529c2475 fix(core): resolve ACP Operation Aborted Errors in grep_search (#23821)
Co-authored-by: Sri Pasumarthi <sripas@google.com>
2026-03-27 23:12:34 +00:00
Tommaso Sciortino 9e74a7ec18 feat(core): implement windows sandbox expansion and denial detection (#24027) 2026-03-27 22:35:01 +00:00
Sandy Tao 4034c030e7 feat(context): add configurable memoryBoundaryMarkers setting (#24020) 2026-03-27 21:51:32 +00:00
Gal Zahavi 765fb67011 Enable network access in sandbox configuration (#24055) 2026-03-27 21:28:01 +00:00
Jacob Richman 97c99f263a Fix test flakes by globally mocking ink-spinner (#24044) 2026-03-27 21:19:46 +00:00
gemini-cli-robot f1a3c35dee Changelog for v0.36.0-preview.5 (#24046)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-27 21:19:26 +00:00
Jacob Richman ebe98fdee9 Debug command. (#23851)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-27 21:05:22 +00:00
Abhi ba71ffa736 fix(core): switch to subshells for shell tool wrapping to fix heredocs and edge cases (#24024) 2026-03-27 20:34:39 +00:00
joshualitt 320c8aba4c feat(core): Land AgentHistoryProvider. (#23978) 2026-03-27 19:22:35 +00:00
Sri Pasumarthi e7dccabf14 fix(acp): prevent crash on empty response in ACP mode (#23952) 2026-03-27 18:49:13 +00:00
Jacob Richman a84d4d876e Increase memory limited for eslint. (#24022) 2026-03-27 18:40:26 +00:00
Emily Hedlund 29031ea7cf refactor(core): improve ignore resolution and fix directory-matching bug (#23816) 2026-03-27 17:12:26 +00:00
Spencer f3977392e6 revert(core): support custom base URL via env vars (#23976) 2026-03-27 17:10:14 +00:00
Emily Hedlund 535667baf6 feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor createSandboxManager (#23936) 2026-03-27 16:57:26 +00:00
Tommaso Sciortino 33cf2da1df disable extension-reload test (#24018) 2026-03-27 16:19:15 +00:00
Abhi 104587bae8 feat(core): subagent isolation and cleanup hardening (#23903) 2026-03-27 03:43:39 +00:00
Yuna Seol aca8e1af05 fix(core): explicitly set error names to avoid bundling renaming issues (#23913) 2026-03-27 03:40:49 +00:00
Sam Roberts 6f92642524 ACP integration documents (#22254)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: nmcnamara-eng <118702206+nmcnamara-eng@users.noreply.github.com>
2026-03-27 02:10:29 +00:00
gemini-cli-robot 8413dd62ef Changelog for v0.35.2 (#23960)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-27 02:10:03 +00:00
Sri Pasumarthi 750dec5d8d fix(acp): Update allow approval policy flow for ACP clients to fix config persistence and compatible with TUI (#23818) 2026-03-27 01:42:17 +00:00
gemini-cli-robot 335b36893b Changelog for v0.36.0-preview.4 (#23935)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-27 01:08:39 +00:00
Jenna Inouye 25a20f8e4e Docs: Update 'docs-writer' skill for relative links (#21463) 2026-03-27 00:32:20 +00:00
Jacob Richman b5ba88b001 dep(update) Update Ink version to 6.5.0 (#23843) 2026-03-26 23:49:51 +00:00
Tommaso Sciortino 8868b34c75 refactor(core): delegate sandbox denial parsing to SandboxManager (#23928) 2026-03-26 22:10:15 +00:00
Aditya Bijalwan 73dd7328df feat(core): implement persistent browser session management (#21306)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-26 21:33:37 +00:00
Spencer d25ce0e143 fix(core): remove shell outputChunks buffer caching to prevent memory bloat and sanitize prompt input (#23751) 2026-03-26 21:16:07 +00:00
David Pierce 30397816da feat(sandbox): implement secret visibility lockdown for env files (#23712)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-26 20:35:21 +00:00
Gen Zhang 84f1c19265 feat(cli): enable notifications cross-platform via terminal bell fallback (#21618)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-03-26 20:10:49 +00:00
Gal Zahavi d33170931c fix(core): allow disabling environment variable redaction (#23927) 2026-03-26 20:04:44 +00:00
Jenna Inouye 1d230dbfbf Docs: Update quotas and pricing (#23835) 2026-03-26 19:29:37 +00:00
Sehoon Shon c92ae8a359 feat(core): define TrajectoryProvider interface (#23050) 2026-03-26 19:24:06 +00:00
Keith Schaab bf03543bf6 fix(a2a-server): A2A server should execute ask policies in interactive mode (#23831) 2026-03-26 19:10:18 +00:00
matt korwel 1d2fbbf9c3 feat(gcp): add development worker infrastructure (#23814) 2026-03-26 19:01:37 +00:00
Adib234 9762bf2965 fix(plan): after exiting plan mode switches model to a flash model (#23885) 2026-03-26 18:45:03 +00:00
ruomeng c888da5f73 fix(core): replace hardcoded non-interactive ASK_USER denial with explicit policy rules (#23668) 2026-03-26 18:35:12 +00:00
Dev Randalpura aa4d9316a9 feat(core): new skill to look for duplicated code while reviewing PRs (#23704) 2026-03-26 18:32:30 +00:00
Aditya Bijalwan 5755ec2dcf fix(browser): keep input blocker active across navigations (#22562)
Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com>
2026-03-26 16:54:49 +00:00
gemini-cli-robot a3c1c659fd Changelog for v0.35.1 (#23840)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-26 16:43:23 +00:00
Sehoon Shon 49534209f2 fix(cli): prioritize primary name matches in slash command search (#23850) 2026-03-26 12:18:57 +00:00
Chris Williams 9e7f52b8f5 Merge examples of use into quickstart documentation (#23319) 2026-03-26 02:57:23 +00:00
Gal Zahavi 30e0ab102a feat(sandbox): dynamic Linux sandbox expansion and worktree support (#23692)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-26 01:58:45 +00:00
Alisa 2e03e3aed5 feat(evals): add reliability harvester and 500/503 retry support (#23626) 2026-03-26 01:48:45 +00:00
195 changed files with 8157 additions and 2783 deletions
+89
View File
@@ -0,0 +1,89 @@
# --- STAGE 1: Base Runtime ---
FROM docker.io/library/node:20-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
curl \
dnsutils \
less \
jq \
ca-certificates \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# --- STAGE 2: Builder (Compile Main) ---
FROM base AS builder
WORKDIR /build
COPY . .
RUN npm ci --ignore-scripts
RUN npm run bundle
# Run the official release preparation script to move the bundle and assets into packages/cli
RUN node scripts/prepare-npm-release.js
# --- STAGE 3: Development Environment ---
FROM base AS development
WORKDIR /home/node/dev/main
# Set up npm global package folder
RUN mkdir -p /usr/local/share/npm-global \
&& chown -R node:node /usr/local/share/npm-global
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Copy package.json to extract versions for global tools
COPY package.json /tmp/package.json
# Install Build Tools, Global Dev Tools (pinned), and Linters
ARG ACTIONLINT_VER=1.7.7
ARG SHELLCHECK_VER=0.11.0
ARG YAMLLINT_VER=1.35.1
RUN apt-get update && apt-get install -y --no-install-recommends \
make \
g++ \
gh \
git \
unzip \
rsync \
ripgrep \
procps \
psmisc \
lsof \
socat \
tmux \
docker.io \
build-essential \
libsecret-1-dev \
libkrb5-dev \
file \
&& curl -sSLo /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VER}/actionlint_${ACTIONLINT_VER}_linux_amd64.tar.gz \
&& tar -xzf /tmp/actionlint.tar.gz -C /usr/local/bin actionlint \
&& curl -sSLo /tmp/shellcheck.tar.xz https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VER}/shellcheck-v${SHELLCHECK_VER}.linux.x86_64.tar.xz \
&& tar -xf /tmp/shellcheck.tar.xz -C /usr/local/bin --strip-components=1 shellcheck-v${SHELLCHECK_VER}/shellcheck \
&& pip3 install --break-system-packages yamllint==${YAMLLINT_VER} \
&& export TSX_VER=$(node -p "require('/tmp/package.json').devDependencies.tsx") \
&& export VITEST_VER=$(node -p "require('/tmp/package.json').devDependencies.vitest") \
&& export PRETTIER_VER=$(node -p "require('/tmp/package.json').devDependencies.prettier") \
&& export ESLINT_VER=$(node -p "require('/tmp/package.json').devDependencies.eslint") \
&& export CROSS_ENV_VER=$(node -p "require('/tmp/package.json').devDependencies['cross-env']") \
&& npm install -g tsx@$TSX_VER vitest@$VITEST_VER prettier@$PRETTIER_VER eslint@$ESLINT_VER cross-env@$CROSS_ENV_VER typescript@5.3.3 \
&& npm install -g @google/gemini-cli@nightly && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-nightly \
&& npm install -g @google/gemini-cli@preview && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-preview \
&& npm install -g @google/gemini-cli@latest && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-stable \
&& apt-get purge -y build-essential libsecret-1-dev libkrb5-dev \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm
# Copy the bundled CLI package to a permanent location and install it
# We MUST not delete this source folder as 'npm install -g <folder>'
# often symlinks to it for local folder installs.
COPY --from=builder /build/packages/cli /usr/local/lib/gemini-cli
RUN npm install -g /usr/local/lib/gemini-cli
USER node
CMD ["/bin/bash"]
+10
View File
@@ -0,0 +1,10 @@
node_modules
.git
.gemini/workspaces
dist
!packages/*/dist/*.tgz
bundle
out
*.log
.env
.DS_Store
+58
View File
@@ -0,0 +1,58 @@
substitutions:
_IMAGE_NAME: 'development'
_ARTIFACT_REGISTRY_REPO: 'us-docker.pkg.dev/gemini-code-dev/gemini-cli'
steps:
# Step 1: Install root dependencies
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Install Dependencies'
entrypoint: 'npm'
args: ['install']
# Step 2: Authenticate for Docker
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Authenticate docker'
entrypoint: 'npm'
args: ['run', 'auth']
# Step 3: Build workspace packages
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Build packages'
entrypoint: 'npm'
args: ['run', 'build:packages']
# Step 4: Build Development Image
- name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder'
id: 'Build Development Image'
entrypoint: 'bash'
env:
- 'RAW_BRANCH_VALUE=${BRANCH_NAME}'
args:
- '-c'
- |-
IMAGE_BASE="${_ARTIFACT_REGISTRY_REPO}/${_IMAGE_NAME}"
# Determine the primary tag (branch name or 'latest' for main)
# Use $$ for shell variables to avoid Cloud Build attempting premature substitution
RAW_BRANCH="$$RAW_BRANCH_VALUE"
if [ "$${RAW_BRANCH}" == "main" ]; then
TAG_PRIMARY="latest"
else
TAG_PRIMARY=$$(echo "$${RAW_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g' | tr '[:upper:]' '[:lower:]')
fi
# Use SHORT_SHA if available (Cloud Build) or fallback to latest-dev
TAG_SHA="$${SHORT_SHA:-latest-dev}"
echo "📦 Building Development Image for: $${RAW_BRANCH} -> $${TAG_PRIMARY} ($${TAG_SHA})"
docker build -f .gcp/Dockerfile.development \
-t "$${IMAGE_BASE}:$${TAG_SHA}" \
-t "$${IMAGE_BASE}:$${TAG_PRIMARY}" .
docker push "$${IMAGE_BASE}:$${TAG_SHA}"
docker push "$${IMAGE_BASE}:$${TAG_PRIMARY}"
options:
defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET'
dynamicSubstitutions: true
+12 -3
View File
@@ -65,8 +65,6 @@ accessible.
- **UI and code:** Use **bold** for UI elements and `code font` for filenames,
snippets, commands, and API elements. Focus on the task when discussing
interaction.
- **Links:** Use descriptive anchor text; avoid "click here." Ensure the link
makes sense out of context.
- **Accessibility:** Use semantic HTML elements correctly (headings, lists,
tables).
- **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text
@@ -100,6 +98,18 @@ accessible.
> This is an example of a multi-line note that will be preserved
> by Prettier.
### Links
- **Accessibility:** Use descriptive anchor text; avoid "click here." Ensure the
link makes sense out of context, such as when being read by a screen reader.
- **Use relative links in docs:** Use relative links in documentation (`/docs/`)
to ensure portability. Use paths relative to the current file's directory
(for example, `../tools/` from `docs/cli/`). Do not include the `/docs/`
section of a path, but do verify that the resulting relative link exists. This
does not apply to meta files such as README.MD and CONTRIBUTING.MD.
- **When changing headings, check for deep links:** If a user is changing a
heading, check for deep links to that heading in other pages and update
accordingly.
### Structure
- **BLUF:** Start with an introduction explaining what to expect.
- **Experimental features:** If a feature is clearly noted as experimental,
@@ -157,7 +167,6 @@ documentation.
- **Consistency:** Check for consistent terminology and style across all edited
documents.
## Phase 4: Verification and finalization
Perform a final quality check to ensure that all changes are correctly formatted
and that all links are functional.
@@ -0,0 +1,69 @@
---
name: review-duplication
description: Use this skill during code reviews to proactively investigate the codebase for duplicated functionality, reinvented wheels, or failure to reuse existing project best practices and shared utilities.
---
# Review Duplication
## Overview
This skill provides a structured workflow for investigating a codebase during a code review to identify duplicated logic, reinvented utilities, and missed opportunities to reuse established patterns. By executing this workflow, you ensure that new code integrates seamlessly with the existing project architecture.
## Workflow: Investigating for Duplication
When reviewing code, perform the following steps before finalizing your review:
### 1. Extract Core Logic
Analyze the new code to identify the core algorithms, utility functions, generic data structures, or UI components being introduced. Look beyond the specific business logic to see the underlying mechanics.
### 2. Hypothesize Existing Locations & Trace Dependencies
Think about where this type of code *would* live if it already existed in the project. Provide absolute paths from the repo root to disambiguate.
- **Utilities:** `packages/core/src/utils/`, `packages/cli/src/utils/`
- **UI Components:** `packages/cli/src/ui/components/`, `packages/cli/src/ui/`
- **Services:** `packages/core/src/services/`, `packages/cli/src/services/`
- **Configuration:** `packages/core/src/config/`, `packages/cli/src/config/`
- **Core Logic:** Call out `packages/core/` if functionality does not appear React UI specific.
**Trace Third-Party Dependencies:** If the PR introduces a new import for a utility library (e.g., `lodash.merge`, `date-fns`), trace how and where the project currently uses that library. There is likely an existing wrapper or shared utility.
**Check Package Files:** Before flagging a custom implementation of a complex algorithm, check `package.json` to see if a standard library (like `lodash` or `uuid`) is already installed that provides this functionality.
### 3. Investigate the Codebase (Sub-Agent Delegation)
Delegate the heavy lifting of codebase investigation to specialized sub-agents. They are optimized to perform deep searches and semantic mapping without bloating your session history.
To ensure a comprehensive review, you MUST formulate highly specific objectives for the sub-agents, providing them with the "scents" you discovered in Step 1.
- **Codebase Investigator:** Use the `codebase_investigator` as your primary researcher. When delegating, formulate an objective that asks specific, investigative questions about the codebase, explicitly including these search vectors:
- **Structural Similarity:** Ask if existing code uses the same underlying APIs (e.g., "Does any existing code use `Intl.DateTimeFormat` or `setTimeout` for similar purposes?").
- **Naming Conventions:** Ask if there are existing symbols with similar naming patterns (e.g., "Are there existing symbols with naming patterns like `*Format*` or `*Debounce*`?").
- **Comments & Documentation:** Ask if keywords from the PR's comments or JSDoc exist in describing similar behavior elsewhere.
- **Architectural Fit:** Ask where this type of logic is currently centralized (e.g., "Where is centralized date formatting logic located?").
- **Refactoring Guidance:** Crucially, ask the sub-agent to explain *how* the new code could be refactored to use any existing logic it finds.
- **Generalist Agent:** Use the `generalist` for detailed, turn-intensive comparisons. For example: "Review the implementation of `MyNewComponent` in the PR and compare it semantically against all components in `packages/ui/src`. Are there any existing components that could be extended or used instead?"
- **Retain Fast Path for Simple Searches:** For extremely simple, unambiguous checks (e.g., "Does `package.json` include `lodash`?"), perform a direct search to save time. Default to delegation for any open-ended "investigations."
### 4. Evaluate Best Practices
Check if the new code aligns with the project's established conventions.
- **Error Handling:** Does it use the project's standard error classes or logging mechanisms?
- **State Management:** Does it bypass established stores or contexts?
- **Styling:** Does it hardcode colors or spacing instead of using theme variables?
If the PR introduces a new pattern, compare it against the documented standards and explicitly confirm if an existing project pattern should have been used instead.
### 5. Formulate Constructive Feedback
If you discover that the PR duplicates existing functionality or ignores a best practice:
- Provide a clear review comment.
- **Identify the Source:** Explicitly mention the absolute or project-relative file path and the specific symbol (function, component, class) that should be reused.
- **Implementation Guidance:** Provide a brief code snippet or a clear explanation showing **how** to integrate the existing code to fulfill the task's requirements.
- **Explain the Value:** Briefly explain why reusing the existing code is beneficial (e.g., maintainability, consistency, built-in edge case handling).
Example comment:
> "It looks like this PR introduces a new `formatDate` utility. We already have a robust, tested `formatDate` function in `src/utils/dateHelpers.ts`.
>
> You can replace your implementation by importing it like this:
> ```typescript
> import { formatDate } from '../utils/dateHelpers';
>
> // Then use it here:
> const displayDate = formatDate(userDate, 'MMM Do, YYYY');
> ```
> Reusing this ensures that the date formatting remains consistent with the rest of the application and handles timezone conversions correctly."
+12
View File
@@ -334,8 +334,20 @@ jobs:
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GEMINI_MODEL: 'gemini-3-pro-preview'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: 'npm run test:always_passing_evals'
- name: 'Upload Reliability Logs'
if: "always() && steps.check_evals.outputs.should_run == 'true'"
uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4
with:
name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}'
path: 'evals/logs/api-reliability.jsonl'
retention-days: 7
e2e:
name: 'E2E'
if: |
+2
View File
@@ -61,6 +61,8 @@ jobs:
GEMINI_MODEL: '${{ matrix.model }}'
RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}"
TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}'
# Disable Vitest internal retries to avoid double-retrying;
# custom retry logic is handled in evals/test-helper.ts
VITEST_RETRY: 0
run: |
CMD="npm run test:all_evals"
+8 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.35.0
# Latest stable release: v0.35.2
Released: March 24, 2026
Released: March 26, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,11 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@keith.schaab in
[#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(cli): customizable keyboard shortcuts by @scidomino in
[#21945](https://github.com/google-gemini/gemini-cli/pull/21945)
- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in
@@ -380,4 +385,4 @@ npm install -g @google/gemini-cli
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.0
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
+10 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.3
# Preview release: v0.36.0-preview.5
Released: March 25, 2026
Released: March 27, 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).
@@ -31,6 +31,13 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch
version v0.36.0-preview.0 and create version 0.36.0-preview.1 by
@gemini-cli-robot in
@@ -379,4 +386,4 @@ npm install -g @google/gemini-cli@preview
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
+126
View File
@@ -0,0 +1,126 @@
# ACP Mode
ACP (Agent Client Protocol) mode is a special operational mode of Gemini CLI
designed for programmatic control, primarily for IDE and other developer tool
integrations. It uses a JSON-RPC protocol over stdio to communicate between
Gemini CLI agent and a client.
To start Gemini CLI in ACP mode, use the `--acp` flag:
```bash
gemini --acp
```
## Agent Client Protocol (ACP)
ACP is an open protocol that standardizes how AI coding agents communicate with
code editors and IDEs. It addresses the challenge of fragmented distribution,
where agents traditionally needed custom integrations for each client. With ACP,
developers can implement their agent once, and it becomes compatible with any
ACP-compliant editor.
For a comprehensive introduction to ACP, including its architecture and
benefits, refer to the official
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
documentation.
### Existing integrations using ACP
The ACP Agent Registry simplifies the distribution and management of
ACP-compatible agents across various IDEs. Gemini CLI is an ACP-compatible agent
and can be found in this registry.
For more general information about the registry, and how to use it with specific
IDEs like JetBrains and Zed, refer to the
[IDE Integration](../ide-integration/index.md) documentation.
You can also find more information on the official
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
## Architecture and protocol basics
ACP mode establishes a client-server relationship between your tool (the client)
and Gemini CLI (the server).
- **Communication:** The entire communication happens over standard input/output
(stdio) using the JSON-RPC 2.0 protocol.
- **Client's role:** The client is responsible for sending requests (e.g.,
prompts) and handling responses and notifications from Gemini CLI.
- **Gemini CLI's role:** In ACP mode, Gemini CLI listens for incoming JSON-RPC
requests, processes them, and sends back responses.
The core of the ACP implementation can be found in
`packages/cli/src/acp/acpClient.ts`.
### Extending with MCP
ACP can be used with the Model Context Protocol (MCP). This lets an ACP client
(like an IDE) expose its own functionality as "tools" that the Gemini model can
use.
1. The client implements an **MCP server** that advertises its tools.
2. During the ACP `initialize` handshake, the client provides the connection
details for its MCP server.
3. Gemini CLI connects to the MCP server, discovers the available tools, and
makes them available to the AI model.
4. When the model decides to use one of these tools, Gemini CLI sends a tool
call request to the MCP server.
This mechanism lets for a powerful, two-way integration where the agent can
leverage the IDE's capabilities to perform tasks. The MCP client logic is in
`packages/core/src/tools/mcp-client.ts`.
## Capabilities and supported methods
The ACP protocol exposes a number of methods for ACP clients (e.g. IDEs) to
control Gemini CLI.
### Core methods
- `initialize`: Establishes the initial connection and lets the client to
register its MCP server.
- `authenticate`: Authenticates the user.
- `newSession`: Starts a new chat session.
- `loadSession`: Loads a previous session.
- `prompt`: Sends a prompt to the agent.
- `cancel`: Cancels an ongoing prompt.
### Session control
- `setSessionMode`: Allows changing the approval level for tool calls (e.g., to
`auto-approve`).
- `unstable_setSessionModel`: Changes the model for the current session.
### File system proxy
ACP includes a proxied file system service. This means that when the agent needs
to read or write files, it does so through the ACP client. This is a security
feature that ensures the agent only has access to the files that the client (and
by extension, the user) has explicitly allowed.
## Debugging and telemetry
You can get insights into the ACP communication and the agent's behavior through
debugging logs and telemetry.
### Debugging logs
To enable general debugging logs, start Gemini CLI with the `--debug` flag:
```bash
gemini --acp --debug
```
### Telemetry
For more detailed telemetry, you can use the following environment variables to
capture telemetry data to a file:
- `GEMINI_TELEMETRY_ENABLED=true`
- `GEMINI_TELEMETRY_TARGET=local`
- `GEMINI_TELEMETRY_OUTFILE=/path/to/your/log.json`
This will write a JSON log file containing detailed information about all the
events happening within the agent, including ACP requests and responses. The
integration test `integration-tests/acp-telemetry.test.ts` provides a working
example of how to set this up.
+5 -5
View File
@@ -15,14 +15,14 @@ CLI works in the background.
## Requirements
Currently, system notifications are only supported on macOS.
### Terminal support
The CLI uses the OSC 9 terminal escape sequence to trigger system notifications.
This is supported by several modern terminal emulators. If your terminal does
not support OSC 9 notifications, Gemini CLI falls back to a system alert sound
to get your attention.
This is supported by several modern terminal emulators including iTerm2,
WezTerm, Ghostty, and Kitty. If your terminal does not support OSC 9
notifications, Gemini CLI falls back to a terminal bell (BEL) to get your
attention. Most terminals respond to BEL with a taskbar flash or system alert
sound.
## Enable notifications
+16 -12
View File
@@ -29,7 +29,7 @@ they appear in the UI.
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
@@ -155,17 +155,21 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| 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` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| 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` |
| 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` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
| UI Label | Setting | Description | Default |
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| 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` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| 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` |
| 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` |
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
### Skills
-141
View File
@@ -1,141 +0,0 @@
# Gemini CLI examples
Gemini CLI helps you automate common engineering tasks by combining AI reasoning
with local system tools. This document provides examples of how to use the CLI
for file management, code analysis, and data transformation.
<!-- prettier-ignore -->
> [!NOTE]
> These examples demonstrate potential capabilities. Your actual
> results can vary based on the model used and your project environment.
## Rename your photographs based on content
You can use Gemini CLI to automate file management tasks that require visual
analysis. In this example, Gemini CLI renames images based on their actual
subject matter.
Scenario: You have a folder containing the following files:
```bash
photos/photo1.png
photos/photo2.png
photos/photo3.png
```
Give Gemini the following prompt:
```cli
Rename the photos in my "photos" directory based on their contents.
```
Result: Gemini asks for permission to rename your files.
Select **Allow once** and your files are renamed:
```bash
photos/yellow_flowers.png
photos/antique_dresser.png
photos/green_android_robot.png
```
## Explain a repository by reading its code
Gemini CLI is effective for rapid codebase exploration. The following example
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
Scenario: You want to understand how a popular open-source utility works by
inspecting its code, not just its README.
Give Gemini CLI the following prompt:
```cli
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
```
Result: Gemini performs a sequence of actions to answer your request.
1. First, it asks for permission to run `git clone` to download the repository.
2. Next, it finds the important source files and asks for permission to read
them.
3. Finally, after analyzing the code, it provides a summary.
Gemini CLI returns an explanation based on the actual source code:
```markdown
The `chalk` library is a popular npm package for styling terminal output with
colors. After analyzing the source code, here's how it works:
- **Core functionality:** The main file sets up a chainable API. Each color or
modifier (like `bold` or `italic`) is a getter that appends the corresponding
ANSI escape code to an internal stack.
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
getters. The `red` getter adds the red color code, and the `bold` getter adds
the bold code.
- **Output generation:** When the chain is treated as a string (e.g., in
`console.log`), a final `toString()` method is called. This method joins all
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
a reset code at the end. This produces the final, styled string that the
terminal can render.
```
## Combine two spreadsheets into one spreadsheet
Gemini CLI can process and transform data across multiple files. Use this
capability to merge reports or reformat data sets without manual copying.
Scenario: You have two .csv files: `Revenue - 2023.csv` and
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
Give Gemini CLI the following prompt:
```cli
Combine the two .csv files into a single .csv file, with each year a different column.
```
Result: Gemini CLI reads each file and then asks for permission to write a new
file. Provide your permission and Gemini CLI provides the combined data:
```csv
Month,2023,2024
January,0,1000
February,0,1200
March,0,2400
April,900,500
May,1000,800
June,1000,900
July,1200,1000
August,1800,400
September,2000,2000
October,2400,3400
November,3400,1800
December,2100,9000
```
## Run unit tests
Gemini CLI can generate boilerplate code and tests based on your existing
implementation. This example demonstrates how to request code coverage for a
JavaScript component.
Scenario: You've written a simple login page. You wish to write unit tests to
ensure that your login page has code coverage.
Give Gemini CLI the following prompt:
```cli
Write unit tests for Login.js.
```
Result: Gemini CLI asks for permission to write a new file and creates a test
for your login page.
## Next steps
- Follow the [File management](../cli/tutorials/file-management.md) guide to
start working with your codebase.
- Follow the [Quickstart](./index.md) to start your first session.
- See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of
available commands.
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
<!-- prettier-ignore -->
> [!NOTE]
+127 -1
View File
@@ -62,7 +62,133 @@ Once installed and authenticated, you can start using Gemini CLI by issuing
commands and prompts in your terminal. Ask it to generate code, explain files,
and more.
To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md).
<!-- prettier-ignore -->
> [!NOTE]
> These examples demonstrate potential capabilities. Your actual
> results can vary based on the model used and your project environment.
### Rename your photographs based on content
You can use Gemini CLI to automate file management tasks that require visual
analysis. In this example, Gemini CLI renames images based on their actual
subject matter.
Scenario: You have a folder containing the following files:
```bash
photos/photo1.png
photos/photo2.png
photos/photo3.png
```
Give Gemini the following prompt:
```cli
Rename the photos in my "photos" directory based on their contents.
```
Result: Gemini asks for permission to rename your files.
Select **Allow once** and your files are renamed:
```bash
photos/yellow_flowers.png
photos/antique_dresser.png
photos/green_android_robot.png
```
### Explain a repository by reading its code
Gemini CLI is effective for rapid codebase exploration. The following example
shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project.
Scenario: You want to understand how a popular open-source utility works by
inspecting its code, not just its README.
Give Gemini CLI the following prompt:
```cli
Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works.
```
Result: Gemini performs a sequence of actions to answer your request.
1. First, it asks for permission to run `git clone` to download the repository.
2. Next, it finds the important source files and asks for permission to read
them.
3. Finally, after analyzing the code, it provides a summary.
Gemini CLI returns an explanation based on the actual source code:
```markdown
The `chalk` library is a popular npm package for styling terminal output with
colors. After analyzing the source code, here's how it works:
- **Core functionality:** The main file sets up a chainable API. Each color or
modifier (like `bold` or `italic`) is a getter that appends the corresponding
ANSI escape code to an internal stack.
- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing
getters. The `red` getter adds the red color code, and the `bold` getter adds
the bold code.
- **Output generation:** When the chain is treated as a string (e.g., in
`console.log`), a final `toString()` method is called. This method joins all
the stored ANSI codes, wraps them around the input string ('Hello'), and adds
a reset code at the end. This produces the final, styled string that the
terminal can render.
```
### Combine two spreadsheets into one spreadsheet
Gemini CLI can process and transform data across multiple files. Use this
capability to merge reports or reformat data sets without manual copying.
Scenario: You have two .csv files: `Revenue - 2023.csv` and
`Revenue - 2024.csv`. Each file contains monthly revenue figures.
Give Gemini CLI the following prompt:
```cli
Combine the two .csv files into a single .csv file, with each year a different column.
```
Result: Gemini CLI reads each file and then asks for permission to write a new
file. Provide your permission and Gemini CLI provides the combined data:
```csv
Month,2023,2024
January,0,1000
February,0,1200
March,0,2400
April,900,500
May,1000,800
June,1000,900
July,1200,1000
August,1800,400
September,2000,2000
October,2400,3400
November,3400,1800
December,2100,9000
```
### Run unit tests
Gemini CLI can generate boilerplate code and tests based on your existing
implementation. This example demonstrates how to request code coverage for a
JavaScript component.
Scenario: You've written a simple login page. You wish to write unit tests to
ensure that your login page has code coverage.
Give Gemini CLI the following prompt:
```cli
Write unit tests for Login.js.
```
Result: Gemini CLI asks for permission to write a new file and creates a test
for your login page.
## Check usage and quota
+99 -24
View File
@@ -1,15 +1,29 @@
# IDE integration
# IDE Integration
Gemini CLI can integrate with your IDE to provide a more seamless and
context-aware experience. This integration allows the CLI to understand your
workspace better and enables powerful features like native in-editor diffing.
Currently, the supported IDEs are [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), and other editors that
support VS Code extensions. To build support for other editors, see the
[IDE Companion Extension Spec](./ide-companion-spec.md).
There are two primary ways to integrate Gemini CLI with an IDE:
## Features
1. **VS Code companion extension**: Install the "Gemini CLI Companion"
extension on [Antigravity](https://antigravity.google),
[Visual Studio Code](https://code.visualstudio.com/), or other VS Code
compatible editors.
2. **Agent Client Protocol (ACP)**: An open protocol for interoperability
between AI coding agents and IDEs. This method is used for integrations with
tools like JetBrains and Zed, which leverage the ACP Agent Registry for easy
discovery and installation of compatible agents like Gemini CLI.
## VS Code companion extension
The **Gemini CLI Companion extension** grants Gemini CLI direct access to your
VS Code compatible IDEs and improves your experience by providing real-time
context such as open files, cursor positions, and text selection. The extension
also enables a native diffing interface so you can seamlessly review and apply
AI-generated code changes directly within your editor.
### Features
- **Workspace context:** The CLI automatically gains awareness of your workspace
to provide more relevant and accurate responses. This context includes:
@@ -19,8 +33,8 @@ support VS Code extensions. To build support for other editors, see the
truncated).
- **Native diffing:** When Gemini suggests code modifications, you can view the
changes directly within your IDE's native diff viewer. This allows you to
review, edit, and accept or reject the suggested changes seamlessly.
changes directly within your IDE's native diff viewer. This lets you review,
edit, and accept or reject the suggested changes seamlessly.
- **VS Code commands:** You can access Gemini CLI features directly from the VS
Code Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`):
@@ -32,18 +46,18 @@ support VS Code extensions. To build support for other editors, see the
- `Gemini CLI: View Third-Party Notices`: Displays the third-party notices for
the extension.
## Installation and setup
### Installation and setup
There are three ways to set up the IDE integration:
### 1. Automatic nudge (recommended)
#### 1. Automatic nudge (recommended)
When you run Gemini CLI inside a supported editor, it will automatically detect
your environment and prompt you to connect. Answering "Yes" will automatically
run the necessary setup, which includes installing the companion extension and
enabling the connection.
### 2. Manual installation from CLI
#### 2. Manual installation from CLI
If you previously dismissed the prompt or want to install the extension
manually, you can run the following command inside Gemini CLI:
@@ -54,7 +68,7 @@ manually, you can run the following command inside Gemini CLI:
This will find the correct extension for your IDE and install it.
### 3. Manual installation from a marketplace
#### 3. Manual installation from a marketplace
You can also install the extension directly from a marketplace.
@@ -75,9 +89,9 @@ You can also install the extension directly from a marketplace.
> After manually installing the extension, you must run `/ide enable` in the CLI
> to activate the integration.
## Usage
### Usage
### Enabling and disabling
#### Enabling and disabling
You can control the IDE integration from within the CLI:
@@ -93,7 +107,7 @@ You can control the IDE integration from within the CLI:
When enabled, Gemini CLI will automatically attempt to connect to the IDE
companion extension.
### Checking the status
#### Checking the status
To check the connection status and see the context the CLI has received from the
IDE, run:
@@ -108,9 +122,9 @@ recently opened files it is aware of.
<!-- prettier-ignore -->
> [!NOTE]
> The file list is limited to 10 recently accessed files within your
> workspace and only includes local files on disk.)
> workspace and only includes local files on disk.
### Working with diffs
#### Working with diffs
When you ask Gemini to modify a file, it can open a diff view directly in your
editor.
@@ -135,6 +149,63 @@ accepting them.
If you select Allow for this session in the CLI, changes will no longer show
up in the IDE as they will be auto-accepted.
## Agent Client Protocol (ACP)
ACP is an open protocol that standardizes how AI coding agents communicate with
code editors and IDEs. It addresses the challenge of fragmented distribution,
where agents traditionally needed custom integrations for each client. With ACP,
developers can implement their agent once, and it becomes compatible with any
ACP-compliant editor.
For a comprehensive introduction to ACP, including its architecture and
benefits, refer to the official
[ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
documentation.
### The ACP Agent Registry
Gemini CLI is officially available in the **ACP Agent Registry**. This allows
you to install and update Gemini CLI directly within supporting IDEs and
eliminates the need for manual downloads or IDE-specific extensions.
Using the registry ensures:
- **Ease of use**: Discover and install agents directly within your IDE
settings.
- **Latest versions**: Ensures users always have access to the most up-to-date
agent implementations.
For more details on how the registry works, visit the official
[ACP Agent Registry](https://agentclientprotocol.com/get-started/registry) page.
You can learn about how specific IDEs leverage this integration in the following
section.
### IDE-specific integration
Gemini CLI is an ACP-compatible agent available in the ACP Agent Registry.
Heres how different IDEs leverage the ACP and the registry:
#### JetBrains IDEs
JetBrains IDEs (like IntelliJ IDEA, PyCharm, or GoLand) offer built-in registry
support, allowing users to find and install ACP-compatible agents directly.
For more details, refer to the official
[JetBrains AI Blog announcement](https://blog.jetbrains.com/ai/2026/01/acp-agent-registry/).
#### Zed
Zed, a modern code editor, also integrates with the ACP Agent Registry. This
allows Zed users to easily browse, install, and manage ACP agents.
Learn more about Zed's integration with the ACP Registry in their
[blog post](https://zed.dev/blog/acp-registry).
#### Other ACP-compatible IDEs
Any other IDE that supports the ACP Agent Registry can install Gemini CLI
directly through their in-built registry features.
## Using with sandboxing
If you are using Gemini CLI within a sandbox, please be aware of the following:
@@ -151,10 +222,9 @@ If you are using Gemini CLI within a sandbox, please be aware of the following:
## Troubleshooting
If you encounter issues with IDE integration, here are some common error
messages and how to resolve them.
### VS Code companion extension errors
### Connection errors
#### Connection errors
- **Message:**
`🔴 Disconnected: Failed to connect to IDE companion extension in [IDE Name]. Please ensure the extension is running. To install the extension, run /ide install.`
@@ -174,7 +244,7 @@ messages and how to resolve them.
- **Solution:** Run `/ide enable` to try and reconnect. If the issue
continues, open a new terminal window or restart your IDE.
### Manual PID override
#### Manual PID override
If automatic IDE detection fails, or if you are running Gemini CLI in a
standalone terminal and want to manually associate it with a specific IDE
@@ -196,7 +266,7 @@ $env:GEMINI_CLI_IDE_PID=12345
When this variable is set, Gemini CLI will skip automatic detection and attempt
to connect using the provided PID.
### Configuration errors
#### Configuration errors
- **Message:**
`🔴 Disconnected: Directory mismatch. Gemini CLI is running in a different location than the open workspace in [IDE Name]. Please run the CLI from one of the following directories: [List of directories]`
@@ -210,7 +280,7 @@ to connect using the provided PID.
- **Cause:** You have no workspace open in your IDE.
- **Solution:** Open a workspace in your IDE and restart the CLI.
### General errors
#### General errors
- **Message:**
`IDE integration is not supported in your current environment. To use this feature, run Gemini CLI in one of these supported IDEs: [List of IDEs]`
@@ -220,9 +290,14 @@ to connect using the provided PID.
IDE, like Antigravity or VS Code.
- **Message:**
`No installer is available for IDE. Please install the Gemini CLI Companion extension manually from the marketplace.`
`No installer is available for IDE. Please install Gemini CLI Companion extension manually from the marketplace.`
- **Cause:** You ran `/ide install`, but the CLI does not have an automated
installer for your specific IDE.
- **Solution:** Open your IDE's extension marketplace, search for "Gemini CLI
Companion", and
[install it manually](#3-manual-installation-from-a-marketplace).
### ACP integration errors
For issues related to ACP integration, please refer to the debugging and
telemetry section in the [ACP Mode](../cli/acp-mode.md) documentation.
-2
View File
@@ -19,8 +19,6 @@ Jump in to Gemini CLI.
on your system.
- **[Authentication](./get-started/authentication.md):** Setup instructions for
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
+1
View File
@@ -13,6 +13,7 @@
"/docs/faq": "/docs/resources/faq",
"/docs/get-started/configuration": "/docs/reference/configuration",
"/docs/get-started/configuration-v1": "/docs/reference/configuration",
"/docs/get-started/examples": "/docs/get-started/index",
"/docs/index": "/docs",
"/docs/quota-and-pricing": "/docs/resources/quota-and-pricing",
"/docs/tos-privacy": "/docs/resources/tos-privacy",
+101 -63
View File
@@ -133,7 +133,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.enableNotifications`** (boolean):
- **Description:** Enable run-event notifications for action-required prompts
and session completion. Currently macOS only.
and session completion.
- **Default:** `false`
- **`general.checkpointing.enabled`** (boolean):
@@ -670,6 +670,11 @@ their corresponding top-level category object in your `settings.json` file.
"modelConfig": {
"model": "gemini-3-pro-preview"
}
},
"agent-history-provider-summarizer": {
"modelConfig": {
"model": "gemini-3-flash-preview"
}
}
}
```
@@ -1282,6 +1287,18 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Maximum number of directories to search for memory.
- **Default:** `200`
- **`context.memoryBoundaryMarkers`** (array):
- **Description:** File or directory names that mark the boundary for
GEMINI.md discovery. The upward traversal stops at the first directory
containing any of these markers. An empty array disables parent traversal.
- **Default:**
```json
[".git"]
```
- **Requires restart:** Yes
- **`context.includeDirectories`** (array):
- **Description:** Additional directories to include in the workspace context.
Missing directories will be skipped with a warning.
@@ -1677,6 +1694,28 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.agentHistoryTruncation`** (boolean):
- **Description:** Enable truncation window logic for the Agent History
Provider.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.agentHistoryTruncationThreshold`** (number):
- **Description:** The maximum number of messages before history is truncated.
- **Default:** `30`
- **Requires restart:** Yes
- **`experimental.agentHistoryRetainedMessages`** (number):
- **Description:** The number of recent messages to retain after truncation.
- **Default:** `15`
- **Requires restart:** Yes
- **`experimental.agentHistorySummarization`** (boolean):
- **Description:** Enable summarization of truncated content via a small model
for the Agent History Provider.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.topicUpdateNarration`** (boolean):
- **Description:** Enable the experimental Topic & Update communication model
for reduced chattiness and structured progress reporting.
@@ -2160,37 +2199,14 @@ You can customize this behavior in your `settings.json` file:
Arguments passed directly when running the CLI can override other configurations
for that specific session.
- **`--model <model_name>`** (**`-m <model_name>`**):
- Specifies the Gemini model to use for this session.
- Example: `npm start -- --model gemini-3-pro-preview`
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
- **Deprecated:** Use positional arguments instead.
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
non-interactive mode.
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
- Starts an interactive session with the provided prompt as the initial input.
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `gemini -i "explain this code"`
- **`--output-format <format>`**:
- **Description:** Specifies the format of the CLI output for non-interactive
mode.
- **Values:**
- `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output.
- `stream-json`: A streaming JSON output that emits real-time events.
- **Note:** For structured output and scripting, use the
`--output-format json` or `--output-format stream-json` flag.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--debug`** (**`-d`**):
- Enables debug mode for this session, providing more verbose output. Open the
debug console with F12 to see the additional logging.
- **`--help`** (or **`-h`**):
- Displays help information about command-line arguments.
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
- **`--acp`**:
- Starts the agent in Agent Communication Protocol (ACP) mode.
- **`--allowed-mcp-server-names`**:
- A comma-separated list of MCP server names to allow for the session.
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation
dialog.
- Example: `gemini --allowed-tools "ShellTool(git status)"`
- **`--approval-mode <mode>`**:
- Sets the approval mode for tool calls. Available modes:
- `default`: Prompt for approval on each tool call (default behavior)
@@ -2204,35 +2220,24 @@ for that specific session.
- Cannot be used together with `--yolo`. Use `--approval-mode=yolo` instead of
`--yolo` for the new unified approach.
- Example: `gemini --approval-mode auto_edit`
- **`--allowed-tools <tool1,tool2,...>`**:
- A comma-separated list of tool names that will bypass the confirmation
dialog.
- Example: `gemini --allowed-tools "ShellTool(git status)"`
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
- Use the special term `gemini -e none` to disable all extensions.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--debug`** (**`-d`**):
- Enables debug mode for this session, providing more verbose output. Open the
debug console with F12 to see the additional logging.
- **`--delete-session <identifier>`**:
- Delete a specific chat session by its index number or full session UUID.
- Use `--list-sessions` first to see available sessions, their indices, and
UUIDs.
- Example: `gemini --delete-session 3` or
`gemini --delete-session a1b2c3d4-e5f6-7890-abcd-ef1234567890`
- **`--extensions <extension_name ...>`** (**`-e <extension_name ...>`**):
- Specifies a list of extensions to use for the session. If not provided, all
available extensions are used.
- Use the special term `gemini -e none` to disable all extensions.
- Example: `gemini -e my-extension -e my-other-extension`
- **`--fake-responses`**:
- Path to a file with fake model responses for testing.
- **`--help`** (or **`-h`**):
- Displays help information about command-line arguments.
- **`--include-directories <dir1,dir2,...>`**:
- Includes additional directories in the workspace for multi-directory
support.
@@ -2240,19 +2245,52 @@ for that specific session.
- 5 directories can be added at maximum.
- Example: `--include-directories /path/to/project1,/path/to/project2` or
`--include-directories /path/to/project1 --include-directories /path/to/project2`
- **`--list-extensions`** (**`-l`**):
- Lists all available extensions and exits.
- **`--list-sessions`**:
- List all available chat sessions for the current project and exit.
- Shows session indices, dates, message counts, and preview of first user
message.
- Example: `gemini --list-sessions`
- **`--model <model_name>`** (**`-m <model_name>`**):
- Specifies the Gemini model to use for this session.
- Example: `npm start -- --model gemini-3-pro-preview`
- **`--output-format <format>`**:
- **Description:** Specifies the format of the CLI output for non-interactive
mode.
- **Values:**
- `text`: (Default) The standard human-readable output.
- `json`: A machine-readable JSON output.
- `stream-json`: A streaming JSON output that emits real-time events.
- **Note:** For structured output and scripting, use the
`--output-format json` or `--output-format stream-json` flag.
- **`--prompt <your_prompt>`** (**`-p <your_prompt>`**):
- **Deprecated:** Use positional arguments instead.
- Used to pass a prompt directly to the command. This invokes Gemini CLI in a
non-interactive mode.
- **`--prompt-interactive <your_prompt>`** (**`-i <your_prompt>`**):
- Starts an interactive session with the provided prompt as the initial input.
- The prompt is processed within the interactive session, not before it.
- Cannot be used when piping input from stdin.
- Example: `gemini -i "explain this code"`
- **`--record-responses`**:
- Path to a file to record model responses for testing.
- **`--resume [session_id]`** (**`-r [session_id]`**):
- Resume a previous chat session. Use "latest" for the most recent session,
provide a session index number, or provide a full session UUID.
- If no session_id is provided, defaults to "latest".
- Example: `gemini --resume 5` or `gemini --resume latest` or
`gemini --resume a1b2c3d4-e5f6-7890-abcd-ef1234567890` or `gemini --resume`
- See [Session Management](../cli/session-management.md) for more details.
- **`--sandbox`** (**`-s`**):
- Enables sandbox mode for this session.
- **`--screen-reader`**:
- Enables screen reader mode, which adjusts the TUI for better compatibility
with screen readers.
- **`--version`**:
- Displays the version of the CLI.
- **`--experimental-acp`**:
- Starts the agent in ACP mode.
- **`--allowed-mcp-server-names`**:
- Allowed MCP server names.
- **`--fake-responses`**:
- Path to a file with fake model responses for testing.
- **`--record-responses`**:
- Path to a file to record model responses for testing.
- **`--yolo`**:
- Enables YOLO mode, which automatically approves all tool calls.
## Context files (hierarchical instructional context)
+7 -6
View File
@@ -86,12 +86,13 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
+23 -9
View File
@@ -12,6 +12,21 @@ quota for your needs, see the [Plans page](https://geminicli.com/plans/).
This article outlines the specific quotas and pricing applicable to Gemini CLI
when using different authentication methods.
The following table summarizes the available quotas and their respective limits:
| Authentication method | Tier / Subscription | Maximum requests per user per day |
| :-------------------- | :------------------------------ | :-------------------------------- |
| **Google account** | Gemini Code Assist (Individual) | 1,000 requests |
| | Google AI Pro | 1,500 requests |
| | Google AI Ultra | 2,000 requests |
| **Gemini API key** | Free tier (Unpaid) | 250 requests |
| | Pay-as-you-go (Paid) | Varies |
| **Vertex AI** | Express mode (Free) | Varies |
| | Pay-as-you-go (Paid) | Varies |
| **Google Workspace** | Code Assist Standard | 1,500 requests |
| | Code Assist Enterprise | 2,000 requests |
| | Workspace AI Ultra | 2,000 requests |
Generally, there are three categories to choose from:
- Free Usage: Ideal for experimentation and light use.
@@ -20,6 +35,9 @@ Generally, there are three categories to choose from:
- Pay-As-You-Go: The most flexible option for professional use, long-running
tasks, or when you need full control over your usage.
Requests are limited per user per minute and are subject to the availability of
the service in times of high demand.
## Free usage
Access to Gemini CLI begins with a generous free tier, perfect for
@@ -33,8 +51,7 @@ authorization type.
For users who authenticate by using their Google account to access Gemini Code
Assist for individuals. This includes:
- 1000 model requests / user / day
- 60 model requests / user / minute
- 1000 maximum model requests / user / day
- Model requests will be made across the Gemini model family as determined by
Gemini CLI.
@@ -46,8 +63,7 @@ Learn more at
If you are using a Gemini API key, you can also benefit from a free tier. This
includes:
- 250 model requests / user / day
- 10 model requests / user / minute
- 250 maximum model requests / user / day
- Model requests to Flash model only.
Learn more at
@@ -59,7 +75,7 @@ Vertex AI offers an Express Mode without the need to enable billing. This
includes:
- 90 days before you need to enable billing.
- Quotas and models are variable and specific to your account.
- Quotas and models are specific to your account and their limits vary.
Learn more at
[Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas).
@@ -112,11 +128,9 @@ Standard/Plus and AI Expanded, are not supported._
This includes the following request limits:
- Gemini Code Assist Standard edition:
- 1500 model requests / user / day
- 120 model requests / user / minute
- 1500 maximum model requests / user / day
- Gemini Code Assist Enterprise edition:
- 2000 model requests / user / day
- 120 model requests / user / minute
- 2000 maximum model requests / user / day
- Model requests will be made across the Gemini model family as determined by
Gemini CLI.
+11 -2
View File
@@ -12,7 +12,6 @@
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Examples", "slug": "docs/get-started/examples" },
{ "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" },
{
"label": "Gemini 3 on Gemini CLI",
@@ -112,7 +111,17 @@
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{
"label": "IDE integration",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/ide-integration" },
{
"label": "Developer guide: ACP mode",
"slug": "docs/cli/acp-mode"
}
]
},
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
+207
View File
@@ -0,0 +1,207 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { internalEvalTest } from './test-helper.js';
import { TestRig } from '@google/gemini-cli-test-utils';
// Mock TestRig to control API success/failure
vi.mock('@google/gemini-cli-test-utils', () => {
return {
TestRig: vi.fn().mockImplementation(() => ({
setup: vi.fn(),
run: vi.fn(),
cleanup: vi.fn(),
readToolLogs: vi.fn().mockReturnValue([]),
_lastRunStderr: '',
})),
};
});
describe('evalTest reliability logic', () => {
const LOG_DIR = path.resolve(process.cwd(), 'evals/logs');
const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl');
beforeEach(() => {
vi.clearAllMocks();
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
afterEach(() => {
if (fs.existsSync(RELIABILITY_LOG)) {
fs.unlinkSync(RELIABILITY_LOG);
}
});
it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 500 error
mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down'));
// Execute the test function directly
await internalEvalTest({
name: 'test-api-failure',
prompt: 'do something',
assert: async () => {},
});
// Verify retries: 1 initial + 3 retries = 4 setups/runs
expect(mockRig.run).toHaveBeenCalledTimes(4);
// Verify log content
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(4);
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].status).toBe('RETRY');
expect(entries[0].attempt).toBe(0);
expect(entries[3].status).toBe('SKIP');
expect(entries[3].attempt).toBe(3);
expect(entries[3].testName).toBe('test-api-failure');
});
it('should fail immediately on non-500 errors (like assertion failures)', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate a real logic error/bug
mockRig.run.mockResolvedValue('Success');
const assertError = new Error('Assertion failed: expected foo to be bar');
// Expect the test function to throw immediately
await expect(
internalEvalTest({
name: 'test-logic-failure',
prompt: 'do something',
assert: async () => {
throw assertError;
},
}),
).rejects.toThrow('Assertion failed');
// Verify NO retries: only 1 attempt
expect(mockRig.run).toHaveBeenCalledTimes(1);
// Verify NO reliability log was created (it's not an API error)
expect(fs.existsSync(RELIABILITY_LOG)).toBe(false);
});
it('should recover if a retry succeeds', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Fail once, then succeed
mockRig.run
.mockRejectedValueOnce(new Error('status: INTERNAL'))
.mockResolvedValueOnce('Success');
await internalEvalTest({
name: 'test-recovery',
prompt: 'do something',
assert: async () => {},
});
// Ran twice: initial (fail) + retry 1 (success)
expect(mockRig.run).toHaveBeenCalledTimes(2);
// Log should only have the one RETRY entry
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
expect(logContent.length).toBe(1);
expect(JSON.parse(logContent[0]).status).toBe('RETRY');
});
it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
// Simulate permanent 503 error
mockRig.run.mockRejectedValue(
new Error('status: UNAVAILABLE - Service Busy'),
);
await internalEvalTest({
name: 'test-api-503',
prompt: 'do something',
assert: async () => {},
});
expect(mockRig.run).toHaveBeenCalledTimes(4);
const logContent = fs
.readFileSync(RELIABILITY_LOG, 'utf-8')
.trim()
.split('\n');
const entries = logContent.map((line) => JSON.parse(line));
expect(entries[0].errorCode).toBe('503');
expect(entries[3].status).toBe('SKIP');
});
it('should throw if an absolute path is used in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-absolute-path',
prompt: 'do something',
files: {
'/etc/passwd': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: /etc/passwd');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
it('should throw if directory traversal is detected in files', async () => {
const mockRig = new TestRig() as any;
(TestRig as any).mockReturnValue(mockRig);
mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp');
// Create a mock test-dir
if (!fs.existsSync(mockRig.testDir)) {
fs.mkdirSync(mockRig.testDir, { recursive: true });
}
try {
await expect(
internalEvalTest({
name: 'test-traversal',
prompt: 'do something',
files: {
'../sensitive.txt': 'hacked',
},
assert: async () => {},
}),
).rejects.toThrow('Invalid file path in test case: ../sensitive.txt');
} finally {
if (fs.existsSync(mockRig.testDir)) {
fs.rmSync(mockRig.testDir, { recursive: true, force: true });
}
}
});
});
+171 -71
View File
@@ -39,87 +39,34 @@ export * from '@google/gemini-cli-test-utils';
export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES';
export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
const fn = async () => {
runEval(
policy,
evalCase.name,
() => internalEvalTest(evalCase),
evalCase.timeout,
);
}
export async function internalEvalTest(evalCase: EvalCase) {
const maxRetries = 3;
let attempt = 0;
while (attempt <= maxRetries) {
const rig = new TestRig();
const { logDir, sanitizedName } = await prepareLogDir(evalCase.name);
const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`);
const logFile = path.join(logDir, `${sanitizedName}.log`);
let isSuccess = false;
try {
rig.setup(evalCase.name, evalCase.params);
// Symlink node modules to reduce the amount of time needed to
// bootstrap test projects.
symlinkNodeModules(rig.testDir || '');
if (evalCase.files) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(evalCase.files)) {
const fullPath = path.join(rig.testDir!, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
// If it's an agent file, calculate hash for acknowledgement
if (
filePath.startsWith('.gemini/agents/') &&
filePath.endsWith('.md')
) {
const hash = crypto
.createHash('sha256')
.update(content)
.digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
// Write acknowledged_agents.json to the home directory
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(
ackPath,
JSON.stringify(acknowledgedAgents, null, 2),
);
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
await setupTestFiles(rig, evalCase.files);
}
symlinkNodeModules(rig.testDir || '');
// If messages are provided, write a session file so --resume can load it.
let sessionId: string | undefined;
if (evalCase.messages) {
@@ -188,6 +135,37 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
await evalCase.assert(rig, result);
isSuccess = true;
return; // Success! Exit the retry loop.
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorCode = getApiErrorCode(errorMessage);
if (errorCode) {
const status = attempt < maxRetries ? 'RETRY' : 'SKIP';
logReliabilityEvent(
evalCase.name,
attempt,
status,
errorCode,
errorMessage,
);
if (attempt < maxRetries) {
attempt++;
console.warn(
`[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`,
);
continue; // Retry
}
console.warn(
`[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`,
);
return; // Gracefully exit without failing the test
}
throw error; // Real failure
} finally {
if (isSuccess) {
await fs.promises.unlink(activityLogFile).catch((err) => {
@@ -206,9 +184,131 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
);
await rig.cleanup();
}
}
}
function getApiErrorCode(message: string): '500' | '503' | undefined {
if (
message.includes('status: UNAVAILABLE') ||
message.includes('code: 503') ||
message.includes('Service Unavailable')
) {
return '503';
}
if (
message.includes('status: INTERNAL') ||
message.includes('code: 500') ||
message.includes('Internal error encountered')
) {
return '500';
}
return undefined;
}
/**
* Log reliability event for later harvesting.
*
* Note: Uses synchronous file I/O to ensure the log is persisted even if the
* test process is abruptly terminated by a timeout or CI crash. Performance
* impact is negligible compared to long-running evaluation tests.
*/
function logReliabilityEvent(
testName: string,
attempt: number,
status: 'RETRY' | 'SKIP',
errorCode: '500' | '503',
errorMessage: string,
) {
const reliabilityLog = {
timestamp: new Date().toISOString(),
testName,
model: process.env.GEMINI_MODEL || 'unknown',
attempt,
status,
errorCode,
error: errorMessage,
};
runEval(policy, evalCase.name, fn, evalCase.timeout);
try {
const relDir = path.resolve(process.cwd(), 'evals/logs');
fs.mkdirSync(relDir, { recursive: true });
fs.appendFileSync(
path.join(relDir, 'api-reliability.jsonl'),
JSON.stringify(reliabilityLog) + '\n',
);
} catch (logError) {
console.error('Failed to write reliability log:', logError);
}
}
/**
* Helper to setup test files and git repository.
*
* Note: While this is an async function (due to parseAgentMarkdown), it
* intentionally uses synchronous filesystem and child_process operations
* for simplicity and to ensure sequential environment preparation.
*/
async function setupTestFiles(rig: TestRig, files: Record<string, string>) {
const acknowledgedAgents: Record<string, Record<string, string>> = {};
const projectRoot = fs.realpathSync(rig.testDir!);
for (const [filePath, content] of Object.entries(files)) {
if (filePath.includes('..') || path.isAbsolute(filePath)) {
throw new Error(`Invalid file path in test case: ${filePath}`);
}
const fullPath = path.join(projectRoot, filePath);
if (!fullPath.startsWith(projectRoot)) {
throw new Error(`Path traversal detected: ${filePath}`);
}
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) {
const hash = crypto.createHash('sha256').update(content).digest('hex');
try {
const agentDefs = await parseAgentMarkdown(fullPath, content);
if (agentDefs.length > 0) {
const agentName = agentDefs[0].name;
if (!acknowledgedAgents[projectRoot]) {
acknowledgedAgents[projectRoot] = {};
}
acknowledgedAgents[projectRoot][agentName] = hash;
}
} catch (error) {
console.warn(
`Failed to parse agent for test acknowledgement: ${filePath}`,
error,
);
}
}
}
if (Object.keys(acknowledgedAgents).length > 0) {
const ackPath = path.join(
rig.homeDir!,
'.gemini',
'acknowledgments',
'agents.json',
);
fs.mkdirSync(path.dirname(ackPath), { recursive: true });
fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2));
}
const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const };
execSync('git init --initial-branch=main', execOptions);
execSync('git config user.email "test@example.com"', execOptions);
execSync('git config user.name "Test User"', execOptions);
// Temporarily disable the interactive editor and git pager
// to avoid hanging the tests. It seems the the agent isn't
// consistently honoring the instructions to avoid interactive
// commands.
execSync('git config core.editor "true"', execOptions);
execSync('git config core.pager "cat"', execOptions);
execSync('git config commit.gpgsign false', execOptions);
execSync('git add .', execOptions);
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
/**
-4
View File
@@ -16,10 +16,6 @@ export default defineConfig({
},
test: {
testTimeout: 300000, // 5 minutes
// Retry in CI but not nightly to avoid blocking on API error.
retry: process.env['VITEST_RETRY']
? parseInt(process.env['VITEST_RETRY'], 10)
: 3,
reporters: ['default', 'json'],
outputFile: {
json: 'evals/logs/report.json',
+122 -137
View File
@@ -10,13 +10,9 @@ import { TestMcpServer } from './test-mcp-server.js';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { safeJsonStringify } from '@google/gemini-cli-core/src/utils/safeJsonStringify.js';
import { env } from 'node:process';
import { platform } from 'node:os';
import stripAnsi from 'strip-ansi';
const itIf = (condition: boolean) => (condition ? it : it.skip);
describe('extension reloading', () => {
let rig: TestRig;
@@ -26,141 +22,130 @@ describe('extension reloading', () => {
afterEach(async () => await rig.cleanup());
const sandboxEnv = env['GEMINI_SANDBOX'];
// Fails in linux non-sandbox e2e tests
// always fails
// TODO(#14527): Re-enable this once fixed
// Fails in sandbox mode, can't check for local extension updates.
itIf(
(!sandboxEnv || sandboxEnv === 'false') &&
platform() !== 'win32' &&
platform() !== 'linux',
)(
'installs a local extension, updates it, checks it was reloaded properly',
async () => {
const serverA = new TestMcpServer();
const portA = await serverA.start({
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
const extension = {
name: 'test-extension',
version: '0.0.1',
mcpServers: {
'test-server': {
httpUrl: `http://localhost:${portA}/mcp`,
},
it.skip('installs a local extension, updates it, checks it was reloaded properly', async () => {
const serverA = new TestMcpServer();
const portA = await serverA.start({
hello: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
const extension = {
name: 'test-extension',
version: '0.0.1',
mcpServers: {
'test-server': {
httpUrl: `http://localhost:${portA}/mcp`,
},
};
},
};
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
},
});
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// defensive cleanup from previous tests.
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
} catch {
/* empty */
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
// Now create the update, but its not installed yet
const serverB = new TestMcpServer();
const portB = await serverB.start({
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
extension.version = '0.0.2';
extension.mcpServers['test-server'].httpUrl =
`http://localhost:${portB}/mcp`;
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive({ args: '--debug' });
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText(
'test-extension (v0.0.1) - active (update available)',
);
// Wait for the UI to settle and retry the command until we see the update
await new Promise((resolve) => setTimeout(resolve, 1000));
// Poll for the updated list
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
);
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
await run.type('\r'); // consent
await run.expectText(
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
);
// Poll for the updated extension version
await rig.pollCommand(
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
),
30000,
);
// Poll for the updated mcp tool
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
);
await run.sendText('/quit');
await run.type('\r');
// Clean things up.
await serverA.stop();
await serverB.stop();
rig.setup('extension reload test', {
settings: {
experimental: { extensionReloading: true },
},
});
const testServerPath = join(rig.testDir!, 'gemini-extension.json');
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// defensive cleanup from previous tests.
try {
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
},
);
} catch {
/* empty */
}
const result = await rig.runCommand(
['--debug', 'extensions', 'install', `${rig.testDir!}`],
{ stdin: 'y\n' },
);
expect(result).toContain('test-extension');
// Now create the update, but its not installed yet
const serverB = new TestMcpServer();
const portB = await serverB.start({
goodbye: () => ({ content: [{ type: 'text', text: 'world' }] }),
});
extension.version = '0.0.2';
extension.mcpServers['test-server'].httpUrl =
`http://localhost:${portB}/mcp`;
writeFileSync(testServerPath, safeJsonStringify(extension, 2));
// Start the CLI.
const run = await rig.runInteractive({ args: '--debug' });
await run.expectText('You have 1 extension with an update available');
// See the outdated extension
await run.sendText('/extensions list');
await run.type('\r');
await run.expectText('test-extension (v0.0.1) - active (update available)');
// Wait for the UI to settle and retry the command until we see the update
await new Promise((resolve) => setTimeout(resolve, 1000));
// Poll for the updated list
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
);
// Update the extension, expect the list to update, and mcp servers as well.
await run.sendKeys('\u0015/extensions update test-extension');
await run.expectText('/extensions update test-extension');
await run.type('\r');
await new Promise((resolve) => setTimeout(resolve, 500));
await run.type('\r');
await run.expectText(
` * test-server (remote): http://localhost:${portB}/mcp`,
);
await run.type('\r'); // consent
await run.expectText(
'Extension "test-extension" successfully updated: 0.0.1 → 0.0.2',
);
// Poll for the updated extension version
await rig.pollCommand(
async () => {
await run.sendText('/extensions list');
await run.type('\r');
},
() =>
stripAnsi(run.output).includes(
'test-extension (v0.0.2) - active (updated)',
),
30000,
);
// Poll for the updated mcp tool
await rig.pollCommand(
async () => {
await run.sendText('/mcp list');
await run.type('\r');
},
() => {
const output = stripAnsi(run.output);
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
);
await run.sendText('/quit');
await run.type('\r');
// Clean things up.
await serverA.stop();
await serverB.stop();
await rig.runCommand(['extensions', 'uninstall', 'test-extension']);
});
});
+67 -1
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
import { GEMINI_DIR, TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -227,4 +229,68 @@ describe('Plan Mode', () => {
`Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`,
).toBe(true);
});
it('should switch from a pro model to a flash model after exiting plan mode', async () => {
const plansDir = 'plans-folder';
const planFilename = 'my-plan.md';
await rig.setup('should-switch-to-flash', {
settings: {
model: {
name: 'auto-gemini-2.5',
},
experimental: { plan: true },
tools: {
core: ['exit_plan_mode', 'run_shell_command'],
allowed: ['exit_plan_mode', 'run_shell_command'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const fullPlansDir = join(rig.testDir!, plansDir);
mkdirSync(fullPlansDir, { recursive: true });
writeFileSync(join(fullPlansDir, planFilename), 'Execute echo hello');
await rig.run({
approvalMode: 'plan',
stdin: `Exit plan mode using ${planFilename} and then run a shell command \`echo hello\`.`,
});
const exitCallFound = await rig.waitForToolCall('exit_plan_mode');
expect(exitCallFound, 'Expected exit_plan_mode to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to be called').toBe(
true,
);
const apiRequests = rig.readAllApiRequest();
const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown');
const proRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('pro'),
);
const flashRequests = apiRequests.filter((r) =>
r.attributes?.model?.includes('flash'),
);
expect(
proRequests.length,
`Expected at least one Pro request. Models used: ${modelNames.join(', ')}`,
).toBeGreaterThanOrEqual(1);
expect(
flashRequests.length,
`Expected at least one Flash request after mode switch. Models used: ${modelNames.join(', ')}`,
).toBeGreaterThanOrEqual(1);
});
});
+6 -5
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -10089,9 +10089,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.4.11",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz",
"integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
@@ -10116,6 +10116,7 @@
"type-fest": "^4.27.0",
"wrap-ansi": "^9.0.0",
"ws": "^8.18.0",
"yargs": "^17.7.2",
"yoga-layout": "~3.2.1"
},
"engines": {
@@ -17550,7 +17551,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+3 -3
View File
@@ -52,7 +52,7 @@
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --cache --max-warnings 0",
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
"lint:fix": "eslint . --fix --ext .ts,.tsx && eslint integration-tests --fix && eslint scripts --fix && npm run format",
"lint:ci": "npm run lint:all",
"lint:all": "node scripts/lint.js",
@@ -68,7 +68,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -136,7 +136,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+18 -4
View File
@@ -352,23 +352,37 @@ describe('loadConfig', () => {
});
describe('interactivity', () => {
it('should set interactive true when not headless', async () => {
it('should always set interactive true', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: true,
enableInteractiveShell: true,
}),
);
});
it('should set interactive false when headless', async () => {
it('should set enableInteractiveShell based on headless mode', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableInteractiveShell: true,
}),
);
vi.mocked(isHeadlessMode).mockReturnValue(true);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
interactive: false,
enableInteractiveShell: false,
}),
);
+1 -1
View File
@@ -125,7 +125,7 @@ export async function loadConfig(
trustedFolder: true,
extensionLoader,
checkpointing,
interactive: !isHeadlessMode(),
interactive: true,
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
@@ -109,6 +109,12 @@ export function createMockConfig(
enableEnvironmentVariableRedaction: false,
},
}),
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
isExperimentalAgentHistorySummarizationEnabled: vi
.fn()
.mockReturnValue(false),
...overrides,
} as unknown as Config;
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.11",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+261 -2
View File
@@ -28,6 +28,7 @@ import {
LlmRole,
type GitService,
processSingleFileContent,
InvalidStreamError,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -99,6 +100,8 @@ vi.mock(
const actual = await importOriginal();
return {
...actual,
updatePolicy: vi.fn(),
createPolicyUpdater: vi.fn(),
ReadManyFilesTool: vi.fn().mockImplementation(() => ({
name: 'read_many_files',
kind: 'read',
@@ -181,6 +184,20 @@ describe('GeminiAgent', () => {
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
storage: {
getWorkspaceAutoSavedPolicyPath: vi.fn(),
getAutoSavedPolicyPath: vi.fn(),
setClientName: vi.fn(),
},
setClientName: vi.fn(),
get config() {
return this;
},
@@ -201,7 +218,10 @@ describe('GeminiAgent', () => {
(loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig);
(loadSettings as unknown as Mock).mockImplementation(() => ({
merged: {
security: { auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE } },
security: {
auth: { selectedType: AuthType.LOGIN_WITH_GOOGLE },
enablePermanentToolApproval: true,
},
mcpServers: {},
},
setValue: vi.fn(),
@@ -687,7 +707,10 @@ describe('Session', () => {
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: { settings: {} },
merged: {
security: { enablePermanentToolApproval: true },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings);
});
@@ -763,6 +786,32 @@ describe('Session', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (InvalidStreamError)', async () => {
mockChat.sendMessageStream.mockRejectedValue(
new InvalidStreamError('Empty response', 'NO_RESPONSE_TEXT'),
);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle prompt with empty response (NO_RESPONSE_TEXT anomaly)', async () => {
mockChat.sendMessageStream.mockRejectedValue({ type: 'NO_RESPONSE_TEXT' });
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Hi' }],
});
expect(mockChat.sendMessageStream).toHaveBeenCalled();
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
@@ -1026,6 +1075,166 @@ describe('Session', () => {
);
});
it('should exclude always allow and save permanent option when enablePermanentToolApproval is false', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
const confirmationDetails = {
type: 'edit',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
const customSettings = {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: {
security: { enablePermanentToolApproval: false },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings;
const localSession = new Session(
'session-2',
mockChat,
mockConfig,
mockConnection,
customSettings,
);
mockConnection.requestPermission.mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await localSession.prompt({
sessionId: 'session-2',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
}),
]),
}),
);
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlways,
}),
]),
}),
);
});
it('should include always allow and save permanent option when enablePermanentToolApproval is true', async () => {
mockConfig.getDisableAlwaysAllow = vi.fn().mockReturnValue(false);
const confirmationDetails = {
type: 'edit',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
const customSettings = {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: {
security: { enablePermanentToolApproval: true },
mcpServers: {},
},
errors: [],
} as unknown as LoadedSettings;
const localSession = new Session(
'session-2',
mockChat,
mockConfig,
mockConnection,
customSettings,
);
mockConnection.requestPermission.mockResolvedValueOnce({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await localSession.prompt({
sessionId: 'session-2',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
optionId: ToolConfirmationOutcome.ProceedAlwaysAndSave,
name: 'Allow for this file in all future sessions',
}),
]),
}),
);
});
it('should use filePath for ACP diff content in permission request', async () => {
const confirmationDetails = {
type: 'edit',
@@ -1154,6 +1363,56 @@ describe('Session', () => {
);
});
it('should call updatePolicy when tool permission triggers always allow', async () => {
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedAlways,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
const { updatePolicy } = await import('@google/gemini-cli-core');
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(confirmationDetails.onConfirm).toHaveBeenCalled();
expect(updatePolicy).toHaveBeenCalled();
});
it('should use filePath for ACP diff content in tool result', async () => {
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
+87 -5
View File
@@ -48,7 +48,9 @@ import {
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
processSingleFileContent,
InvalidStreamError,
type AgentLoopContext,
updatePolicy,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -64,6 +66,7 @@ import {
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 { z } from 'zod';
@@ -133,6 +136,7 @@ export class GeminiAgent {
args: acp.InitializeRequest,
): Promise<acp.InitializeResponse> {
this.clientCapabilities = args.clientCapabilities;
const authMethods = [
{
id: AuthType.LOGIN_WITH_GOOGLE,
@@ -322,6 +326,7 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(
sessionId,
chat,
@@ -512,6 +517,12 @@ export class GeminiAgent {
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
createPolicyUpdater(
config.getPolicyEngine(),
config.messageBus,
config.storage,
);
return config;
}
@@ -841,6 +852,37 @@ export class Session {
return { stopReason: CoreToolCallStatus.Cancelled };
}
if (
error instanceof InvalidStreamError ||
(error &&
typeof error === 'object' &&
'type' in error &&
error.type === 'NO_RESPONSE_TEXT')
) {
// The stream ended with an empty response or malformed tool call.
// Treat this as a graceful end to the model's turn rather than a crash.
return {
stopReason: 'end_turn',
_meta: {
quota: {
token_count: {
input_tokens: totalInputTokens,
output_tokens: totalOutputTokens,
},
model_usage: Array.from(modelUsageMap.entries()).map(
([modelName, counts]) => ({
model: modelName,
token_count: {
input_tokens: counts.input,
output_tokens: counts.output,
},
}),
),
},
},
};
}
throw new acp.RequestError(
getErrorStatus(error) || 500,
getAcpErrorMessage(error),
@@ -1012,6 +1054,7 @@ export class Session {
options: toPermissionOptions(
confirmationDetails,
this.context.config,
this.settings.merged.security.enablePermanentToolApproval,
),
toolCall: {
toolCallId: callId,
@@ -1036,6 +1079,16 @@ export class Session {
await confirmationDetails.onConfirm(outcome);
// Update policy to enable Always Allow persistence
await updatePolicy(
tool,
outcome,
confirmationDetails,
this.context,
this.context.messageBus,
invocation,
);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
@@ -1785,6 +1838,7 @@ const basicPermissionOptions = [
function toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
config: Config,
enablePermanentToolApproval: boolean = false,
): acp.PermissionOption[] {
const disableAlwaysAllow = config.getDisableAlwaysAllow();
const options: acp.PermissionOption[] = [];
@@ -1794,37 +1848,65 @@ function toPermissionOptions(
case 'edit':
options.push({
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Allow All Edits',
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: `Always Allow ${confirmation.rootCommand}`,
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: `Always Allow ${confirmation.serverName}`,
name: 'Allow all server tools for this session',
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
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: `Always Allow`,
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':
+8
View File
@@ -91,6 +91,14 @@ describe('GeminiAgent Session Resume', () => {
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
}),
messageBus: {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
+21 -12
View File
@@ -143,12 +143,17 @@ vi.mock('@google/gemini-cli-core', async () => {
respectGeminiIgnore: true,
customIgnoreFilePaths: [],
},
createPolicyEngineConfig: vi.fn(async () => ({
rules: [],
checkers: [],
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
createPolicyEngineConfig: vi.fn(
async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({
rules: [],
checkers: [],
defaultDecision: interactive
? ServerConfig.PolicyDecision.ASK_USER
: ServerConfig.PolicyDecision.DENY,
approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT,
nonInteractive: !interactive,
}),
),
getAdminErrorMessage: vi.fn(
(_feature) =>
`YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`,
@@ -984,6 +989,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200, // maxDirs
['.git'], // boundaryMarkers
);
});
@@ -1013,6 +1019,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
@@ -1041,6 +1048,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
});
@@ -1117,12 +1125,7 @@ describe('mergeExcludeTools', () => {
]);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const config = await loadCliConfig(
settings,
'test-session',
argv,
);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getExcludeTools()).toEqual(
new Set(['tool1', 'tool2', 'tool3', 'tool4', 'tool5']),
);
@@ -3460,6 +3463,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3481,6 +3486,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
}),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -3504,6 +3511,8 @@ describe('Policy Engine Integration in loadCliConfig', () => {
],
}),
expect.anything(),
undefined,
expect.anything(),
);
});
});
+11 -1
View File
@@ -642,6 +642,7 @@ export async function loadCliConfig(
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
);
memoryContent = result.memoryContent;
fileCount = result.fileCount;
@@ -792,8 +793,8 @@ export async function loadCliConfig(
effectiveSettings,
approvalMode,
workspacePoliciesDir,
interactive,
);
policyEngineConfig.nonInteractive = !interactive;
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const specifiedModel =
@@ -896,6 +897,7 @@ export async function loadCliConfig(
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
discoveryMaxDirs: settings.context?.discoveryMaxDirs,
memoryBoundaryMarkers: settings.context?.memoryBoundaryMarkers,
importFormat: settings.context?.importFormat,
debugMode,
question,
@@ -975,6 +977,14 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
experimentalAgentHistoryTruncation:
settings.experimental?.agentHistoryTruncation,
experimentalAgentHistoryTruncationThreshold:
settings.experimental?.agentHistoryTruncationThreshold,
experimentalAgentHistoryRetainedMessages:
settings.experimental?.agentHistoryRetainedMessages,
experimentalAgentHistorySummarization:
settings.experimental?.agentHistorySummarization,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
@@ -199,6 +199,7 @@ describe('ExtensionManager theme loading', () => {
respectGeminiIgnore: true,
}),
getDiscoveryMaxDirs: () => 200,
getMemoryBoundaryMarkers: () => ['.git'],
getMcpClientManager: () => ({
getMcpInstructions: () => '',
startExtension: vi.fn().mockResolvedValue(undefined),
@@ -605,12 +605,12 @@ describe('Policy Engine Integration Tests', () => {
it('should verify non-interactive mode transformation', async () => {
const settings: Settings = {};
const config = await createPolicyEngineConfig(
const engineConfig = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
undefined,
false,
);
// Enable non-interactive mode
const engineConfig = { ...config, nonInteractive: true };
const engine = new PolicyEngine(engineConfig);
// ASK_USER should become DENY in non-interactive mode
+7 -1
View File
@@ -53,6 +53,7 @@ export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
workspacePoliciesDir?: string,
interactive: boolean = true,
): Promise<PolicyEngineConfig> {
// Explicitly construct PolicySettings from Settings to ensure type safety
// and avoid accidental leakage of other settings properties.
@@ -68,7 +69,12 @@ export async function createPolicyEngineConfig(
settings.admin?.secureModeEnabled,
};
return createCorePolicyEngineConfig(policySettings, approvalMode);
return createCorePolicyEngineConfig(
policySettings,
approvalMode,
undefined,
interactive,
);
}
export function createPolicyUpdater(
+18 -18
View File
@@ -93,7 +93,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -122,7 +122,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'lxc',
image: 'default/image',
});
@@ -148,7 +148,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -161,7 +161,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'sandbox-exec',
image: 'default/image',
});
@@ -174,7 +174,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -187,7 +187,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -210,7 +210,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'podman',
image: 'default/image',
});
@@ -244,7 +244,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'env/image',
});
@@ -257,7 +257,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -285,7 +285,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'docker',
image: 'default/image',
});
@@ -339,7 +339,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
command: 'podman',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -356,7 +356,7 @@ describe('loadSandboxConfig', () => {
enabled: true,
image: 'custom/image',
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -372,7 +372,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: false,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -388,7 +388,7 @@ describe('loadSandboxConfig', () => {
sandbox: {
enabled: true,
allowedPaths: ['/settings-path'],
networkAccess: false,
networkAccess: true,
},
},
},
@@ -410,7 +410,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -425,7 +425,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -442,7 +442,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
@@ -460,7 +460,7 @@ describe('loadSandboxConfig', () => {
expect(config).toEqual({
enabled: true,
allowedPaths: [],
networkAccess: false,
networkAccess: true,
command: 'runsc',
image: 'default/image',
});
+2 -2
View File
@@ -131,7 +131,7 @@ export async function loadSandboxConfig(
let sandboxValue: boolean | string | null | undefined;
let allowedPaths: string[] = [];
let networkAccess = false;
let networkAccess = true;
let customImage: string | undefined;
if (
@@ -142,7 +142,7 @@ export async function loadSandboxConfig(
const config = sandboxOption;
sandboxValue = config.enabled ? (config.command ?? true) : false;
allowedPaths = config.allowedPaths ?? [];
networkAccess = config.networkAccess ?? false;
networkAccess = config.networkAccess ?? true;
customImage = config.image;
} else if (typeof sandboxOption !== 'object' || sandboxOption === null) {
sandboxValue = sandboxOption;
+54 -1
View File
@@ -261,7 +261,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: false,
description:
'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.',
'Enable run-event notifications for action-required prompts and session completion.',
showInDialog: true,
},
checkpointing: {
@@ -1291,6 +1291,19 @@ const SETTINGS_SCHEMA = {
description: 'Maximum number of directories to search for memory.',
showInDialog: true,
},
memoryBoundaryMarkers: {
type: 'array',
label: 'Memory Boundary Markers',
category: 'Context',
requiresRestart: true,
default: ['.git'] as string[],
description:
'File or directory names that mark the boundary for GEMINI.md discovery. ' +
'The upward traversal stops at the first directory containing any of these markers. ' +
'An empty array disables parent traversal.',
showInDialog: false,
items: { type: 'string' },
},
includeDirectories: {
type: 'array',
label: 'Include Directories',
@@ -2141,6 +2154,46 @@ const SETTINGS_SCHEMA = {
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
showInDialog: true,
},
agentHistoryTruncation: {
type: 'boolean',
label: 'Agent History Truncation',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable truncation window logic for the Agent History Provider.',
showInDialog: true,
},
agentHistoryTruncationThreshold: {
type: 'number',
label: 'Agent History Truncation Threshold',
category: 'Experimental',
requiresRestart: true,
default: 30,
description:
'The maximum number of messages before history is truncated.',
showInDialog: true,
},
agentHistoryRetainedMessages: {
type: 'number',
label: 'Agent History Retained Messages',
category: 'Experimental',
requiresRestart: true,
default: 15,
description:
'The number of recent messages to retain after truncation.',
showInDialog: true,
},
agentHistorySummarization: {
type: 'boolean',
label: 'Agent History Summarization',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable summarization of truncated content via a small model for the Agent History Provider.',
showInDialog: true,
},
topicUpdateNarration: {
type: 'boolean',
label: 'Topic & Update Narration',
@@ -88,6 +88,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -107,6 +109,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -131,6 +135,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -163,6 +169,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -201,6 +209,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -237,6 +247,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
),
}),
expect.anything(),
undefined,
expect.anything(),
);
});
@@ -278,6 +290,8 @@ describe('Workspace-Level Policy CLI Integration', () => {
workspacePoliciesDir: undefined,
}),
expect.anything(),
undefined,
expect.anything(),
);
} finally {
// Restore for other tests
-5
View File
@@ -671,11 +671,6 @@ export async function main() {
}
}
// Register SessionEnd hook for graceful exit
registerCleanup(async () => {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
if (!input) {
debugLogger.error(
`No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`,
+86 -4
View File
@@ -6,7 +6,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { main } from './gemini.js';
import { debugLogger, type Config } from '@google/gemini-cli-core';
import {
debugLogger,
SessionEndReason,
type Config,
type HookSystem,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -197,11 +202,11 @@ describe('gemini.tsx main function cleanup', () => {
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
errors: [],
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as ReturnType<typeof loadSettings>);
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
@@ -238,7 +243,8 @@ describe('gemini.tsx main function cleanup', () => {
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
getUseAlternateBuffer: vi.fn(() => false),
} as unknown as Config);
await main();
@@ -248,4 +254,80 @@ describe('gemini.tsx main function cleanup', () => {
expect.objectContaining({ message: 'Cleanup failed' }),
);
});
it('should register SessionEnd hook exactly once in non-interactive mode', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { registerCleanup } = await import('./utils/cleanup.js');
const mockHookSystem = {
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
} as unknown as HookSystem;
vi.mocked(parseArguments).mockResolvedValue({
promptInteractive: false,
} as unknown as Awaited<ReturnType<typeof parseArguments>>);
vi.mocked(loadCliConfig).mockResolvedValue(
buildMockConfig({
getHookSystem: vi.fn(() => mockHookSystem),
}),
);
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
await main();
const registeredCallbacks = vi
.mocked(registerCleanup)
.mock.calls.map(([fn]) => fn);
for (const fn of registeredCallbacks) await fn();
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1);
expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Exit,
);
});
function buildMockConfig(overrides: Partial<Config> = {}): Config {
return {
isInteractive: vi.fn(() => false),
getQuestion: vi.fn(() => 'test'),
getSandbox: vi.fn(() => false),
getDebugMode: vi.fn(() => false),
getPolicyEngine: vi.fn(),
getMessageBus: () => ({ subscribe: vi.fn() }),
getEnableHooks: vi.fn(() => true),
getHookSystem: vi.fn(() => undefined),
initialize: vi.fn(),
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
getContentGeneratorConfig: vi.fn(),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
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'),
getApprovalMode: vi.fn(() => 'default'),
getCoreTools: vi.fn(() => []),
getTelemetryEnabled: vi.fn(() => false),
getTelemetryLogPromptsEnabled: vi.fn(() => false),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => false),
setTerminalBackground: vi.fn(),
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
getUseAlternateBuffer: vi.fn(() => false),
...overrides,
} as unknown as Config;
}
});
@@ -163,6 +163,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type { SpinnerName } from 'cli-spinners';
export function mockInkSpinner() {
vi.mock('ink-spinner', async () => {
const { Text } = await import('ink');
const cliSpinners = (await import('cli-spinners')).default;
return {
default: function MockSpinner({ type = 'dots' }: { type?: SpinnerName }) {
const spinner = cliSpinners[type];
const frame = spinner ? spinner.frames[0] : '⠋';
return <Text>{frame}</Text>;
},
};
});
}
+1
View File
@@ -568,6 +568,7 @@ const mockUIActions: UIActions = {
handleOverageMenuChoice: vi.fn(),
handleEmptyWalletChoice: vi.fn(),
setQueueErrorMessage: vi.fn(),
addMessage: vi.fn(),
popAllMessages: vi.fn(),
handleApiKeySubmit: vi.fn(),
handleApiKeyCancel: vi.fn(),
+3 -1
View File
@@ -726,7 +726,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Wrap handleDeleteSession to return a Promise for UIActions interface
const handleDeleteSession = useCallback(
async (session: SessionInfo): Promise<void> => {
handleDeleteSessionSync(session);
await handleDeleteSessionSync(session);
},
[handleDeleteSessionSync],
);
@@ -2502,6 +2502,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -2593,6 +2594,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleResumeSession,
handleDeleteSession,
setQueueErrorMessage,
addMessage,
popAllMessages,
handleApiKeySubmit,
handleApiKeyCancel,
@@ -9,6 +9,7 @@ import {
SessionEndReason,
SessionStartSource,
flushTelemetry,
resetBrowserSession,
} from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
@@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = {
if (geminiClient) {
context.ui.setDebugMessage('Clearing terminal and resetting chat.');
// Close persistent browser sessions before resetting chat
await resetBrowserSession();
// If resetChat fails, the exception will propagate and halt the command,
// which is the correct behavior to signal a failure to the user.
await geminiClient.resetChat();
@@ -152,6 +152,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
@@ -191,6 +191,7 @@ describe('InputPrompt', () => {
setCleanUiDetailsVisible: mockSetCleanUiDetailsVisible,
toggleCleanUiDetailsVisible: mockToggleCleanUiDetailsVisible,
revealCleanUiDetailsTemporarily: mockRevealCleanUiDetailsTemporarily,
addMessage: vi.fn(),
};
beforeEach(() => {
@@ -352,6 +353,8 @@ describe('InputPrompt', () => {
vi.mocked(clipboardy.read).mockResolvedValue('');
props = {
onQueueMessage: vi.fn(),
buffer: mockBuffer,
onSubmit: vi.fn(),
userMessages: [],
@@ -1099,6 +1102,76 @@ describe('InputPrompt', () => {
unmount();
});
it('queues a message when Tab is pressed during generation', async () => {
props.buffer.setText('A new prompt');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.onQueueMessage).toHaveBeenCalledWith('A new prompt');
expect(props.buffer.text).toBe('');
});
unmount();
});
it('shows an error when attempting to queue a slash command', async () => {
props.buffer.setText('/clear');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Slash commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('shows an error when attempting to queue a shell command', async () => {
props.shellModeActive = true;
props.buffer.setText('ls');
props.streamingState = StreamingState.Responding;
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t');
});
await waitFor(() => {
expect(props.setQueueErrorMessage).toHaveBeenCalledWith(
'Shell commands cannot be queued',
);
expect(props.onQueueMessage).not.toHaveBeenCalled();
});
unmount();
});
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
props.buffer.setText(' '); // Set buffer to whitespace
@@ -117,6 +117,7 @@ export interface InputPromptProps {
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
popAllMessages?: () => string | undefined;
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
@@ -211,6 +212,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setQueueErrorMessage,
streamingState,
popAllMessages,
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
@@ -690,6 +692,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation;
const isQueueMessageKey = keyMatchers[Command.QUEUE_MESSAGE](key);
const isPlainTab =
key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd;
const hasTabCompletionInteraction =
@@ -698,6 +701,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive ||
commandSearchActive;
if (
isGenerating &&
isQueueMessageKey &&
!hasTabCompletionInteraction &&
buffer.text.trim().length > 0
) {
const trimmedMessage = buffer.text.trim();
const isSlash = isSlashCommand(trimmedMessage);
if (isSlash || shellModeActive) {
setQueueErrorMessage(
`${shellModeActive ? 'Shell' : 'Slash'} commands cannot be queued`,
);
} else if (onQueueMessage) {
onQueueMessage(buffer.text);
buffer.setText('');
resetCompletionState();
resetReverseSearchCompletionState();
}
resetPlainTabPress();
return true;
}
if (isPlainTab && shellModeActive) {
resetPlainTabPress();
if (!shouldShowSuggestions) {
@@ -1293,6 +1319,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shortcutsHelpVisible,
setShortcutsHelpVisible,
tryLoadQueuedMessages,
onQueueMessage,
setQueueErrorMessage,
resetReverseSearchCompletionState,
setBannerVisible,
activePtyId,
setEmbeddedShellFocused,
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../hooks/useComposerStatus.js', () => ({
useComposerStatus: vi.fn(),
}));
describe('<StatusRow />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const defaultUiState: Partial<UIState> = {
currentTip: undefined,
thought: null,
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
it('renders status and tip correctly when they both fit', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
currentWittyPhrase: 'I am witty',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
expect(output).toContain('I am witty');
expect(output).toContain('Tip: Test Tip');
});
it('renders correctly when interactive shell is waiting', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: true,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState: defaultUiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
});
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
});
+24 -11
View File
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
onResize={onStatusResize}
/>
);
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -60,7 +60,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -71,7 +71,7 @@
<text x="828" y="308" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">false</text>
<text x="891" y="308" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="325" fill="#afafaf" textLength="756" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="45" y="325" fill="#afafaf" textLength="738" lengthAdjust="spacingAndGlyphs">Enable run-event notifications for action-required prompts and session completion.</text>
<text x="891" y="325" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -20,7 +20,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -66,7 +66,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -112,7 +112,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -204,7 +204,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -250,7 +250,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -296,7 +296,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -342,7 +342,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -388,7 +388,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable automatic updates. │
│ │
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion.
│ Enable run-event notifications for action-required prompts and session completion.
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
@@ -8,11 +8,6 @@ import { render, cleanup } from '../../../test-utils/render.js';
import { SubagentProgressDisplay } from './SubagentProgressDisplay.js';
import type { SubagentProgress } from '@google/gemini-cli-core';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { Text } from 'ink';
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
}));
describe('<SubagentProgressDisplay />', () => {
afterEach(() => {
@@ -70,6 +70,7 @@ export interface UIActions {
handleResumeSession: (session: SessionInfo) => Promise<void>;
handleDeleteSession: (session: SessionInfo) => Promise<void>;
setQueueErrorMessage: (message: string | null) => void;
addMessage: (message: string) => void;
popAllMessages: () => string | undefined;
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
@@ -45,20 +45,18 @@ function addShellCommandToGeminiHistory(
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
: resultText;
// Escape backticks to prevent prompt injection breakouts
const safeQuery = rawQuery.replace(/\\/g, '\\\\').replace(/\x60/g, '\\\x60');
const safeModelContent = modelContent
.replace(/\\/g, '\\\\')
.replace(/\x60/g, '\\\x60');
// eslint-disable-next-line @typescript-eslint/no-floating-promises
geminiClient.addHistory({
role: 'user',
parts: [
{
text: `I ran the following shell command:
\`\`\`sh
${rawQuery}
\`\`\`
This produced the following result:
\`\`\`
${modelContent}
\`\`\``,
text: `I ran the following shell command:\n\`\`\`sh\n${safeQuery}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${safeModelContent}\n\`\`\``,
},
],
});
@@ -444,7 +442,7 @@ export const useShellCommandProcessor = (
}
let mainContent: string;
if (isBinary(result.rawOutput)) {
if (isBinaryStream || isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
@@ -98,7 +98,7 @@ export const useSessionBrowser = (
* Deletes a session by ID using the ChatRecordingService.
*/
handleDeleteSession: useCallback(
(session: SessionInfo) => {
async (session: SessionInfo) => {
// Note: Chat sessions are stored on disk using a filename derived from
// the session, e.g. "session-<timestamp>-<sessionIdPrefix>.json".
// The ChatRecordingService.deleteSession API expects this file basename
@@ -108,7 +108,7 @@ export const useSessionBrowser = (
.getGeminiClient()
?.getChatRecordingService();
if (chatRecordingService) {
chatRecordingService.deleteSession(session.file);
await chatRecordingService.deleteSession(session.file);
}
} catch (error) {
coreEvents.emitFeedback('error', 'Error deleting session:', error);
@@ -691,6 +691,40 @@ describe('useSlashCompletion', () => {
});
unmount();
});
it('should rank primary name prefix matches higher than alias prefix matches', async () => {
const slashCommands = [
createTestCommand({
name: 'footer',
altNames: ['statusline'],
description: 'Configure footer',
}),
createTestCommand({
name: 'stats',
altNames: ['usage'],
description: 'Check stats',
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/stat',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
// 'stats' should be first because 'stat' is a prefix match on its name
// while 'footer' only matches 'stat' via its alias 'statusline'
expect(result.current.suggestions[0].label).toBe('stats');
expect(result.current.suggestions[1].label).toBe('footer');
});
unmount();
});
});
describe('Sub-Commands', () => {
@@ -272,13 +272,45 @@ function useCommandSuggestions(
}
if (!signal.aborted) {
// Sort potentialSuggestions so that exact match (by name or altName) comes first
// Sort potentialSuggestions so that exact name/prefix match comes first,
// prioritizing primary name over altNames.
const lowerPartial = partial.toLowerCase();
const sortedSuggestions = [...potentialSuggestions].sort((a, b) => {
const aIsExact = matchesCommand(a, partial);
const bIsExact = matchesCommand(b, partial);
if (aIsExact && !bIsExact) return -1;
if (!aIsExact && bIsExact) return 1;
return 0;
// 1. Exact name match
const aNameExact = a.name.toLowerCase() === lowerPartial;
const bNameExact = b.name.toLowerCase() === lowerPartial;
if (aNameExact && !bNameExact) return -1;
if (!aNameExact && bNameExact) return 1;
// 2. Exact altName match
const aAltExact =
a.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) ||
false;
const bAltExact =
b.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) ||
false;
if (aAltExact && !bAltExact) return -1;
if (!aAltExact && bAltExact) return 1;
// 3. Prefix name match
const aNamePrefix = a.name.toLowerCase().startsWith(lowerPartial);
const bNamePrefix = b.name.toLowerCase().startsWith(lowerPartial);
if (aNamePrefix && !bNamePrefix) return -1;
if (!aNamePrefix && bNamePrefix) return 1;
// 4. Prefix altName match
const aAltPrefix =
a.altNames?.some((alt) =>
alt.toLowerCase().startsWith(lowerPartial),
) || false;
const bAltPrefix =
b.altNames?.some((alt) =>
alt.toLowerCase().startsWith(lowerPartial),
) || false;
if (aAltPrefix && !bAltPrefix) return -1;
if (!aAltPrefix && bAltPrefix) return 1;
return 0; // Maintain FZF score order for other matches
});
const finalSuggestions = sortedSuggestions.map((cmd) => {
+5
View File
@@ -74,6 +74,7 @@ export enum Command {
// Text Input
SUBMIT = 'input.submit',
QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
@@ -354,6 +355,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// Text Input
// Must also exclude shift to allow shift+enter for newline
[Command.SUBMIT, [new KeyBinding('enter')]],
[Command.QUEUE_MESSAGE, [new KeyBinding('tab')]],
[
Command.NEWLINE,
[
@@ -488,6 +490,7 @@ export const commandCategories: readonly CommandCategory[] = [
title: 'Text Input',
commands: [
Command.SUBMIT,
Command.QUEUE_MESSAGE,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
@@ -593,6 +596,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Text Input
[Command.SUBMIT]: 'Submit the current prompt.',
[Command.QUEUE_MESSAGE]:
'Queue the current prompt to be processed after the current task finishes.',
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',
+20 -1
View File
@@ -803,7 +803,26 @@ function setupNetworkLogging(
// Flush buffered logs
flushBuffer();
break;
case 'trigger-debugger': {
import('node:inspector')
.then((inspector) => {
inspector.open();
debugLogger.log(
'Node debugger attached. Open chrome://inspect in Chrome to start debugging.',
);
return import('./events.js');
})
.then(({ appEvents, AppEvent, TransientMessageType }) => {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Debugger attached from DevTools.',
type: TransientMessageType.Hint,
});
})
.catch((err) =>
debugLogger.debug('Failed to trigger debugger:', err),
);
break;
}
case 'ping':
sendMessage({ type: 'pong', timestamp: Date.now() });
break;
+8
View File
@@ -11,6 +11,7 @@ import {
shutdownTelemetry,
isTelemetrySdkInitialized,
ExitCodes,
resetBrowserSession,
} from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
@@ -72,6 +73,13 @@ export async function runExitCleanup() {
}
cleanupFunctions.length = 0; // Clear the array
// Close persistent browser sessions before disposing config
try {
await resetBrowserSession();
} catch (_) {
// Ignore errors during browser cleanup
}
if (configForTelemetry) {
try {
await configForTelemetry.dispose();
+4 -3
View File
@@ -19,6 +19,7 @@ import {
debugLogger,
coreEvents,
getErrorMessage,
getErrorType,
} from '@google/gemini-cli-core';
import { runSyncCleanup } from './cleanup.js';
@@ -82,7 +83,7 @@ export function handleError(
timestamp: new Date().toISOString(),
status: 'error',
error: {
type: error instanceof Error ? error.constructor.name : 'Error',
type: getErrorType(error),
message: errorMessage,
},
stats: streamFormatter.convertToStreamStats(metrics, 0),
@@ -177,7 +178,7 @@ export function handleCancellationError(config: Config): never {
timestamp: new Date().toISOString(),
status: 'error',
error: {
type: 'FatalCancellationError',
type: getErrorType(cancellationError),
message: cancellationError.message,
},
stats: streamFormatter.convertToStreamStats(metrics, 0),
@@ -218,7 +219,7 @@ export function handleMaxTurnsExceededError(config: Config): never {
timestamp: new Date().toISOString(),
status: 'error',
error: {
type: 'FatalTurnLimitedError',
type: getErrorType(maxTurnsError),
message: maxTurnsError.message,
},
stats: streamFormatter.convertToStreamStats(metrics, 0),
@@ -106,6 +106,8 @@ describe('Session Cleanup (Refactored)', () => {
);
// Session directory
await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true });
// Subagent chats directory
await fs.mkdir(path.join(chatsDir, sessionId), { recursive: true });
}
async function seedSessions() {
@@ -274,6 +276,7 @@ describe('Session Cleanup (Refactored)', () => {
existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)),
).toBe(false);
expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted
expect(existsSync(path.join(chatsDir, sessions[1].id))).toBe(false); // Subagent chats directory should be deleted
});
it('should NOT delete sessions within the cutoff date', async () => {
+8 -36
View File
@@ -13,6 +13,8 @@ import {
Storage,
TOOL_OUTPUTS_DIR,
type Config,
deleteSessionArtifactsAsync,
deleteSubagentSessionDirAndArtifactsAsync,
} from '@google/gemini-cli-core';
import type { Settings, SessionRetentionSettings } from '../config/settings.js';
import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js';
@@ -59,48 +61,18 @@ function deriveShortIdFromFileName(fileName: string): string | null {
return null;
}
/**
* Gets the log path for a session ID.
*/
function getSessionLogPath(tempDir: string, safeSessionId: string): string {
return path.join(tempDir, 'logs', `session-${safeSessionId}.jsonl`);
}
/**
* Cleans up associated artifacts (logs, tool-outputs, directory) for a session.
*/
async function deleteSessionArtifactsAsync(
async function cleanupSessionAndSubagentsAsync(
sessionId: string,
config: Config,
): Promise<void> {
const tempDir = config.storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
// Cleanup logs
const logsDir = path.join(tempDir, 'logs');
const safeSessionId = sanitizeFilenamePart(sessionId);
const logPath = getSessionLogPath(tempDir, safeSessionId);
if (logPath.startsWith(logsDir)) {
await fs.unlink(logPath).catch(() => {});
}
// Cleanup tool outputs
const toolOutputDir = path.join(
tempDir,
TOOL_OUTPUTS_DIR,
`session-${safeSessionId}`,
);
const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR);
if (toolOutputDir.startsWith(toolOutputsBase)) {
await fs
.rm(toolOutputDir, { recursive: true, force: true })
.catch(() => {});
}
// Cleanup session directory
const sessionDir = path.join(tempDir, safeSessionId);
if (safeSessionId && sessionDir.startsWith(tempDir + path.sep)) {
await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => {});
}
await deleteSessionArtifactsAsync(sessionId, tempDir);
await deleteSubagentSessionDirAndArtifactsAsync(sessionId, chatsDir, tempDir);
}
/**
@@ -201,7 +173,7 @@ export async function cleanupExpiredSessions(
await fs.unlink(filePath);
if (fullSessionId) {
await deleteSessionArtifactsAsync(fullSessionId, config);
await cleanupSessionAndSubagentsAsync(fullSessionId, config);
}
result.deleted++;
} else {
@@ -230,7 +202,7 @@ export async function cleanupExpiredSessions(
const sessionId = sessionToDelete.sessionInfo?.id;
if (sessionId) {
await deleteSessionArtifactsAsync(sessionId, config);
await cleanupSessionAndSubagentsAsync(sessionId, config);
}
if (config.getDebugMode()) {
+1 -1
View File
@@ -97,7 +97,7 @@ export async function deleteSession(
try {
// Use ChatRecordingService to delete the session
const chatRecordingService = new ChatRecordingService(config);
chatRecordingService.deleteSession(sessionToDelete.file);
await chatRecordingService.deleteSession(sessionToDelete.file);
const time = formatRelativeTime(sessionToDelete.lastUpdated);
writeToStdout(
@@ -43,7 +43,7 @@ describe('terminal notifications', () => {
});
});
it('returns false without writing on non-macOS platforms', async () => {
it('emits notification on non-macOS platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
configurable: true,
@@ -54,8 +54,8 @@ describe('terminal notifications', () => {
body: 'b',
});
expect(shown).toBe(false);
expect(writeToStdout).not.toHaveBeenCalled();
expect(shown).toBe(true);
expect(writeToStdout).toHaveBeenCalled();
});
it('returns false without writing when disabled', async () => {
@@ -69,6 +69,7 @@ describe('terminal notifications', () => {
});
it('emits OSC 9 notification when supported terminal is detected', async () => {
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
@@ -126,6 +127,7 @@ describe('terminal notifications', () => {
});
it('strips terminal control sequences and newlines from payload text', async () => {
vi.stubEnv('WT_SESSION', '');
vi.stubEnv('TERM_PROGRAM', 'iTerm.app');
const shown = await notifyViaTerminal(true, {
@@ -75,17 +75,10 @@ export function buildRunEventNotificationContent(
export function isNotificationsEnabled(settings: LoadedSettings): boolean {
const general = settings.merged.general as
| {
enableNotifications?: boolean;
enableMacOsNotifications?: boolean;
}
| { enableNotifications?: boolean }
| undefined;
return (
process.platform === 'darwin' &&
(general?.enableNotifications === true ||
general?.enableMacOsNotifications === true)
);
return general?.enableNotifications === true;
}
function buildTerminalNotificationMessage(
@@ -112,7 +105,7 @@ export async function notifyViaTerminal(
notificationsEnabled: boolean,
content: RunEventNotificationContent,
): Promise<boolean> {
if (!notificationsEnabled || process.platform !== 'darwin') {
if (!notificationsEnabled) {
return false;
}
+4
View File
@@ -8,6 +8,10 @@ import { vi, beforeEach, afterEach } from 'vitest';
import { format } from 'node:util';
import { coreEvents } from '@google/gemini-cli-core';
import { themeManager } from './src/ui/themes/theme-manager.js';
import { mockInkSpinner } from './src/test-utils/mockSpinner.js';
// Globally mock ink-spinner to prevent non-deterministic snapshot/act flakes.
mockInkSpinner();
// Unset CI environment variable so that ink renders dynamically as it does in a real terminal
if (process.env.CI !== undefined) {
@@ -7,7 +7,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
resetBrowserSession,
} from './browserAgentFactory.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { makeFakeConfig } from '../../test-utils/config.js';
@@ -15,7 +15,6 @@ import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../../policy/types.js';
import type { Config } from '../../config/config.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import type { PolicyEngine } from '../../policy/policy-engine.js';
import type { BrowserManager } from './browserManager.js';
// Create mock browser manager
const mockBrowserManager = {
@@ -35,9 +34,17 @@ const mockBrowserManager = {
};
// Mock dependencies
vi.mock('./browserManager.js', () => ({
BrowserManager: vi.fn(() => mockBrowserManager),
}));
vi.mock('./browserManager.js', () => {
const instancesMap = new Map();
const MockBrowserManager = vi.fn() as unknown as Record<string, unknown>;
// Add static methods — use mockImplementation for lazy eval (hoisting-safe)
MockBrowserManager['getInstance'] = vi.fn();
MockBrowserManager['resetAll'] = vi.fn().mockResolvedValue(undefined);
MockBrowserManager['instances'] = instancesMap;
return {
BrowserManager: MockBrowserManager,
};
});
vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
@@ -60,9 +67,16 @@ describe('browserAgentFactory', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
// Set up getInstance to return mockBrowserManager
// (Can't do this in vi.mock factory due to hoisting)
const { BrowserManager: MockBM } = await import('./browserManager.js');
(MockBM as unknown as Record<string, ReturnType<typeof vi.fn>>)[
'getInstance'
].mockReturnValue(mockBrowserManager);
vi.mocked(injectAutomationOverlay).mockClear();
// Reset mock implementations
@@ -99,7 +113,7 @@ describe('browserAgentFactory', () => {
} as unknown as MessageBus;
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
});
@@ -302,6 +316,23 @@ describe('browserAgentFactory', () => {
});
});
describe('resetBrowserSession', () => {
it('should delegate to BrowserManager.resetAll', async () => {
const { BrowserManager: MockBrowserManager } = await import(
'./browserManager.js'
);
await resetBrowserSession();
expect(
(
MockBrowserManager as unknown as Record<
string,
ReturnType<typeof vi.fn>
>
)['resetAll'],
).toHaveBeenCalled();
});
});
describe('Policy Registration', () => {
let mockPolicyEngine: {
addRule: ReturnType<typeof vi.fn>;
@@ -421,25 +452,6 @@ describe('browserAgentFactory', () => {
);
});
});
describe('cleanupBrowserAgent', () => {
it('should call close on browser manager', async () => {
await cleanupBrowserAgent(
mockBrowserManager as unknown as BrowserManager,
);
expect(mockBrowserManager.close).toHaveBeenCalled();
});
it('should handle errors during cleanup gracefully', async () => {
const errorManager = {
close: vi.fn().mockRejectedValue(new Error('Close failed')),
} as unknown as BrowserManager;
// Should not throw
await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined();
});
});
});
describe('buildBrowserSystemPrompt', () => {
@@ -62,8 +62,8 @@ export async function createBrowserAgentDefinition(
'Creating browser agent definition with isolated MCP tools...',
);
// Create and initialize browser manager with isolated MCP client
const browserManager = new BrowserManager(config);
// Get or create browser manager singleton for this session mode/profile
const browserManager = BrowserManager.getInstance(config);
await browserManager.ensureConnection();
if (printOutput) {
@@ -242,19 +242,10 @@ export async function createBrowserAgentDefinition(
}
/**
* Cleans up browser resources after agent execution.
* Closes all persistent browser sessions and cleans up resources.
*
* @param browserManager The browser manager to clean up
* Call this on /clear commands and CLI exit to reset browser state.
*/
export async function cleanupBrowserAgent(
browserManager: BrowserManager,
): Promise<void> {
try {
await browserManager.close();
debugLogger.log('Browser agent cleanup complete');
} catch (error) {
debugLogger.error(
`Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`,
);
}
export async function resetBrowserSession(): Promise<void> {
await BrowserManager.resetAll();
}
@@ -26,7 +26,10 @@ vi.mock('../../utils/debugLogger.js', () => ({
vi.mock('./browserAgentFactory.js', () => ({
createBrowserAgentDefinition: vi.fn(),
cleanupBrowserAgent: vi.fn(),
}));
vi.mock('./inputBlocker.js', () => ({
removeInputBlocker: vi.fn(),
}));
vi.mock('../local-executor.js', () => ({
@@ -35,10 +38,8 @@ vi.mock('../local-executor.js', () => ({
},
}));
import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
import { removeInputBlocker } from './inputBlocker.js';
import { LocalAgentExecutor } from '../local-executor.js';
import type { ToolLiveOutput } from '../../tools/tools.js';
@@ -190,7 +191,7 @@ describe('BrowserAgentInvocation', () => {
vi.mocked(LocalAgentExecutor.create).mockResolvedValue(
mockExecutor as never,
);
vi.mocked(cleanupBrowserAgent).mockClear();
vi.mocked(removeInputBlocker).mockClear();
});
it('should return result text and call cleanup on success', async () => {
@@ -209,7 +210,7 @@ describe('BrowserAgentInvocation', () => {
expect((result.llmContent as Array<{ text: string }>)[0].text).toContain(
'Browser agent finished',
);
expect(cleanupBrowserAgent).toHaveBeenCalled();
expect(removeInputBlocker).toHaveBeenCalled();
});
it('should work without updateOutput (fire-and-forget)', async () => {
@@ -239,7 +240,7 @@ describe('BrowserAgentInvocation', () => {
const result = await invocation.execute(controller.signal);
expect(result.error).toBeDefined();
expect(cleanupBrowserAgent).toHaveBeenCalled();
expect(removeInputBlocker).toHaveBeenCalled();
});
// ─── Structured SubagentProgress emission tests ───────────────────────
@@ -33,10 +33,7 @@ import {
isToolActivityError,
} from '../types.js';
import type { MessageBus } from '../../confirmation-bus/message-bus.js';
import {
createBrowserAgentDefinition,
cleanupBrowserAgent,
} from './browserAgentFactory.js';
import { createBrowserAgentDefinition } from './browserAgentFactory.js';
import { removeInputBlocker } from './inputBlocker.js';
import {
sanitizeThoughtContent,
@@ -368,10 +365,9 @@ ${displayResult}
},
};
} finally {
// Always cleanup browser resources
// Clean up input blocker, but keep browserManager alive for persistent sessions
if (browserManager) {
await removeInputBlocker(browserManager);
await cleanupBrowserAgent(browserManager);
}
}
}
@@ -9,6 +9,7 @@ import { BrowserManager } from './browserManager.js';
import { makeFakeConfig } from '../../test-utils/config.js';
import type { Config } from '../../config/config.js';
import { injectAutomationOverlay } from './automationOverlay.js';
import { injectInputBlocker } from './inputBlocker.js';
import { coreEvents } from '../../utils/events.js';
// Mock the MCP SDK
@@ -54,6 +55,13 @@ vi.mock('./automationOverlay.js', () => ({
injectAutomationOverlay: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./inputBlocker.js', () => ({
injectInputBlocker: vi.fn().mockResolvedValue(undefined),
removeInputBlocker: vi.fn().mockResolvedValue(undefined),
suspendInputBlocker: vi.fn().mockResolvedValue(undefined),
resumeInputBlocker: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
@@ -78,6 +86,7 @@ describe('BrowserManager', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(injectAutomationOverlay).mockClear();
vi.mocked(injectInputBlocker).mockClear();
vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {});
// Re-establish consent mock after resetAllMocks
@@ -118,8 +127,10 @@ describe('BrowserManager', () => {
);
});
afterEach(() => {
afterEach(async () => {
vi.restoreAllMocks();
// Clear singleton cache to avoid cross-test leakage
await BrowserManager.resetAll();
});
describe('MCP bundled path resolution', () => {
@@ -691,22 +702,198 @@ describe('BrowserManager', () => {
});
});
describe('getInstance', () => {
it('should return the same instance for the same session mode', () => {
const instance1 = BrowserManager.getInstance(mockConfig);
const instance2 = BrowserManager.getInstance(mockConfig);
expect(instance1).toBe(instance2);
});
it('should return different instances for different session modes', () => {
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
const instance1 = BrowserManager.getInstance(mockConfig);
const instance2 = BrowserManager.getInstance(isolatedConfig);
expect(instance1).not.toBe(instance2);
});
it('should return different instances for different profile paths', () => {
const config1 = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { profilePath: '/path/a' },
},
});
const config2 = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { profilePath: '/path/b' },
},
});
const instance1 = BrowserManager.getInstance(config1);
const instance2 = BrowserManager.getInstance(config2);
expect(instance1).not.toBe(instance2);
});
});
describe('resetAll', () => {
it('should close all instances and clear the cache', async () => {
const instance1 = BrowserManager.getInstance(mockConfig);
await instance1.ensureConnection();
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
const instance2 = BrowserManager.getInstance(isolatedConfig);
await instance2.ensureConnection();
await BrowserManager.resetAll();
// After resetAll, getInstance should return new instances
const instance3 = BrowserManager.getInstance(mockConfig);
expect(instance3).not.toBe(instance1);
});
it('should handle errors during cleanup gracefully', async () => {
const instance = BrowserManager.getInstance(mockConfig);
await instance.ensureConnection();
// Make close throw by overriding the client's close method
const client = await instance.getRawMcpClient();
vi.mocked(client.close).mockRejectedValueOnce(new Error('close failed'));
// Should not throw
await expect(BrowserManager.resetAll()).resolves.toBeUndefined();
});
});
describe('isConnected', () => {
it('should return false before connection', () => {
const manager = new BrowserManager(mockConfig);
expect(manager.isConnected()).toBe(false);
});
it('should return true after successful connection', async () => {
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
expect(manager.isConnected()).toBe(true);
});
it('should return false after close', async () => {
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
await manager.close();
expect(manager.isConnected()).toBe(false);
});
});
describe('reconnection', () => {
it('should reconnect after unexpected disconnect', async () => {
const manager = new BrowserManager(mockConfig);
await manager.ensureConnection();
// Simulate transport closing unexpectedly via the onclose callback
const transportInstance =
vi.mocked(StdioClientTransport).mock.results[0]?.value;
if (transportInstance?.onclose) {
transportInstance.onclose();
}
// Manager should recognize disconnection
expect(manager.isConnected()).toBe(false);
// ensureConnection should reconnect
await manager.ensureConnection();
expect(manager.isConnected()).toBe(true);
});
});
describe('concurrency', () => {
it('should not call connectMcp twice when ensureConnection is called concurrently', async () => {
const manager = new BrowserManager(mockConfig);
// Call ensureConnection twice simultaneously without awaiting the first
const [p1, p2] = [manager.ensureConnection(), manager.ensureConnection()];
await Promise.all([p1, p2]);
// connectMcp (via StdioClientTransport constructor) should only have been called once
// Each connection attempt creates a new StdioClientTransport
});
});
describe('overlay re-injection in callTool', () => {
it('should re-inject overlay after click in non-headless mode', async () => {
it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => {
// Enable input disabling in config
mockConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: false,
disableUserInput: true,
},
},
});
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: '1_2' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after navigate_page in non-headless mode', async () => {
it('should re-inject overlay and input blocker after navigate_page in non-headless mode when input disabling is enabled', async () => {
mockConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: false,
disableUserInput: true,
},
},
});
const manager = new BrowserManager(mockConfig);
await manager.callTool('navigate_page', { url: 'https://example.com' });
expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined);
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
});
it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => {
it('should re-inject overlay and input blocker after click_at, new_page, press_key, handle_dialog when input disabling is enabled', async () => {
mockConfig = makeFakeConfig({
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: false,
disableUserInput: true,
},
},
});
const manager = new BrowserManager(mockConfig);
for (const tool of [
'click_at',
@@ -715,12 +902,15 @@ describe('BrowserManager', () => {
'handle_dialog',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
vi.mocked(injectInputBlocker).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).toHaveBeenCalledTimes(1);
expect(injectInputBlocker).toHaveBeenCalledTimes(1);
expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined);
}
});
it('should NOT re-inject overlay after read-only tools', async () => {
it('should NOT re-inject overlay or input blocker after read-only tools', async () => {
const manager = new BrowserManager(mockConfig);
for (const tool of [
'take_snapshot',
@@ -729,8 +919,10 @@ describe('BrowserManager', () => {
'fill',
]) {
vi.mocked(injectAutomationOverlay).mockClear();
vi.mocked(injectInputBlocker).mockClear();
await manager.callTool(tool, {});
expect(injectAutomationOverlay).not.toHaveBeenCalled();
expect(injectInputBlocker).not.toHaveBeenCalled();
}
});
@@ -763,8 +955,6 @@ describe('BrowserManager', () => {
const manager = new BrowserManager(mockConfig);
await manager.callTool('click', { uid: 'bad' });
expect(injectAutomationOverlay).not.toHaveBeenCalled();
});
});
@@ -40,6 +40,12 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile';
// Default timeout for MCP operations
const MCP_TIMEOUT_MS = 60_000;
// Maximum reconnection attempts before giving up
const MAX_RECONNECT_RETRIES = 3;
// Base delay (ms) for exponential backoff between reconnection attempts
const RECONNECT_BASE_DELAY_MS = 500;
/**
* Tools that can cause a full-page navigation (explicitly or implicitly).
*
@@ -92,10 +98,73 @@ export interface McpToolCallResult {
* in the main ToolRegistry. Tools are kept local to the browser agent.
*/
export class BrowserManager {
// --- Static singleton management ---
private static instances = new Map<string, BrowserManager>();
/**
* Returns the cache key for a given config.
* Uses `sessionMode:profilePath` so different profiles get separate instances.
*/
private static getInstanceKey(config: Config): string {
const browserConfig = config.getBrowserAgentConfig();
const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent';
const profilePath = browserConfig.customConfig.profilePath ?? 'default';
return `${sessionMode}:${profilePath}`;
}
/**
* Returns an existing BrowserManager for the current config's session mode
* and profile, or creates a new one.
*/
static getInstance(config: Config): BrowserManager {
const key = BrowserManager.getInstanceKey(config);
let instance = BrowserManager.instances.get(key);
if (!instance) {
instance = new BrowserManager(config);
BrowserManager.instances.set(key, instance);
debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
} else {
debugLogger.log(
`Reusing existing BrowserManager singleton (key: ${key})`,
);
}
return instance;
}
/**
* Closes all cached BrowserManager instances and clears the cache.
* Called on /clear commands and CLI exit.
*/
static async resetAll(): Promise<void> {
const results = await Promise.allSettled(
Array.from(BrowserManager.instances.values()).map((instance) =>
instance.close(),
),
);
for (const result of results) {
if (result.status === 'rejected') {
debugLogger.error(
`Error during BrowserManager cleanup: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`,
);
}
}
BrowserManager.instances.clear();
}
/**
* Alias for resetAll used by CLI exit cleanup for clarity.
*/
static async closeAll(): Promise<void> {
await BrowserManager.resetAll();
}
// --- Instance state ---
// Raw MCP SDK Client - NOT the wrapper McpClient
private rawMcpClient: Client | undefined;
private mcpTransport: StdioClientTransport | undefined;
private discoveredTools: McpTool[] = [];
private disconnected = false;
private connectionPromise: Promise<void> | undefined;
/** State for action rate limiting */
private actionCounter = 0;
@@ -215,6 +284,10 @@ export class BrowserManager {
// Re-inject the automation overlay and input blocker after tools that
// can cause a full-page navigation. chrome-devtools-mcp emits no MCP
// notifications, so callTool() is the only interception point.
//
// The input blocker injection is idempotent: the injected function
// reuses the existing DOM element when present and only recreates
// it when navigation has actually replaced the page DOM.
if (
!result.isError &&
POTENTIALLY_NAVIGATING_TOOLS.has(toolName) &&
@@ -224,17 +297,8 @@ export class BrowserManager {
if (this.shouldInjectOverlay) {
await injectAutomationOverlay(this, signal);
}
// Only re-inject the input blocker for tools that *reliably*
// replace the page DOM (navigate_page, new_page, select_page).
// click/click_at are handled by pointer-events suspend/resume
// in mcpToolWrapper — no full re-inject roundtrip needed.
// press_key/handle_dialog only sometimes navigate.
const reliableNavigation =
toolName === 'navigate_page' ||
toolName === 'new_page' ||
toolName === 'select_page';
if (this.shouldDisableInput && reliableNavigation) {
await injectInputBlocker(this);
if (this.shouldDisableInput) {
await injectInputBlocker(this, signal);
}
} catch {
// Never let overlay/blocker failures interrupt the tool result
@@ -271,14 +335,53 @@ export class BrowserManager {
};
}
/**
* Returns whether the MCP client is currently connected and healthy.
*/
isConnected(): boolean {
return this.rawMcpClient !== undefined && !this.disconnected;
}
/**
* Ensures browser and MCP client are connected.
* If a previous connection was lost (e.g., user closed the browser),
* this will reconnect with exponential backoff (up to MAX_RECONNECT_RETRIES).
*
* Concurrent callers share a single in-flight connection promise so that
* two subagents racing at startup do not trigger duplicate connectMcp() calls.
*/
async ensureConnection(): Promise<void> {
if (this.rawMcpClient) {
// Already connected and healthy — nothing to do
if (this.rawMcpClient && !this.disconnected) {
return;
}
// A connection is already being established — wait for it instead of racing
if (this.connectionPromise) {
return this.connectionPromise;
}
// If previously connected but transport died, clean up before reconnecting
if (this.disconnected) {
debugLogger.log(
'Previous browser connection was lost. Cleaning up before reconnecting...',
);
await this.close();
this.disconnected = false;
}
// Start connecting; store the promise so concurrent callers can join it
this.connectionPromise = this.connectWithRetry().finally(() => {
this.connectionPromise = undefined;
});
return this.connectionPromise;
}
/**
* Connects to chrome-devtools-mcp with exponential backoff retry.
*/
private async connectWithRetry(): Promise<void> {
// Request browser consent if needed (first-run privacy notice)
const consentGranted = await getBrowserConsentIfNeeded();
if (!consentGranted) {
@@ -288,7 +391,23 @@ export class BrowserManager {
);
}
await this.connectMcp();
let lastError: Error | undefined;
for (let attempt = 0; attempt < MAX_RECONNECT_RETRIES; attempt++) {
try {
await this.connectMcp();
return;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < MAX_RECONNECT_RETRIES - 1) {
const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt);
debugLogger.log(
`Connection attempt ${attempt + 1} failed, retrying in ${delay}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
/**
@@ -322,6 +441,7 @@ export class BrowserManager {
}
this.discoveredTools = [];
this.connectionPromise = undefined;
}
/**
@@ -447,7 +567,7 @@ export class BrowserManager {
'chrome-devtools-mcp transport closed unexpectedly. ' +
'The MCP server process may have crashed.',
);
this.rawMcpClient = undefined;
this.disconnected = true;
};
this.mcpTransport.onerror = (error: Error) => {
debugLogger.error(
@@ -5,7 +5,12 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { injectInputBlocker, removeInputBlocker } from './inputBlocker.js';
import {
injectInputBlocker,
removeInputBlocker,
suspendInputBlocker,
resumeInputBlocker,
} from './inputBlocker.js';
import type { BrowserManager } from './browserManager.js';
describe('inputBlocker', () => {
@@ -28,6 +33,7 @@ describe('inputBlocker', () => {
{
function: expect.stringContaining('__gemini_input_blocker'),
},
undefined,
);
});
@@ -77,6 +83,29 @@ describe('inputBlocker', () => {
injectInputBlocker(mockBrowserManager),
).resolves.toBeUndefined();
});
it('should be safe to call multiple times (idempotent injection)', async () => {
await injectInputBlocker(mockBrowserManager);
await injectInputBlocker(mockBrowserManager);
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2);
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
1,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
undefined,
);
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
2,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
undefined,
);
});
});
describe('removeInputBlocker', () => {
@@ -88,6 +117,7 @@ describe('inputBlocker', () => {
{
function: expect.stringContaining('__gemini_input_blocker'),
},
undefined,
);
});
@@ -110,4 +140,38 @@ describe('inputBlocker', () => {
).resolves.toBeUndefined();
});
});
describe('suspendInputBlocker and resumeInputBlocker', () => {
it('should not throw when blocker element is missing', async () => {
// Simulate evaluate_script resolving successfully even if the DOM element is absent.
mockBrowserManager.callTool = vi.fn().mockResolvedValue({
content: [{ type: 'text', text: 'Script ran on page and returned:' }],
});
await expect(
suspendInputBlocker(mockBrowserManager),
).resolves.toBeUndefined();
await expect(
resumeInputBlocker(mockBrowserManager),
).resolves.toBeUndefined();
expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2);
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
1,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
undefined,
);
expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith(
2,
'evaluate_script',
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
undefined,
);
});
});
});
@@ -198,11 +198,14 @@ const RESUME_BLOCKER_FUNCTION = `() => {
*/
export async function injectInputBlocker(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: INPUT_BLOCKER_FUNCTION,
});
await browserManager.callTool(
'evaluate_script',
{ function: INPUT_BLOCKER_FUNCTION },
signal,
);
debugLogger.log('Input blocker injected successfully');
} catch (error) {
// Log but don't throw - input blocker is a UX enhancement, not critical functionality
@@ -222,11 +225,14 @@ export async function injectInputBlocker(
*/
export async function removeInputBlocker(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: REMOVE_BLOCKER_FUNCTION,
});
await browserManager.callTool(
'evaluate_script',
{ function: REMOVE_BLOCKER_FUNCTION },
signal,
);
debugLogger.log('Input blocker removed successfully');
} catch (error) {
// Log but don't throw - removal failure is not critical
@@ -244,11 +250,14 @@ export async function removeInputBlocker(
*/
export async function suspendInputBlocker(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: SUSPEND_BLOCKER_FUNCTION,
});
await browserManager.callTool(
'evaluate_script',
{ function: SUSPEND_BLOCKER_FUNCTION },
signal,
);
} catch {
// Non-critical — tool call will still attempt to proceed
}
@@ -260,11 +269,14 @@ export async function suspendInputBlocker(
*/
export async function resumeInputBlocker(
browserManager: BrowserManager,
signal?: AbortSignal,
): Promise<void> {
try {
await browserManager.callTool('evaluate_script', {
function: RESUME_BLOCKER_FUNCTION,
});
await browserManager.callTool(
'evaluate_script',
{ function: RESUME_BLOCKER_FUNCTION },
signal,
);
} catch {
// Non-critical
}
@@ -224,6 +224,7 @@ describe('mcpToolWrapper', () => {
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
expect.any(AbortSignal),
);
// Second call: click
@@ -241,6 +242,7 @@ describe('mcpToolWrapper', () => {
expect.objectContaining({
function: expect.stringContaining('__gemini_input_blocker'),
}),
expect.any(AbortSignal),
);
});
@@ -129,7 +129,7 @@ class McpToolInvocation extends BaseToolInvocation<
// chrome-devtools-mcp's interactability checks pass.
// Only toggles pointer-events CSS — no DOM change, no flicker.
if (this.needsBlockerSuspend) {
await suspendInputBlocker(this.browserManager);
await suspendInputBlocker(this.browserManager, signal);
}
const result: McpToolCallResult = await this.browserManager.callTool(
@@ -155,7 +155,7 @@ class McpToolInvocation extends BaseToolInvocation<
// Resume input blocker after interactive tool completes.
if (this.needsBlockerSuspend) {
await resumeInputBlocker(this.browserManager);
await resumeInputBlocker(this.browserManager, signal);
}
if (result.isError) {
@@ -181,7 +181,7 @@ class McpToolInvocation extends BaseToolInvocation<
// Resume on error path too so the blocker is always restored
if (this.needsBlockerSuspend) {
await resumeInputBlocker(this.browserManager).catch(() => {});
await resumeInputBlocker(this.browserManager, signal).catch(() => {});
}
debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`);
+102 -10
View File
@@ -69,6 +69,10 @@ import {
type FunctionDeclaration,
} from '@google/genai';
import type { Config } from '../config/config.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { GeminiClient } from '../core/client.js';
import type { SandboxManager } from '../services/sandboxManager.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MockTool } from '../test-utils/mock-tool.js';
import { getDirectoryContextString } from '../utils/environmentContext.js';
import { z } from 'zod';
@@ -377,10 +381,8 @@ describe('LocalAgentExecutor', () => {
describe('create (Initialization and Validation)', () => {
it('should explicitly map execution context properties to prevent unintended propagation', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const mockGeminiClient =
{} as unknown as import('../core/client.js').GeminiClient;
const mockSandboxManager =
{} as unknown as import('../services/sandboxManager.js').SandboxManager;
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const extendedContext = {
config: mockConfig,
promptId: mockConfig.promptId,
@@ -391,7 +393,7 @@ describe('LocalAgentExecutor', () => {
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
unintendedProperty: 'should not be here',
} as unknown as import('../config/agent-loop-context.js').AgentLoopContext;
} as unknown as AgentLoopContext;
const executor = await LocalAgentExecutor.create(
definition,
@@ -414,7 +416,7 @@ describe('LocalAgentExecutor', () => {
expect(executionContext).toBeDefined();
expect(executionContext.config).toBe(extendedContext.config);
expect(executionContext.promptId).toBe(extendedContext.promptId);
expect(executionContext.promptId).toBeDefined();
expect(executionContext.geminiClient).toBe(extendedContext.geminiClient);
expect(executionContext.sandboxManager).toBe(
extendedContext.sandboxManager,
@@ -445,7 +447,99 @@ describe('LocalAgentExecutor', () => {
expect(executionContext.messageBus).not.toBe(extendedContext.messageBus);
});
it('should create successfully with allowed tools', async () => {
it('should propagate parentSessionId from context when creating executionContext', async () => {
const parentSessionId = 'top-level-session-id';
const currentPromptId = 'subagent-a-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: currentPromptId,
parentSessionId,
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(parentSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should fall back to promptId if parentSessionId is missing (top-level subagent)', async () => {
const rootSessionId = 'root-session-id';
const mockGeminiClient = {} as unknown as GeminiClient;
const mockSandboxManager = {} as unknown as SandboxManager;
const mockMessageBus = {
derive: () => ({}),
} as unknown as MessageBus;
const mockToolRegistry = {
getMessageBus: () => mockMessageBus,
getAllToolNames: () => [],
sortTools: () => {},
} as unknown as ToolRegistry;
const context = {
config: mockConfig,
promptId: rootSessionId,
// parentSessionId is undefined
toolRegistry: mockToolRegistry,
promptRegistry: {} as unknown as PromptRegistry,
resourceRegistry: {} as unknown as ResourceRegistry,
geminiClient: mockGeminiClient,
sandboxManager: mockSandboxManager,
messageBus: mockMessageBus,
} as unknown as AgentLoopContext;
const definition = createTestDefinition([]);
const executor = await LocalAgentExecutor.create(definition, context);
mockModelResponse([
{
name: TASK_COMPLETE_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const chatConstructorArgs =
MockedGeminiChat.mock.calls[MockedGeminiChat.mock.calls.length - 1];
const executionContext = chatConstructorArgs[0];
expect(executionContext.parentSessionId).toBe(rootSessionId);
expect(executionContext.promptId).toBe(executor['agentId']);
});
it('should successfully with allowed tools', async () => {
const definition = createTestDefinition([LS_TOOL_NAME]);
const executor = await LocalAgentExecutor.create(
definition,
@@ -500,9 +594,7 @@ describe('LocalAgentExecutor', () => {
onActivity,
);
expect(executor['agentId']).toMatch(
new RegExp(`^${parentId}-${definition.name}-`),
);
expect(executor['agentId']).toBeDefined();
});
it('should correctly apply templates to initialMessages', async () => {
+3 -11
View File
@@ -121,7 +121,8 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private get executionContext(): AgentLoopContext {
return {
config: this.context.config,
promptId: this.context.promptId,
promptId: this.agentId,
parentSessionId: this.context.parentSessionId || this.context.promptId, // Always preserve the main agent session ID
geminiClient: this.context.geminiClient,
sandboxManager: this.context.sandboxManager,
toolRegistry: this.toolRegistry,
@@ -255,9 +256,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
agentToolRegistry.sortTools();
// Get the parent prompt ID from context
const parentPromptId = context.promptId;
// Get the parent tool call ID from context
const toolContext = getToolCallContext();
const parentCallId = toolContext?.callId;
@@ -265,7 +263,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
return new LocalAgentExecutor(
definition,
context,
parentPromptId,
agentToolRegistry,
agentPromptRegistry,
agentResourceRegistry,
@@ -283,7 +280,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
private constructor(
definition: LocalAgentDefinition<TOutput>,
context: AgentLoopContext,
parentPromptId: string | undefined,
toolRegistry: ToolRegistry,
promptRegistry: PromptRegistry,
resourceRegistry: ResourceRegistry,
@@ -299,11 +295,7 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.compressionService = new ChatCompressionService();
this.parentCallId = parentCallId;
const randomIdPart = Math.random().toString(36).slice(2, 8);
// parentPromptId will be undefined if this agent is invoked directly
// (top-level), rather than as a sub-agent.
const parentPrefix = parentPromptId ? `${parentPromptId}-` : '';
this.agentId = `${parentPrefix}${this.definition.name}-${randomIdPart}`;
this.agentId = Math.random().toString(36).slice(2, 8);
}
/**
+3
View File
@@ -32,6 +32,7 @@ export class ProjectIdRequiredError extends Error {
super(
'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca',
);
this.name = 'ProjectIdRequiredError';
}
}
@@ -42,6 +43,7 @@ export class ProjectIdRequiredError extends Error {
export class ValidationCancelledError extends Error {
constructor() {
super('User cancelled account validation');
this.name = 'ValidationCancelledError';
}
}
@@ -51,6 +53,7 @@ export class IneligibleTierError extends Error {
constructor(ineligibleTiers: IneligibleTier[]) {
const reasons = ineligibleTiers.map((t) => t.reasonMessage).join(', ');
super(reasons);
this.name = 'IneligibleTierError';
this.ineligibleTiers = ineligibleTiers;
}
}
@@ -23,6 +23,9 @@ export interface AgentLoopContext {
/** The unique ID for the current user turn or agent thought loop. */
readonly promptId: string;
/** The unique ID for the parent session if this is a subagent. */
readonly parentSessionId?: string;
/** The registry of tools available to the agent in this context. */
readonly toolRegistry: ToolRegistry;
+12
View File
@@ -92,6 +92,7 @@ vi.mock('../tools/tool-registry', () => {
ToolRegistryMock.prototype.sortTools = vi.fn();
ToolRegistryMock.prototype.getAllTools = vi.fn(() => []); // Mock methods if needed
ToolRegistryMock.prototype.getTool = vi.fn();
ToolRegistryMock.prototype.getAllToolNames = vi.fn(() => []);
ToolRegistryMock.prototype.getFunctionDeclarations = vi.fn(() => []);
return { ToolRegistry: ToolRegistryMock };
});
@@ -1563,6 +1564,17 @@ describe('Server Config (config.ts)', () => {
expect(config.getSandboxNetworkAccess()).toBe(false);
});
});
it('should have independent TopicState across instances', () => {
const config1 = new Config(baseParams);
const config2 = new Config(baseParams);
config1.topicState.setTopic('Topic 1');
config2.topicState.setTopic('Topic 2');
expect(config1.topicState.getTopic()).toBe('Topic 1');
expect(config2.topicState.getTopic()).toBe('Topic 2');
});
});
describe('GemmaModelRouterSettings', () => {
+80 -5
View File
@@ -10,6 +10,8 @@ import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { inspect } from 'node:util';
import process from 'node:process';
import { z } from 'zod';
import type { ConversationRecord } from '../services/chatRecordingService.js';
export type { ConversationRecord };
import {
AuthType,
createContentGenerator,
@@ -34,6 +36,7 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js';
import { WebSearchTool } from '../tools/web-search.js';
import { AskUserTool } from '../tools/ask-user.js';
import { UpdateTopicTool, TopicState } from '../tools/topicTool.js';
import { ExitPlanModeTool } from '../tools/exit-plan-mode.js';
import { EnterPlanModeTool } from '../tools/enter-plan-mode.js';
import { GeminiClient } from '../core/client.js';
@@ -231,6 +234,25 @@ export interface ResolvedExtensionSetting {
source?: string;
}
export interface TrajectoryProvider {
/** Prefix used to identify sessions from this provider (e.g., 'ext:') */
prefix: string;
/** Optional display name for UI Tabs */
displayName?: string;
/** Return an array of conversational tags/ids */
listSessions(workspaceUri?: string): Promise<
Array<{
id: string;
mtime: string;
name?: string;
displayName?: string;
messageCount?: number;
}>
>;
/** Load a single conversation payload */
loadSession(id: string): Promise<ConversationRecord | null>;
}
export interface AgentRunConfig {
maxTimeMinutes?: number;
maxTurns?: number;
@@ -386,6 +408,8 @@ export interface GeminiCLIExtension {
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
/** Loaded JS module for trajectory decoding */
trajectoryProviderModule?: TrajectoryProvider;
}
export interface ExtensionInstallMetadata {
@@ -658,6 +682,11 @@ export interface ConfigParameters {
adminSkillsEnabled?: boolean;
experimentalJitContext?: boolean;
experimentalMemoryManager?: boolean;
experimentalAgentHistoryTruncation?: boolean;
experimentalAgentHistoryTruncationThreshold?: number;
experimentalAgentHistoryRetainedMessages?: number;
experimentalAgentHistorySummarization?: boolean;
memoryBoundaryMarkers?: string[];
topicUpdateNarration?: boolean;
toolOutputMasking?: Partial<ToolOutputMaskingConfig>;
disableLLMCorrection?: boolean;
@@ -700,6 +729,7 @@ export class Config implements McpContext, AgentLoopContext {
private clientVersion: string;
private fileSystemService: FileSystemService;
private trackerService?: TrackerService;
readonly topicState = new TopicState();
private contentGeneratorConfig!: ContentGeneratorConfig;
private contentGenerator!: ContentGenerator;
readonly modelConfigService: ModelConfigService;
@@ -886,6 +916,11 @@ export class Config implements McpContext, AgentLoopContext {
private readonly experimentalJitContext: boolean;
private readonly experimentalMemoryManager: boolean;
private readonly experimentalAgentHistoryTruncation: boolean;
private readonly experimentalAgentHistoryTruncationThreshold: number;
private readonly experimentalAgentHistoryRetainedMessages: number;
private readonly experimentalAgentHistorySummarization: boolean;
private readonly memoryBoundaryMarkers: readonly string[];
private readonly topicUpdateNarration: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
@@ -921,7 +956,9 @@ export class Config implements McpContext, AgentLoopContext {
networkAccess: false,
};
this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir);
this._sandboxManager = createSandboxManager(this.sandbox, {
workspace: params.targetDir,
});
if (
!(this._sandboxManager instanceof NoopSandboxManager) &&
@@ -942,8 +979,10 @@ export class Config implements McpContext, AgentLoopContext {
'default';
this._sandboxManager = createSandboxManager(
this.sandbox,
params.targetDir,
this._sandboxPolicyManager,
{
workspace: params.targetDir,
policyManager: this._sandboxPolicyManager,
},
initialApprovalMode,
);
@@ -1091,6 +1130,15 @@ export class Config implements McpContext, AgentLoopContext {
this.experimentalJitContext = params.experimentalJitContext ?? true;
this.experimentalMemoryManager = params.experimentalMemoryManager ?? false;
this.experimentalAgentHistoryTruncation =
params.experimentalAgentHistoryTruncation ?? false;
this.experimentalAgentHistoryTruncationThreshold =
params.experimentalAgentHistoryTruncationThreshold ?? 30;
this.experimentalAgentHistoryRetainedMessages =
params.experimentalAgentHistoryRetainedMessages ?? 15;
this.experimentalAgentHistorySummarization =
params.experimentalAgentHistorySummarization ?? false;
this.memoryBoundaryMarkers = params.memoryBoundaryMarkers ?? ['.git'];
this.topicUpdateNarration = params.topicUpdateNarration ?? false;
this.modelSteering = params.modelSteering ?? false;
this.injectionService = new InjectionService(() =>
@@ -1595,8 +1643,10 @@ export class Config implements McpContext, AgentLoopContext {
private refreshSandboxManager(): void {
this._sandboxManager = createSandboxManager(
this.sandbox,
this.targetDir,
this._sandboxPolicyManager,
{
workspace: this.targetDir,
policyManager: this._sandboxPolicyManager,
},
this.getApprovalMode(),
);
this.shellExecutionConfig.sandboxManager = this._sandboxManager;
@@ -2265,10 +2315,30 @@ export class Config implements McpContext, AgentLoopContext {
return this.experimentalJitContext;
}
getMemoryBoundaryMarkers(): readonly string[] {
return this.memoryBoundaryMarkers;
}
isMemoryManagerEnabled(): boolean {
return this.experimentalMemoryManager;
}
isExperimentalAgentHistoryTruncationEnabled(): boolean {
return this.experimentalAgentHistoryTruncation;
}
getExperimentalAgentHistoryTruncationThreshold(): number {
return this.experimentalAgentHistoryTruncationThreshold;
}
getExperimentalAgentHistoryRetainedMessages(): number {
return this.experimentalAgentHistoryRetainedMessages;
}
isExperimentalAgentHistorySummarizationEnabled(): boolean {
return this.experimentalAgentHistorySummarization;
}
isTopicUpdateNarrationEnabled(): boolean {
return this.topicUpdateNarration;
}
@@ -2413,6 +2483,7 @@ export class Config implements McpContext, AgentLoopContext {
if (isPlanModeTransition || isYoloModeTransition) {
if (this._geminiClient?.isInitialized()) {
this._geminiClient.clearCurrentSequenceModel();
this._geminiClient.setTools().catch((err) => {
debugLogger.error('Failed to update tools', err);
});
@@ -3285,6 +3356,10 @@ export class Config implements McpContext, AgentLoopContext {
}
};
maybeRegister(UpdateTopicTool, () =>
registry.registerTool(new UpdateTopicTool(this, this.messageBus)),
);
maybeRegister(LSTool, () =>
registry.registerTool(new LSTool(this, this.messageBus)),
);
@@ -243,6 +243,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
model: 'gemini-3-pro-preview',
},
},
'agent-history-provider-summarizer': {
modelConfig: {
model: 'gemini-3-flash-preview',
},
},
},
overrides: [
{

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