Compare commits

...

80 Commits

Author SHA1 Message Date
gemini-cli-robot 0d4d80eb53 chore(release): v0.33.0-preview.14 2026-03-10 22:16:53 +00:00
gemini-cli-robot 0d27afcc7d fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467 [CONFLICTS] (#21930)
Co-authored-by: Coco Sheng <cocosheng@google.com>
Co-authored-by: Abhi <abhipatel@google.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-03-10 14:35:44 -07:00
gemini-cli-robot 8948a9075f chore(release): v0.33.0-preview.13 2026-03-10 20:51:02 +00:00
gemini-cli-robot ca0db7ec4a fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 (#21922)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-10 20:11:17 +00:00
gemini-cli-robot 524a84bfec chore(release): v0.33.0-preview.12 2026-03-10 15:18:49 +00:00
gemini-cli-robot 6431ea8724 fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 (#21876)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-10 14:48:38 +00:00
gemini-cli-robot 0db01a9d79 chore(release): v0.33.0-preview.11 2026-03-10 13:15:34 +00:00
gemini-cli-robot 0af0fb6a50 fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 (#21859)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-10 12:17:21 +00:00
gemini-cli-robot 78b0b3dd4d chore(release): v0.33.0-preview.10 2026-03-10 01:56:08 +00:00
gemini-cli-robot c18aae398c fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch version v0.33.0-preview.9 and create version 0.33.0-preview.10 (#21800)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-03-09 18:25:59 -07:00
gemini-cli-robot 94ccdc75c2 chore(release): v0.33.0-preview.9 2026-03-10 00:44:59 +00:00
gemini-cli-robot 5e80faf389 fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236 [CONFLICTS] (#21788)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-03-09 17:14:42 -07:00
gemini-cli-robot c2e92125c3 chore(release): v0.33.0-preview.8 2026-03-09 23:38:08 +00:00
gemini-cli-robot 302b48a0b3 fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch version v0.33.0-preview.7 and create version 0.33.0-preview.8 (#21782)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-09 18:59:01 -04:00
gemini-cli-robot 8fa4585ebf chore(release): v0.33.0-preview.7 2026-03-09 19:52:20 +00:00
gemini-cli-robot 5d2983f12a chore(release): v0.33.0-preview.6 2026-03-09 18:28:31 +00:00
gemini-cli-robot 9912ea8686 fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch version v0.33.0-preview.5 and create version 0.33.0-preview.6 (#21720)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-09 13:59:58 -04:00
gemini-cli-robot 508aaeda89 chore(release): v0.33.0-preview.5 2026-03-07 01:33:18 +00:00
gemini-cli-robot a6256cdabf fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425 [CONFLICTS] (#21478)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
Co-authored-by: Abhi <abhipatel@google.com>
2026-03-07 00:29:28 +00:00
gemini-cli-robot c316fc6c51 chore(release): v0.33.0-preview.4 2026-03-06 01:20:57 +00:00
gemini-cli-robot 7e6e40c814 fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch version v0.33.0-preview.3 and create version 0.33.0-preview.4 (#21349)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 16:53:40 -08:00
gemini-cli-robot b25c8137f6 chore(release): v0.33.0-preview.3 2026-03-05 23:56:03 +00:00
gemini-cli-robot 03a8fc3113 fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171 [CONFLICTS] (#21336)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
2026-03-05 23:22:08 +00:00
gemini-cli-robot 393e9a171f chore(release): v0.33.0-preview.2 2026-03-05 22:10:39 +00:00
gemini-cli-robot 59702f913d fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch version v0.33.0-preview.1 and create version 0.33.0-preview.2 (#21300)
Co-authored-by: Adib234 <30782825+Adib234@users.noreply.github.com>
2026-03-05 12:28:20 -08:00
gemini-cli-robot b3439e1458 chore(release): v0.33.0-preview.1 2026-03-04 04:12:41 +00:00
gemini-cli-robot b21b289e26 fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch version v0.33.0-preview.0 and create version 0.33.0-preview.1 (#21047)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-03 18:48:23 -08:00
gemini-cli-robot 56a63a35e2 chore(release): v0.33.0-preview.0 2026-03-03 23:20:33 +00:00
Shreya Keshive 34f0c1538b feat(acp): add set models interface (#20991) 2026-03-03 22:29:42 +00:00
Sehoon Shon c70c95ead3 remove hardcoded tiername when missing tier (#21022) 2026-03-03 22:16:37 +00:00
Dev Randalpura f3bbe6e77a fix(core): send shell output to model on cancel (#20501) 2026-03-03 22:10:16 +00:00
Abhi 28e79831ac fix(core): sanitize and length-check MCP tool qualified names (#20987) 2026-03-03 21:38:52 +00:00
Sam Roberts fdf5b18cfb Format the quota/limit style guide. (#21017) 2026-03-03 21:37:05 +00:00
ruomeng b5f3eb2c9c feat(plan): add copy subcommand to plan (#20491) (#20988) 2026-03-03 21:36:51 +00:00
Ale Aadithya 2a84090dd5 Docs/add hooks reference (#20961)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-03 21:29:15 +00:00
Sri Pasumarthi 27d7aeb1ed feat: Implement slash command handling in ACP for /memory,/init,/extensions and /restore (#20528) 2026-03-03 21:29:14 +00:00
Tommaso Sciortino d6c560498b fix(cli): pin clipboardy to ~5.2.x (#21009) 2026-03-03 21:01:29 +00:00
Sam Roberts 4500da339b Update docs-writer skill with new resource (#20917) 2026-03-03 20:49:18 +00:00
Ishaan Gupta 4be08a2261 refactor common settings logic for skills,agents (#17490)
Co-authored-by: ved015 <vedant.04.mahajan@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-03 19:25:17 +00:00
Adib234 fe332bbef7 feat(evals): add behavioral evals for ask_user tool (#20620) 2026-03-03 17:51:15 +00:00
kartik 50af050623 fix: pre-load @file references from external editor prompts (#20963)
Signed-off-by: Kartik Angiras <angiraskartik@gmail.com>
2026-03-03 17:41:30 +00:00
Tommaso Sciortino e5207eb67f fix(cli): wait for background auto-update before relaunching (#20904) 2026-03-03 17:18:29 +00:00
Adam Weidman f15bcaf499 feat(agents): directly indicate auth required state (#20986) 2026-03-03 17:10:12 +00:00
Adib234 5f2f60bed6 feat(plan): enable built-in research subagents in plan mode (#20972) 2026-03-03 16:52:27 +00:00
Mahima Shanware c332d1e636 feat(extensions): add support for plan directory in extension manifest (#20354)
Co-authored-by: Mahima Shanware <mshanware@google.com>
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-03-03 16:50:18 +00:00
Sehoon Shon aa158e18d3 fix(core): enforce optionality for API response fields in code_assist (#20714) 2026-03-03 16:17:34 +00:00
Jerop Kipruto fca29b0bd8 fix(plan): clean up session directories and plans on deletion (#20914) 2026-03-03 14:11:25 +00:00
Keith Guerin 1e2afbb514 feat(cli): invert context window display to show usage (#20071)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-03-03 09:22:29 +00:00
Bryan Morgan 208291f391 fix(ci): handle empty APP_ID in stale PR closer (#20919) 2026-03-03 00:14:36 -05:00
Jacob Richman 8303edbb54 Code review fixes as a pr (#20612) 2026-03-03 04:32:50 +00:00
Aswin Ashok 0d69f9f7fa Build binary (#18933)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-03-03 01:02:19 +00:00
Christian Gunderman 46231a1755 ci(evals): only run evals in CI if prompts or tools changed (#20898)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-03 00:29:31 +00:00
Sandy Tao 2e7722d6a3 fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2 models (#20897) 2026-03-02 23:21:13 +00:00
Yuna Seol 69e15a50d1 fix(core): skip telemetry logging for AbortError exceptions (#19477)
Co-authored-by: Yuna Seol <yunaseol@google.com>
2026-03-02 23:14:31 +00:00
Christian Gunderman 25f59a0099 Add some dos and don'ts to behavioral evals README. (#20629) 2026-03-02 23:14:00 +00:00
Adib234 01927a36d1 feat(plan): support annotating plans with feedback for iteration (#20876) 2026-03-02 23:03:59 +00:00
Shreya Keshive 06ddfa5c4c feat(admin): enable 30 day default retention for chat history & remove warning (#20853) 2026-03-02 22:44:49 +00:00
Christian Gunderman 3f7ef816f1 fix(core): increase default headers timeout to 5 minutes (#20890) 2026-03-02 22:36:58 +00:00
Jerop Kipruto d05ba11a31 refactor(core): replace manual syncPlanModeTools with declarative policy rules (#20596) 2026-03-02 22:30:50 +00:00
Hamdanbinhashim e43b1cff58 docs: fix broken markdown links in main README.md (#20300) 2026-03-02 21:51:52 +00:00
Allen Hutchison bb6d1a2775 feat(core): add tool name validation in TOML policy files (#19281)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-02 21:47:21 +00:00
Nayana Parameswarappa dd9ccc9807 Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider (#20121) 2026-03-02 21:37:44 +00:00
Pyush Sinha 8133d63ac6 refactor(cli): fully remove React anti patterns, improve type safety and fix UX oversights in SettingsDialog.tsx (#18963)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 21:30:58 +00:00
Sandy Tao 18d0375a7f feat(core): support authenticated A2A agent card discovery (#20622)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
Co-authored-by: Adam Weidman <65992621+adamfweidman@users.noreply.github.com>
2026-03-02 21:29:31 +00:00
Keith Guerin 31ca57ec94 feat: redesign header to be compact with ASCII icon (#18713)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 21:12:17 +00:00
Abhi b7a8f0d1f9 fix(core): ensure subagents use qualified MCP tool names (#20801) 2026-03-02 21:12:13 +00:00
Abdul Tawab 1502e5cbc3 style(cli) : Dialog pattern for /hooks Command (#17930) 2026-03-02 21:12:05 +00:00
Christian Gunderman 7ca3a33f8b Subagent activity UX. (#17570) 2026-03-02 21:04:31 +00:00
Sandy Tao ce5a2d0760 feat(core): truncate large MCP tool output (#19365) 2026-03-02 21:01:49 +00:00
Sam Roberts aa321b3d8c Update CODEOWNERS for README.md reviewers (#20860) 2026-03-02 20:54:05 +00:00
David Pierce 3a7a6e1540 Add install as an option when extension is selected. (#20358) 2026-03-02 20:41:16 +00:00
Tommaso Sciortino 66530e44c8 document node limitation for shift+tab (#20877) 2026-03-02 20:31:52 +00:00
Christian Gunderman b034dcd412 Do not block CI on evals (#20870) 2026-03-02 20:31:02 +00:00
Aishanee Shah 659301ff83 feat(core): centralize read_file limits and update gemini-3 description (#20619) 2026-03-02 20:11:58 +00:00
Sandy Tao 446a4316c4 feat(core): implement HTTP authentication support for A2A remote agents (#20510)
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-03-02 19:59:48 +00:00
Tommaso Sciortino 48412a068e Add /unassign support (#20864)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-03-02 19:54:26 +00:00
Adib234 2e1efaebe4 fix(plan): deflake plan mode integration tests (#20477) 2026-03-02 19:51:44 +00:00
Sandy Tao 7c9fceba7f fix(core): reduce LLM-based loop detection false positives (#20701) 2026-03-02 19:08:15 +00:00
Adam Weidman 740efa2ac2 Merge User and Agent Card Descriptions #20849 (#20850) 2026-03-02 17:59:29 +00:00
Abhi 703759cfae fix(cli): allow sub-agent confirmation requests in UI while preventing background flicker (#20722) 2026-03-01 02:39:25 +00:00
285 changed files with 14102 additions and 4856 deletions
+4
View File
@@ -45,6 +45,10 @@ Write precisely to ensure your instructions are unambiguous.
specific verbs.
- **Examples:** Use meaningful names in examples; avoid placeholders like
"foo" or "bar."
- **Quota and limit terminology:** For any content involving resource capacity
or using the word "quota" or "limit", strictly adhere to the guidelines in
the `quota-limit-style-guide.md` resource file. Generally, Use "quota" for the
administrative bucket and "limit" for the numerical ceiling.
### Formatting and syntax
Apply consistent formatting to make documentation visually organized and
@@ -0,0 +1,61 @@
# Style Guide: Quota vs. Limit
This guide defines the usage of "quota," "limit," and related terms in
user-facing interfaces.
## TL;DR
- **`quota`**: The administrative "bucket." Use for settings, billing, and
requesting increases. (e.g., "Adjust your storage **quota**.")
- **`limit`**: The real-time numerical "ceiling." Use for error messages when a
user is blocked. (e.g., "You've reached your request **limit**.")
- **When blocked, combine them:** Explain the **limit** that was hit and the
**quota** that is the remedy. (e.g., "You've reached the request **limit** for
your developer **quota**.")
- **Related terms:** Use `usage` for consumption tracking, `restriction` for
fixed rules, and `reset` for when a limit refreshes.
---
## Detailed Guidelines
### Definitions
- **Quota is the "what":** It identifies the category of resource being managed
(e.g., storage quota, GPU quota, request/prompt quota).
- **Limit is the "how much":** It defines the numerical boundary.
Use **quota** when referring to the administrative concept or the request for
more. Use **limit** when discussing the specific point of exhaustion.
### When to use "quota"
Use this term for **account management, billing, and settings.** It describes
the entitlement the user has purchased or been assigned.
**Examples:**
- **Navigation label:** Quota and usage
- **Contextual help:** Your **usage quota** is managed by your organization. To
request an increase, contact your administrator.
### When to use "limit"
Use this term for **real-time feedback, notifications, and error messages.** It
identifies the specific wall the user just hit.
**Examples:**
- **Error message:** Youve reached the 50-request-per-minute **limit**.
- **Inline warning:** Input exceeds the 32k token **limit**.
### How to use both together
When a user is blocked, combine both terms to explain the **event** (limit) and
the **remedy** (quota).
**Example:**
- **Heading:** Daily usage limit reached
- **Body:** You've reached the maximum daily capacity for your developer quota.
To continue working today, upgrade your quota.
+1
View File
@@ -14,3 +14,4 @@
# Docs have a dedicated approver group in addition to maintainers
/docs/ @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
/README.md @google-gemini/gemini-cli-maintainers @google-gemini/gemini-cli-docs
+8
View File
@@ -290,6 +290,7 @@ jobs:
with:
ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}'
fetch-depth: 0
- name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -302,7 +303,14 @@ jobs:
- name: 'Build project'
run: 'npm run build'
- name: 'Check if evals should run'
id: 'check_evals'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- name: 'Run Evals (Required to pass)'
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: 'npm run test:always_passing_evals'
+3 -1
View File
@@ -124,7 +124,9 @@ jobs:
- name: 'Link Checker'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --accept 200,503 ./**/*.md'
# Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
fail: true
test_linux:
name: 'Test (Linux) - ${{ matrix.node-version }}, ${{ matrix.shard }}'
-1
View File
@@ -117,7 +117,6 @@ jobs:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -23,6 +23,10 @@ jobs:
steps:
- name: 'Generate GitHub App Token'
id: 'generate_token'
env:
APP_ID: '${{ secrets.APP_ID }}'
if: |-
${{ env.APP_ID != '' }}
uses: 'actions/create-github-app-token@v2'
with:
app-id: '${{ secrets.APP_ID }}'
@@ -33,7 +37,7 @@ jobs:
env:
DRY_RUN: '${{ inputs.dry_run }}'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
github-token: '${{ steps.generate_token.outputs.token || secrets.GITHUB_TOKEN }}'
script: |
const dryRun = process.env.DRY_RUN === 'true';
const thirtyDaysAgo = new Date();
+41 -1
View File
@@ -25,7 +25,7 @@ jobs:
if: |-
github.repository == 'google-gemini/gemini-cli' &&
github.event_name == 'issue_comment' &&
contains(github.event.comment.body, '/assign')
(contains(github.event.comment.body, '/assign') || contains(github.event.comment.body, '/unassign'))
runs-on: 'ubuntu-latest'
steps:
- name: 'Generate GitHub App Token'
@@ -38,6 +38,7 @@ jobs:
permission-issues: 'write'
- name: 'Assign issue to user'
if: "contains(github.event.comment.body, '/assign')"
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -108,3 +109,42 @@ jobs:
issue_number: issueNumber,
body: `👋 @${commenter}, you've been assigned to this issue! Thank you for taking the time to contribute. Make sure to check out our [contributing guidelines](https://github.com/google-gemini/gemini-cli/blob/main/CONTRIBUTING.md).`
});
- name: 'Unassign issue from user'
if: "contains(github.event.comment.body, '/unassign')"
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |
const issueNumber = context.issue.number;
const commenter = context.actor;
const owner = context.repo.owner;
const repo = context.repo.repo;
const commentBody = context.payload.comment.body.trim();
if (commentBody !== '/unassign') {
return;
}
const issue = await github.rest.issues.get({
owner: owner,
repo: repo,
issue_number: issueNumber,
});
const isAssigned = issue.data.assignees.some(assignee => assignee.login === commenter);
if (isAssigned) {
await github.rest.issues.removeAssignees({
owner: owner,
repo: repo,
issue_number: issueNumber,
assignees: [commenter]
});
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: issueNumber,
body: `👋 @${commenter}, you have been unassigned from this issue.`
});
}
+3 -1
View File
@@ -22,4 +22,6 @@ jobs:
id: 'lychee'
uses: 'lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2' # ratchet: lycheeverse/lychee-action@v2.6.1
with:
args: '--verbose --no-progress --accept 200,503 ./**/*.md'
# Exclude GEMINI.md because the absolute GitHub URL in CONTRIBUTING.md (which is symlinked)
# causes intermittent 429 Too Many Requests errors from GitHub API rate limits.
args: '--verbose --no-progress --accept 200,503 --exclude "GEMINI\\.md" ./**/*.md'
+160
View File
@@ -0,0 +1,160 @@
name: 'Test Build Binary'
on:
workflow_dispatch:
permissions:
contents: 'read'
defaults:
run:
shell: 'bash'
jobs:
build-node-binary:
name: 'Build Binary (${{ matrix.os }})'
runs-on: '${{ matrix.os }}'
strategy:
fail-fast: false
matrix:
include:
- os: 'ubuntu-latest'
platform_name: 'linux-x64'
arch: 'x64'
- os: 'windows-latest'
platform_name: 'win32-x64'
arch: 'x64'
- os: 'macos-latest' # Apple Silicon (ARM64)
platform_name: 'darwin-arm64'
arch: 'arm64'
- os: 'macos-latest' # Intel (x64) running on ARM via Rosetta
platform_name: 'darwin-x64'
arch: 'x64'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Optimize Windows Performance'
if: "matrix.os == 'windows-latest'"
run: |
Set-MpPreference -DisableRealtimeMonitoring $true
Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue
Set-Service -Name "wsearch" -StartupType Disabled
Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue
Set-Service -Name "SysMain" -StartupType Disabled
shell: 'powershell'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
with:
node-version-file: '.nvmrc'
architecture: '${{ matrix.arch }}'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Check Secrets'
id: 'check_secrets'
run: |
echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
- name: 'Setup Windows SDK (Windows)'
if: "matrix.os == 'windows-latest'"
uses: 'microsoft/setup-msbuild@v2'
- name: 'Add Signtool to Path (Windows)'
if: "matrix.os == 'windows-latest'"
run: |
$signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName
echo "Found signtool at: $signtoolPath"
echo "$signtoolPath" >> $env:GITHUB_PATH
shell: 'pwsh'
- name: 'Setup macOS Keychain'
if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'"
env:
BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}'
P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}'
KEYCHAIN_PASSWORD: 'temp-password'
run: |
# Create the P12 file
echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
# Create a temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import the certificate
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
# Allow codesign to access it
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Set Identity for build script
echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV"
- name: 'Setup Windows Certificate'
if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'"
env:
PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}'
PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}'
run: |
$pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64")
$certPath = Join-Path (Get-Location) "cert.pfx"
[IO.File]::WriteAllBytes($certPath, $pfx_cert_byte)
echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV
echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV
shell: 'pwsh'
- name: 'Build Binary'
run: 'npm run build:binary'
- name: 'Build Core Package'
run: 'npm run build -w @google/gemini-cli-core'
- name: 'Verify Output Exists'
run: |
if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini"
elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe"
else
echo "Error: Binary not found in dist/${{ matrix.platform_name }}/"
ls -R dist/
exit 1
fi
- name: 'Smoke Test Binary'
run: |
echo "Running binary smoke test..."
if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
"./dist/${{ matrix.platform_name }}/gemini.exe" --version
else
"./dist/${{ matrix.platform_name }}/gemini" --version
fi
- name: 'Run Integration Tests'
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")"
else
BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini"
fi
echo "Using binary at $BINARY_PATH"
export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH"
npm run test:integration:sandbox:none -- --testTimeout=600000
- name: 'Upload Artifact'
uses: 'actions/upload-artifact@v4'
with:
name: 'gemini-cli-${{ matrix.platform_name }}'
path: 'dist/${{ matrix.platform_name }}/'
retention-days: 5
+1 -1
View File
@@ -61,4 +61,4 @@ gemini-debug.log
.genkit
.gemini-clipboard/
.eslintcache
evals/logs/
evals/logs/
+7 -4
View File
@@ -75,11 +75,14 @@ Replace `<PR_NUMBER>` with your pull request number. Authors are encouraged to
run this on their own PRs for self-review, and reviewers should use it to
augment their manual review process.
### Self assigning issues
### Self-assigning and unassigning issues
To assign an issue to yourself, simply add a comment with the text `/assign`.
The comment must contain only that text and nothing else. This command will
assign the issue to you, provided it is not already assigned.
To assign an issue to yourself, simply add a comment with the text `/assign`. To
unassign yourself from an issue, add a comment with the text `/unassign`.
The comment must contain only that text and nothing else. These commands will
assign or unassign the issue as requested, provided the conditions are met
(e.g., an issue must be unassigned to be assigned).
Please note that you can have a maximum of 3 issues assigned to you at any given
time.
+11 -9
View File
@@ -282,14 +282,14 @@ gemini
quickly.
- [**Authentication Setup**](./docs/get-started/authentication.md) - Detailed
auth configuration.
- [**Configuration Guide**](./docs/get-started/configuration.md) - Settings and
- [**Configuration Guide**](./docs/reference/configuration.md) - Settings and
customization.
- [**Keyboard Shortcuts**](./docs/cli/keyboard-shortcuts.md) - Productivity
tips.
- [**Keyboard Shortcuts**](./docs/reference/keyboard-shortcuts.md) -
Productivity tips.
### Core Features
- [**Commands Reference**](./docs/cli/commands.md) - All slash commands
- [**Commands Reference**](./docs/reference/commands.md) - All slash commands
(`/help`, `/chat`, etc).
- [**Custom Commands**](./docs/cli/custom-commands.md) - Create your own
reusable commands.
@@ -323,15 +323,16 @@ gemini
- [**Enterprise Guide**](./docs/cli/enterprise.md) - Deploy and manage in a
corporate environment.
- [**Telemetry & Monitoring**](./docs/cli/telemetry.md) - Usage tracking.
- [**Tools API Development**](./docs/core/tools-api.md) - Create custom tools.
- [**Tools API Development**](./docs/reference/tools-api.md) - Create custom
tools.
- [**Local development**](./docs/local-development.md) - Local development
tooling.
### Troubleshooting & Support
- [**Troubleshooting Guide**](./docs/troubleshooting.md) - Common issues and
solutions.
- [**FAQ**](./docs/faq.md) - Frequently asked questions.
- [**Troubleshooting Guide**](./docs/resources/troubleshooting.md) - Common
issues and solutions.
- [**FAQ**](./docs/resources/faq.md) - Frequently asked questions.
- Use `/bug` command to report issues directly from the CLI.
### Using MCP Servers
@@ -377,7 +378,8 @@ for planned features and priorities.
### Uninstall
See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
See the [Uninstall Guide](./docs/resources/uninstall.md) for removal
instructions.
## 📄 Legal
+3 -2
View File
@@ -464,8 +464,9 @@ on GitHub.
page in their default browser directly from the CLI using the `/extension`
explore command. ([pr](https://github.com/google-gemini/gemini-cli/pull/11846)
by [@JayadityaGit](https://github.com/JayadityaGit)).
- **Configurable compression:** Users can modify the compression threshold in
`/settings`. The default has been made more proactive
- **Configurable compression:** Users can modify the context compression
threshold in `/settings` (decimal with percentage display). The default has
been made more proactive
([pr](https://github.com/google-gemini/gemini-cli/pull/12317) by
[@scidomino](https://github.com/scidomino)).
- **API key authentication:** Users can now securely enter and store their
+38 -64
View File
@@ -1,4 +1,4 @@
# Plan Mode (experimental)
# Plan Mode
Plan Mode is a read-only environment for architecting robust solutions before
implementation. It allows you to:
@@ -8,63 +8,8 @@ implementation. It allows you to:
- **Design:** Understand problems, evaluate trade-offs, and choose a solution.
- **Plan:** Align on an execution strategy before any code is modified.
> **Note:** This is a preview feature currently under active development. Your
> feedback is invaluable as we refine this feature. If you have ideas,
> suggestions, or encounter issues:
>
> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on
> GitHub.
> - Use the **/bug** command within Gemini CLI to file an issue.
- [Enabling Plan Mode](#enabling-plan-mode)
- [How to use Plan Mode](#how-to-use-plan-mode)
- [Entering Plan Mode](#entering-plan-mode)
- [Planning Workflow](#planning-workflow)
- [Exiting Plan Mode](#exiting-plan-mode)
- [Tool Restrictions](#tool-restrictions)
- [Customizing Planning with Skills](#customizing-planning-with-skills)
- [Customizing Policies](#customizing-policies)
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing)
## Enabling Plan Mode
To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the
following to your `settings.json`:
```json
{
"experimental": {
"plan": true
}
}
```
## How to use Plan Mode
### Entering Plan Mode
You can configure Gemini CLI to start in Plan Mode by default or enter it
manually during a session.
- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by
default:
1. Type `/settings` in the CLI.
2. Search for **Default Approval Mode**.
3. Set the value to **Plan**.
Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually
update:
```json
{
"general": {
"defaultApprovalMode": "plan"
}
}
```
Plan Mode is enabled by default. You can manage this setting using the
`/settings` command.
- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes
(`Default` -> `Auto-Edit` -> `Plan`).
@@ -109,8 +54,9 @@ structure, and consultation level are proportional to the task's complexity:
- **Iterate:** Provide feedback to refine the plan.
- **Refine manually:** Press **Ctrl + X** to open the plan file in your
[preferred external editor]. This allows you to manually refine the plan
steps before approval. The CLI will automatically refresh and show the
updated plan after you save and close the editor.
steps before approval. If you make any changes and save the file, the CLI
will automatically send the updated plan back to the agent for review and
iteration.
For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills).
@@ -124,6 +70,10 @@ To exit Plan Mode, you can:
- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the
finalized plan for your approval.
### Commands
- **`/plan copy`**: Copy the currently approved plan to your clipboard.
## Tool Restrictions
Plan Mode enforces strict safety policies to prevent accidental changes.
@@ -132,6 +82,7 @@ These are the only allowed tools:
- **FileSystem (Read):** [`read_file`], [`list_directory`], [`glob`]
- **Search:** [`grep_search`], [`google_web_search`]
- **Research Subagents:** [`codebase_investigator`], [`cli_help`]
- **Interaction:** [`ask_user`]
- **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`,
`postgres_read_schema`) are allowed.
@@ -202,16 +153,17 @@ priority = 100
modes = ["plan"]
```
#### Example: Enable research subagents in Plan Mode
#### Example: Enable custom subagents in Plan Mode
You can enable experimental research [subagents] like `codebase_investigator` to
help gather architecture details during the planning phase.
Built-in research [subagents] like [`codebase_investigator`] and [`cli_help`]
are enabled by default in Plan Mode. You can enable additional [custom
subagents] by adding a rule to your policy.
`~/.gemini/policies/research-subagents.toml`
```toml
[[rule]]
toolName = "codebase_investigator"
toolName = "my_custom_subagent"
decision = "allow"
priority = 100
modes = ["plan"]
@@ -289,6 +241,24 @@ performance. You can disable this automatic switching in your settings:
}
```
## Cleanup
By default, Gemini CLI automatically cleans up old session data, including all
associated plan files and task trackers.
- **Default behavior:** Sessions (and their plans) are retained for **30 days**.
- **Configuration:** You can customize this behavior via the `/settings` command
(search for **Session Retention**) or in your `settings.json` file. See
[session retention] for more details.
Manual deletion also removes all associated artifacts:
- **Command Line:** Use `gemini --delete-session <index|id>`.
- **Session Browser:** Press `/resume`, navigate to a session, and press `x`.
If you use a [custom plans directory](#custom-plan-directory-and-policies),
those files are not automatically deleted and must be managed manually.
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
@@ -299,7 +269,10 @@ performance. You can disable this automatic switching in your settings:
[MCP tools]: /docs/tools/mcp-server.md
[`save_memory`]: /docs/tools/memory.md
[`activate_skill`]: /docs/cli/skills.md
[`codebase_investigator`]: /docs/core/subagents.md#codebase_investigator
[`cli_help`]: /docs/core/subagents.md#cli_help
[subagents]: /docs/core/subagents.md
[custom subagents]: /docs/core/subagents.md#creating-custom-subagents
[policy engine]: /docs/reference/policy-engine.md
[`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode
[`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode
@@ -310,3 +283,4 @@ performance. You can disable this automatic switching in your settings:
[auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing
[preferred external editor]: /docs/reference/configuration.md#general
[session retention]: /docs/cli/session-management.md#session-retention
+16 -7
View File
@@ -121,27 +121,36 @@ session lengths.
### Session retention
To prevent your history from growing indefinitely, enable automatic cleanup
policies in your settings.
By default, Gemini CLI automatically cleans up old session data to prevent your
history from growing indefinitely. When a session is deleted, Gemini CLI also
removes all associated data, including implementation plans, task trackers, tool
outputs, and activity logs.
The default policy is to **retain sessions for 30 days**.
#### Configuration
You can customize these policies using the `/settings` command or by manually
editing your `settings.json` file:
```json
{
"general": {
"sessionRetention": {
"enabled": true,
"maxAge": "30d", // Keep sessions for 30 days
"maxCount": 50 // Keep the 50 most recent sessions
"maxAge": "30d",
"maxCount": 50
}
}
}
```
- **`enabled`**: (boolean) Master switch for session cleanup. Defaults to
`false`.
`true`.
- **`maxAge`**: (string) Duration to keep sessions (for example, "24h", "7d",
"4w"). Sessions older than this are deleted.
"4w"). Sessions older than this are deleted. Defaults to `"30d"`.
- **`maxCount`**: (number) Maximum number of sessions to retain. The oldest
sessions exceeding this count are deleted.
sessions exceeding this count are deleted. Defaults to undefined (unlimited).
- **`minRetention`**: (string) Minimum retention period (safety limit). Defaults
to `"1d"`. Sessions newer than this period are never deleted by automatic
cleanup.
+11 -11
View File
@@ -32,8 +32,8 @@ they appear in the UI.
| 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` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `undefined` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `true` |
| Keep chat history | `general.sessionRetention.maxAge` | Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w") | `"30d"` |
### Output
@@ -60,7 +60,7 @@ they appear in the UI.
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory path in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window remaining percentage. | `true` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
@@ -89,13 +89,13 @@ they appear in the UI.
### Model
| UI Label | Setting | Description | Default |
| ----------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
| UI Label | Setting | Description | Default |
| ----------------------------- | ---------------------------- | -------------------------------------------------------------------------------------- | ----------- |
| Model | `model.name` | The Gemini model to use for conversations. | `undefined` |
| Max Session Turns | `model.maxSessionTurns` | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| Context Compression Threshold | `model.compressionThreshold` | The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3). | `0.5` |
| Disable Loop Detection | `model.disableLoopDetection` | Disable automatic detection and prevention of infinite loops. | `false` |
| Skip Next Speaker Check | `model.skipNextSpeakerCheck` | Skip the next speaker check. | `true` |
### Context
@@ -144,7 +144,7 @@ they appear in the UI.
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| 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 planning features (Plan Mode and tools). | `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` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
+9 -1
View File
@@ -122,7 +122,10 @@ The manifest file defines the extension's behavior and configuration.
}
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"]
"excludeTools": ["run_shell_command"],
"plan": {
"directory": ".gemini/plans"
}
}
```
@@ -157,6 +160,11 @@ The manifest file defines the extension's behavior and configuration.
`"excludeTools": ["run_shell_command(rm -rf)"]` will block the `rm -rf`
command. Note that this differs from the MCP server `excludeTools`
functionality, which can be listed in the MCP server config.
- `plan`: Planning features configuration.
- `directory`: The directory where planning artifacts are stored. This serves
as a fallback if the user hasn't specified a plan directory in their
settings. If not specified by either the extension or the user, the default
is `~/.gemini/tmp/<project>/<session-id>/plans/`.
When Gemini CLI starts, it loads all the extensions and merges their
configurations. If there are any conflicts, the workspace configuration takes
+5 -2
View File
@@ -268,8 +268,11 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature requires the `experimental.plan` setting to be
enabled in your configuration.
- **Note:** This feature is enabled by default. It can be disabled via the
`experimental.plan` setting in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
### `/policies`
+5 -10
View File
@@ -159,12 +159,12 @@ their corresponding top-level category object in your `settings.json` file.
- **`general.sessionRetention.enabled`** (boolean):
- **Description:** Enable automatic session cleanup
- **Default:** `false`
- **Default:** `true`
- **`general.sessionRetention.maxAge`** (string):
- **Description:** Automatically delete chats older than this time period
(e.g., "30d", "7d", "24h", "1w")
- **Default:** `undefined`
- **Default:** `"30d"`
- **`general.sessionRetention.maxCount`** (number):
- **Description:** Alternative: Maximum number of sessions to keep (most
@@ -175,11 +175,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Minimum retention period (safety limit, defaults to "1d")
- **Default:** `"1d"`
- **`general.sessionRetention.warningAcknowledged`** (boolean):
- **Description:** INTERNAL: Whether the user has acknowledged the session
retention warning
- **Default:** `false`
#### `output`
- **`output.format`** (enum):
@@ -268,7 +263,7 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`ui.footer.hideContextPercentage`** (boolean):
- **Description:** Hides the context window remaining percentage.
- **Description:** Hides the context window usage percentage.
- **Default:** `true`
- **`ui.hideFooter`** (boolean):
@@ -1015,8 +1010,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **`experimental.plan`** (boolean):
- **Description:** Enable planning features (Plan Mode and tools).
- **Default:** `false`
- **Description:** Enable Plan Mode.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.modelSteering`** (boolean):
+10
View File
@@ -152,3 +152,13 @@ available combinations.
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
## Limitations
- On [Windows Terminal](https://en.wikipedia.org/wiki/Windows_Terminal):
- `shift+enter` is not supported.
- `shift+tab`
[is not supported](https://github.com/google-gemini/gemini-cli/issues/20314)
on Node 20 and earlier versions of Node 22.
- On macOS's [Terminal](<https://en.wikipedia.org/wiki/Terminal_(macOS)>):
- `shift+enter` is not supported.
+8 -1
View File
@@ -94,7 +94,14 @@
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{
"label": "Hooks",
"collapsed": true,
"items": [
{ "label": "Overview", "slug": "docs/hooks" },
{ "label": "Reference", "slug": "docs/hooks/reference" }
]
},
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
+3
View File
@@ -88,6 +88,9 @@ const cliConfig = {
outfile: 'bundle/gemini.js',
define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version),
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
pkg.config?.sandboxImageUri,
),
},
plugins: createWasmPlugins(),
alias: {
+35 -12
View File
@@ -25,6 +25,18 @@ const __dirname = path.dirname(__filename);
const projectRoot = __dirname;
const currentYear = new Date().getFullYear();
const commonRestrictedSyntaxRules = [
{
selector: 'CallExpression[callee.name="require"]',
message: 'Avoid using require(). Use ES6 imports instead.',
},
{
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
];
export default tseslint.config(
{
// Global ignores
@@ -120,18 +132,7 @@ export default tseslint.config(
'no-cond-assign': 'error',
'no-debugger': 'error',
'no-duplicate-case': 'error',
'no-restricted-syntax': [
'error',
{
selector: 'CallExpression[callee.name="require"]',
message: 'Avoid using require(). Use ES6 imports instead.',
},
{
selector: 'ThrowStatement > Literal:not([value=/^\\w+Error:/])',
message:
'Do not throw string literals or non-Error objects. Throw new Error("...") instead.',
},
],
'no-restricted-syntax': ['error', ...commonRestrictedSyntaxRules],
'no-unsafe-finally': 'error',
'no-unused-expressions': 'off', // Disable base rule
'@typescript-eslint/no-unused-expressions': [
@@ -171,6 +172,28 @@ export default tseslint.config(
],
},
},
{
// API Response Optionality enforcement for Code Assist
files: ['packages/core/src/code_assist/**/*.{ts,tsx}'],
rules: {
'no-restricted-syntax': [
'error',
...commonRestrictedSyntaxRules,
{
selector:
'TSInterfaceDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
message:
'All fields in API response interfaces (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
},
{
selector:
'TSTypeAliasDeclaration[id.name=/.+Response$/] TSPropertySignature:not([optional=true])',
message:
'All fields in API response types (*Response) must be marked as optional (?) to prevent developers from accidentally assuming a field will always be present based on current backend behavior.',
},
],
},
},
{
// Rules that only apply to product code
files: ['packages/*/src/**/*.{ts,tsx}'],
+44 -1
View File
@@ -3,7 +3,8 @@
Behavioral evaluations (evals) are tests designed to validate the agent's
behavior in response to specific prompts. They serve as a critical feedback loop
for changes to system prompts, tool definitions, and other model-steering
mechanisms.
mechanisms, and as a tool for assessing feature reliability by model, and
preventing regressions.
## Why Behavioral Evals?
@@ -30,6 +31,48 @@ CLI's features.
those that are generally reliable but might occasionally vary
(`USUALLY_PASSES`).
## Best Practices
When designing behavioral evals, aim for scenarios that accurately reflect
real-world usage while remaining small and maintainable.
- **Realistic Complexity**: Evals should be complicated enough to be
"realistic." They should operate on actual files and a source directory,
mirroring how a real agent interacts with a workspace. Remember that the agent
may behave differently in a larger codebase, so we want to avoid scenarios
that are too simple to be realistic.
- _Good_: An eval that provides a small, functional React component and asks
the agent to add a specific feature, requiring it to read the file,
understand the context, and write the correct changes.
- _Bad_: An eval that simply asks the agent a trivia question or asks it to
write a generic script without providing any local workspace context.
- **Maintainable Size**: Evals should be small enough to reason about and
maintain. We probably can't check in an entire repo as a test case, though
over time we will want these evals to mature into more and more realistic
scenarios.
- _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and
a test file) that isolates the specific behavior being evaluated.
- _Bad_: A test setup containing dozens of files from a complex framework
where the setup logic itself is prone to breaking.
- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific
to ensure the test passes for the right reason.
- _Good_: Checking that a modified file contains a specific AST node or exact
string, or verifying that a tool was called with with the right parameters.
- _Bad_: Only checking for a tool call, which could happen for an unrelated
reason. Expecting specific LLM output.
- **Fail First**: Have tests that failed before your prompt or tool change. We
want to be sure the test fails before your "fix". It's pretty easy to
accidentally create a passing test that asserts behaviors we get for free. In
general, every eval should be accompanied by prompt change, and most prompt
changes should be accompanied by an eval.
- _Good_: Observing a failure, writing an eval that reliably reproduces the
failure, modifying the prompt/tool, and then verifying the eval passes.
- _Bad_: Writing an eval that passes on the first run and assuming your new
prompt change was responsible.
- **Less is More**: Prefer fewer, more realistic tests that assert the major
paths vs. more tests that are more unit-test like. These are evals, so the
value is in testing how the agent works in a semi-realistic scenario.
## Creating an Evaluation
Evaluations are located in the `evals` directory. Each evaluation is a Vitest
+92
View File
@@ -0,0 +1,92 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('ask_user', () => {
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to present multiple choice options',
prompt: `Use the ask_user tool to ask me what my favorite color is. Provide 3 options: red, green, or blue.`,
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool to clarify ambiguous requirements',
files: {
'package.json': JSON.stringify({ name: 'my-app', version: '1.0.0' }),
},
prompt: `I want to build a new feature in this app. Ask me questions to clarify the requirements before proceeding.`,
assert: async (rig) => {
const wasToolCalled = await rig.waitForToolCall('ask_user');
expect(wasToolCalled, 'Expected ask_user tool to be called').toBe(true);
},
});
evalTest('USUALLY_PASSES', {
name: 'Agent uses AskUser tool before performing significant ambiguous rework',
files: {
'packages/core/src/index.ts': '// index\nexport const version = "1.0.0";',
'packages/core/src/util.ts': '// util\nexport function help() {}',
'packages/core/package.json': JSON.stringify({
name: '@google/gemini-cli-core',
}),
'README.md': '# Gemini CLI',
},
prompt: `Refactor the entire core package to be better.`,
assert: async (rig) => {
const wasPlanModeCalled = await rig.waitForToolCall('enter_plan_mode');
expect(wasPlanModeCalled, 'Expected enter_plan_mode to be called').toBe(
true,
);
const wasAskUserCalled = await rig.waitForToolCall('ask_user');
expect(
wasAskUserCalled,
'Expected ask_user tool to be called to clarify the significant rework',
).toBe(true);
},
});
// --- Regression Tests for Recent Fixes ---
// Regression test for issue #20177: Ensure the agent does not use `ask_user` to
// confirm shell commands. Fixed via prompt refinements and tool definition
// updates to clarify that shell command confirmation is handled by the UI.
// See fix: https://github.com/google-gemini/gemini-cli/pull/20504
evalTest('USUALLY_PASSES', {
name: 'Agent does NOT use AskUser to confirm shell commands',
files: {
'package.json': JSON.stringify({
scripts: { build: 'echo building' },
}),
},
prompt: `Run 'npm run build' in the current directory.`,
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const wasShellCalled = toolLogs.some(
(log) => log.toolRequest.name === 'run_shell_command',
);
const wasAskUserCalled = toolLogs.some(
(log) => log.toolRequest.name === 'ask_user',
);
expect(
wasShellCalled,
'Expected run_shell_command tool to be called',
).toBe(true);
expect(
wasAskUserCalled,
'ask_user should not be called to confirm shell commands',
).toBe(false);
},
});
});
+2 -2
View File
@@ -55,7 +55,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
@@ -120,7 +120,7 @@ describe.skip('ACP Environment and Auth', () => {
const bundlePath = join(import.meta.dirname, '..', 'bundle/gemini.js');
child = spawn('node', [bundlePath, '--experimental-acp'], {
child = spawn('node', [bundlePath, '--acp'], {
cwd: rig.homeDir!,
stdio: ['pipe', 'pipe', 'inherit'],
env: {
+1 -1
View File
@@ -58,7 +58,7 @@ describe('ACP telemetry', () => {
'node',
[
bundlePath,
'--experimental-acp',
'--acp',
'--fake-responses',
join(rig.testDir!, 'fake-responses.json'),
],
+2 -2
View File
@@ -104,7 +104,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- hello')
) && output.includes('- mcp_test-server_hello')
);
},
30000, // 30s timeout
@@ -148,7 +148,7 @@ describe('extension reloading', () => {
return (
output.includes(
'test-server (from test-extension) - Ready (1 tool)',
) && output.includes('- goodbye')
) && output.includes('- mcp_test-server_goodbye')
);
},
30000,
+3 -2
View File
@@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => {
// BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json');
const escapedPath = JSON.stringify(messageCountFile);
const beforeModelScript = `
const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0;
let counts = [];
try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {}
try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
counts.push(messageCount);
fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts));
fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' }));
`;
const beforeModelScriptPath = rig.createScript(
+3 -1
View File
@@ -81,7 +81,9 @@ describe('JSON output', () => {
const message = (thrown as Error).message;
// Use a regex to find the first complete JSON object in the string
const jsonMatch = message.match(/{[\s\S]*}/);
// We expect the JSON to start with a quote (e.g. {"error": ...}) to avoid
// matching random error objects printed to stderr (like ENOENT).
const jsonMatch = message.match(/{\s*"[\s\S]*}/);
// Fail if no JSON-like text was found
expect(
+87 -34
View File
@@ -4,8 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
@@ -62,50 +64,98 @@ describe('Plan Mode', () => {
});
});
it.skip('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
it('should allow write_file to the plans directory in plan mode', async () => {
const plansDir = '.gemini/tmp/foo/123/plans';
const testName =
'should allow write_file to the plans directory in plan mode';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
general: { defaultApprovalMode: 'plan' },
},
},
);
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
const toolLogs = rig.readToolLogs();
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
const planWrite = writeLogs.find(
const run = await rig.runInteractive({
approvalMode: 'plan',
});
await run.type('Create a file called plan.md in the plans directory.');
await run.type('\r');
await rig.expectToolCallSuccess(['write_file'], 30000, (args) =>
args.includes('plan.md'),
);
const toolLogs = rig.readToolLogs();
const planWrite = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
expect(planWrite?.toolRequest.success).toBe(true);
});
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
it('should deny write_file to non-plans directory in plan mode', async () => {
const plansDir = '.gemini/tmp/foo/123/plans';
const testName =
'should deny write_file to non-plans directory in plan mode';
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
const run = await rig.runInteractive({
approvalMode: 'plan',
});
expect(planWrite?.toolRequest.success).toBe(true);
await run.type('Create a file called hello.txt in the current directory.');
await run.type('\r');
const toolLogs = rig.readToolLogs();
const writeLog = toolLogs.find(
(l) =>
l.toolRequest.name === 'write_file' &&
l.toolRequest.args.includes('hello.txt'),
);
// In Plan Mode, writes outside the plans directory should be blocked.
// Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail.
if (writeLog) {
expect(writeLog.toolRequest.success).toBe(false);
}
});
it('should be able to enter plan mode from default mode', async () => {
@@ -119,6 +169,12 @@ describe('Plan Mode', () => {
},
});
// Disable the interactive terminal setup prompt in tests
writeFileSync(
join(rig.homeDir!, GEMINI_DIR, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }, null, 2),
);
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
@@ -126,10 +182,7 @@ describe('Plan Mode', () => {
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
const enterPlanCallFound = await rig.waitForToolCall('enter_plan_mode');
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
+6 -1
View File
@@ -18,6 +18,7 @@ const { shell } = getShellConfiguration();
function getLineCountCommand(): { command: string; tool: string } {
switch (shell) {
case 'powershell':
return { command: `Measure-Object -Line`, tool: 'Measure-Object' };
case 'cmd':
return { command: `find /c /v`, tool: 'find' };
case 'bash':
@@ -238,8 +239,12 @@ describe('run_shell_command', () => {
});
it('should succeed in yolo mode', async () => {
const isWindows = process.platform === 'win32';
await rig.setup('should succeed in yolo mode', {
settings: { tools: { core: ['run_shell_command'] } },
settings: {
tools: { core: ['run_shell_command'] },
shell: isWindows ? { enableInteractiveShell: false } : undefined,
},
});
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
+271 -24
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"workspaces": [
"packages/*"
],
@@ -6298,16 +6298,36 @@
"node": ">= 12"
}
},
"node_modules/clipboardy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.0.0.tgz",
"integrity": "sha512-MQfKHaD09eP80Pev4qBxZLbxJK/ONnqfSYAPlCmPh+7BDboYtO/3BmB6HGzxDIT0SlTRc2tzS8lQqfcdLtZ0Kg==",
"node_modules/clipboard-image": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/clipboard-image/-/clipboard-image-0.1.0.tgz",
"integrity": "sha512-SWk7FgaXLNFld19peQ/rTe0n97lwR1WbkqxV6JKCAOh7U52AKV/PeMFCyt/8IhBdqyDA8rdyewQMKZqvWT5Akg==",
"license": "MIT",
"dependencies": {
"execa": "^9.6.0",
"run-jxa": "^3.0.0"
},
"bin": {
"clipboard-image": "cli.js"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clipboardy": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-5.2.1.tgz",
"integrity": "sha512-RWp4E/ivQAzgF4QSWA9sjeW+Bjo+U2SvebkDhNIfO7y65eGdXPUxMTdIKYsn+bxM3ItPHGm3e68Bv3fgQ3mARw==",
"license": "MIT",
"dependencies": {
"clipboard-image": "^0.1.0",
"execa": "^9.6.1",
"is-wayland": "^0.1.0",
"is-wsl": "^3.1.0",
"is64bit": "^2.0.0"
"is64bit": "^2.0.0",
"powershell-utils": "^0.2.0"
},
"engines": {
"node": ">=20"
@@ -6563,6 +6583,9 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">=18"
},
@@ -6729,6 +6752,33 @@
"node": ">= 8"
}
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
"integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==",
"license": "MIT",
"dependencies": {
"type-fest": "^1.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/crypto-random-string/node_modules/type-fest": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -8419,9 +8469,9 @@
}
},
"node_modules/execa": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.0.tgz",
"integrity": "sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==",
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
"integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==",
"license": "MIT",
"dependencies": {
"@sindresorhus/merge-streams": "^4.0.0",
@@ -8539,6 +8589,15 @@
"express": ">= 4.11"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8790,13 +8849,24 @@
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
"node": ">= 0.8"
}
},
"node_modules/finalhandler/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -11671,6 +11741,21 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/macos-version": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/macos-version/-/macos-version-6.0.0.tgz",
"integrity": "sha512-O2S8voA+pMfCHhBn/TIYDXzJ1qNHpPDU32oFxglKnVdJABiYYITt45oLkV9yhwA3E2FDwn3tQqUFrTsr1p3sBQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -11806,6 +11891,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -13356,6 +13447,18 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/powershell-utils": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
"integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -14238,6 +14341,107 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/run-jxa/-/run-jxa-3.0.0.tgz",
"integrity": "sha512-4f2CrY7H+sXkKXJn/cE6qRA3z+NMVO7zvlZ/nUV0e62yWftpiLAfw5eV9ZdomzWd2TXWwEIiGjAT57+lWIzzvA==",
"license": "MIT",
"dependencies": {
"execa": "^5.1.1",
"macos-version": "^6.0.0",
"subsume": "^4.0.0",
"type-fest": "^2.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/execa": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^6.0.0",
"human-signals": "^2.1.0",
"is-stream": "^2.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^4.0.1",
"onetime": "^5.1.2",
"signal-exit": "^3.0.3",
"strip-final-newline": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/run-jxa/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-jxa/node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
}
},
"node_modules/run-jxa/node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/run-jxa/node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/run-jxa/node_modules/strip-final-newline": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/run-jxa/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -15174,6 +15378,34 @@
"integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==",
"license": "MIT"
},
"node_modules/subsume": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/subsume/-/subsume-4.0.0.tgz",
"integrity": "sha512-BWnYJElmHbYZ/zKevy+TG+SsyoFCmRPDHJbR1MzLxkPOv1Jp/4hGhVUtP98s+wZBsBsHwCXvPTP0x287/WMjGg==",
"license": "MIT",
"dependencies": {
"escape-string-regexp": "^5.0.0",
"unique-string": "^3.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/subsume/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/superagent": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz",
@@ -16156,6 +16388,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/unique-string": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz",
"integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==",
"license": "MIT",
"dependencies": {
"crypto-random-string": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/universal-user-agent": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
@@ -17056,7 +17303,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
"@google-cloud/storage": "^7.16.0",
@@ -17114,7 +17361,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17126,7 +17373,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -17197,7 +17444,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.8",
@@ -17462,7 +17709,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17477,7 +17724,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17494,7 +17741,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17511,7 +17758,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.14"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -37,10 +37,12 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js",
"build:binary": "node scripts/build_binary.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts",
"test": "npm run test --workspaces --if-present && npm run test:sea-launch",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:sea-launch": "vitest run sea/sea-launch.test.js",
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { CoderAgentExecutor } from './executor.js';
import type {
ExecutionEventBus,
RequestContext,
TaskStore,
} from '@a2a-js/sdk/server';
import { EventEmitter } from 'node:events';
import { requestStorage } from '../http/requestStorage.js';
// Mocks for constructor dependencies
vi.mock('../config/config.js', () => ({
loadConfig: vi.fn().mockReturnValue({
getSessionId: () => 'test-session',
getTargetDir: () => '/tmp',
getCheckpointingEnabled: () => false,
}),
loadEnvironment: vi.fn(),
setTargetDir: vi.fn().mockReturnValue('/tmp'),
}));
vi.mock('../config/settings.js', () => ({
loadSettings: vi.fn().mockReturnValue({}),
}));
vi.mock('../config/extension.js', () => ({
loadExtensions: vi.fn().mockReturnValue([]),
}));
vi.mock('../http/requestStorage.js', () => ({
requestStorage: {
getStore: vi.fn(),
},
}));
vi.mock('./task.js', () => {
const mockTaskInstance = (taskId: string, contextId: string) => ({
id: taskId,
contextId,
taskState: 'working',
acceptUserMessage: vi
.fn()
.mockImplementation(async function* (context, aborted) {
const isConfirmation = (
context.userMessage.parts as Array<{ kind: string }>
).some((p) => p.kind === 'confirmation');
// Hang only for main user messages (text), allow confirmations to finish quickly
if (!isConfirmation && aborted) {
await new Promise((resolve) => {
aborted.addEventListener('abort', resolve, { once: true });
});
}
yield { type: 'content', value: 'hello' };
}),
acceptAgentMessage: vi.fn().mockResolvedValue(undefined),
scheduleToolCalls: vi.fn().mockResolvedValue(undefined),
waitForPendingTools: vi.fn().mockResolvedValue(undefined),
getAndClearCompletedTools: vi.fn().mockReturnValue([]),
addToolResponsesToHistory: vi.fn(),
sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}),
cancelPendingTools: vi.fn(),
setTaskStateAndPublishUpdate: vi.fn(),
dispose: vi.fn(),
getMetadata: vi.fn().mockResolvedValue({}),
geminiClient: {
initialize: vi.fn().mockResolvedValue(undefined),
},
toSDKTask: () => ({
id: taskId,
contextId,
kind: 'task',
status: { state: 'working', timestamp: new Date().toISOString() },
metadata: {},
history: [],
artifacts: [],
}),
});
const MockTask = vi.fn().mockImplementation(mockTaskInstance);
(MockTask as unknown as { create: Mock }).create = vi
.fn()
.mockImplementation(async (taskId: string, contextId: string) =>
mockTaskInstance(taskId, contextId),
);
return { Task: MockTask };
});
describe('CoderAgentExecutor', () => {
let executor: CoderAgentExecutor;
let mockTaskStore: TaskStore;
let mockEventBus: ExecutionEventBus;
beforeEach(() => {
vi.clearAllMocks();
mockTaskStore = {
save: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
} as unknown as TaskStore;
mockEventBus = new EventEmitter() as unknown as ExecutionEventBus;
mockEventBus.publish = vi.fn();
mockEventBus.finished = vi.fn();
executor = new CoderAgentExecutor(mockTaskStore);
});
it('should distinguish between primary and secondary execution', async () => {
const taskId = 'test-task';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
// Mock requestStorage for primary
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
// First execution (Primary)
const primaryPromise = executor.execute(requestContext, mockEventBus);
// Give it enough time to reach line 490 in executor.ts
await new Promise((resolve) => setTimeout(resolve, 50));
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
const wrapper = executor.getTask(taskId);
expect(wrapper).toBeDefined();
// Mock requestStorage for secondary
const secondarySocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: secondarySocket },
});
const secondaryRequestContext = {
userMessage: {
messageId: 'msg-2',
taskId,
contextId,
parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const secondaryPromise = executor.execute(
secondaryRequestContext,
mockEventBus,
);
// Secondary execution should NOT add to executingTasks (already there)
// and should return early after its loop
await secondaryPromise;
// Task should still be in executingTasks and NOT disposed
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Now simulate secondary socket closure - it should NOT affect primary
secondarySocket.emit('end');
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(true);
expect(wrapper?.task.dispose).not.toHaveBeenCalled();
// Set to terminal state to verify disposal on finish
wrapper!.task.taskState = 'completed';
// Now close primary socket
mockSocket.emit('end');
await primaryPromise;
expect(
(
executor as unknown as { executingTasks: Set<string> }
).executingTasks.has(taskId),
).toBe(false);
expect(wrapper?.task.dispose).toHaveBeenCalled();
});
it('should evict task from cache when it reaches terminal state', async () => {
const taskId = 'test-task-terminal';
const contextId = 'test-context';
const mockSocket = new EventEmitter();
(requestStorage.getStore as Mock).mockReturnValue({
req: { socket: mockSocket },
});
const requestContext = {
userMessage: {
messageId: 'msg-1',
taskId,
contextId,
parts: [{ kind: 'text', text: 'hi' }],
metadata: {
coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' },
},
},
} as unknown as RequestContext;
const primaryPromise = executor.execute(requestContext, mockEventBus);
await new Promise((resolve) => setTimeout(resolve, 50));
const wrapper = executor.getTask(taskId)!;
expect(wrapper).toBeDefined();
// Simulate terminal state
wrapper.task.taskState = 'completed';
// Finish primary execution
mockSocket.emit('end');
await primaryPromise;
expect(executor.getTask(taskId)).toBeUndefined();
expect(wrapper.task.dispose).toHaveBeenCalled();
});
});
+55 -16
View File
@@ -252,6 +252,10 @@ export class CoderAgentExecutor implements AgentExecutor {
);
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`);
// Cleanup listener subscriptions to avoid memory leaks.
wrapper.task.dispose();
this.tasks.delete(taskId);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
@@ -320,23 +324,26 @@ export class CoderAgentExecutor implements AgentExecutor {
if (store) {
// Grab the raw socket from the request object
const socket = store.req.socket;
const onClientEnd = () => {
const onSocketEnd = () => {
logger.info(
`[CoderAgentExecutor] Client socket closed for task ${taskId}. Cancelling execution.`,
`[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`,
);
if (!abortController.signal.aborted) {
abortController.abort();
}
// Clean up the listener to prevent memory leaks
socket.removeListener('close', onClientEnd);
socket.removeListener('end', onSocketEnd);
};
// Listen on the socket's 'end' event (remote closed the connection)
socket.on('end', onClientEnd);
socket.on('end', onSocketEnd);
socket.once('close', () => {
socket.removeListener('end', onSocketEnd);
});
// It's also good practice to remove the listener if the task completes successfully
abortSignal.addEventListener('abort', () => {
socket.removeListener('end', onClientEnd);
socket.removeListener('end', onSocketEnd);
});
logger.info(
`[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`,
@@ -457,6 +464,26 @@ export class CoderAgentExecutor implements AgentExecutor {
return;
}
// Check if this is the primary/initial execution for this task
const isPrimaryExecution = !this.executingTasks.has(taskId);
if (!isPrimaryExecution) {
logger.info(
`[CoderAgentExecutor] Primary execution already active for task ${taskId}. Starting secondary loop for message ${userMessage.messageId}.`,
);
currentTask.eventBus = eventBus;
for await (const _ of currentTask.acceptUserMessage(
requestContext,
abortController.signal,
)) {
logger.info(
`[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`,
);
}
// End this execution-- the original/source will be resumed.
return;
}
logger.info(
`[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`,
);
@@ -598,18 +625,30 @@ export class CoderAgentExecutor implements AgentExecutor {
}
}
} finally {
this.executingTasks.delete(taskId);
logger.info(
`[CoderAgentExecutor] Saving final state for task ${taskId}.`,
);
try {
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
saveError,
if (isPrimaryExecution) {
this.executingTasks.delete(taskId);
logger.info(
`[CoderAgentExecutor] Saving final state for task ${taskId}.`,
);
try {
await this.taskStore?.save(wrapper.toSDKTask());
logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`);
} catch (saveError) {
logger.error(
`[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`,
saveError,
);
}
if (
['canceled', 'failed', 'completed'].includes(currentTask.taskState)
) {
logger.info(
`[CoderAgentExecutor] Task ${taskId} reached terminal state ${currentTask.taskState}. Evicting and disposing.`,
);
wrapper.task.dispose();
this.tasks.delete(taskId);
}
}
}
}
@@ -0,0 +1,655 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { Task } from './task.js';
import {
type Config,
MessageBusType,
ToolConfirmationOutcome,
ApprovalMode,
Scheduler,
type MessageBus,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
describe('Task Event-Driven Scheduler', () => {
let mockConfig: Config;
let mockEventBus: ExecutionEventBus;
let messageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
}) as Config;
messageBus = mockConfig.getMessageBus();
mockEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
});
it('should instantiate Scheduler when enabled', () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
expect(task.scheduler).toBeInstanceOf(Scheduler);
});
it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
status: expect.objectContaining({
state: 'submitted', // initial task state
}),
metadata: expect.objectContaining({
coderAgent: expect.objectContaining({
kind: 'tool-call-update',
}),
}),
}),
);
});
it('should handle tool confirmations by publishing to MessageBus', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
// Simulate MessageBus event to stash the correlationId
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Simulate A2A client confirmation
const part = {
kind: 'data',
data: {
callId: '1',
outcome: 'proceed_once',
},
};
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart(part);
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate Rejection (Cancel)
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'cancel' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: false,
}),
);
const toolCall2 = {
request: { callId: '2', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall2] });
// Simulate ModifyWithEditor
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'modify_with_editor' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
outcome: ToolConfirmationOutcome.ModifyWithEditor,
payload: undefined,
}),
);
});
it('should handle MCP Server tool operations correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-1',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Simulate ProceedOnce for MCP
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-1',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedOnce,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-2',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_server' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-2',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysServer,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-3',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_tool' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-3',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysTool,
}),
);
});
it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'call_mcp_tool', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-mcp-4',
confirmationDetails: {
type: 'mcp',
title: 'MCP Server Operation',
prompt: 'test_mcp',
},
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_always_and_save' },
});
expect(handled).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-mcp-4',
confirmed: true,
outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
}),
);
});
it('should execute without confirmation in YOLO mode and not transition to input-required', async () => {
// Enable YOLO mode
const yoloConfig = createMockConfig({
isEventDrivenSchedulerEnabled: () => true,
getApprovalMode: () => ApprovalMode.YOLO,
}) as Config;
const yoloMessageBus = yoloConfig.getMessageBus();
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test', prompt: 'test' },
};
const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly
expect(yoloMessageBus.publish).not.toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
}),
);
// Should NOT transition to input-required since it was auto-approved
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should handle output updates via the message bus', async () => {
// @ts-expect-error - Calling private constructor
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
liveOutput: 'chunk1',
};
// Simulate MessageBus event
// Simulate MessageBus event
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
if (!handler) {
throw new Error('TOOL_CALLS_UPDATE handler not found');
}
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall],
});
// Should publish artifact update for output
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'artifact-update',
artifact: expect.objectContaining({
artifactId: 'tool-1-output',
parts: [{ kind: 'text', text: 'chunk1' }],
}),
}),
);
});
it('should complete artifact creation without hanging', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCallId = 'create-file-123';
task['_registerToolCall'](toolCallId, 'executing');
const toolCall = {
request: {
callId: toolCallId,
name: 'writeFile',
args: { path: 'test.sh' },
},
status: 'success',
result: { ok: true },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({ type: MessageBusType.TOOL_CALLS_UPDATE, toolCalls: [toolCall] });
// The tool should be complete and registered appropriately, eventually
// triggering the toolCompletionPromise resolution when all clear.
const internalTask = task as unknown as {
completedToolCalls: unknown[];
pendingToolCalls: Map<string, string>;
};
expect(internalTask.completedToolCalls.length).toBe(1);
expect(internalTask.pendingToolCalls.size).toBe(0);
});
it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
// Initialize the ID for the first turn (happens internally upon LLM stream)
task.currentAgentMessageId = 'test-id-123';
// Simulate sending multiple text chunks
task._sendTextContent('chunk 1');
task._sendTextContent('chunk 2');
// Both text contents should have been published with the same messageId
const textCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.kind === 'message',
);
expect(textCalls.length).toBe(2);
expect(textCalls[0][0].status.message.messageId).toBe('test-id-123');
expect(textCalls[1][0].status.message.messageId).toBe('test-id-123');
// Simulate starting a new turn by calling getAndClearCompletedTools
// (which precedes sendCompletedToolsToLlm where a new ID is minted)
task.getAndClearCompletedTools();
// sendCompletedToolsToLlm internally rolls the ID forward.
// Simulate what sendCompletedToolsToLlm does:
const internalTask = task as unknown as {
setTaskStateAndPublishUpdate: (state: string, change: unknown) => void;
};
internalTask.setTaskStateAndPublishUpdate('working', {});
// Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn
task.currentAgentMessageId = 'test-id-456';
task._sendTextContent('chunk 3');
const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter(
(call) => call[0].status?.message?.messageId === 'test-id-456',
);
expect(secondTurnCalls.length).toBe(1);
expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3');
});
it('should handle parallel tool calls correctly', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-1',
confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' },
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
// Publish update for both tool calls simultaneously
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
});
// Confirm first tool call
const handled1 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '1', outcome: 'proceed_once' },
});
expect(handled1).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-1',
confirmed: true,
}),
);
// Confirm second tool call
const handled2 = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: '2', outcome: 'cancel' },
});
expect(handled2).toBe(true);
expect(messageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-2',
confirmed: false,
}),
);
});
it('should wait for executing tools before transitioning to input-required state', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
task.setTaskStateAndPublishUpdate = vi.fn();
// Register tool 1 as executing
task['_registerToolCall']('1', 'executing');
const toolCall1 = {
request: { callId: '1', name: 'ls', args: {} },
status: 'executing',
};
const toolCall2 = {
request: { callId: '2', name: 'pwd', args: {} },
status: 'awaiting_approval',
correlationId: 'corr-2',
confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' },
};
const handler = (messageBus.subscribe as Mock).mock.calls.find(
(call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE,
)?.[1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1, toolCall2],
});
// Should NOT transition to input-required yet
expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
// Complete tool 1
const toolCall1Complete = {
...toolCall1,
status: 'success',
result: { ok: true },
};
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [toolCall1Complete, toolCall2],
});
// Now it should transition
expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith(
'input-required',
expect.anything(),
undefined,
undefined,
true,
);
});
it('should ignore confirmations for unknown tool calls', async () => {
// @ts-expect-error - Calling private constructor
const task = new Task('task-id', 'context-id', mockConfig, mockEventBus);
const handled = await (
task as unknown as {
_handleToolConfirmationPart: (part: unknown) => Promise<boolean>;
}
)._handleToolConfirmationPart({
kind: 'data',
data: { callId: 'unknown-id', outcome: 'proceed_once' },
});
// Should return false for unhandled tool call
expect(handled).toBe(false);
// Should not publish anything to the message bus
expect(messageBus.publish).not.toHaveBeenCalled();
});
});
+28 -2
View File
@@ -504,13 +504,14 @@ describe('Task', () => {
});
describe('auto-approval', () => {
it('should auto-approve tool calls when autoExecute is true', () => {
it('should NOT publish ToolCallConfirmationEvent when autoExecute is true', () => {
task.autoExecute = true;
const onConfirmSpy = vi.fn();
const toolCalls = [
{
request: { callId: '1' },
status: 'awaiting_approval',
correlationId: 'test-corr-id',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
@@ -524,9 +525,17 @@ describe('Task', () => {
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeUndefined();
});
it('should auto-approve tool calls when approval mode is YOLO', () => {
it('should NOT publish ToolCallConfirmationEvent when approval mode is YOLO', () => {
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
task.autoExecute = false;
const onConfirmSpy = vi.fn();
@@ -534,6 +543,7 @@ describe('Task', () => {
{
request: { callId: '1' },
status: 'awaiting_approval',
correlationId: 'test-corr-id',
confirmationDetails: {
type: 'edit',
onConfirm: onConfirmSpy,
@@ -547,6 +557,14 @@ describe('Task', () => {
expect(onConfirmSpy).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
);
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeUndefined();
});
it('should NOT auto-approve when autoExecute is false and mode is not YOLO', () => {
@@ -567,6 +585,14 @@ describe('Task', () => {
task._schedulerToolCallsUpdate(toolCalls);
expect(onConfirmSpy).not.toHaveBeenCalled();
const calls = (mockEventBus.publish as Mock).mock.calls;
// Search if ToolCallConfirmationEvent was published
const confEvent = calls.find(
(call) =>
call[0].metadata?.coderAgent?.kind ===
CoderAgentEvent.ToolCallConfirmationEvent,
);
expect(confEvent).toBeDefined();
});
});
});
+282 -43
View File
@@ -5,6 +5,7 @@
*/
import {
Scheduler,
CoreToolScheduler,
type GeminiClient,
GeminiEventType,
@@ -27,9 +28,12 @@ import {
type ToolCallConfirmationDetails,
type Config,
type UserTierId,
type AnsiOutput,
type ToolLiveOutput,
isSubagentProgress,
EDIT_TOOL_NAMES,
processRestorableToolCalls,
MessageBusType,
type ToolCallsUpdateMessage,
} from '@google/gemini-cli-core';
import {
type ExecutionEventBus,
@@ -92,21 +96,30 @@ function isToolCallConfirmationDetails(
export class Task {
id: string;
contextId: string;
scheduler: CoreToolScheduler;
scheduler: Scheduler | CoreToolScheduler;
config: Config;
geminiClient: GeminiClient;
pendingToolConfirmationDetails: Map<string, ToolCallConfirmationDetails>;
pendingCorrelationIds: Map<string, string> = new Map();
taskState: TaskState;
eventBus?: ExecutionEventBus;
completedToolCalls: CompletedToolCall[];
processedToolCallIds: Set<string> = new Set();
skipFinalTrueAfterInlineEdit = false;
modelInfo?: string;
currentPromptId: string | undefined;
currentAgentMessageId = uuidv4();
promptCount = 0;
autoExecute: boolean;
private get isYoloMatch(): boolean {
return (
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
);
}
// For tool waiting logic
private pendingToolCalls: Map<string, string> = new Map(); //toolCallId --> status
private toolsAlreadyConfirmed: Set<string> = new Set();
private toolCompletionPromise?: Promise<void>;
private toolCompletionNotifier?: {
resolve: () => void;
@@ -123,7 +136,13 @@ export class Task {
this.id = id;
this.contextId = contextId;
this.config = config;
this.scheduler = this.createScheduler();
if (this.config.isEventDrivenSchedulerEnabled()) {
this.scheduler = this.setupEventDrivenScheduler();
} else {
this.scheduler = this.createLegacyScheduler();
}
this.geminiClient = this.config.getGeminiClient();
this.pendingToolConfirmationDetails = new Map();
this.taskState = 'submitted';
@@ -223,7 +242,7 @@ export class Task {
logger.info(
`[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`,
);
return this.toolCompletionPromise;
await this.toolCompletionPromise;
}
cancelPendingTools(reason: string): void {
@@ -236,6 +255,13 @@ export class Task {
this.toolCompletionNotifier.reject(new Error(reason));
}
this.pendingToolCalls.clear();
this.pendingCorrelationIds.clear();
if (this.scheduler instanceof Scheduler) {
this.scheduler.cancelAll();
} else {
this.scheduler.cancelAll(new AbortController().signal);
}
// Reset the promise for any future operations, ensuring it's in a clean state.
this._resetToolCompletionPromise();
}
@@ -248,7 +274,7 @@ export class Task {
kind: 'message',
role,
parts: [{ kind: 'text', text }],
messageId: uuidv4(),
messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(),
taskId: this.id,
contextId: this.contextId,
};
@@ -336,11 +362,13 @@ export class Task {
private _schedulerOutputUpdate(
toolCallId: string,
outputChunk: string | AnsiOutput,
outputChunk: ToolLiveOutput,
): void {
let outputAsText: string;
if (typeof outputChunk === 'string') {
outputAsText = outputChunk;
} else if (isSubagentProgress(outputChunk)) {
outputAsText = JSON.stringify(outputChunk);
} else {
outputAsText = outputChunk
.map((line) => line.map((token) => token.text).join(''))
@@ -414,26 +442,34 @@ export class Task {
// Only send an update if the status has actually changed.
if (hasChanged) {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
// Skip sending confirmation event if we are going to auto-approve it anyway
if (
tc.status === 'awaiting_approval' &&
tc.confirmationDetails &&
this.isYoloMatch
) {
logger.info(
`[Task] Skipping ToolCallConfirmationEvent for ${tc.request.callId} due to YOLO mode.`,
);
} else {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
const event = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false, // Always false for these continuous updates
);
this.eventBus?.publish(event);
const event = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false, // Always false for these continuous updates
);
this.eventBus?.publish(event);
}
}
});
if (
this.autoExecute ||
this.config.getApprovalMode() === ApprovalMode.YOLO
) {
if (this.isYoloMatch) {
logger.info(
'[Task] ' +
(this.autoExecute ? '' : 'YOLO mode enabled. ') +
@@ -481,7 +517,7 @@ export class Task {
}
}
private createScheduler(): CoreToolScheduler {
private createLegacyScheduler(): CoreToolScheduler {
const scheduler = new CoreToolScheduler({
outputUpdateHandler: this._schedulerOutputUpdate.bind(this),
onAllToolCallsComplete: this._schedulerAllToolCallsComplete.bind(this),
@@ -492,6 +528,171 @@ export class Task {
return scheduler;
}
private messageBusListener?: (message: ToolCallsUpdateMessage) => void;
private setupEventDrivenScheduler(): Scheduler {
const messageBus = this.config.getMessageBus();
const scheduler = new Scheduler({
schedulerId: this.id,
config: this.config,
messageBus,
getPreferredEditor: () => DEFAULT_GUI_EDITOR,
});
this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this);
messageBus.subscribe<ToolCallsUpdateMessage>(
MessageBusType.TOOL_CALLS_UPDATE,
this.messageBusListener,
);
return scheduler;
}
dispose(): void {
if (this.messageBusListener) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusListener);
this.messageBusListener = undefined;
}
if (this.scheduler instanceof Scheduler) {
this.scheduler.dispose();
}
}
private handleEventDrivenToolCallsUpdate(
event: ToolCallsUpdateMessage,
): void {
if (event.type !== MessageBusType.TOOL_CALLS_UPDATE) {
return;
}
const toolCalls = event.toolCalls;
toolCalls.forEach((tc) => {
this.handleEventDrivenToolCall(tc);
});
this.checkInputRequiredState();
}
private handleEventDrivenToolCall(tc: ToolCall): void {
const callId = tc.request.callId;
// Do not process events for tools that have already been finalized.
// This prevents duplicate completions if the state manager emits a snapshot containing
// already resolved tools whose IDs were removed from pendingToolCalls.
if (
this.processedToolCallIds.has(callId) ||
this.completedToolCalls.some((c) => c.request.callId === callId)
) {
return;
}
const previousStatus = this.pendingToolCalls.get(callId);
const hasChanged = previousStatus !== tc.status;
// 1. Handle Output
if (tc.status === 'executing' && tc.liveOutput) {
this._schedulerOutputUpdate(callId, tc.liveOutput);
}
// 2. Handle terminal states
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
this.toolsAlreadyConfirmed.delete(callId);
if (hasChanged) {
logger.info(
`[Task] Tool call ${callId} completed with status: ${tc.status}`,
);
this.completedToolCalls.push(tc);
this._resolveToolCall(callId);
}
} else {
// Keep track of pending tools
this._registerToolCall(callId, tc.status);
}
// 3. Handle Confirmation Stash
if (tc.status === 'awaiting_approval' && tc.confirmationDetails) {
const details = tc.confirmationDetails;
if (tc.correlationId) {
this.pendingCorrelationIds.set(callId, tc.correlationId);
}
this.pendingToolConfirmationDetails.set(callId, {
...details,
onConfirm: async () => {},
} as ToolCallConfirmationDetails);
}
// 4. Publish Status Updates to A2A event bus
if (hasChanged) {
const coderAgentMessage: CoderAgentMessage =
tc.status === 'awaiting_approval'
? { kind: CoderAgentEvent.ToolCallConfirmationEvent }
: { kind: CoderAgentEvent.ToolCallUpdateEvent };
const message = this.toolStatusMessage(tc, this.id, this.contextId);
const statusUpdate = this._createStatusUpdateEvent(
this.taskState,
coderAgentMessage,
message,
false,
);
this.eventBus?.publish(statusUpdate);
}
}
private checkInputRequiredState(): void {
if (this.isYoloMatch) {
return;
}
// 6. Handle Input Required State
let isAwaitingApproval = false;
let isExecuting = false;
for (const [callId, status] of this.pendingToolCalls.entries()) {
if (status === 'executing' || status === 'scheduled') {
isExecuting = true;
} else if (
status === 'awaiting_approval' &&
!this.toolsAlreadyConfirmed.has(callId)
) {
isAwaitingApproval = true;
}
}
if (
isAwaitingApproval &&
!isExecuting &&
!this.skipFinalTrueAfterInlineEdit
) {
this.skipFinalTrueAfterInlineEdit = false;
const wasAlreadyInputRequired = this.taskState === 'input-required';
this.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
/*final*/ true,
);
// Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream.
// The IDE client will open a new stream with the confirmation reply.
if (!wasAlreadyInputRequired && this.toolCompletionNotifier) {
this.toolCompletionNotifier.resolve();
}
}
}
private _pickFields<
T extends ToolCall | AnyDeclarativeTool,
K extends UnionKeys<T>,
@@ -702,7 +903,16 @@ export class Task {
};
this.setTaskStateAndPublishUpdate('working', stateChange);
await this.scheduler.schedule(updatedRequests, abortSignal);
// Pre-register tools to ensure waitForPendingTools sees them as pending
// before the async scheduler enqueues them and fires the event bus update.
for (const req of updatedRequests) {
if (!this.pendingToolCalls.has(req.callId)) {
this._registerToolCall(req.callId, 'scheduled');
}
}
// Fire and forget so we don't block the executor loop before waitForPendingTools can be called
void this.scheduler.schedule(updatedRequests, abortSignal);
}
async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise<void> {
@@ -826,9 +1036,15 @@ export class Task {
) {
return false;
}
if (!part.data['outcome']) {
return false;
}
const callId = part.data['callId'];
const outcomeString = part.data['outcome'];
this.toolsAlreadyConfirmed.add(callId);
let confirmationOutcome: ToolConfirmationOutcome | undefined;
if (outcomeString === 'proceed_once') {
@@ -841,6 +1057,8 @@ export class Task {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer;
} else if (outcomeString === 'proceed_always_tool') {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool;
} else if (outcomeString === 'proceed_always_and_save') {
confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave;
} else if (outcomeString === 'modify_with_editor') {
confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor;
} else {
@@ -851,8 +1069,9 @@ export class Task {
}
const confirmationDetails = this.pendingToolConfirmationDetails.get(callId);
const correlationId = this.pendingCorrelationIds.get(callId);
if (!confirmationDetails) {
if (!confirmationDetails && !correlationId) {
logger.warn(
`[Task] Received tool confirmation for unknown or already processed callId: ${callId}`,
);
@@ -874,24 +1093,35 @@ export class Task {
// This will trigger the scheduler to continue or cancel the specific tool.
// The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled).
// If `edit` tool call, pass updated payload if presesent
if (confirmationDetails.type === 'edit') {
const newContent = part.data['newContent'];
const payload =
typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
// If `edit` tool call, pass updated payload if present
const newContent = part.data['newContent'];
const payload =
confirmationDetails?.type === 'edit' && typeof newContent === 'string'
? ({ newContent } as ToolConfirmationPayload)
: undefined;
this.skipFinalTrueAfterInlineEdit = !!payload;
try {
if (correlationId) {
await this.config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId,
confirmed:
confirmationOutcome !== ToolConfirmationOutcome.Cancel &&
confirmationOutcome !==
ToolConfirmationOutcome.ModifyWithEditor,
outcome: confirmationOutcome,
payload,
});
} else if (confirmationDetails?.onConfirm) {
// Fallback for legacy callback-based confirmation
await confirmationDetails.onConfirm(confirmationOutcome, payload);
} finally {
// Once confirmationDetails.onConfirm finishes (or fails) with a payload,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
}
} else {
await confirmationDetails.onConfirm(confirmationOutcome);
} finally {
// Once confirmation payload is sent or callback finishes,
// reset skipFinalTrueAfterInlineEdit so that external callers receive
// their call has been completed.
this.skipFinalTrueAfterInlineEdit = false;
}
} finally {
if (gcpProject) {
@@ -907,6 +1137,7 @@ export class Task {
// Note !== ToolConfirmationOutcome.ModifyWithEditor does not work!
if (confirmationOutcome !== 'modify_with_editor') {
this.pendingToolConfirmationDetails.delete(callId);
this.pendingCorrelationIds.delete(callId);
}
// If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool.
@@ -940,6 +1171,9 @@ export class Task {
getAndClearCompletedTools(): CompletedToolCall[] {
const tools = [...this.completedToolCalls];
for (const tool of tools) {
this.processedToolCallIds.add(tool.request.callId);
}
this.completedToolCalls = [];
return tools;
}
@@ -1000,6 +1234,7 @@ export class Task {
};
// Set task state to working as we are about to call LLM
this.setTaskStateAndPublishUpdate('working', stateChange);
this.currentAgentMessageId = uuidv4();
yield* this.geminiClient.sendMessageStream(
llmParts,
aborted,
@@ -1021,6 +1256,10 @@ export class Task {
if (confirmationHandled) {
anyConfirmationHandled = true;
// If a confirmation was handled, the scheduler will now run the tool (or cancel it).
// We resolve the toolCompletionPromise manually in checkInputRequiredState
// to break the original execution loop, so we must reset it here so the
// new loop correctly awaits the tool's final execution.
this._resetToolCompletionPromise();
// We don't send anything to the LLM for this part.
// The subsequent tool execution will eventually lead to resolveToolCall.
continue;
@@ -1035,6 +1274,7 @@ export class Task {
if (hasContentForLlm) {
this.currentPromptId =
this.config.getSessionId() + '########' + this.promptCount++;
this.currentAgentMessageId = uuidv4();
logger.info('[Task] Sending new parts to LLM.');
const stateChange: StateChange = {
kind: CoderAgentEvent.StateChangeEvent,
@@ -1080,7 +1320,6 @@ export class Task {
if (content === '') {
return;
}
logger.info('[Task] Sending text content to event bus.');
const message = this._createTextMessage(content);
const textContent: TextContent = {
kind: CoderAgentEvent.TextContentEvent,
@@ -1112,7 +1351,7 @@ export class Task {
data: content,
} as Part,
],
messageId: uuidv4(),
messageId: this.currentAgentMessageId,
taskId: this.id,
contextId: this.contextId,
};
+2
View File
@@ -105,6 +105,8 @@ export async function loadConfig(
checkpointing,
interactive: true,
enableInteractiveShell: true,
enableEventDrivenScheduler:
settings.experimental?.enableEventDrivenScheduler ?? true,
ptyInfo: 'auto',
};
@@ -37,6 +37,12 @@ export interface Settings {
showMemoryUsage?: boolean;
checkpointing?: CheckpointingSettings;
folderTrust?: boolean;
general?: {
previewFeatures?: boolean;
};
experimental?: {
enableEventDrivenScheduler?: boolean;
};
// Git-aware file filtering settings
fileFiltering?: {
+44 -5
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import express, { type Request } from 'express';
import type { AgentCard, Message } from '@a2a-js/sdk';
import {
@@ -13,8 +13,9 @@ import {
InMemoryTaskStore,
DefaultExecutionEventBus,
type AgentExecutionEvent,
UnauthenticatedUser,
} from '@a2a-js/sdk/server';
import { A2AExpressApp } from '@a2a-js/sdk/server/express'; // Import server components
import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type { AgentSettings } from '../types.js';
@@ -55,8 +56,17 @@ const coderAgentCard: AgentCard = {
pushNotifications: false,
stateTransitionHistory: true,
},
securitySchemes: undefined,
security: undefined,
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
},
basicAuth: {
type: 'http',
scheme: 'basic',
},
},
security: [{ bearerAuth: [] }, { basicAuth: [] }],
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
@@ -81,6 +91,35 @@ export function updateCoderAgentCardUrl(port: number) {
coderAgentCard.url = `http://localhost:${port}/`;
}
const customUserBuilder: UserBuilder = async (req: Request) => {
const auth = req.headers['authorization'];
if (auth) {
const scheme = auth.split(' ')[0];
logger.info(
`[customUserBuilder] Received Authorization header with scheme: ${scheme}`,
);
}
if (!auth) return new UnauthenticatedUser();
// 1. Bearer Auth
if (auth.startsWith('Bearer ')) {
const token = auth.substring(7);
if (token === 'valid-token') {
return { userName: 'bearer-user', isAuthenticated: true };
}
}
// 2. Basic Auth
if (auth.startsWith('Basic ')) {
const credentials = Buffer.from(auth.substring(6), 'base64').toString();
if (credentials === 'admin:password') {
return { userName: 'basic-user', isAuthenticated: true };
}
}
return new UnauthenticatedUser();
};
async function handleExecuteCommand(
req: express.Request,
res: express.Response,
@@ -204,7 +243,7 @@ export async function createApp() {
requestStorage.run({ req }, next);
});
const appBuilder = new A2AExpressApp(requestHandler);
const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder);
expressApp = appBuilder.setupRoutes(expressApp, '');
expressApp.use(express.json());
@@ -64,6 +64,7 @@ export function createMockConfig(
getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getUserTier: vi.fn(),
isEventDrivenSchedulerEnabled: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn(),
getPolicyEngine: vi.fn(),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.33.0-nightly.20260228.1ca5c05d0",
"version": "0.33.0-preview.14",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-nightly.20260228.1ca5c05d0"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.33.0-preview.14"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -38,7 +38,7 @@
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"clipboardy": "~5.2.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
@@ -14,7 +14,8 @@ import {
type Mock,
type Mocked,
} from 'vitest';
import { GeminiAgent, Session } from './zedIntegration.js';
import { GeminiAgent, Session } from './acpClient.js';
import type { CommandHandler } from './commandHandler.js';
import * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
@@ -26,6 +27,7 @@ import {
type Config,
type MessageBus,
LlmRole,
type GitService,
} from '@google/gemini-cli-core';
import {
SettingScope,
@@ -62,7 +64,33 @@ vi.mock('node:path', async (importOriginal) => {
};
});
// Mock ReadManyFilesTool
vi.mock('../ui/commands/memoryCommand.js', () => ({
memoryCommand: {
name: 'memory',
action: vi.fn(),
},
}));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: vi.fn().mockReturnValue({
name: 'extensions',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn().mockReturnValue({
name: 'restore',
action: vi.fn(),
}),
}));
vi.mock('../ui/commands/initCommand.js', () => ({
initCommand: {
name: 'init',
action: vi.fn(),
},
}));
vi.mock(
'@google/gemini-cli-core',
async (
@@ -144,7 +172,10 @@ describe('GeminiAgent', () => {
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Awaited<ReturnType<typeof loadCliConfig>>>;
mockSettings = {
merged: {
@@ -177,7 +208,16 @@ describe('GeminiAgent', () => {
});
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
expect(response.authMethods).toHaveLength(4);
const gatewayAuth = response.authMethods?.find(
(m) => m.id === AuthType.GATEWAY,
);
expect(gatewayAuth?._meta).toEqual({
gateway: {
protocol: 'google',
restartRequired: 'false',
},
});
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
@@ -197,6 +237,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -216,6 +258,8 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
undefined,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -224,7 +268,47 @@ describe('GeminiAgent', () => {
);
});
it('should authenticate correctly with gateway method', async () => {
await agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
baseUrl: 'https://example.com',
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.GATEWAY,
undefined,
'https://example.com',
{ Authorization: 'Bearer token' },
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.GATEWAY,
);
});
it('should throw acp.RequestError when gateway payload is malformed', async () => {
await expect(
agent.authenticate({
methodId: AuthType.GATEWAY,
_meta: {
gateway: {
// Invalid baseUrl
baseUrl: 123,
headers: { Authorization: 'Bearer token' },
},
},
} as unknown as acp.AuthenticateRequest),
).rejects.toThrow(/Malformed gateway payload/);
});
it('should create a new session', async () => {
vi.useFakeTimers();
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
@@ -237,6 +321,17 @@ describe('GeminiAgent', () => {
expect(loadCliConfig).toHaveBeenCalled();
expect(mockConfig.initialize).toHaveBeenCalled();
expect(mockConfig.getGeminiClient).toHaveBeenCalled();
// Verify deferred call
await vi.runAllTimersAsync();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
}),
}),
);
vi.useRealTimers();
});
it('should return modes without plan mode when plan is disabled', async () => {
@@ -263,6 +358,38 @@ describe('GeminiAgent', () => {
],
currentModeId: 'default',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should include preview models when user has access', async () => {
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
const response = await agent.newSession({
cwd: '/tmp',
mcpServers: [],
});
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-3',
name: expect.stringContaining('Auto'),
}),
expect.objectContaining({
modelId: 'gemini-3.1-pro-preview',
name: 'gemini-3.1-pro-preview',
}),
]),
);
});
it('should return modes with plan mode when plan is enabled', async () => {
@@ -290,6 +417,15 @@ describe('GeminiAgent', () => {
],
currentModeId: 'plan',
});
expect(response.models).toEqual({
availableModels: expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-2.5',
name: 'Auto (Gemini 2.5)',
}),
]),
currentModelId: 'gemini-pro',
});
});
it('should fail session creation if Gemini API key is missing', async () => {
@@ -439,6 +575,32 @@ describe('GeminiAgent', () => {
}),
).rejects.toThrow('Session not found: unknown');
});
it('should delegate setModel to session (unstable)', async () => {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
agent as unknown as { sessions: Map<string, Session> }
).sessions.get('test-session-id');
if (!session) throw new Error('Session not found');
session.setModel = vi.fn().mockReturnValue({});
const result = await agent.unstable_setSessionModel({
sessionId: 'test-session-id',
modelId: 'gemini-2.0-pro-exp',
});
expect(session.setModel).toHaveBeenCalledWith('gemini-2.0-pro-exp');
expect(result).toEqual({});
});
it('should throw error when setting model on non-existent session (unstable)', async () => {
await expect(
agent.unstable_setSessionModel({
sessionId: 'unknown',
modelId: 'gemini-2.0-pro-exp',
}),
).rejects.toThrow('Session not found: unknown');
});
});
describe('Session', () => {
@@ -477,6 +639,7 @@ describe('Session', () => {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getActiveModel: vi.fn().mockReturnValue('gemini-pro'),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getMcpServers: vi.fn(),
getFileService: vi.fn().mockReturnValue({
shouldIgnoreFile: vi.fn().mockReturnValue(false),
}),
@@ -486,7 +649,10 @@ describe('Session', () => {
getDebugMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
setApprovalMode: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(false),
setModel: vi.fn(),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
waitForMcpInit: vi.fn(),
} as unknown as Mocked<Config>;
mockConnection = {
@@ -495,13 +661,38 @@ describe('Session', () => {
sendNotification: vi.fn(),
} as unknown as Mocked<acp.AgentSideConnection>;
session = new Session('session-1', mockChat, mockConfig, mockConnection);
session = new Session('session-1', mockChat, mockConfig, mockConnection, {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
merged: { settings: {} },
errors: [],
} as unknown as LoadedSettings);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should send available commands', async () => {
await session.sendAvailableCommands();
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'available_commands_update',
availableCommands: expect.arrayContaining([
expect.objectContaining({ name: 'memory' }),
expect.objectContaining({ name: 'extensions' }),
expect.objectContaining({ name: 'restore' }),
expect.objectContaining({ name: 'init' }),
]),
}),
}),
);
});
it('should await MCP initialization before processing a prompt', async () => {
const stream = createMockStream([
{
@@ -551,6 +742,113 @@ describe('Session', () => {
expect(result).toEqual({ stopReason: 'end_turn' });
});
it('should handle /memory command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/memory view' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/memory view',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions list' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions list',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /extensions explore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/extensions explore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/extensions explore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /restore command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/restore' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith(
'/restore',
expect.any(Object),
);
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle /init command', async () => {
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
const result = await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: '/init' }],
});
expect(result).toEqual({ stopReason: 'end_turn' });
expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object));
expect(mockChat.sendMessageStream).not.toHaveBeenCalled();
});
it('should handle tool calls', async () => {
const stream1 = createMockStream([
{
@@ -1207,4 +1505,30 @@ describe('Session', () => {
'Invalid or unavailable mode: invalid-mode',
);
});
it('should set model on config', () => {
session.setModel('gemini-2.0-flash-exp');
expect(mockConfig.setModel).toHaveBeenCalledWith('gemini-2.0-flash-exp');
});
it('should handle unquoted commands from autocomplete (with empty leading parts)', async () => {
// Mock handleCommand to verify it gets called
const handleCommandSpy = vi
.spyOn(
(session as unknown as { commandHandler: CommandHandler })
.commandHandler,
'handleCommand',
)
.mockResolvedValue(true);
await session.prompt({
sessionId: 'session-1',
prompt: [
{ type: 'text', text: '' },
{ type: 'text', text: '/memory' },
],
});
expect(handleCommandSpy).toHaveBeenCalledWith('/memory', expect.anything());
});
});
@@ -4,15 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
GeminiChat,
ToolResult,
ToolCallConfirmationDetails,
FilterFilesOptions,
ConversationRecord,
} from '@google/gemini-cli-core';
import {
type Config,
type GeminiChat,
type ToolResult,
type ToolCallConfirmationDetails,
type FilterFilesOptions,
type ConversationRecord,
CoreToolCallStatus,
AuthType,
logToolCall,
@@ -39,6 +37,16 @@ import {
ApprovalMode,
getVersion,
convertSessionToClientHistory,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
getDisplayString,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -61,11 +69,14 @@ import { loadCliConfig } from '../config/config.js';
import { runExitCleanup } from '../utils/cleanup.js';
import { SessionSelector } from '../utils/sessionUtils.js';
export async function runZedIntegration(
import { CommandHandler } from './commandHandler.js';
export async function runAcpClient(
config: Config,
settings: LoadedSettings,
argv: CliArgs,
) {
// ... (skip unchanged lines) ...
const { stdout: workingStdout } = createWorkingStdio();
const stdout = Writable.toWeb(workingStdout) as WritableStream;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -87,6 +98,8 @@ export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
private baseUrl: string | undefined;
private customHeaders: Record<string, string> | undefined;
constructor(
private config: Config,
@@ -120,6 +133,17 @@ export class GeminiAgent {
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
},
{
id: AuthType.GATEWAY,
name: 'AI API Gateway',
description: 'Use a custom AI API Gateway',
_meta: {
gateway: {
protocol: 'google',
restartRequired: 'false',
},
},
},
];
await this.config.initialize();
@@ -168,7 +192,38 @@ export class GeminiAgent {
if (apiKey) {
this.apiKey = apiKey;
}
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
// Extract gateway details if present
const gatewaySchema = z.object({
baseUrl: z.string().optional(),
headers: z.record(z.string()).optional(),
});
let baseUrl: string | undefined;
let headers: Record<string, string> | undefined;
if (meta?.['gateway']) {
const result = gatewaySchema.safeParse(meta['gateway']);
if (result.success) {
baseUrl = result.data.baseUrl;
headers = result.data.headers;
} else {
throw new acp.RequestError(
-32602,
`Malformed gateway payload: ${result.error.message}`,
);
}
}
this.baseUrl = baseUrl;
this.customHeaders = headers;
await this.config.refreshAuth(
method,
apiKey ?? this.apiKey,
baseUrl,
headers,
);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
}
@@ -198,7 +253,12 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType, this.apiKey);
await config.refreshAuth(
authType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -240,16 +300,37 @@ export class GeminiAgent {
const geminiClient = config.getGeminiClient();
const chat = await geminiClient.startChat();
const session = new Session(sessionId, chat, config, this.connection);
const session = new Session(
sessionId,
chat,
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
loadedSettings,
);
const response = {
sessionId,
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
async loadSession({
@@ -291,6 +372,7 @@ export class GeminiAgent {
geminiClient.getChat(),
config,
this.connection,
this.settings,
);
this.sessions.set(sessionId, session);
@@ -298,12 +380,27 @@ export class GeminiAgent {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.streamHistory(sessionData.messages);
return {
setTimeout(() => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
session.sendAvailableCommands();
}, 0);
const { availableModels, currentModelId } = buildAvailableModels(
config,
this.settings,
);
const response = {
modes: {
availableModes: buildAvailableModes(config.isPlanEnabled()),
currentModeId: config.getApprovalMode(),
},
models: {
availableModels,
currentModelId,
},
};
return response;
}
private async initializeSessionConfig(
@@ -323,7 +420,12 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType, this.apiKey);
await config.refreshAuth(
selectedAuthType,
this.apiKey,
this.baseUrl,
this.customHeaders,
);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -414,16 +516,28 @@ export class GeminiAgent {
}
return session.setMode(params.modeId);
}
async unstable_setSessionModel(
params: acp.SetSessionModelRequest,
): Promise<acp.SetSessionModelResponse> {
const session = this.sessions.get(params.sessionId);
if (!session) {
throw new Error(`Session not found: ${params.sessionId}`);
}
return session.setModel(params.modelId);
}
}
export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
constructor(
private readonly id: string,
private readonly chat: GeminiChat,
private readonly config: Config,
private readonly connection: acp.AgentSideConnection,
private readonly settings: LoadedSettings,
) {}
async cancelPendingPrompt(): Promise<void> {
@@ -446,6 +560,27 @@ export class Session {
return {};
}
private getAvailableCommands() {
return this.commandHandler.getAvailableCommands();
}
async sendAvailableCommands(): Promise<void> {
const availableCommands = this.getAvailableCommands().map((command) => ({
name: command.name,
description: command.description,
}));
await this.sendUpdate({
sessionUpdate: 'available_commands_update',
availableCommands,
});
}
setModel(modelId: acp.ModelId): acp.SetSessionModelResponse {
this.config.setModel(modelId);
return {};
}
async streamHistory(messages: ConversationRecord['messages']): Promise<void> {
for (const msg of messages) {
const contentString = partListUnionToString(msg.content);
@@ -528,6 +663,41 @@ export class Session {
const parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
// Command interception
let commandText = '';
for (const part of parts) {
if (typeof part === 'object' && part !== null) {
if ('text' in part) {
// It is a text part
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-type-assertion
const text = (part as any).text;
if (typeof text === 'string') {
commandText += text;
}
} else {
// Non-text part (image, embedded resource)
// Stop looking for command
break;
}
}
}
commandText = commandText.trim();
if (
commandText &&
(commandText.startsWith('/') || commandText.startsWith('$'))
) {
// If we found a command, pass it to handleCommand
// Note: handleCommand currently expects `commandText` to be the command string
// It uses `parts` argument but effectively ignores it in current implementation
const handled = await this.handleCommand(commandText, parts);
if (handled) {
return { stopReason: 'end_turn' };
}
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
@@ -627,9 +797,28 @@ export class Session {
return { stopReason: 'end_turn' };
}
private async sendUpdate(
update: acp.SessionNotification['update'],
): Promise<void> {
private async handleCommand(
commandText: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parts: Part[],
): Promise<boolean> {
const gitService = await this.config.getGitService();
const commandContext = {
config: this.config,
settings: this.settings,
git: gitService,
sendMessage: async (text: string) => {
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text },
});
},
};
return this.commandHandler.handleCommand(commandText, commandContext);
}
private async sendUpdate(update: acp.SessionUpdate): Promise<void> {
const params: acp.SessionNotification = {
sessionId: this.id,
update,
@@ -1377,3 +1566,94 @@ function buildAvailableModes(isPlanEnabled: boolean): acp.SessionMode[] {
return modes;
}
function buildAvailableModels(
config: Config,
settings: LoadedSettings,
): {
availableModels: Array<{
modelId: string;
name: string;
description?: string;
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const mainOptions = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
},
];
if (shouldShowPreviewModels) {
mainOptions.unshift({
value: PREVIEW_GEMINI_MODEL_AUTO,
title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO),
description: useGemini31
? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash'
: 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash',
});
}
const manualOptions = [
{
value: DEFAULT_GEMINI_MODEL,
title: getDisplayString(DEFAULT_GEMINI_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL),
},
{
value: DEFAULT_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL),
},
];
if (shouldShowPreviewModels) {
const previewProModel = useGemini31
? PREVIEW_GEMINI_3_1_MODEL
: PREVIEW_GEMINI_MODEL;
const previewProValue = useCustomToolModel
? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL
: previewProModel;
manualOptions.unshift(
{
value: previewProValue,
title: getDisplayString(previewProModel),
},
{
value: PREVIEW_GEMINI_FLASH_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL),
},
);
}
const scaleOptions = (
options: Array<{ value: string; title: string; description?: string }>,
) =>
options.map((o) => ({
modelId: o.value,
name: o.title,
description: o.description,
}));
return {
availableModels: [
...scaleOptions(mainOptions),
...scaleOptions(manualOptions),
],
currentModelId: preferredModel,
};
}
@@ -13,7 +13,7 @@ import {
type Mocked,
type Mock,
} from 'vitest';
import { GeminiAgent } from './zedIntegration.js';
import { GeminiAgent } from './acpClient.js';
import * as acp from '@agentclientprotocol/sdk';
import {
ApprovalMode,
@@ -92,7 +92,11 @@ describe('GeminiAgent Session Resume', () => {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
getApprovalMode: vi.fn().mockReturnValue('default'),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getModel: vi.fn().mockReturnValue('gemini-pro'),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
} as unknown as Mocked<Config>;
mockSettings = {
merged: {
@@ -200,9 +204,18 @@ describe('GeminiAgent Session Resume', () => {
name: 'YOLO',
description: 'Auto-approves all tools',
},
{
id: ApprovalMode.PLAN,
name: 'Plan',
description: 'Read-only mode',
},
],
currentModeId: ApprovalMode.DEFAULT,
},
models: {
availableModels: expect.any(Array) as unknown,
currentModelId: 'gemini-pro',
},
});
// Verify resumeChat received the correct arguments
@@ -0,0 +1,30 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandHandler } from './commandHandler.js';
import { describe, it, expect } from 'vitest';
describe('CommandHandler', () => {
it('parses commands correctly', () => {
const handler = new CommandHandler();
// @ts-expect-error - testing private method
const parse = (query: string) => handler.parseSlashCommand(query);
const memShow = parse('/memory show');
expect(memShow.commandToExecute?.name).toBe('memory show');
expect(memShow.args).toBe('');
const memAdd = parse('/memory add hello world');
expect(memAdd.commandToExecute?.name).toBe('memory add');
expect(memAdd.args).toBe('hello world');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
const init = parse('/init');
expect(init.commandToExecute?.name).toBe('init');
});
});
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Command, CommandContext } from './commands/types.js';
import { CommandRegistry } from './commands/commandRegistry.js';
import { MemoryCommand } from './commands/memory.js';
import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
export class CommandHandler {
private registry: CommandRegistry;
constructor() {
this.registry = CommandHandler.createRegistry();
}
private static createRegistry(): CommandRegistry {
const registry = new CommandRegistry();
registry.register(new MemoryCommand());
registry.register(new ExtensionsCommand());
registry.register(new InitCommand());
registry.register(new RestoreCommand());
return registry;
}
getAvailableCommands(): Array<{ name: string; description: string }> {
return this.registry.getAllCommands().map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}
/**
* Parses and executes a command string if it matches a registered command.
* Returns true if a command was handled, false otherwise.
*/
async handleCommand(
commandText: string,
context: CommandContext,
): Promise<boolean> {
const { commandToExecute, args } = this.parseSlashCommand(commandText);
if (commandToExecute) {
await this.runCommand(commandToExecute, args, context);
return true;
}
return false;
}
private async runCommand(
commandToExecute: Command,
args: string,
context: CommandContext,
): Promise<void> {
try {
const result = await commandToExecute.execute(
context,
args ? args.split(/\s+/) : [],
);
let messageContent = '';
if (typeof result.data === 'string') {
messageContent = result.data;
} else if (
typeof result.data === 'object' &&
result.data !== null &&
'content' in result.data
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
messageContent = (result.data as Record<string, any>)[
'content'
] as string;
} else {
messageContent = JSON.stringify(result.data, null, 2);
}
await context.sendMessage(messageContent);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
await context.sendMessage(`Error: ${errorMessage}`);
}
}
/**
* Parses a raw slash command string into its matching headless command and arguments.
* Mirrors `packages/cli/src/utils/commands.ts` logic.
*/
private parseSlashCommand(query: string): {
commandToExecute: Command | undefined;
args: string;
} {
const trimmed = query.trim();
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p);
let currentCommands = this.registry.getAllCommands();
let commandToExecute: Command | undefined;
let pathIndex = 0;
for (const part of commandPath) {
const foundCommand = currentCommands.find((cmd) => {
const expectedName = commandPath.slice(0, pathIndex + 1).join(' ');
return (
cmd.name === part ||
cmd.name === expectedName ||
cmd.aliases?.includes(part) ||
cmd.aliases?.includes(expectedName)
);
});
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
} else {
break;
}
}
const args = parts.slice(pathIndex).join(' ');
return { commandToExecute, args };
}
}
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { debugLogger } from '@google/gemini-cli-core';
import type { Command } from './types.js';
export class CommandRegistry {
private readonly commands = new Map<string, Command>();
register(command: Command) {
if (this.commands.has(command.name)) {
debugLogger.warn(`Command ${command.name} already registered. Skipping.`);
return;
}
this.commands.set(command.name, command);
for (const subCommand of command.subCommands ?? []) {
this.register(subCommand);
}
}
get(commandName: string): Command | undefined {
return this.commands.get(commandName);
}
getAllCommands(): Command[] {
return [...this.commands.values()];
}
}
+428
View File
@@ -0,0 +1,428 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { listExtensions } from '@google/gemini-cli-core';
import { SettingScope } from '../../config/settings.js';
import {
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { getErrorMessage } from '../../utils/errors.js';
import { McpServerEnablementManager } from '../../config/mcp/mcpServerEnablement.js';
import { stat } from 'node:fs/promises';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { Config } from '@google/gemini-cli-core';
export class ExtensionsCommand implements Command {
readonly name = 'extensions';
readonly description = 'Manage extensions.';
readonly subCommands = [
new ListExtensionsCommand(),
new ExploreExtensionsCommand(),
new EnableExtensionCommand(),
new DisableExtensionCommand(),
new InstallExtensionCommand(),
new LinkExtensionCommand(),
new UninstallExtensionCommand(),
new RestartExtensionCommand(),
new UpdateExtensionCommand(),
];
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ListExtensionsCommand().execute(context, _);
}
}
export class ListExtensionsCommand implements Command {
readonly name = 'extensions list';
readonly description = 'Lists all installed extensions.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensions = listExtensions(context.config);
const data = extensions.length ? extensions : 'No extensions installed.';
return { name: this.name, data };
}
}
export class ExploreExtensionsCommand implements Command {
readonly name = 'extensions explore';
readonly description = 'Explore available extensions.';
async execute(
_context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const extensionsUrl = 'https://geminicli.com/extensions/';
return {
name: this.name,
data: `View or install available extensions at ${extensionsUrl}`,
};
}
}
function getEnableDisableContext(
config: Config,
args: string[],
invocationName: string,
) {
const extensionManager = config.getExtensionLoader();
if (!(extensionManager instanceof ExtensionManager)) {
return {
error: `Cannot ${invocationName} extensions in this environment.`,
};
}
if (args.length === 0) {
return {
error: `Usage: /extensions ${invocationName} <extension> [--scope=<user|workspace|session>]`,
};
}
let scope = SettingScope.User;
if (args.includes('--scope=workspace') || args.includes('workspace')) {
scope = SettingScope.Workspace;
} else if (args.includes('--scope=session') || args.includes('session')) {
scope = SettingScope.Session;
}
const name = args.filter(
(a) =>
!a.startsWith('--scope') && !['user', 'workspace', 'session'].includes(a),
)[0];
let names: string[] = [];
if (name === '--all') {
let extensions = extensionManager.getExtensions();
if (invocationName === 'enable') {
extensions = extensions.filter((ext) => !ext.isActive);
}
if (invocationName === 'disable') {
extensions = extensions.filter((ext) => ext.isActive);
}
names = extensions.map((ext) => ext.name);
} else if (name) {
names = [name];
} else {
return { error: 'No extension name provided.' };
}
return { extensionManager, names, scope };
}
export class EnableExtensionCommand implements Command {
readonly name = 'extensions enable';
readonly description = 'Enable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'enable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.enableExtension(name, scope);
output.push(`Extension "${name}" enabled for scope "${scope}".`);
const extension = extensionManager
.getExtensions()
.find((e) => e.name === name);
if (extension?.mcpServers) {
const mcpEnablementManager = McpServerEnablementManager.getInstance();
const mcpClientManager = context.config.getMcpClientManager();
const enabledServers = await mcpEnablementManager.autoEnableServers(
Object.keys(extension.mcpServers),
);
if (mcpClientManager && enabledServers.length > 0) {
const restartPromises = enabledServers.map((serverName) =>
mcpClientManager.restartServer(serverName).catch((error) => {
output.push(
`Failed to restart MCP server '${serverName}': ${getErrorMessage(error)}`,
);
}),
);
await Promise.all(restartPromises);
output.push(`Re-enabled MCP servers: ${enabledServers.join(', ')}`);
}
}
} catch (e) {
output.push(`Failed to enable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class DisableExtensionCommand implements Command {
readonly name = 'extensions disable';
readonly description = 'Disable an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const enableContext = getEnableDisableContext(
context.config,
args,
'disable',
);
if ('error' in enableContext) {
return { name: this.name, data: enableContext.error };
}
const { names, scope, extensionManager } = enableContext;
const output: string[] = [];
for (const name of names) {
try {
await extensionManager.disableExtension(name, scope);
output.push(`Extension "${name}" disabled for scope "${scope}".`);
} catch (e) {
output.push(`Failed to disable "${name}": ${getErrorMessage(e)}`);
}
}
return { name: this.name, data: output.join('\n') || 'No action taken.' };
}
}
export class InstallExtensionCommand implements Command {
readonly name = 'extensions install';
readonly description = 'Install an extension from a git repo or local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot install extensions in this environment.',
};
}
const source = args.join(' ').trim();
if (!source) {
return { name: this.name, data: `Usage: /extensions install <source>` };
}
if (/[;&|`'"]/.test(source)) {
return {
name: this.name,
data: `Invalid source: contains disallowed characters.`,
};
}
try {
const installMetadata = await inferInstallMetadata(source);
const extension =
await extensionLoader.installOrUpdateExtension(installMetadata);
return {
name: this.name,
data: `Extension "${extension.name}" installed successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to install extension from "${source}": ${getErrorMessage(error)}`,
};
}
}
}
export class LinkExtensionCommand implements Command {
readonly name = 'extensions link';
readonly description = 'Link an extension from a local path.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot link extensions in this environment.',
};
}
const sourceFilepath = args.join(' ').trim();
if (!sourceFilepath) {
return { name: this.name, data: `Usage: /extensions link <source>` };
}
try {
await stat(sourceFilepath);
} catch (_error) {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
try {
const extension = await extensionLoader.installOrUpdateExtension({
source: sourceFilepath,
type: 'link',
});
return {
name: this.name,
data: `Extension "${extension.name}" linked successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to link extension: ${getErrorMessage(error)}`,
};
}
}
}
export class UninstallExtensionCommand implements Command {
readonly name = 'extensions uninstall';
readonly description = 'Uninstall an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return {
name: this.name,
data: 'Cannot uninstall extensions in this environment.',
};
}
const name = args.join(' ').trim();
if (!name) {
return {
name: this.name,
data: `Usage: /extensions uninstall <extension-name>`,
};
}
try {
await extensionLoader.uninstallExtension(name, false);
return {
name: this.name,
data: `Extension "${name}" uninstalled successfully.`,
};
} catch (error) {
return {
name: this.name,
data: `Failed to uninstall extension "${name}": ${getErrorMessage(error)}`,
};
}
}
}
export class RestartExtensionCommand implements Command {
readonly name = 'extensions restart';
readonly description = 'Restart an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot restart extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions restart <extension-names>|--all',
};
}
let extensionsToRestart = extensionLoader
.getExtensions()
.filter((e) => e.isActive);
if (names) {
extensionsToRestart = extensionsToRestart.filter((e) =>
names.includes(e.name),
);
}
if (extensionsToRestart.length === 0) {
return {
name: this.name,
data: 'No active extensions matched the request.',
};
}
const output: string[] = [];
for (const extension of extensionsToRestart) {
try {
await extensionLoader.restartExtension(extension);
output.push(`Restarted "${extension.name}".`);
} catch (e) {
output.push(
`Failed to restart "${extension.name}": ${getErrorMessage(e)}`,
);
}
}
return { name: this.name, data: output.join('\n') };
}
}
export class UpdateExtensionCommand implements Command {
readonly name = 'extensions update';
readonly description = 'Update an extension.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const extensionLoader = context.config.getExtensionLoader();
if (!(extensionLoader instanceof ExtensionManager)) {
return { name: this.name, data: 'Cannot update extensions.' };
}
const all = args.includes('--all');
const names = all ? null : args.filter((a) => !!a);
if (!all && names?.length === 0) {
return {
name: this.name,
data: 'Usage: /extensions update <extension-names>|--all',
};
}
return {
name: this.name,
data: 'Headless extension updating requires internal UI dispatches. Please use `gemini extensions update` directly in the terminal.',
};
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { performInit } from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class InitCommand implements Command {
name = 'init';
description = 'Analyzes the project and creates a tailored GEMINI.md file';
requiresWorkspace = true;
async execute(
context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const targetDir = context.config.getTargetDir();
if (!targetDir) {
throw new Error('Command requires a workspace.');
}
const geminiMdPath = path.join(targetDir, 'GEMINI.md');
const result = performInit(fs.existsSync(geminiMdPath));
switch (result.type) {
case 'message':
return {
name: this.name,
data: result,
};
case 'submit_prompt':
fs.writeFileSync(geminiMdPath, '', 'utf8');
if (typeof result.content !== 'string') {
throw new Error('Init command content must be a string.');
}
// Inform the user since we can't trigger the UI-based interactive agent loop here directly.
// We output the prompt text they can use to re-trigger the generation manually,
// or just seed the GEMINI.md file as we've done above.
return {
name: this.name,
data: {
type: 'message',
messageType: 'info',
content: `A template GEMINI.md has been created at ${geminiMdPath}.\n\nTo populate it with project context, you can run the following prompt in a new chat:\n\n${result.content}`,
},
};
default:
throw new Error('Unknown result type from performInit');
}
}
}
+121
View File
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
} from '@google/gemini-cli-core';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
readonly subCommands = [
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly requiresWorkspace = true;
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
return new ShowMemoryCommand().execute(context, _);
}
}
export class ShowMemoryCommand implements Command {
readonly name = 'memory show';
readonly description = 'Shows the current memory contents.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = showMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class RefreshMemoryCommand implements Command {
readonly name = 'memory refresh';
readonly aliases = ['memory reload'];
readonly description = 'Refreshes the memory from the source.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = await refreshMemory(context.config);
return { name: this.name, data: result.content };
}
}
export class ListMemoryCommand implements Command {
readonly name = 'memory list';
readonly description = 'Lists the paths of the GEMINI.md files in use.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
const result = listMemoryFiles(context.config);
return { name: this.name, data: result.content };
}
}
export class AddMemoryCommand implements Command {
readonly name = 'memory add';
readonly description = 'Add content to the memory.';
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const textToAdd = args.join(' ').trim();
const result = addMemory(textToAdd);
if (result.type === 'message') {
return { name: this.name, data: result.content };
}
const toolRegistry = context.config.getToolRegistry();
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const signal = abortController.signal;
await context.sendMessage(`Saving memory via ${result.toolName}...`);
await tool.buildAndExecute(result.toolArgs, signal, undefined, {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
getCheckpointInfoList,
getToolCallDataSchema,
isNodeError,
performRestore,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
export class RestoreCommand implements Command {
readonly name = 'restore';
readonly description =
'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created';
readonly requiresWorkspace = true;
readonly subCommands = [new ListCheckpointsCommand()];
async execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse> {
const { config, git: gitService } = context;
const argsStr = args.join(' ');
try {
if (!argsStr) {
return await new ListCheckpointsCommand().execute(context);
}
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const selectedFile = argsStr.endsWith('.json')
? argsStr
: `${argsStr}.json`;
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
const filePath = path.join(checkpointDir, selectedFile);
let data: string;
try {
data = await fs.readFile(filePath, 'utf-8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return {
name: this.name,
data: `File not found: ${selectedFile}`,
};
}
throw error;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const toolCallData = JSON.parse(data);
const ToolCallDataSchema = getToolCallDataSchema();
const parseResult = ToolCallDataSchema.safeParse(toolCallData);
if (!parseResult.success) {
return {
name: this.name,
data: 'Checkpoint file is invalid or corrupted.',
};
}
const restoreResultGenerator = performRestore(
parseResult.data,
gitService,
);
const restoreResult = [];
for await (const result of restoreResultGenerator) {
restoreResult.push(result);
}
// Format the result nicely since Zed just dumps data
const formattedResult = restoreResult
.map((r) => {
if (r.type === 'message') {
return `[${r.messageType.toUpperCase()}] ${r.content}`;
} else if (r.type === 'load_history') {
return `Loaded history with ${r.clientHistory.length} messages.`;
}
return `Restored: ${JSON.stringify(r)}`;
})
.join('\n');
return {
name: this.name,
data: formattedResult,
};
} catch (error) {
return {
name: this.name,
data: `An unexpected error occurred during restore: ${error}`,
};
}
}
}
export class ListCheckpointsCommand implements Command {
readonly name = 'restore list';
readonly description = 'Lists all available checkpoints.';
async execute(context: CommandContext): Promise<CommandExecutionResponse> {
const { config } = context;
try {
if (!config.getCheckpointingEnabled()) {
return {
name: this.name,
data: 'Checkpointing is not enabled. Please enable it in your settings (`general.checkpointing.enabled: true`) to use /restore.',
};
}
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch (_e) {
// Ignore
}
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
if (jsonFiles.length === 0) {
return { name: this.name, data: 'No checkpoints found.' };
}
const checkpointFiles = new Map<string, string>();
for (const file of jsonFiles) {
const filePath = path.join(checkpointDir, file);
const data = await fs.readFile(filePath, 'utf-8');
checkpointFiles.set(file, data);
}
const checkpointInfoList = getCheckpointInfoList(checkpointFiles);
const formatted = checkpointInfoList
.map((info) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i = info as Record<string, any>;
const fileName = String(i['fileName'] || 'Unknown');
const toolName = String(i['toolName'] || 'Unknown');
const status = String(i['status'] || 'Unknown');
const timestamp = new Date(
Number(i['timestamp']) || 0,
).toLocaleString();
return `- **${fileName}**: ${toolName} (Status: ${status}) [${timestamp}]`;
})
.join('\n');
return {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch (_error) {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
};
}
}
}
+40
View File
@@ -0,0 +1,40 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, GitService } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
export interface CommandContext {
config: Config;
settings: LoadedSettings;
git?: GitService;
sendMessage: (text: string) => Promise<void>;
}
export interface CommandArgument {
readonly name: string;
readonly description: string;
readonly isRequired?: boolean;
}
export interface Command {
readonly name: string;
readonly aliases?: string[];
readonly description: string;
readonly arguments?: CommandArgument[];
readonly subCommands?: Command[];
readonly requiresWorkspace?: boolean;
execute(
context: CommandContext,
args: string[],
): Promise<CommandExecutionResponse>;
}
export interface CommandExecutionResponse {
readonly name: string;
readonly data: unknown;
}
+101 -2
View File
@@ -19,6 +19,8 @@ import {
debugLogger,
ApprovalMode,
type MCPServerConfig,
type GeminiCLIExtension,
Storage,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -2628,13 +2630,13 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan setting is missing', async () => {
it('should allow plan approval mode by default when --approval-mode=plan is used', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({});
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN);
});
it('should pass planSettings.directory from settings to config', async () => {
@@ -3524,4 +3526,101 @@ describe('loadCliConfig mcpEnabled', () => {
expect(config.getAllowedMcpServers()).toEqual(['serverA']);
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
describe('extension plan settings', () => {
beforeEach(() => {
vi.spyOn(Storage.prototype, 'getProjectTempDir').mockReturnValue(
'/mock/home/user/.gemini/tmp/test-project',
);
});
it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should NOT use plan directory from active extension when user has specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
});
it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: false,
plan: { directory: 'ext-plans-dir-inactive' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).not.toContain(
'ext-plans-dir-inactive',
);
});
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
// No extensions providing plan directory
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
const config = await loadCliConfig(settings, 'test-session', argv);
// Should return the default managed temp directory path
expect(config.storage.getPlansDir()).toBe(
path.join(
'/mock',
'home',
'user',
'.gemini',
'tmp',
'test-project',
'test-session',
'plans',
),
);
});
});
});
+19 -4
View File
@@ -81,7 +81,8 @@ export interface CliArgs {
policy: string[] | undefined;
allowedMcpServerNames: string[] | undefined;
allowedTools: string[] | undefined;
experimentalAcp: boolean | undefined;
acp?: boolean;
experimentalAcp?: boolean;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
resume: string | typeof RESUME_LATEST | undefined;
@@ -177,10 +178,15 @@ export async function parseArguments(
.filter(Boolean),
),
})
.option('experimental-acp', {
.option('acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('experimental-acp', {
type: 'boolean',
description:
'Starts the agent in ACP mode (deprecated, use --acp instead)',
})
.option('allowed-mcp-server-names', {
type: 'array',
string: true,
@@ -511,6 +517,10 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
const extensionPlanSettings = extensionManager
.getExtensions()
.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent: string | HierarchicalMemory = '';
@@ -628,6 +638,7 @@ export async function loadCliConfig(
// -i/--prompt-interactive forces interactive mode with an initial prompt
const interactive =
!!argv.promptInteractive ||
!!argv.acp ||
!!argv.experimentalAcp ||
(!isHeadlessMode({ prompt: argv.prompt, query: argv.query }) &&
!argv.isCommand);
@@ -754,6 +765,7 @@ export async function loadCliConfig(
}
return new Config({
acpMode: !!argv.acp || !!argv.experimentalAcp,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -817,7 +829,7 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
@@ -827,7 +839,9 @@ export async function loadCliConfig(
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -861,6 +875,7 @@ export async function loadCliConfig(
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
maxAttempts: settings.general?.maxAttempts,
ptyInfo: ptyInfo?.name,
disableLLMCorrection: settings.tools?.disableLLMCorrection,
@@ -886,6 +886,7 @@ Would you like to attempt to install via "git clone" instead?`,
themes: config.themes,
rules,
checkers,
plan: config.plan,
};
} catch (e) {
debugLogger.error(
+9
View File
@@ -33,6 +33,15 @@ export interface ExtensionConfig {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
}
export interface ExtensionUpdateInfo {
@@ -89,13 +89,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from allowed server should be allowed
// Tools from allowed server should be allowed
expect(
(await engine.check({ name: 'allowed-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_allowed-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'allowed-server__another_tool' },
{ name: 'mcp_allowed-server_another_tool' },
undefined,
)
).decision,
@@ -103,13 +103,13 @@ describe('Policy Engine Integration Tests', () => {
// Tools from trusted server should be allowed
expect(
(await engine.check({ name: 'trusted-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engine.check(
{ name: 'trusted-server__special_tool' },
{ name: 'mcp_trusted-server_special_tool' },
undefined,
)
).decision,
@@ -117,17 +117,17 @@ describe('Policy Engine Integration Tests', () => {
// Tools from blocked server should be denied
expect(
(await engine.check({ name: 'blocked-server__tool1' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_tool1' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any_tool' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_any_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
// Tools from unknown servers should use default
expect(
(await engine.check({ name: 'unknown-server__tool' }, undefined))
(await engine.check({ name: 'mcp_unknown-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ASK_USER);
});
@@ -147,12 +147,16 @@ describe('Policy Engine Integration Tests', () => {
// ANY tool with a server name should be allowed
expect(
(await engine.check({ name: 'mcp-server__tool' }, 'mcp-server'))
(await engine.check({ name: 'mcp_mcp-server_tool' }, 'mcp-server'))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'another-server__tool' }, 'another-server'))
.decision,
(
await engine.check(
{ name: 'mcp_another-server_tool' },
'another-server',
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Built-in tools should NOT be allowed by the MCP wildcard
@@ -167,7 +171,7 @@ describe('Policy Engine Integration Tests', () => {
allowed: ['my-server'],
},
tools: {
exclude: ['my-server__dangerous-tool'],
exclude: ['mcp_my-server_dangerous-tool'],
},
};
@@ -180,20 +184,24 @@ describe('Policy Engine Integration Tests', () => {
// MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 4.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
(
await engine.check(
{ name: 'mcp_my-server_dangerous-tool' },
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
});
it('should handle complex mixed configurations', async () => {
const settings: Settings = {
tools: {
allowed: ['custom-tool', 'my-server__special-tool'],
allowed: ['custom-tool', 'mcp_my-server_special-tool'],
exclude: ['glob', 'dangerous-tool'],
},
mcp: {
@@ -238,21 +246,21 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'custom-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// MCP server tools
expect(
(await engine.check({ name: 'allowed-server__tool' }, undefined))
(await engine.check({ name: 'mcp_allowed-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__tool' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'blocked-server__tool' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
@@ -479,7 +487,7 @@ describe('Policy Engine Integration Tests', () => {
expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
(r) => r.toolName === 'mcp_blocked-server_*',
);
expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude
@@ -489,11 +497,13 @@ describe('Policy Engine Integration Tests', () => {
expect(specificToolRule?.priority).toBe(4.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
(r) => r.toolName === 'mcp_trusted-server_*',
);
expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
const mcpServerRule = rules.find(
(r) => r.toolName === 'mcp_mcp-server_*',
);
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
@@ -505,18 +515,19 @@ describe('Policy Engine Integration Tests', () => {
(await engine.check({ name: 'blocked-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'blocked-server__any' }, undefined))
(await engine.check({ name: 'mcp_blocked-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'specific-tool' }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'trusted-server__any' }, undefined))
(await engine.check({ name: 'mcp_trusted-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect(
(await engine.check({ name: 'mcp-server__any' }, undefined)).decision,
(await engine.check({ name: 'mcp_mcp-server_any' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
expect((await engine.check({ name: 'glob' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
@@ -545,7 +556,7 @@ describe('Policy Engine Integration Tests', () => {
// Exclusion (195) should win over trust (90)
expect(
(await engine.check({ name: 'conflicted-server__tool' }, undefined))
(await engine.check({ name: 'mcp_conflicted-server_tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -556,7 +567,7 @@ describe('Policy Engine Integration Tests', () => {
excluded: ['my-server'], // Priority 195 - DENY
},
tools: {
allowed: ['my-server__special-tool'], // Priority 100 - ALLOW
allowed: ['mcp_my-server_special-tool'], // Priority 100 - ALLOW
},
};
@@ -569,11 +580,11 @@ describe('Policy Engine Integration Tests', () => {
// Server exclusion (195) wins over specific tool allow (100)
// This might be counterintuitive but follows the priority system
expect(
(await engine.check({ name: 'my-server__special-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_special-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
expect(
(await engine.check({ name: 'my-server__other-tool' }, undefined))
(await engine.check({ name: 'mcp_my-server_other-tool' }, undefined))
.decision,
).toBe(PolicyDecision.DENY);
});
@@ -643,13 +654,13 @@ describe('Policy Engine Integration Tests', () => {
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
const server2Rule = rules.find((r) => r.toolName === 'mcp_server2_*');
expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*');
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
+3 -1
View File
@@ -102,7 +102,9 @@ export async function loadSandboxConfig(
const packageJson = await getPackageJson(__dirname);
const image =
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri;
process.env['GEMINI_SANDBOX_IMAGE'] ??
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined;
}
-3
View File
@@ -185,9 +185,6 @@ export interface SessionRetentionSettings {
/** Minimum retention period (safety limit, defaults to "1d") */
minRetention?: string;
/** INTERNAL: Whether the user has acknowledged the session retention warning */
warningAcknowledged?: boolean;
}
export interface SettingsError {
@@ -424,12 +424,10 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false);
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe(
'Enable planning features (Plan Mode and tools).',
);
expect(setting.description).toBe('Enable Plan Mode.');
});
it('should have hooksConfig.notifications setting in schema', () => {
+11 -16
View File
@@ -117,6 +117,10 @@ export interface SettingDefinition {
* For map-like objects without explicit `properties`, describes the shape of the values.
*/
additionalProperties?: SettingCollectionDefinition;
/**
* Optional unit to display after the value (e.g. '%').
*/
unit?: string;
/**
* Optional reference identifier for generators that emit a `$ref`.
*/
@@ -339,7 +343,7 @@ const SETTINGS_SCHEMA = {
label: 'Enable Session Cleanup',
category: 'General',
requiresRestart: false,
default: false,
default: true as boolean,
description: 'Enable automatic session cleanup',
showInDialog: true,
},
@@ -348,7 +352,7 @@ const SETTINGS_SCHEMA = {
label: 'Keep chat history',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
default: '30d' as string,
description:
'Automatically delete chats older than this time period (e.g., "30d", "7d", "24h", "1w")',
showInDialog: true,
@@ -372,16 +376,6 @@ const SETTINGS_SCHEMA = {
description: `Minimum retention period (safety limit, defaults to "${DEFAULT_MIN_RETENTION}")`,
showInDialog: false,
},
warningAcknowledged: {
type: 'boolean',
label: 'Warning Acknowledged',
category: 'General',
requiresRestart: false,
default: false,
showInDialog: false,
description:
'INTERNAL: Whether the user has acknowledged the session retention warning',
},
},
description: 'Settings for automatic session cleanup.',
},
@@ -605,7 +599,7 @@ const SETTINGS_SCHEMA = {
category: 'UI',
requiresRestart: false,
default: true,
description: 'Hides the context window remaining percentage.',
description: 'Hides the context window usage percentage.',
showInDialog: true,
},
},
@@ -923,13 +917,14 @@ const SETTINGS_SCHEMA = {
},
compressionThreshold: {
type: 'number',
label: 'Compression Threshold',
label: 'Context Compression Threshold',
category: 'Model',
requiresRestart: true,
default: 0.5 as number,
description:
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
unit: '%',
},
disableLoopDetection: {
type: 'boolean',
@@ -1807,8 +1802,8 @@ const SETTINGS_SCHEMA = {
label: 'Plan',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable planning features (Plan Mode and tools).',
default: true,
description: 'Enable Plan Mode.',
showInDialog: true,
},
modelSteering: {
+4 -4
View File
@@ -79,7 +79,7 @@ import {
type InitializationResult,
} from './core/initializer.js';
import { validateAuthMethod } from './config/auth.js';
import { runZedIntegration } from './zed-integration/zedIntegration.js';
import { runAcpClient } from './acp/acpClient.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
@@ -243,7 +243,7 @@ export async function startInteractiveUI(
<ScrollProvider>
<OverflowProvider>
<SessionStatsProvider>
<VimModeProvider settings={settings}>
<VimModeProvider>
<AppContainer
config={config}
startupWarnings={startupWarnings}
@@ -672,8 +672,8 @@ export async function main() {
await getOauthClient(settings.merged.security.auth.selectedType, config);
}
if (config.getExperimentalZedIntegration()) {
return runZedIntegration(config, settings, argv);
if (config.getAcpMode()) {
return runAcpClient(config, settings, argv);
}
let input = config.getQuestion();
+2 -2
View File
@@ -179,7 +179,7 @@ describe('gemini.tsx main function cleanup', () => {
vi.restoreAllMocks();
});
it('should log error when cleanupExpiredSessions fails', async () => {
it.skip('should log error when cleanupExpiredSessions fails', async () => {
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
@@ -216,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => {
getMcpServers: () => ({}),
getMcpClientManager: vi.fn(),
getIdeMode: vi.fn(() => false),
getExperimentalZedIntegration: vi.fn(() => true),
getAcpMode: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getProjectRoot: vi.fn(() => '/'),
@@ -129,7 +129,7 @@ describe('BuiltinCommandLoader', () => {
vi.clearAllMocks();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
getEnableHooksUI: () => false,
@@ -287,7 +287,7 @@ describe('BuiltinCommandLoader profile', () => {
vi.resetModules();
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: () => false,
getEnableExtensionReloading: () => false,
getEnableHooks: () => false,
+1 -1
View File
@@ -42,7 +42,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
getAcpMode: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
+6 -2
View File
@@ -528,12 +528,13 @@ export const mockSettings = new LoadedSettings(
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
const baseMockUiState = {
history: [],
renderMarkdown: true,
streamingState: StreamingState.Idle,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: 'black',
terminalBackgroundColor: 'black' as const,
cleanUiDetailsVisible: false,
allowPlanMode: true,
activePtyId: undefined,
@@ -552,6 +553,9 @@ const baseMockUiState = {
warningText: '',
},
bannerVisible: false,
nightly: false,
updateInfo: null,
pendingHistoryItems: [],
};
export const mockAppState: AppState = {
@@ -752,7 +756,7 @@ export const renderWithProviders = (
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
-130
View File
@@ -2544,136 +2544,6 @@ describe('AppContainer State Management', () => {
});
});
describe('Expansion Persistence', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: ReturnType<typeof render>['stdin'];
const setupExpansionPersistenceTest = async (
HighPriorityChild?: React.FC,
) => {
const getTree = () => (
<SettingsContext.Provider value={mockSettings}>
<KeypressProvider config={mockConfig}>
<OverflowProvider>
<AppContainer
config={mockConfig}
version="1.0.0"
initializationResult={mockInitResult}
/>
{HighPriorityChild && <HighPriorityChild />}
</OverflowProvider>
</KeypressProvider>
</SettingsContext.Provider>
);
const renderResult = render(getTree());
stdin = renderResult.stdin;
await act(async () => {
vi.advanceTimersByTime(100);
});
rerender = () => renderResult.rerender(getTree());
unmount = () => renderResult.unmount();
};
const writeStdin = async (sequence: string) => {
await act(async () => {
stdin.write(sequence);
// Advance timers to allow escape sequence parsing and broadcasting
vi.advanceTimersByTime(100);
});
rerender();
};
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should reset expansion when a key is NOT handled by anyone', async () => {
await setupExpansionPersistenceTest();
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// Press a random key that no one handles (hits Low priority fallback)
await writeStdin('x');
// Should be reset to true (collapsed)
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should toggle expansion when Ctrl+O is pressed', async () => {
await setupExpansionPersistenceTest();
// Initial state is collapsed
expect(capturedUIState.constrainHeight).toBe(true);
// Press Ctrl+O to expand (Ctrl+O is sequence \x0f)
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(false);
// Press Ctrl+O again to collapse
await writeStdin('\x0f');
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
it('should NOT collapse when a high-priority component handles the key (e.g., up/down arrows)', async () => {
const NavigationHandler = () => {
// use real useKeypress
useKeypress(
(key: Key) => {
if (key.name === 'up' || key.name === 'down') {
return true; // Handle navigation
}
return false;
},
{ isActive: true, priority: true }, // High priority
);
return null;
};
await setupExpansionPersistenceTest(NavigationHandler);
// Expand first
act(() => capturedUIActions.setConstrainHeight(false));
rerender();
expect(capturedUIState.constrainHeight).toBe(false);
// 1. Simulate Up arrow (handled by high priority child)
// CSI A is Up arrow
await writeStdin('\u001b[A');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 2. Simulate Down arrow (handled by high priority child)
// CSI B is Down arrow
await writeStdin('\u001b[B');
// Should STILL be expanded
expect(capturedUIState.constrainHeight).toBe(false);
// 3. Sanity check: press an unhandled key
await writeStdin('x');
// Should finally collapse
expect(capturedUIState.constrainHeight).toBe(true);
unmount();
});
});
describe('Shortcuts Help Visibility', () => {
let handleGlobalKeypress: (key: Key) => boolean;
let mockedUseKeypress: Mock;
+5 -39
View File
@@ -146,7 +146,6 @@ import { requestConsentInteractive } from '../config/extensions/consent.js';
import { useSessionBrowser } from './hooks/useSessionBrowser.js';
import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
@@ -1383,11 +1382,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1548,28 +1543,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const handleAutoEnableRetention = useCallback(() => {
const userSettings = settings.forScope(SettingScope.User).settings;
const currentRetention = userSettings.general?.sessionRetention ?? {};
settings.setValue(SettingScope.User, 'general.sessionRetention', {
...currentRetention,
enabled: true,
maxAge: '30d',
warningAcknowledged: true,
});
}, [settings]);
const {
shouldShowWarning: shouldShowRetentionWarning,
checkComplete: retentionCheckComplete,
sessionsToDeleteCount,
} = useSessionRetentionCheck(
config,
settings.merged,
handleAutoEnableRetention,
);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
@@ -1896,10 +1869,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
],
);
useKeypress(handleGlobalKeypress, {
isActive: true,
priority: KeypressPriority.Low,
});
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useKeypress(
() => {
@@ -2015,7 +1985,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const nightly = props.version.includes('nightly');
const dialogsVisible =
(shouldShowRetentionWarning && retentionCheckComplete) ||
shouldShowIdePrompt ||
shouldShowIdePrompt ||
isFolderTrustDialogOpen ||
isPolicyUpdateDialogOpen ||
@@ -2202,9 +2172,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
history: historyManager.history,
historyManager,
isThemeDialogOpen,
shouldShowRetentionWarning:
shouldShowRetentionWarning && retentionCheckComplete,
sessionsToDeleteCount: sessionsToDeleteCount ?? 0,
themeError,
isAuthenticating,
isConfigInitialized,
@@ -2334,9 +2302,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}),
[
isThemeDialogOpen,
shouldShowRetentionWarning,
retentionCheckComplete,
sessionsToDeleteCount,
themeError,
isAuthenticating,
isConfigInitialized,
@@ -2,20 +2,20 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
@@ -47,34 +47,31 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
Composer
"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
@@ -110,20 +107,17 @@ DialogManager
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
@@ -146,6 +140,9 @@ HistoryItemDisplay
Notifications
Composer
"
@@ -21,6 +21,10 @@ import {
ConfigExtensionDialog,
type ConfigExtensionDialogProps,
} from '../components/ConfigExtensionDialog.js';
import {
ExtensionRegistryView,
type ExtensionRegistryViewProps,
} from '../components/views/ExtensionRegistryView.js';
import { type CommandContext, type SlashCommand } from './types.js';
import {
@@ -39,6 +43,8 @@ import {
} from '../../config/extension-manager.js';
import { SettingScope } from '../../config/settings.js';
import { stat } from 'node:fs/promises';
import { type RegistryExtension } from '../../config/extensionRegistryClient.js';
import { waitFor } from '../../test-utils/async.js';
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
@@ -167,6 +173,7 @@ describe('extensionsCommand', () => {
},
ui: {
dispatchExtensionStateUpdate: mockDispatchExtensionState,
removeComponent: vi.fn(),
},
});
});
@@ -429,6 +436,61 @@ describe('extensionsCommand', () => {
throw new Error('Explore action not found');
}
it('should return ExtensionRegistryView custom dialog when experimental.extensionRegistry is true', async () => {
mockContext.services.settings.merged.experimental.extensionRegistry = true;
const result = await exploreAction(mockContext, '');
expect(result).toBeDefined();
if (result?.type !== 'custom_dialog') {
throw new Error('Expected custom_dialog');
}
const component =
result.component as ReactElement<ExtensionRegistryViewProps>;
expect(component.type).toBe(ExtensionRegistryView);
expect(component.props.extensionManager).toBe(mockExtensionLoader);
});
it('should handle onSelect and onClose in ExtensionRegistryView', async () => {
mockContext.services.settings.merged.experimental.extensionRegistry = true;
const result = await exploreAction(mockContext, '');
if (result?.type !== 'custom_dialog') {
throw new Error('Expected custom_dialog');
}
const component =
result.component as ReactElement<ExtensionRegistryViewProps>;
const extension = {
extensionName: 'test-ext',
url: 'https://github.com/test/ext.git',
} as RegistryExtension;
vi.mocked(inferInstallMetadata).mockResolvedValue({
source: extension.url,
type: 'git',
});
mockInstallExtension.mockResolvedValue({ name: extension.url });
// Call onSelect
component.props.onSelect?.(extension);
await waitFor(() => {
expect(inferInstallMetadata).toHaveBeenCalledWith(extension.url);
expect(mockInstallExtension).toHaveBeenCalledWith({
source: extension.url,
type: 'git',
});
});
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(1);
// Call onClose
component.props.onClose?.();
expect(mockContext.ui.removeComponent).toHaveBeenCalledTimes(2);
});
it("should add an info message and call 'open' in a non-sandbox environment", async () => {
// Ensure no special environment variables that would affect behavior
vi.stubEnv('NODE_ENV', '');
@@ -280,7 +280,9 @@ async function exploreAction(
type: 'custom_dialog' as const,
component: React.createElement(ExtensionRegistryView, {
onSelect: (extension) => {
debugLogger.debug(`Selected extension: ${extension.extensionName}`);
debugLogger.log(`Selected extension: ${extension.extensionName}`);
void installAction(context, extension.url);
context.ui.removeComponent();
},
onClose: () => context.ui.removeComponent(),
extensionManager,
@@ -7,7 +7,6 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { hooksCommand } from './hooksCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import type { HookRegistryEntry } from '@google/gemini-cli-core';
import { HookType, HookEventName, ConfigSource } from '@google/gemini-cli-core';
import type { CommandContext } from './types.js';
@@ -127,13 +126,10 @@ describe('hooksCommand', () => {
createMockHook('test-hook', HookEventName.BeforeTool, true),
]);
await hooksCommand.action(mockContext, '');
const result = await hooksCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
}),
);
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
});
@@ -161,7 +157,7 @@ describe('hooksCommand', () => {
});
});
it('should display panel even when hook system is not enabled', async () => {
it('should return custom_dialog even when hook system is not enabled', async () => {
mockConfig.getHookSystem.mockReturnValue(null);
const panelCmd = hooksCommand.subCommands!.find(
@@ -171,17 +167,13 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
await panelCmd.action(mockContext, '');
const result = await panelCmd.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: [],
}),
);
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
it('should display panel when no hooks are configured', async () => {
it('should return custom_dialog when no hooks are configured', async () => {
mockHookSystem.getAllHooks.mockReturnValue([]);
(mockContext.services.settings.merged as Record<string, unknown>)[
'hooksConfig'
@@ -194,17 +186,13 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
await panelCmd.action(mockContext, '');
const result = await panelCmd.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: [],
}),
);
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
it('should display hooks list when hooks are configured', async () => {
it('should return custom_dialog when hooks are configured', async () => {
const mockHooks: HookRegistryEntry[] = [
createMockHook('echo-test', HookEventName.BeforeTool, true),
createMockHook('notify', HookEventName.AfterAgent, false),
@@ -222,14 +210,10 @@ describe('hooksCommand', () => {
throw new Error('panel command must have an action');
}
await panelCmd.action(mockContext, '');
const result = await panelCmd.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.HOOKS_LIST,
hooks: mockHooks,
}),
);
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
});
+18 -11
View File
@@ -4,9 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, CommandContext } from './types.js';
import { createElement } from 'react';
import type {
SlashCommand,
CommandContext,
OpenCustomDialogActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { MessageType, type HistoryItemHooksList } from '../types.js';
import type {
HookRegistryEntry,
MessageActionReturn,
@@ -15,13 +19,14 @@ import { getErrorMessage } from '@google/gemini-cli-core';
import { SettingScope, isLoadableSettingScope } from '../../config/settings.js';
import { enableHook, disableHook } from '../../utils/hookSettings.js';
import { renderHookActionFeedback } from '../../utils/hookUtils.js';
import { HooksDialog } from '../components/HooksDialog.js';
/**
* Display a formatted list of hooks with their status
* Display a formatted list of hooks with their status in a dialog
*/
async function panelAction(
function panelAction(
context: CommandContext,
): Promise<void | MessageActionReturn> {
): MessageActionReturn | OpenCustomDialogActionReturn {
const { config } = context.services;
if (!config) {
return {
@@ -34,12 +39,13 @@ async function panelAction(
const hookSystem = config.getHookSystem();
const allHooks = hookSystem?.getAllHooks() || [];
const hooksListItem: HistoryItemHooksList = {
type: MessageType.HOOKS_LIST,
hooks: allHooks,
return {
type: 'custom_dialog',
component: createElement(HooksDialog, {
hooks: allHooks,
onClose: () => context.ui.removeComponent(),
}),
};
context.ui.addItem(hooksListItem);
}
/**
@@ -343,6 +349,7 @@ const panelCommand: SlashCommand = {
altNames: ['list', 'show'],
description: 'Display all registered hooks with their status',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: panelAction,
};
@@ -393,5 +400,5 @@ export const hooksCommand: SlashCommand = {
enableAllCommand,
disableAllCommand,
],
action: async (context: CommandContext) => panelCommand.action!(context, ''),
action: (context: CommandContext) => panelCommand.action!(context, ''),
};
@@ -14,7 +14,9 @@ import {
coreEvents,
processSingleFileContent,
type ProcessedFileReadResult,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { copyToClipboard } from '../utils/commandUtils.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -25,6 +27,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
emitFeedback: vi.fn(),
},
processSingleFileContent: vi.fn(),
readFileWithEncoding: vi.fn(),
partToString: vi.fn((val) => val),
};
});
@@ -35,9 +38,14 @@ vi.mock('node:path', async (importOriginal) => {
...actual,
default: { ...actual },
join: vi.fn((...args) => args.join('/')),
basename: vi.fn((p) => p.split('/').pop()),
};
});
vi.mock('../utils/commandUtils.js', () => ({
copyToClipboard: vi.fn(),
}));
describe('planCommand', () => {
let mockContext: CommandContext;
@@ -115,4 +123,46 @@ describe('planCommand', () => {
text: '# Approved Plan Content',
});
});
describe('copy subcommand', () => {
it('should copy the approved plan to clipboard', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(mockPlanPath);
vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content');
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(readFileWithEncoding).toHaveBeenCalledWith(mockPlanPath);
expect(copyToClipboard).toHaveBeenCalledWith('# Plan Content');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'info',
'Plan copied to clipboard (approved-plan.md).',
);
});
it('should warn if no approved plan is found', async () => {
vi.mocked(
mockContext.services.config!.getApprovedPlanPath,
).mockReturnValue(undefined);
const copySubCommand = planCommand.subCommands?.find(
(sc) => sc.name === 'copy',
);
if (!copySubCommand?.action) throw new Error('Copy action missing');
await copySubCommand.action(mockContext, '');
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
'No approved plan found to copy.',
);
});
});
});
+43 -2
View File
@@ -4,22 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
import {
type CommandContext,
CommandKind,
type SlashCommand,
} from './types.js';
import {
ApprovalMode,
coreEvents,
debugLogger,
processSingleFileContent,
partToString,
readFileWithEncoding,
} from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
import * as path from 'node:path';
import { copyToClipboard } from '../utils/commandUtils.js';
async function copyAction(context: CommandContext) {
const config = context.services.config;
if (!config) {
debugLogger.debug('Plan copy command: config is not available in context');
return;
}
const planPath = config.getApprovedPlanPath();
if (!planPath) {
coreEvents.emitFeedback('warning', 'No approved plan found to copy.');
return;
}
try {
const content = await readFileWithEncoding(planPath);
await copyToClipboard(content);
coreEvents.emitFeedback(
'info',
`Plan copied to clipboard (${path.basename(planPath)}).`,
);
} catch (error) {
coreEvents.emitFeedback('error', `Failed to copy plan: ${error}`, error);
}
}
export const planCommand: SlashCommand = {
name: 'plan',
description: 'Switch to Plan Mode and view current plan',
kind: CommandKind.BUILT_IN,
autoExecute: true,
autoExecute: false,
action: async (context) => {
const config = context.services.config;
if (!config) {
@@ -62,4 +94,13 @@ export const planCommand: SlashCommand = {
);
}
},
subCommands: [
{
name: 'copy',
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: copyAction,
},
],
};
@@ -19,6 +19,7 @@ import {
BaseSettingsDialog,
type SettingsDialogItem,
} from './shared/BaseSettingsDialog.js';
import { getNestedValue, isRecord } from '../../utils/settingsUtils.js';
/**
* Configuration field definition for agent settings
@@ -111,32 +112,12 @@ interface AgentConfigDialogProps {
onSave?: () => void;
}
/**
* Get a nested value from an object using a path array
*/
function getNestedValue(
obj: Record<string, unknown> | undefined,
path: string[],
): unknown {
if (!obj) return undefined;
let current: unknown = obj;
for (const key of path) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Set a nested value in an object using a path array, creating intermediate objects as needed
*/
function setNestedValue(
obj: Record<string, unknown>,
path: string[],
value: unknown,
): Record<string, unknown> {
function setNestedValue(obj: unknown, path: string[], value: unknown): unknown {
if (!isRecord(obj)) return obj;
const result = { ...obj };
let current = result;
@@ -144,12 +125,17 @@ function setNestedValue(
const key = path[i];
if (current[key] === undefined || current[key] === null) {
current[key] = {};
} else {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current[key] = { ...(current[key] as Record<string, unknown>) };
} else if (isRecord(current[key])) {
current[key] = { ...current[key] };
}
const next = current[key];
if (isRecord(next)) {
current = next;
} else {
// Cannot traverse further through non-objects
return result;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
current = current[key] as Record<string, unknown>;
}
const finalKey = path[path.length - 1];
@@ -267,11 +253,7 @@ export function AgentConfigDialog({
const items: SettingsDialogItem[] = useMemo(
() =>
AGENT_CONFIG_FIELDS.map((field) => {
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
const currentValue = getNestedValue(pendingOverride, field.path);
const defaultValue = getFieldDefaultFromDefinition(field, definition);
const effectiveValue =
currentValue !== undefined ? currentValue : defaultValue;
@@ -324,23 +306,18 @@ export function AgentConfigDialog({
const field = AGENT_CONFIG_FIELDS.find((f) => f.key === key);
if (!field || field.type !== 'boolean') return;
const currentValue = getNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
field.path,
);
const currentValue = getNestedValue(pendingOverride, field.path);
const defaultValue = getFieldDefaultFromDefinition(field, definition);
const effectiveValue =
currentValue !== undefined ? currentValue : defaultValue;
const newValue = !effectiveValue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
pendingOverride,
field.path,
newValue,
) as AgentOverride;
setPendingOverride(newOverride);
setModifiedFields((prev) => new Set(prev).add(key));
@@ -375,9 +352,9 @@ export function AgentConfigDialog({
}
// Update pending override locally
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
pendingOverride,
field.path,
parsed,
) as AgentOverride;
@@ -398,9 +375,9 @@ export function AgentConfigDialog({
if (!field) return;
// Remove the override (set to undefined)
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const newOverride = setNestedValue(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
pendingOverride as Record<string, unknown>,
pendingOverride,
field.path,
undefined,
) as AgentOverride;
@@ -213,6 +213,12 @@ describe('<AppHeader />', () => {
it('should NOT render Tips when tipsShown is 10 or more', async () => {
const mockConfig = makeFakeConfig();
const uiState = {
bannerData: {
defaultText: '',
warningText: '',
},
};
persistentStateMock.setData({ tipsShown: 10 });
@@ -220,6 +226,7 @@ describe('<AppHeader />', () => {
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState,
},
);
await waitUntilReady();
+74 -19
View File
@@ -1,58 +1,113 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { Header } from './Header.js';
import { Tips } from './Tips.js';
import { Box, Text } from 'ink';
import { UserIdentity } from './UserIdentity.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { useBanner } from '../hooks/useBanner.js';
import { useTips } from '../hooks/useTips.js';
import { theme } from '../semantic-colors.js';
import { ThemedGradient } from './ThemedGradient.js';
import { CliSpinner } from './CliSpinner.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
const ICON = `▝▜▄
`;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
if (!showDetails) {
return (
<Box flexDirection="column">
<Header version={version} nightly={false} />
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
</Box>
)}
</Box>
);
}
return (
<Box flexDirection="column">
{!(settings.merged.ui.hideBanner || config.getScreenReader()) && (
<>
<Header version={version} nightly={nightly} />
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
</>
{showHeader && (
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
</Box>
)}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
</Box>
@@ -786,16 +786,21 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
const TITLE_MARGIN = 1;
const FOOTER_HEIGHT = 2; // DialogFooter + margin
const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT;
const listHeight = availableHeight
? Math.max(1, availableHeight - overhead)
: undefined;
const questionHeight =
const questionHeightLimit =
listHeight && !isAlternateBuffer
? Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
: undefined;
const maxItemsToShow =
listHeight && questionHeight
? Math.max(1, Math.floor((listHeight - questionHeight) / 2))
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
: selectionItems.length;
return (
@@ -803,7 +808,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
{progressHeader}
<Box marginBottom={TITLE_MARGIN}>
<MaxSizedBox
maxHeight={questionHeight}
maxHeight={questionHeightLimit}
maxWidth={availableWidth}
overflowDirection="bottom"
>
@@ -231,7 +231,7 @@ const createMockConfig = (overrides = {}): Config =>
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
isPlanEnabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => true),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -17,18 +17,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},
LoadedSettings: class {
constructor() {
// this.merged = {};
}
},
}));
describe('ContextUsageDisplay', () => {
it('renders correct percentage left', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correct percentage used', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={5000}
model="gemini-pro"
@@ -37,27 +28,56 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('50% context left');
expect(output).toContain('50% context used');
unmount();
});
it('renders short label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders correctly when usage is 0%', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={0}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context used');
unmount();
});
it('renders abbreviated label when terminal width is small', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={2000}
model="gemini-pro"
terminalWidth={80}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80%');
expect(output).not.toContain('context left');
expect(output).toContain('20%');
expect(output).not.toContain('context used');
unmount();
});
it('renders 0% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
it('renders 80% correctly', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={8000}
model="gemini-pro"
terminalWidth={120}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('80% context used');
unmount();
});
it('renders 100% when full', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ContextUsageDisplay
promptTokenCount={10000}
model="gemini-pro"
@@ -66,7 +86,7 @@ describe('ContextUsageDisplay', () => {
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('0% context left');
expect(output).toContain('100% context used');
unmount();
});
});
@@ -7,6 +7,11 @@
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { getContextUsagePercentage } from '../utils/contextUsage.js';
import { useSettings } from '../contexts/SettingsContext.js';
import {
MIN_TERMINAL_WIDTH_FOR_FULL_LABEL,
DEFAULT_COMPRESSION_THRESHOLD,
} from '../constants.js';
export const ContextUsageDisplay = ({
promptTokenCount,
@@ -14,17 +19,30 @@ export const ContextUsageDisplay = ({
terminalWidth,
}: {
promptTokenCount: number;
model: string;
model: string | undefined;
terminalWidth: number;
}) => {
const settings = useSettings();
const percentage = getContextUsagePercentage(promptTokenCount, model);
const percentageLeft = ((1 - percentage) * 100).toFixed(0);
const percentageUsed = (percentage * 100).toFixed(0);
const label = terminalWidth < 100 ? '%' : '% context left';
const threshold =
settings.merged.model?.compressionThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
let textColor = theme.text.secondary;
if (percentage >= 1.0) {
textColor = theme.status.error;
} else if (percentage >= threshold) {
textColor = theme.status.warning;
}
const label =
terminalWidth < MIN_TERMINAL_WIDTH_FOR_FULL_LABEL ? '%' : '% context used';
return (
<Text color={theme.text.secondary}>
{percentageLeft}
<Text color={textColor}>
{percentageUsed}
{label}
</Text>
);
@@ -76,7 +76,7 @@ describe('DetailedMessagesDisplay', () => {
unmount();
});
it('hides the F12 hint in low error verbosity mode', async () => {
it('shows the F12 hint even in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
@@ -95,7 +95,7 @@ describe('DetailedMessagesDisplay', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('(F12 to close)');
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
@@ -13,8 +13,6 @@ import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface DetailedMessagesDisplayProps {
messages: ConsoleMessageItem[];
@@ -29,10 +27,6 @@ export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ messages, maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const config = useConfig();
const settings = useSettings();
const showHotkeyHint =
settings.merged.ui.errorVerbosity === 'full' || config.getDebugMode();
const borderAndPadding = 3;
@@ -71,10 +65,7 @@ export const DetailedMessagesDisplay: React.FC<
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console{' '}
{showHotkeyHint && (
<Text color={theme.text.secondary}>(F12 to close)</Text>
)}
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
@@ -37,9 +37,6 @@ import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js';
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
@@ -62,56 +59,8 @@ export const DialogManager = ({
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
shouldShowRetentionWarning,
sessionsToDeleteCount,
} = uiState;
const handleKeep120Days = useCallback(() => {
settings.setValue(
SettingScope.User,
'general.sessionRetention.warningAcknowledged',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.enabled',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.maxAge',
'120d',
);
}, [settings]);
const handleKeep30Days = useCallback(() => {
settings.setValue(
SettingScope.User,
'general.sessionRetention.warningAcknowledged',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.enabled',
true,
);
settings.setValue(
SettingScope.User,
'general.sessionRetention.maxAge',
'30d',
);
}, [settings]);
if (shouldShowRetentionWarning && sessionsToDeleteCount !== undefined) {
return (
<SessionRetentionWarningDialog
onKeep120Days={handleKeep120Days}
onKeep30Days={handleKeep30Days}
sessionsToDeleteCount={sessionsToDeleteCount ?? 0}
/>
);
}
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
@@ -281,14 +230,12 @@ export const DialogManager = ({
return (
<Box flexDirection="column">
<SettingsDialog
settings={settings}
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={async () => {
await runExitCleanup();
process.exit(RELAUNCH_EXIT_CODE);
}}
availableTerminalHeight={terminalHeight - staticExtraHeight}
config={config}
/>
</Box>
);
@@ -11,7 +11,6 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import {
ApprovalMode,
validatePlanContent,
@@ -41,10 +40,6 @@ vi.mock('node:fs', async (importOriginal) => {
...actual,
existsSync: vi.fn(),
realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
readFile: vi.fn(),
},
};
});
@@ -546,7 +541,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled();
});
it('opens plan in external editor when Ctrl+X is pressed', async () => {
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => {
@@ -557,27 +552,16 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication');
});
// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();
// Press Ctrl+X
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
});
await waitFor(() => {
expect(openFileInEditor).toHaveBeenCalledWith(
mockPlanFullPath,
expect.anything(),
expect.anything(),
undefined,
expect(onFeedback).toHaveBeenCalledWith(
'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
);
});
// Verify that content is refreshed (processSingleFileContent called again)
await waitFor(() => {
expect(processSingleFileContent).toHaveBeenCalled();
});
});
},
);
@@ -156,11 +156,15 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
const handleOpenEditor = useCallback(async () => {
try {
await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
onFeedback(
'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
);
refresh();
} catch (err) {
debugLogger.error('Failed to open plan in editor:', err);
}
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh]);
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);
useKeypress(
(key) => {
@@ -243,6 +247,7 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
],
placeholder: 'Type your feedback...',
multiSelect: false,
unconstrainedHeight: false,
},
]}
onSubmit={(answers) => {
+50 -6
View File
@@ -15,6 +15,19 @@ import {
} from '@google/gemini-cli-core';
import type { SessionStatsState } from '../contexts/SessionContext.js';
let mockIsDevelopment = false;
vi.mock('../../utils/installationInfo.js', async (importOriginal) => {
const original =
await importOriginal<typeof import('../../utils/installationInfo.js')>();
return {
...original,
get isDevelopment() {
return mockIsDevelopment;
},
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
@@ -161,7 +174,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
@@ -216,7 +229,7 @@ describe('<Footer />', () => {
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).not.toContain('used');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -249,7 +262,7 @@ describe('<Footer />', () => {
unmount();
});
it('displays the model name and abbreviated context percentage', async () => {
it('displays the model name and abbreviated context used label on narrow terminals', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -267,6 +280,7 @@ describe('<Footer />', () => {
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+%/);
expect(lastFrame()).not.toContain('context used');
unmount();
});
@@ -464,7 +478,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\d+% context left/);
expect(lastFrame()).not.toMatch(/\d+% context used/);
unmount();
});
it('shows the context percentage when hideContextPercentage is false', async () => {
@@ -484,7 +498,7 @@ describe('<Footer />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\d+% context left/);
expect(lastFrame()).toMatch(/\d+% context used/);
unmount();
});
it('renders complete footer in narrow terminal (baseline narrow)', async () => {
@@ -509,7 +523,15 @@ describe('<Footer />', () => {
});
describe('error summary visibility', () => {
it('hides error summary in low verbosity mode', async () => {
beforeEach(() => {
mockIsDevelopment = false;
});
afterEach(() => {
mockIsDevelopment = false;
});
it('hides error summary in low verbosity mode out of dev mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
@@ -530,6 +552,28 @@ describe('<Footer />', () => {
unmount();
});
it('shows error summary in low verbosity mode in dev mode', async () => {
mockIsDevelopment = true;
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
{
width: 120,
uiState: {
sessionStats: mockSessionStats,
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
},
);
await waitUntilReady();
expect(lastFrame()).toContain('F12 for details');
expect(lastFrame()).toContain('2 errors');
unmount();
});
it('shows error summary in full verbosity mode', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Footer />,
+3 -1
View File
@@ -62,7 +62,9 @@ export const Footer: React.FC = () => {
config.getDebugMode() || settings.merged.ui.showMemoryUsage;
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails && errorCount > 0 && (isFullErrorVerbosity || debugMode);
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const hideCWD = settings.merged.ui.footer.hideCWD;
const hideSandboxStatus = settings.merged.ui.footer.hideSandboxStatus;
const hideModelInfo = settings.merged.ui.footer.hideModelInfo;
@@ -32,7 +32,6 @@ import { SkillsList } from './views/SkillsList.js';
import { AgentsStatus } from './views/AgentsStatus.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
@@ -100,6 +99,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
@@ -217,9 +217,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'hooks_list' && (
<HooksList hooks={itemForDisplay.hooks} />
)}
</Box>
);
};
@@ -0,0 +1,248 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { act } from 'react';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { HooksDialog, type HookEntry } from './HooksDialog.js';
describe('HooksDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockHook = (
name: string,
eventName: string,
enabled: boolean,
options?: Partial<HookEntry>,
): HookEntry => ({
config: {
name,
command: `run-${name}`,
type: 'command',
description: `Test hook: ${name}`,
...options?.config,
},
source: options?.source ?? '/mock/path/GEMINI.md',
eventName,
enabled,
...options,
});
describe('snapshots', () => {
it('renders empty hooks dialog', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={[]} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders single hook with security warning, source, and tips', async () => {
const hooks = [createMockHook('test-hook', 'before-tool', true)];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hooks grouped by event name with enabled and disabled status', async () => {
const hooks = [
createMockHook('hook1', 'before-tool', true),
createMockHook('hook2', 'before-tool', false),
createMockHook('hook3', 'after-agent', true),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hook with all metadata (matcher, sequential, timeout)', async () => {
const hooks = [
createMockHook('my-hook', 'before-tool', true, {
matcher: 'shell_exec',
sequential: true,
config: {
name: 'my-hook',
type: 'command',
description: 'A hook with all metadata fields',
timeout: 30,
},
}),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders hook using command as name when name is not provided', async () => {
const hooks: HookEntry[] = [
{
config: {
command: 'echo hello',
type: 'command',
},
source: '/mock/path',
eventName: 'before-tool',
enabled: true,
},
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('keyboard interaction', () => {
it('should call onClose when escape key is pressed', async () => {
const onClose = vi.fn();
const { waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={[]} onClose={onClose} />,
);
await waitUntilReady();
act(() => {
stdin.write('\u001b[27u');
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
});
describe('scrolling behavior', () => {
const createManyHooks = (count: number): HookEntry[] =>
Array.from({ length: count }, (_, i) =>
createMockHook(`hook-${i + 1}`, `event-${(i % 3) + 1}`, i % 2 === 0),
);
it('should not show scroll indicators when hooks fit within maxVisibleHooks', async () => {
const hooks = [
createMockHook('hook1', 'before-tool', true),
createMockHook('hook2', 'after-tool', false),
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={10} />,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).not.toContain('▼');
unmount();
});
it('should show scroll down indicator when there are more hooks than maxVisibleHooks', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('▼');
unmount();
});
it('should scroll down when down arrow is pressed', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Initially should not show up indicator
expect(lastFrame()).not.toContain('▲');
act(() => {
stdin.write('\u001b[B');
});
await waitUntilReady();
// Should now show up indicator after scrolling down
expect(lastFrame()).toContain('▲');
unmount();
});
it('should scroll up when up arrow is pressed after scrolling down', async () => {
const hooks = createManyHooks(15);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down twice
act(() => {
stdin.write('\u001b[B');
stdin.write('\u001b[B');
});
await waitUntilReady();
expect(lastFrame()).toContain('▲');
// Scroll up once
act(() => {
stdin.write('\u001b[A');
});
await waitUntilReady();
// Should still show up indicator (scrolled down once)
expect(lastFrame()).toContain('▲');
unmount();
});
it('should not scroll beyond the end', async () => {
const hooks = createManyHooks(10);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Scroll down many times past the end
act(() => {
for (let i = 0; i < 20; i++) {
stdin.write('\u001b[B');
}
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('▲');
// At the end, down indicator should be hidden
expect(frame).not.toContain('▼');
unmount();
});
it('should not scroll above the beginning', async () => {
const hooks = createManyHooks(10);
const { lastFrame, waitUntilReady, stdin, unmount } = renderWithProviders(
<HooksDialog hooks={hooks} onClose={vi.fn()} maxVisibleHooks={5} />,
);
await waitUntilReady();
// Try to scroll up when already at top
act(() => {
stdin.write('\u001b[A');
});
await waitUntilReady();
expect(lastFrame()).not.toContain('▲');
expect(lastFrame()).toContain('▼');
unmount();
});
});
});

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