Compare commits

...

124 Commits

Author SHA1 Message Date
Mahima Shanware 2a3080bad7 test(cli): fix simulator fake timer leak and align with 10-stall limit 2026-05-26 16:24:28 +00:00
Mahima Shanware d94a2804ce chore: strictly declare non-null local variables in UserSimulator.ts 2026-05-26 16:18:07 +00:00
Mahima Shanware cfd7783c30 chore: fix user simulator type warning 2026-05-26 16:16:49 +00:00
Mahima Shanware fdce2643ca chore: save formatted files 2026-05-26 16:13:48 +00:00
Mahima Shanware 6555622b81 feat(simulator): clean restoration of SIGWINCH refresh logic 2026-05-26 16:13:28 +00:00
Mahima Shanware 583d42b86f feat(simulator): disable all SIGWINCH refresh logic to prevent state drift 2026-05-26 16:13:28 +00:00
Mahima Shanware d472777ac0 feat(simulator): disable dynamic rule generation to ensure benchmark stability 2026-05-26 16:13:28 +00:00
Mahima Shanware a513fa5b06 feat(simulator): implement multi-stage patient refresh with SIGWINCH incremental backoff 2026-05-26 16:13:28 +00:00
Mahima Shanware dcf47f9207 chore: optimize SIGWINCH vision and recovery logic 2026-05-26 16:13:28 +00:00
Mahima Shanware 0c02dec524 feat: implement automatic SIGWINCH repaint in User Simulator to resolve TTY rendering lags 2026-05-26 16:13:27 +00:00
Mahima Shanware ba3f3cb52e feat: increase simulator stabilization delay to 2s to mitigate TTY rendering lag 2026-05-26 16:13:27 +00:00
Mahima Shanware 84bb13bd73 fix(simulator): refine stall recovery and state normalization 2026-05-26 16:13:27 +00:00
Mahima Shanware 251235a55b fix(simulator): improve PTY stabilization and stall recovery 2026-05-26 16:13:27 +00:00
Mahima Shanware 124a9a6d03 feat(simulator): implement episodic memory with background compression
- Add session_notes field to simulator JSON response schema.

- Capture and persist factual session notes in sessionMemory.

- Inject episodic memory into system prompt for off-screen context.

- Add background LLM process to compress memory when it exceeds 5 items.

- Add unit tests for note capture, prompt injection, and compression merge.
2026-05-26 16:13:27 +00:00
Mahima Shanware a2e862e8b4 fix(simulator): allow acting on static screens when blocked to handle UI lag 2026-05-26 16:13:26 +00:00
Mahima Shanware 88d9edf345 feat(cli): fix UserSimulator deadlock on static screens by tracking composite state 2026-05-26 16:13:26 +00:00
Mahima Shanware c6b1051174 feat(core): synchronize toolset in GeminiChat to fix race condition 2026-05-26 16:13:26 +00:00
Mahima Shanware 5a0014e9ae feat(cli): inject internal tool state into UserSimulator and enhance UI noise suppression
- Subscribe UserSimulator to MESSAGE_BUS for tool-calls-update events.

- Explicitly notify simulator when the system is blocked awaiting tool approval.

- Suppress Notifications and ToastDisplay when simulateUser is enabled.

- Suppress warn/error logs in ConsolePatcher during non-interactive simulation.

- Add 100ms post-submission settle delay for robust input injection.

- Update unit tests to verify internal state injection and reliable delays.
2026-05-26 16:13:26 +00:00
Mahima Shanware 447cab514a feat(cli): improve UserSimulator robustness and prompt sensitivity
- Prioritize interactive markers (prompts, confirmation dialogs) over background timers/spinners.

- Suppress background update checks and non-fatal UI warnings when simulateUser is enabled.

- Refine simulator system prompt for more aggressive prompt detection.

- Implement reliable key submission with stabilized terminal initialization and inter-character delays.

- Add unit tests for UserSimulator vision and input submission.
2026-05-26 16:13:26 +00:00
Your Name 0a5bfd86ec Modify UserSimulator prompt to prefer 'Allow for this session' for execution prompts 2026-05-26 16:13:26 +00:00
Hadi Minooei ac63417a59 feat: add option to disable streaming in CLI 2026-05-26 16:13:26 +00:00
Your Name 5bce1f78bd Fix regex gap in UserSimulator to handle 'Xm' timer format 2026-05-26 16:13:25 +00:00
Your Name 27ed3a4d07 Improve screen normalization in UserSimulator to prevent polling loops 2026-05-26 16:13:25 +00:00
Your Name 7cc657add2 Fix race condition in terminal rendering in UserSimulator 2026-05-26 16:13:25 +00:00
Your Name fc05f78e55 Add debug logging to submission flow in InputPrompt 2026-05-26 16:13:25 +00:00
Your Name 6d7620cb80 Add debug logging to handleFinalSubmit in AppContainer 2026-05-26 16:13:25 +00:00
Your Name bea622e5e9 Fix keypress handling when simulateUser is enabled and add debug logging 2026-05-26 16:13:25 +00:00
Your Name 7b39a05afb Add debug logging for keypresses in TextInput 2026-05-26 16:13:25 +00:00
Your Name c044bcd205 Add debug logging for input keys in InputPrompt 2026-05-26 16:13:24 +00:00
Hadi Minooei 78754b9349 feat(cli): add user simulator, docker support, and external knowledge source handling
- Added UserSimulator service for automated interactions.

- Added Docker simulation script and documentation.

- Supported --knowledge-source flag defaulting to ~/.agents/kb.md.
2026-05-26 16:13:24 +00:00
Mahima Shanware b9d3ede51d fix(core): support nested directory structure in Plan Mode write_file and edit 2026-05-26 16:13:15 +00:00
Mahima Shanware d43af03598 fix(core): map ProceedAlways to ProceedOnce in Plan Mode to prevent exiting plan mode 2026-05-26 16:12:40 +00:00
Your Name af332c614a Add debug logging to usePlanContent in ExitPlanModeDialog 2026-05-26 16:12:40 +00:00
Hadi Minooei 922c6af4df fix(build): handle file exists error in build_package 2026-05-26 16:12:40 +00:00
David Pierce 4a5d5cfc9f Update default auto routing (#27071) 2026-05-26 14:31:07 +00:00
Ramón Medrano Llamas 630ecc21b9 fix(cli): filter internal session context from history during resumption (#27391) 2026-05-24 06:38:53 +00:00
Billy Biggs 3cc7e5b096 Revert "fix(core): prevent SIGHUP kills in PTY environments" (#27401) 2026-05-23 20:52:45 +00:00
gemini-cli-robot a00d03efc6 Changelog for v0.43.0 (#27361)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-22 22:26:32 +00:00
gemini-cli-robot 980a2574df Changelog for v0.44.0-preview.0 (#27360)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-22 22:26:24 +00:00
jvargassanchez-dot 5188601de0 fix(core): force update_topic tool to execute sequentially (#27357) 2026-05-22 20:39:27 +00:00
joshualitt e6f92d66f6 feat(context): Complete simplification work. (#27345) 2026-05-22 18:06:40 +00:00
jvargassanchez-dot d1fa323cfb Feat/a2a expose usage metadata (#27288) 2026-05-22 17:51:39 +00:00
Syed Ayman Quadri ba04e99bea fix(cli): prevent Termux relaunch and resize remount loops (#27110)
Co-authored-by: Spencer <spencertang@google.com>
2026-05-22 06:33:52 +00:00
gemini-cli-robot 6afb109953 chore(release): bump version to 0.45.0-nightly.20260521.g854f811be (#27362) 2026-05-22 01:35:39 +00:00
Coco Sheng 854f811be0 perf: optimize issue triage and lifecycle management (#27346) 2026-05-21 18:42:59 +00:00
Dev Randalpura c4d0e3ca36 fix(ui): added volta to auto update check (#27353) 2026-05-21 18:05:33 +00:00
gemini-cli-robot df1d1119c3 chore/release: bump version to 0.44.0-nightly.20260521.g57c42a5c4 (#27324) 2026-05-21 17:52:43 +00:00
joshualitt 50c2e6764f fix(context): Ensure last message is processed. (#27232) 2026-05-21 15:46:26 +00:00
Coco Sheng 906f8a3151 ci: robust stale issue lifecycle and consolidated triage labels (#27015) 2026-05-20 21:50:19 +00:00
adithya32 64cb88d50e fix(windows): resolve interactive shell arrow-key navigation on Windows (#23505) 2026-05-20 21:41:34 +00:00
tison 96903d50a1 Auto detect pnpm global installation path for macOS and Windows (#22748)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-05-20 21:26:11 +00:00
Coco Sheng 5c4420cc27 fix(core): dynamic fallback routing for exhausted quota models (#27315) 2026-05-20 20:59:33 +00:00
PROTHAM f79d5e059c fix(core): prevent SIGHUP kills in PTY environments (WSL2/Kitty/Alacritty) (#27267) 2026-05-20 20:43:14 +00:00
Coco Sheng d7384c446f fix(core): centralize path validation to prevent crashes from malformed prompts (#27211) 2026-05-20 19:55:57 +00:00
Dev Randalpura e440e02866 fix(core): Added date field for the GCal MCP (#27251) 2026-05-20 18:31:52 +00:00
Hashaam Zahid c854b60f75 fix(scripts): scrub CI env vars in dev to keep interactive mode (#27159) 2026-05-20 17:55:54 +00:00
Coco Sheng 99ae4d8b81 fix(core): generalize MCP compliance fix for tool results (#27045) 2026-05-20 17:31:00 +00:00
Debasish aebdba6565 fix(core): improve Alpine shell compatibility (#26770) 2026-05-20 17:16:22 +00:00
Yuvraj Angad Singh 26f8c0f65e refactor: decouple stored session deletion from ChatRecordingService (#22920) (#27039) 2026-05-20 17:09:17 +00:00
Coco Sheng 29481a1562 fix: robust ripgrep path resolution and 1p hermetic execution support (#27253) 2026-05-20 16:29:00 +00:00
Coco Sheng 2c85f57402 fix(cli): integrate PolicyEngine into ACP session to prevent deadlocks (#23507) (#27252) 2026-05-20 16:16:09 +00:00
Ramón Medrano Llamas 124539b5cc fix(devtools): bundle devtools package to avoid resolution errors (#27250) 2026-05-20 07:14:57 +00:00
gemini-cli-robot ec4910f0bb Changelog for v0.43.0-preview.1 (#27297)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-20 03:49:29 +00:00
Andrea Alberti 57c42a5c40 feat(cli): add Sublime Text and Emacs Client editors, improve error messages and documentation (#21090)
Co-authored-by: Ananth Kini <ananthkini1@gmail.com>
2026-05-19 20:03:25 +00:00
ashishch432 8997488ea6 fix(cli): preserve proxy-agent named exports in ESM bundle (#27145) 2026-05-19 19:41:31 +00:00
kaluchi 6589cdf11b Proposal: deterministic encoding for child-process I/O (#27247) 2026-05-19 19:41:04 +00:00
Dev Randalpura 9de4289287 fix(core): correctly handle nullable array types in MCP tools (#27228) 2026-05-19 18:24:18 +00:00
Coco Sheng 37f3a4c90a fix(core): respect NO_PROXY in global fetch dispatcher (#27216) 2026-05-19 17:56:59 +00:00
Om Patel fcc8c62b8b fix(core): prevent path traversal in custome command file injection (#27234) 2026-05-19 17:37:46 +00:00
Adam Weidman c4758ba820 feat(core): wire AgentSession invocations into agent-tool (#26948) 2026-05-19 16:56:43 +00:00
Ramón Medrano Llamas 96a8d1d069 fix(cli): bundle ink worker-entry.js (#27249) 2026-05-19 16:47:36 +00:00
Dev Randalpura 3494fda2cf fix(core): add exception handling to migrateFromFileStorage (#27229) 2026-05-19 15:10:21 +00:00
Coco Sheng 1a024f30a3 fix: allow configured MCP servers in non-interactive mode (#27215) 2026-05-19 14:44:09 +00:00
Adam Weidman 5650fa90d7 feat(core): add RemoteSessionInvocation (#26937) 2026-05-19 14:17:24 +00:00
Keith Schaab 85566a73f6 fix(a2a-server): Implement default policy loading for parity with CLI (#27073) 2026-05-19 14:09:57 +00:00
Rajesh patel 7478859502 fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into prompt Input. (#25139)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-19 00:17:55 +00:00
Tirth Naik f09d45d133 fix(core): prevent isBinary false-positive on Windows PTY streams (#26565) 2026-05-18 23:36:23 +00:00
Daniel Weis 792654c88b refactor: decouple auto model description and configuration from releaseChannel (#27227)
Co-authored-by: David Pierce <davidapierce@google.com>
2026-05-18 20:15:55 +00:00
Adam Weidman 6973b963ae feat(core): add LocalSessionInvocation (#26665) 2026-05-18 19:30:21 +00:00
kaluchi dc47aaa2d9 fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) (#25900)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-18 17:44:05 +00:00
Dev Randalpura 7ff60809ef fix(core): add unit tests for stableStringify (#27212) 2026-05-18 17:23:05 +00:00
joshualitt 055e0f6452 fix(context): Fix snapshot recovery across sessions. (#26939) 2026-05-18 16:44:59 +00:00
Adam Weidman 9d01958cdb feat(skills): add agent-tui and tui-tester skills (#27121) 2026-05-18 15:46:02 +00:00
Adam Weidman 84423e6ea1 fix(core): enforce compile-time exhaustiveness in content-utils (#27207) 2026-05-18 15:16:49 +00:00
Adam Weidman 5611ff40e7 feat(core): add adk.agentSessionSubagentEnabled flag (#26947) 2026-05-17 17:38:34 +00:00
David Pierce 77e65c0db5 fix(core): use hasAccessToPreview for auto model resolution and fix disappearing models (#27112) 2026-05-15 17:26:59 +00:00
Anish Sabharwal b36788eb2a fix(core): add aliases and thinking config for gemini-3.1 models (#27007) 2026-05-15 16:29:15 +00:00
PROTHAM d32c9b77df Fix/web fetch ctrl c abort (#24320)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-15 16:26:03 +00:00
Tommaso Sciortino b213fd68ec security: update dependencies to fix critical and high vulnerabilities (#27077) 2026-05-15 02:01:38 +00:00
sotokisehiro 928a311fb0 fix(core): externalize https-proxy-agent to fix proxy support (#26361) 2026-05-14 22:34:36 +00:00
7. Sun f6494f3862 docs: update sandbox image command (#26774) 2026-05-14 22:08:15 +00:00
Coco Sheng b7f2067dd7 fix(cli): explicitly clear entrypoint when spawning sandbox container (#27059) 2026-05-14 21:50:29 +00:00
Dev Randalpura 7a5a8183bf fix(ui): add ENAMETOOLONG and ENOTDIR to exceptions for file parsing errors (#27069) 2026-05-14 21:23:42 +00:00
Gal Zahavi 0c0d88d90b docs(extensions): clarify env var sanitization policy for MCP and ext… (#22854)
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
Co-authored-by: Jenna Inouye <jinouye@google.com>
2026-05-14 21:23:38 +00:00
PROTHAM 2151653133 fix(core): resolve EISDIR errors during file processing (#21527) (#27041) 2026-05-14 21:21:57 +00:00
Tommaso Sciortino a6ed2cc5e3 fix(deps): update vulnerable dependencies (#27062) 2026-05-14 21:19:27 +00:00
David Pierce 5159b081bd fix(core): ensure stable admin settings comparison across IPC to prevent restart loop (#27066) 2026-05-14 19:44:03 +00:00
Gal Zahavi 918d6b6085 fix(core): ensure Vertex AI sets hasAccessToPreviewModels and remove aggressive 404 fallback revocation (#27067) 2026-05-14 19:42:09 +00:00
Dev Randalpura 6fee663ddc fix(ui): preserve new line at the end of edit window (#27057) 2026-05-14 18:33:41 +00:00
Coco Sheng 456d1aec74 fix(cli): resolve permission denied in sandbox on NixOS and other distros (#27004) 2026-05-14 17:15:12 +00:00
Coco Sheng e3f2d3e1ef fix(core): respect NO_PROXY for network-based MCP servers (#27012) 2026-05-14 17:11:17 +00:00
Sri Pasumarthi b705505dae fix(acp/auth): prevent conflicting credentials on enterprise gateways and support optional API keys natively (#27021) 2026-05-14 15:38:01 +00:00
Spencer 488d71b8c9 feat(core): expose RAG snippets to local log file for debugging (#27016) 2026-05-14 02:34:12 +00:00
Gal Zahavi 77078b3e8a fix(core): ensure stable fallback for restricted preview models (#26999) 2026-05-13 21:46:41 +00:00
ifitisit 1814c7f358 fix(cli): don't crash when an @-mention captures a non-path blob (#25980) 2026-05-13 21:43:08 +00:00
EMERSON BUSSON 724981baf8 fix(core): throttle shell text output and bound live UI buffer (#26955) 2026-05-13 21:34:32 +00:00
Sandy Tao 7504259d72 chore: clean up launched memory features (#26941)
Co-authored-by: Jenna Inouye <jinouye@google.com>
2026-05-13 21:22:56 +00:00
Coco Sheng 0750b01fe4 fix: add system PATH fallback for ripgrep resolution (#26777) (#26868) 2026-05-13 21:05:37 +00:00
Dev Randalpura 41599ce29f fix(core): made context files append instead of replace (#26950)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-13 19:45:30 +00:00
Tommaso Sciortino 74e9079e5b chore: add execution permission to scripts/review.sh (#27009) 2026-05-13 12:22:00 -07:00
AK 9da30b8831 fix(core): isolate subagent thread context (#26449) 2026-05-13 18:55:17 +00:00
Dev Randalpura 71a2c0264e fix(ui): clamped table column widths (#26991)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-13 18:43:49 +00:00
Sahil Kirad fd01cc03bf fix(core): refresh MCP OAuth token usage after re-auth (#26312)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-13 12:01:27 -07:00
Coco Sheng fc4054446f ci: suppress bot comments during standard triage maintenance (#27006) 2026-05-13 18:43:07 +00:00
Coco Sheng 08abe4542d fix(cli): auto-approve shell redirections in AUTO_EDIT mode (#27003) 2026-05-13 18:28:30 +00:00
Coco Sheng 63b4bbfb5d fix(core): handle EISDIR on virtual drives in memory discovery (#26985) 2026-05-13 17:41:49 +00:00
Coco Sheng 1e7063bb0b fix(cli): allow keychain auth for --list-sessions and non-interactive mode (#26921) 2026-05-13 17:35:21 +00:00
Coco Sheng 297d3a3067 fix(core): preserve OAuth refresh tokens during rotation and retrieval (#26924) 2026-05-13 17:19:05 +00:00
David Pierce 749657cbf9 feat(cli): merge Auto modes into a single Auto mode (#26714) 2026-05-13 16:55:43 +00:00
Adam Weidman 8cda688fe2 feat(core): change agent registration to first-wins and prioritize project (#26953) 2026-05-13 01:33:12 +00:00
gemini-cli-robot 5ee05c775e Changelog for v0.43.0-preview.0 (#26959)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-13 00:01:24 +00:00
mahadevan 31d5947d37 Refactor: Eliminate no-unsafe-return suppressions via strict type validation (#20668)
Signed-off-by: M-DEV-1 <mahadevankizhakkedathu@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-05-12 23:45:58 +00:00
gemini-cli-robot 8f03aa320e Changelog for v0.42.0 (#26958)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-05-12 22:54:14 +00:00
gemini-cli-robot 583839ba46 chore(release): bump version to 0.44.0-nightly.20260512.g022e8baef (#26957) 2026-05-12 22:44:04 +00:00
401 changed files with 21105 additions and 10019 deletions
-1
View File
@@ -3,7 +3,6 @@
"extensionReloading": true,
"modelSteering": true,
"autoMemory": true,
"memoryManager": true,
"topicUpdateNarration": true,
"voiceMode": true,
"adk": {
+330
View File
@@ -0,0 +1,330 @@
---
name: agent-tui
description: >
Main Agents: Do NOT use this skill directly. If you need to test the TUI, invoke the `tui_tester` subagent.
Drive terminal UI (TUI) applications programmatically for testing, automation, and inspection.
Use when: automating CLI/TUI interactions, regression testing terminal apps, or verifying interactive behavior.
Also use when: user asks "what is agent-tui", "what does agent-tui do", "demo agent-tui", "show me agent-tui", "how does agent-tui work", or wants to see it in action.
---
## 🚨 CRITICAL: macOS Daemon Workaround & Gemini CLI Usage 🚨
When using `agent-tui` in this macOS environment, the default background daemonization process crashes, causing `Connection refused (os error 61)` errors.
**You MUST start the daemon manually shielded from TTY hangups before running any `agent-tui` commands.** Using `nohup` is insufficient; you must use `tmux` to provide a fully isolated pseudo-terminal.
To support parallel runs, **only restart the daemon if it is not currently running:**
```bash
# Check if daemon is alive, start it in tmux if it is not
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
```
### Session ID vs PID (Crucial for Reconnection)
When `agent-tui run` returns JSON, it includes both a `session_id` and a `pid`. The `pid` is purely informational (the OS process ID of the child command). You **do not** use the `pid` to reconnect or issue commands. You must always use the `session_id` (e.g., `--session <id>`).
If the daemon crashes (`os error 61`), the pseudo-terminal is destroyed. Even if the child `pid` survives as an orphan, you cannot reconnect to it. You must restart the daemon using the workaround above and start a completely new session.
### Testing the Gemini CLI
When testing the Gemini CLI with `agent-tui`, there are several strict requirements to ensure deterministic and accurate behavior:
1. **Build Before Running**: `agent-tui` runs the built JS files, not TypeScript. You **MUST** run `npm run build` or `npm run build:all` after making code changes and before launching the CLI with `agent-tui`.
2. **Bypass Trust Modals**: Always pass `GEMINI_CLI_TRUST_WORKSPACE=true` in the environment. If you don't, any new project-level agents or extensions will trigger a full-screen "Acknowledge and Enable" modal. This modal steals focus, swallows automation keystrokes, and causes `agent-tui wait` commands to time out.
3. **Isolated Environments**: If you need to test without real user credentials or existing agents interfering, isolate the global settings using `GEMINI_CLI_HOME=<some-test-dir>`.
4. **Testing State Deltas (e.g., Reloads)**: If you are testing features that report deltas (e.g., `/agents reload` outputting "1 new local subagent"), you **MUST**:
- Start the CLI *first* so it establishes its baseline registry.
- Use a separate shell command (outside of `agent-tui`) to write the new agent `.md`/`.toml` file.
- Use `agent-tui type` and `press` to trigger the `/agents reload` command inside the running session.
- (If you add the files before starting the CLI, they become part of the baseline and won't trigger the delta logic).
```bash
# Example: Standard isolated run (sandboxed config + bypass trust modals)
env GEMINI_CLI_TRUST_WORKSPACE=true GEMINI_CLI_HOME=test-gemini-home agent-tui run -d "$(pwd)" node packages/cli/dist/index.js
```
# Terminal Automation Mastery
## Prerequisites
- **Supported OS**: macOS or Linux (Windows not supported yet).
- **Verify install**:
```bash
agent-tui --version
```
If not installed, use one of:
```bash
# Recommended: one-line install (macOS/Linux)
curl -fsSL https://raw.githubusercontent.com/pproenca/agent-tui/master/install.sh | sh
```
```bash
# Package manager
npm i -g agent-tui
pnpm add -g agent-tui
bun add -g agent-tui
```
```bash
# Build from source
cargo install --git https://github.com/pproenca/agent-tui.git --path cli/crates/agent-tui
```
If you used the install script, ensure `~/.local/bin` is on your PATH.
## Philosophy: Why Terminal Automation Is Different
Terminal UIs are **stateless from the observer's perspective**. Unlike web browsers with a persistent DOM, terminal automation works with a constantly-refreshed character grid. This fundamental difference shapes everything:
| Web Automation | Terminal Automation |
|----------------|---------------------|
| DOM persists across interactions | Screen buffer is redrawn constantly |
| Selectors are stable | Text positions may shift |
| Query once, act many times | Must re-verify before EVERY action |
| Network events signal completion | Must detect visual stability |
**The Core Insight**: agent-tui gives you vision without memory. Each screenshot is a fresh observation. Previous state means nothing after the UI changes. This isn't a limitation—it's the nature of terminal interaction.
## Mental Model: The Feedback Loop
Think of terminal automation as a **closed-loop control system**:
```
┌──────────────────────────────────────────────┐
│ │
▼ │
OBSERVE ──► DECIDE ──► ACT ──► WAIT ──► VERIFY ───┘
│ │
│ │
└─────── NEVER skip ◄────────────────────┘
```
**Each phase is mandatory.** Skipping verification is the #1 cause of flaky automation.
### The "Fresh Eyes" Principle
Every time you need to interact with the UI:
1. **Take a fresh screenshot** — your previous one is now stale
2. **Locate your target visually** — text positions may have changed
3. **Verify the state** — the UI may have changed unexpectedly
4. **Act only when stable** — animations and loading states cause failures
This feels slower, but it's the only reliable approach. Optimistic reuse of stale state causes intermittent failures that are painful to debug.
## Critical Rules (Non-Negotiable)
> **RULE 1: Atomic Execution (No Pipelining)**
> You are FORBIDDEN from chaining commands with `&&` (e.g., `type "x" && press Enter && wait`). Modals or UI updates can intercept your keystrokes. You MUST execute one atomic action, wait, screenshot, and verify before taking the next action in a new turn.
> **RULE 2: Re-snapshot after EVERY action**
> The UI state is invalidated by any change. Always take a fresh screenshot before acting again.
> **RULE 3: Never act on unstable UI**
> If the UI is animating, loading, or transitioning, `wait --stable` first. Acting during transitions because race conditions.
> **RULE 4: Verify before claiming success**
> Use `wait "expected text" --assert` to confirm outcomes. Don't assume an action worked—prove it.
> **RULE 5: Error Recovery**
> If a `wait` command times out, DO NOT blindly restart or kill the session. Execute `screenshot` to visually diagnose what unexpected UI element (modal, error dialog, lost focus) intercepted the flow.
> **RULE 6: Clean up sessions**
> Always end with `agent-tui kill`. Orphaned sessions consume resources and can interfere with future runs.
## Decision Framework
### Which Screenshot Mode?
Use `screenshot --format json` when parsing automation output, or plain `screenshot` for human readable text.
### How to Wait?
```
What are you waiting for?
├─► Specific text to appear
│ └─► `wait "text" --assert` (fails if not found)
├─► Specific text to disappear
│ └─► `wait "text" --gone --assert`
├─► UI to stop changing (animations, loading)
│ └─► `wait --stable`
└─► Multiple conditions
└─► Chain waits sequentially
```
### How to Act?
```
What do you need to do?
├─► Type text into the terminal
│ └─► `type "text"`
├─► Send keyboard shortcuts/navigation
│ └─► `press Ctrl+C` or `press ArrowDown Enter`
```
## Core Workflow
The canonical automation loop:
```bash
# 1. START: Launch the TUI app
agent-tui run <command> [-- args...]
# 2. OBSERVE: Get current UI state
agent-tui screenshot --format json
# 3. DECIDE: Based on text, determine next action
# (This happens in your head/code)
# 4. ACT: Execute the action
agent-tui type "text"
agent-tui press Enter
# 5. WAIT: Synchronize with UI changes
agent-tui wait "Expected" --assert # or wait --stable
# 6. VERIFY: Confirm the outcome (often combined with step 5)
# If verification fails, handle the error
# 7. REPEAT: Go back to step 2 until done
# 8. CLEANUP: Always clean up
agent-tui kill
```
## Anti-Patterns (What NOT to Do)
### ❌ Acting During Animation/Loading
```bash
# WRONG: Acting immediately on dynamic UI
agent-tui run my-app
agent-tui screenshot --format json # UI might still be loading!
agent-tui type "value" # ❌ Might miss the input field
# RIGHT: Wait for stability first
agent-tui run my-app
agent-tui wait --stable # Let UI settle
agent-tui screenshot --format json # Now it's reliable
agent-tui type "value"
```
### ❌ Assuming Success Without Verification
```bash
# WRONG: Assuming the type worked
agent-tui type "value"
agent-tui press Enter
# ...proceed as if success... # ❌ What if it failed silently?
# RIGHT: Verify the outcome
agent-tui type "value"
agent-tui press Enter
agent-tui wait "Success" --assert # ✓ Proves the action worked
```
### ❌ Skipping Cleanup
```bash
# WRONG: Forgetting to kill the session
agent-tui run my-app
# ...do stuff...
# script ends # ❌ Session left running!
# RIGHT: Always clean up
agent-tui run my-app
# ...do stuff...
agent-tui kill # ✓ Clean exit
```
## Before You Start: Clarify Requirements
Before automating any TUI, gather this information:
1. **Command**: What exactly to run? (`my-app --flag` or `npm start`?)
2. **Success criteria**: What text/state indicates success?
3. **Input sequence**: What keystrokes/data to enter, in what order?
4. **Safety**: Is it safe to submit forms, delete data, etc.?
5. **Auth**: Does it need login? Test credentials?
6. **Live preview**: Does the user want to watch? (`agent-tui live start --open`)
If any of these are unclear, ask before running.
## Demo Mode: Showing What agent-tui Can Do
When a user asks what agent-tui is, wants a demo, or asks "show me how it works":
1. **Don't explain—demonstrate.** Actions speak louder than words.
2. **Use the live preview** so they can watch in real-time.
3. **Run `top`**—it's universal and shows dynamic real-time updates.
**Quick demo trigger phrases:**
- "What is agent-tui?" / "What does agent-tui do?"
- "Demo agent-tui" / "Show me agent-tui"
- "How does agent-tui work?" / "See it in action"
## Failure Recovery
| Symptom | Diagnosis | Solution |
|---------|-----------|----------|
| "Text not found" | Stale view or text moved | Re-snapshot, locate text again |
| Wait times out | UI didn't reach expected state | Check screenshot, verify expectations |
| "Daemon not running" | Daemon crashed or not started | `agent-tui daemon start` |
| Unexpected layout | Wrong terminal size | `agent-tui resize --cols 120 --rows 40` |
| Session unresponsive | App crashed or hung | `agent-tui kill`, then re-run |
| Repeated failures | Something fundamentally wrong | Stop after 3-5 attempts, ask user |
## Self-Discovery: Use --help
You don't need to memorize every flag. The CLI is self-documenting:
```bash
agent-tui --help # List all commands
agent-tui run --help # Options for 'run'
agent-tui screenshot --help # Options for 'screenshot'
agent-tui wait --help # Options for 'wait'
```
**When in doubt, ask the CLI.** This skill teaches *when* and *why* to use commands. For exact flags and syntax, `--help` is authoritative.
## Quick Reference
```bash
# Start app
agent-tui run <cmd> [-- args] # Launch TUI under control
# Observe
agent-tui screenshot # Plain text view
agent-tui screenshot --format json # Machine-readable output
# Act
agent-tui press Enter # Press key(s)
agent-tui press Ctrl+C # Keyboard shortcuts
agent-tui type "text" # Type text
# Wait/Verify
agent-tui wait "text" --assert # Wait for text, fail if not found
agent-tui wait "text" --gone --assert # Wait for text to disappear
agent-tui wait --stable # Wait for UI to stop changing
# Manage
agent-tui sessions # List active sessions
agent-tui live start --open # Start live preview
agent-tui kill # End current session
```
+65
View File
@@ -0,0 +1,65 @@
---
name: tui-tester
description: Expert guidance for testing Gemini CLI behavior and visual output using terminal automation.
---
# TUI Tester Skill
This skill provides the operational manual for verifying Gemini CLI behavioral changes and visual output using terminal automation.
## Core Responsibilities
- **Verify Behavior**: Confirm that code changes result in the expected terminal interactions.
- **Visual Validation**: Ensure the TUI renders correctly across different terminal sizes and states.
- **Regression Testing**: Use automation to prevent breaking existing interactive workflows.
## Critical Protocol
When performing TUI testing, you must adhere to these strict rules:
### 1. Initialization
**YOUR ABSOLUTE FIRST ACTION MUST BE:**
Activate the `agent-tui` skill. This provides the underlying tools needed for terminal automation.
### 2. Environment Setup (macOS / Parallel Safe)
Ensure the global daemon is running and the live preview is open:
```bash
if ! agent-tui sessions >/dev/null 2>&1; then
tmux kill-session -t agent-tui 2>/dev/null || true
agent-tui daemon stop 2>/dev/null || true
rm -f /tmp/agent-tui*
tmux new-session -d -s agent-tui 'agent-tui daemon start --foreground > /tmp/agent-tui-daemon.log 2>&1'
sleep 1
fi
agent-tui live start --open
```
### 3. Session Management
- **Session IDs**: Always use the `session_id` returned by `agent-tui run` for subsequent interactions.
- **Atomic Execution**: Execute exactly one command per turn. Do not pipeline actions.
- **The Loop**: Action -> Wait -> Screenshot -> Verify -> Next Action.
### 4. Gemini CLI Specifics
- **Build First**: Always run `npm run build` or `npm run build:all` before testing local changes.
- **Bypass Trust**: Set `GEMINI_CLI_TRUST_WORKSPACE=true` to avoid focus-stealing modals.
- **Isolate Config**: Use `GEMINI_CLI_HOME` to prevent interference with your personal settings.
## Workflow Example
```bash
# Start the CLI
env GEMINI_CLI_TRUST_WORKSPACE=true agent-tui run node packages/cli/dist/index.js
# Wait for the prompt
agent-tui wait "│" --assert
# Send a command
agent-tui type "/help"
agent-tui press Enter
# Verify output
agent-tui wait "Available Commands" --assert
```
## Error Recovery
If a wait times out, take a fresh screenshot to diagnose the state. If you see `os error 61`, restart the daemon using the tmux method.
+193 -111
View File
@@ -5,131 +5,218 @@
*/
module.exports = async ({ github, context, core }) => {
const rawLabels = process.env.LABELS_OUTPUT;
core.info(`Raw labels JSON: ${rawLabels}`);
let parsedLabels;
try {
// First, try to parse the raw output as JSON.
parsedLabels = JSON.parse(rawLabels);
} catch (jsonError) {
// If that fails, check for a markdown code block.
core.warning(
`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`,
);
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
parsedLabels = JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.setFailed(
`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`,
);
return;
const extractJson = (raw) => {
if (!raw || raw === '[]' || raw === '') return [];
try {
// First, try to parse the raw output as JSON.
return JSON.parse(raw);
} catch {
// If that fails, check for a markdown code block.
core.info(
'Direct JSON parsing failed. Trying to extract from a markdown block.',
);
const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
if (jsonMatch && jsonMatch[1]) {
try {
return JSON.parse(jsonMatch[1].trim());
} catch (markdownError) {
core.warning(
`Failed to parse extracted JSON from markdown block: ${markdownError.message}`,
);
}
}
} else {
// If no markdown block, try to find a raw JSON array in the output.
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
// before the actual JSON response.
const jsonArrayMatch = rawLabels.match(
// Try to find a raw JSON array in the output.
const jsonArrayMatch = raw.match(
/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/,
);
if (jsonArrayMatch) {
try {
parsedLabels = JSON.parse(jsonArrayMatch[0]);
} catch (extractError) {
// It's possible the regex matched from a `[STARTUP]` log all the way to the end
// of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
core.warning(
`Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`,
);
const fallbackMatch = rawLabels.match(
/(\[\s*\{\s*"issue_number"[\s\S]*)/,
);
return JSON.parse(jsonArrayMatch[0]);
} catch {
const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/);
if (fallbackMatch) {
try {
// We might have grabbed trailing noise too, so we find the last closing bracket
const cleaned = fallbackMatch[0].substring(
0,
fallbackMatch[0].lastIndexOf(']') + 1,
);
parsedLabels = JSON.parse(cleaned);
return JSON.parse(cleaned);
} catch (fallbackError) {
core.setFailed(
`Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`,
core.warning(
`Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`,
);
return;
}
} else {
core.setFailed(
`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`,
);
return;
}
}
} else {
core.setFailed(
`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`,
);
return;
}
}
}
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
core.warning('No valid JSON could be extracted from input.');
return [];
};
for (const entry of parsedLabels) {
const issueNumber = entry.issue_number;
if (!issueNumber) {
core.info(
`Skipping entry with no issue number: ${JSON.stringify(entry)}`,
);
continue;
// Collect all outputs from environment variables
// Prioritize EFFORT results over STANDARD results by processing Effort FIRST
// so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic)
const effortRaw = process.env.LABELS_OUTPUT_EFFORT;
const standardRaw = process.env.LABELS_OUTPUT_STANDARD;
const genericRaw = process.env.LABELS_OUTPUT;
const resultsByIssue = new Map();
const processResults = (results, _sourceName) => {
for (const entry of results) {
const issueNumber = entry.issue_number;
if (!issueNumber) continue;
if (!resultsByIssue.has(issueNumber)) {
resultsByIssue.set(issueNumber, {
issue_number: issueNumber,
labels_to_add: [...(entry.labels_to_add || [])],
labels_to_remove: [...(entry.labels_to_remove || [])],
explanation: entry.explanation || '',
effort_analysis: entry.effort_analysis || '',
});
} else {
const existing = resultsByIssue.get(issueNumber);
// Combine labels
existing.labels_to_add = [
...new Set([
...existing.labels_to_add,
...(entry.labels_to_add || []),
]),
];
existing.labels_to_remove = [
...new Set([
...existing.labels_to_remove,
...(entry.labels_to_remove || []),
]),
];
// Combine explanations (if different)
if (
entry.explanation &&
!existing.explanation.includes(entry.explanation)
) {
existing.explanation = existing.explanation
? `${existing.explanation}\n\n${entry.explanation}`
: entry.explanation;
}
// Take effort analysis if present
if (entry.effort_analysis && !existing.effort_analysis) {
existing.effort_analysis = entry.effort_analysis;
}
}
}
};
// Order matters: Effort first so its labels win in conflict resolution
processResults(extractJson(effortRaw), 'EFFORT');
processResults(extractJson(standardRaw), 'STANDARD');
processResults(extractJson(genericRaw), 'GENERIC');
const finalResults = Array.from(resultsByIssue.values());
core.info(`Aggregated triage results for ${finalResults.length} issues.`);
for (const entry of finalResults) {
const issueNumber = entry.issue_number;
let labelsToAdd = entry.labels_to_add || [];
let labelsToRemove = entry.labels_to_remove || [];
let existingLabels = [];
// Fetch existing labels early
try {
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
existingLabels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
} catch (e) {
core.warning(
`Failed to fetch existing labels for #${issueNumber}: ${e.message}`,
);
}
// Programmatic Priority Downgrade Logic
if (labelsToAdd.includes('status/need-information')) {
const targetPriority = labelsToAdd.find((l) => l.startsWith('priority/'));
if (targetPriority) {
let downgradedPriority = null;
if (targetPriority === 'priority/p0')
downgradedPriority = 'priority/p1';
if (targetPriority === 'priority/p1')
downgradedPriority = 'priority/p2';
if (downgradedPriority) {
core.info(
`Programmatically downgrading ${targetPriority} to ${downgradedPriority} due to status/need-information`,
);
labelsToAdd = labelsToAdd.filter((l) => l !== targetPriority);
labelsToAdd.push(downgradedPriority);
}
}
}
labelsToRemove.push('status/need-triage');
if (labelsToAdd.includes('status/manual-triage')) {
// If the AI flagged it for manual triage, remove bot-triaged if it exists
if (
labelsToAdd.includes('status/manual-triage') ||
existingLabels.includes('status/manual-triage')
) {
labelsToRemove.push('status/bot-triaged');
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
} else {
// Standard successful bot triage
labelsToAdd.push('status/bot-triaged');
}
// Deduplicate arrays
labelsToAdd = [...new Set(labelsToAdd)];
labelsToRemove = [...new Set(labelsToRemove)];
// Resolve internal conflicts (e.g., adding P1 and P2)
// We already resolved these by putting Effort first in the combined list
// Enforce mutually exclusive area labels
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
if (areaLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
// Resolve external conflicts with existing labels
if (labelsToAdd.some((l) => l.startsWith('area/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('area/')),
);
const firstArea = areaLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('area/') || l === firstArea,
}
if (labelsToAdd.some((l) => l.startsWith('priority/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('priority/')),
);
}
if (labelsToAdd.some((l) => l.startsWith('kind/'))) {
labelsToRemove.push(
...existingLabels.filter((l) => l.startsWith('kind/')),
);
}
// Enforce mutually exclusive priority labels
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
l.startsWith('priority/'),
// Enforce mutual exclusivity in the TO-ADD list (Architect wins)
const exclusivePrefixes = ['area/', 'priority/', 'kind/'];
for (const prefix of exclusivePrefixes) {
const filtered = labelsToAdd.filter((l) => l.startsWith(prefix));
if (filtered.length > 1) {
const winner = filtered[0]; // First one wins
core.info(
`Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`,
);
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith(prefix) || l === winner,
);
}
}
// Final deduplication and cleanup
labelsToRemove = [...new Set(labelsToRemove)].filter(
(l) => !labelsToAdd.includes(l) && existingLabels.includes(l),
);
labelsToAdd = [...new Set(labelsToAdd)].filter(
(l) => !existingLabels.includes(l),
);
if (priorityLabelsToAdd.length > 1) {
core.warning(
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
);
const firstPriority = priorityLabelsToAdd[0];
labelsToAdd = labelsToAdd.filter(
(l) => !l.startsWith('priority/') || l === firstPriority,
);
}
// Batch label operations
if (labelsToAdd.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -137,10 +224,8 @@ module.exports = async ({ github, context, core }) => {
issue_number: issueNumber,
labels: labelsToAdd,
});
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
core.info(
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`,
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`,
);
}
@@ -154,11 +239,10 @@ module.exports = async ({ github, context, core }) => {
name: label,
});
} catch (e) {
if (e.status !== 404) {
if (e.status !== 404)
core.warning(
`Failed to remove label ${label} from #${issueNumber}: ${e.message}`,
);
}
}
}
core.info(
@@ -166,31 +250,29 @@ module.exports = async ({ github, context, core }) => {
);
}
if (entry.explanation || entry.effort_analysis) {
// Post comment if needed
const needsInfoAdded =
labelsToAdd.includes('status/need-information') &&
!existingLabels.includes('status/need-information');
const hasEffortAnalysis = !!entry.effort_analysis;
if (needsInfoAdded || hasEffortAnalysis) {
let commentBody = '';
if (entry.explanation) {
commentBody += entry.explanation;
}
if (entry.effort_analysis) {
if (needsInfoAdded && entry.explanation) commentBody += entry.explanation;
if (hasEffortAnalysis) {
if (commentBody) commentBody += '\n\n';
commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
}
if (
(!entry.labels_to_add || entry.labels_to_add.length === 0) &&
(!entry.labels_to_remove || entry.labels_to_remove.length === 0)
) {
core.info(
`No labels to add or remove for #${issueNumber}, leaving as is`,
);
if (commentBody) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: commentBody,
});
core.info(`Posted required comment for #${issueNumber}`);
}
}
}
};
+36 -12
View File
@@ -22,29 +22,53 @@ module.exports = async ({ github, context, core }) => {
for (const issue of issuesToCleanup) {
try {
await github.rest.issues.removeLabel({
const { data: issueData } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Successfully removed status/need-triage from #${issue.number}`,
const labels = issueData.labels.map((l) =>
typeof l === 'string' ? l : l.name,
);
} catch (error) {
if (error.status === 404) {
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/need-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/need-triage',
});
core.info(
`Label status/need-triage not found on #${issue.number}, skipping.`,
);
} else {
core.warning(
`Failed to remove label from #${issue.number}: ${error.message}`,
`Successfully removed status/need-triage from #${issue.number}`,
);
}
if (
labels.includes('status/bot-triaged') &&
labels.includes('status/manual-triage')
) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
name: 'status/bot-triaged',
});
core.info(
`Successfully removed status/bot-triaged from #${issue.number} because it requires manual triage`,
);
}
} catch (error) {
core.warning(
`Failed to clean up labels for #${issue.number}: ${error.message}`,
);
}
}
core.info(
`Cleaned up status/need-triage from ${issuesToCleanup.length} issues.`,
`Cleaned up conflicting labels from ${issuesToCleanup.length} issues.`,
);
};
@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const fs = require('node:fs');
module.exports = async ({ github, context, core }) => {
core.info('Fetching open issues to check for conflicting labels...');
const issues = await github.paginate(github.rest.issues.listForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
});
const conflictingLabelIssues = [];
for (const issue of issues) {
if (issue.pull_request) continue;
const areaLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('area/'))
.map((l) => l.name);
const priorityLabels = issue.labels
.filter((l) => l.name && l.name.startsWith('priority/'))
.map((l) => l.name);
if (areaLabels.length > 1 || priorityLabels.length > 1) {
let message = `Issue #${issue.number} has conflicting labels:`;
if (areaLabels.length > 1)
message += ` multiple areas (${areaLabels.join(', ')}).`;
if (priorityLabels.length > 1)
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
core.info(message);
conflictingLabelIssues.push({
number: issue.number,
title: issue.title,
body: issue.body || '',
});
}
}
// Limit to 50 to avoid overwhelming the AI in a single run
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
fs.writeFileSync(
'conflicting_labels_issues.json',
JSON.stringify(issuesToProcess, null, 2),
);
core.info(
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
);
};
+142 -30
View File
@@ -16,6 +16,8 @@ module.exports = async ({ github, context, core }) => {
const owner = context.repo.owner;
const repo = context.repo.repo;
core.info(`Running in ${dryRun ? 'DRY RUN' : 'PRODUCTION'} mode.`);
const STALE_LABEL = 'stale';
const NEED_INFO_LABEL = 'status/need-information';
const EXEMPT_LABELS = [
@@ -79,14 +81,16 @@ module.exports = async ({ github, context, core }) => {
async function processItems(query, callback) {
core.info(`Searching: ${query}`);
try {
const response = await github.rest.search.issuesAndPullRequests({
q: query,
per_page: 100,
sort: 'updated',
order: 'asc',
});
const items = response.data.items;
core.info(`Found ${items.length} items (batch limited).`);
let items = await github.paginate(
github.rest.search.issuesAndPullRequests,
{
q: query,
per_page: 100,
sort: 'updated',
order: 'asc',
},
);
core.info(`Found ${items.length} items.`);
for (const item of items) {
try {
await callback(item);
@@ -114,16 +118,21 @@ module.exports = async ({ github, context, core }) => {
per_page: 5,
});
// Check if the last comment is from a non-maintainer
// Check if the last comment is from a non-maintainer and not a bot
const lastComment = comments[0];
if (
lastComment &&
lastComment.user?.type !== 'Bot' &&
!(await isMaintainer(lastComment.user, lastComment.author_association))
) {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
} else {
core.info(
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
);
await github.rest.issues
.removeLabel({
owner,
@@ -141,10 +150,14 @@ module.exports = async ({ github, context, core }) => {
await processItems(
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
async (item) => {
core.info(
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would close #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
} else {
core.info(
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -156,6 +169,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -163,11 +177,21 @@ module.exports = async ({ github, context, core }) => {
// 2. Handle Stale Mark (60 days inactivity, no stale label)
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
await processItems(
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
async (item) => {
core.info(`Marking #${item.number} as stale.`);
if (!dryRun) {
const isBug = item.labels.some((l) =>
(typeof l === 'string' ? l : l.name).toLowerCase().includes('bug'),
);
const bodyText = isBug
? `This bug report has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. Many issues are resolved in newer releases. Please verify if the issue persists in the latest Gemini CLI version. If it does, please leave a comment to keep this open. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`
: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`;
if (dryRun) {
core.info(`[DRY RUN] Would mark #${item.number} as stale.`);
} else {
core.info(`Marking #${item.number} as stale.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -178,18 +202,97 @@ module.exports = async ({ github, context, core }) => {
owner,
repo,
issue_number: item.number,
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
body: bodyText,
});
}
},
);
// 3. Handle Stale Close (14 days with stale label)
// 3. Handle Stale Removal & Close
await processItems(
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`,
async (item) => {
core.info(`Closing stale item #${item.number}.`);
if (!dryRun) {
// Fetch full timeline to see events and comments
const timeline = await github.paginate(
github.rest.issues.listEventsForTimeline,
{
owner,
repo,
issue_number: item.number,
per_page: 100,
},
);
// Find exactly when the Stale label was added
// We look for the last 'labeled' event for STALE_LABEL
const staleEventIndex = timeline.findLastIndex(
(e) =>
e.event === 'labeled' &&
e.label?.name?.toLowerCase() === STALE_LABEL.toLowerCase(),
);
if (staleEventIndex === -1) return; // Fallback if no event found
const staleEvent = timeline[staleEventIndex];
const eventsAfterStale = timeline.slice(staleEventIndex + 1);
// Check for meaningful activity after the Stale label was applied
const meaningfulEvents = eventsAfterStale.filter((e) => {
const actor = e.actor?.login || '';
const isBot =
actor.includes('[bot]') || actor.includes('github-actions');
if (isBot) return false;
// Explicit whitelist of meaningful events for humans
if (
[
'commented',
'cross-referenced',
'connected',
'reopened',
'assigned',
].includes(e.event)
) {
return true;
}
return false;
});
if (meaningfulEvents.length > 0) {
// Activity detected, remove Stale label
if (dryRun) {
core.info(
`[DRY RUN] Would remove ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
} else {
core.info(
`Removing ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
);
await github.rest.issues
.removeLabel({
owner,
repo,
issue_number: item.number,
name: STALE_LABEL,
})
.catch(() => {});
}
return;
}
// No meaningful activity. Check if 14 days have passed.
const labeledDate = new Date(staleEvent.created_at);
if (labeledDate > closeThreshold) {
// Has not been 14 days since it was ACTUALLY marked stale
return;
}
if (dryRun) {
core.info(`[DRY RUN] Would close stale item #${item.number}.`);
} else {
core.info(`Closing stale item #${item.number}.`);
await github.rest.issues.createComment({
owner,
repo,
@@ -201,6 +304,7 @@ module.exports = async ({ github, context, core }) => {
repo,
issue_number: item.number,
state: 'closed',
state_reason: 'not_planned',
});
}
},
@@ -222,8 +326,12 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(`Nudging PR #${pr.number} for contribution policy.`);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would nudge PR #${pr.number} for contribution policy.`,
);
} else {
core.info(`Nudging PR #${pr.number} for contribution policy.`);
await github.rest.issues.addLabels({
owner,
repo,
@@ -246,10 +354,14 @@ module.exports = async ({ github, context, core }) => {
async (pr) => {
if (await isMaintainer(pr.user, pr.author_association)) return;
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
if (!dryRun) {
if (dryRun) {
core.info(
`[DRY RUN] Would close PR #${pr.number} per contribution policy (no 'help wanted').`,
);
} else {
core.info(
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
);
await github.rest.issues.createComment({
owner,
repo,
@@ -68,6 +68,7 @@ jobs:
ISSUE_NUMBER: '${{ github.event.issue.number }}'
REPOSITORY: '${{ github.repository }}'
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -335,14 +335,41 @@ jobs:
return;
}
const newAreaLabel = labelsToAdd[0];
// Get current labels to resolve conflicts
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const currentLabelNames = currentLabels.map(l => l.name);
const currentAreaLabels = currentLabelNames.filter(name => name.startsWith('area/'));
const labelsToRemove = currentAreaLabels.filter(name => name !== newAreaLabel);
for (const label of labelsToRemove) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
name: label
});
core.info(`Removed conflicting area label: ${label}`);
} catch (e) {
core.warning(`Failed to remove label ${label}: ${e.message}`);
}
}
// Set labels based on triage result
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
labels: [newAreaLabel]
});
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
core.info(`Successfully added labels for #${issueNumber}: ${newAreaLabel}`);
- name: 'Post Issue Analysis Failure Comment'
if: |-
@@ -1,10 +1,6 @@
name: '📋 Gemini Scheduled Issue Triage'
on:
issues:
types:
- 'opened'
- 'reopened'
schedule:
- cron: '0 * * * *' # Runs every hour
workflow_dispatch:
@@ -33,6 +29,18 @@ jobs:
with:
persist-credentials: false
- name: 'Install Utilities'
run: |
sudo apt-get update
sudo apt-get install -y ripgrep
- name: 'Get Current Version'
id: 'get_version'
run: |
VERSION=$(jq -r .version package.json | cut -d'-' -f1)
echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
echo "🚀 Current CLI Version: ${VERSION}"
- name: 'Generate GitHub App Token'
id: 'generate_token'
uses: 'actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b' # ratchet:actions/create-github-app-token@v2
@@ -66,12 +74,20 @@ jobs:
- name: 'Find Issues with Conflicting Labels'
if: |-
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
await findConflictingLabels({ github, context, core });
env:
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🔍 Fetching open issues to find conflicts...'
# Fetch up to 2000 open issues in one quick GraphQL-backed query
gh issue list --repo "${GITHUB_REPOSITORY}" --search "is:issue is:open" --limit 2000 --json number,title,body,labels > all_open_issues.json
echo '🧹 Filtering issues with multiple area/ or priority/ labels...'
jq -c '[ .[] | select( (.labels | map(select(.name | startswith("area/"))) | length) > 1 or (.labels | map(select(.name | startswith("priority/"))) | length) > 1 ) ] | .[0:50]' all_open_issues.json > conflicting_labels_issues.json
CONFLICT_COUNT=$(jq 'length' conflicting_labels_issues.json)
echo "Found ${CONFLICT_COUNT} issues with conflicting labels (capped at 50 for processing)."
- name: 'Find untriaged issues'
if: |-
@@ -85,19 +101,19 @@ jobs:
echo '🔍 Finding issues missing area labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body > no_area_issues.json
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body,labels > no_area_issues.json
echo '🔍 Finding issues missing kind labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body > no_kind_issues.json
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body,labels > no_kind_issues.json
echo '🏷️ Finding issues missing priority labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body,labels > no_priority_issues.json
echo '📏 Finding issues missing effort labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body,labels > no_effort_issues.json
echo '🔄 Merging and deduplicating standard triage issues...'
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
@@ -159,9 +175,11 @@ jobs:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -178,7 +196,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -192,12 +210,12 @@ jobs:
## Steps
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
3. Review the issue title, body and any comments provided in the JSON file.
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage (including their current labels).
3. Review the issue title, body, current labels, and any comments provided in the JSON file.
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
5. Label Policy:
- If the issue already has a kind/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it.
- If the issue has exactly ONE priority/ label, do not change it (unless you are explicitly re-evaluating an ambiguous priority).
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
- If the issue has exactly ONE area/ label, do not change it.
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
@@ -217,11 +235,12 @@ jobs:
]
```
If an issue cannot be classified, do not include it in the output array.
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1", "* **CLI Version:** 0.42.0"), or in the output of the `/about` command.
- **Only for issues classified as kind/bug:** If the version is provided but is more than 6 minor versions older than the most recent release (current version is ${{ steps.get_version.outputs.version }}), apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version.
10. **Only for issues classified as kind/bug:** If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body. (Check both bullet points and bold text). For features and enhancements, the CLI version is NOT required.
11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation.
12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change.
13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
## Guidelines
@@ -234,12 +253,14 @@ jobs:
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
- **Do not manually downgrade the priority.** Always assign the true priority based on the guidelines. The system will handle downgrades programmatically if information is missing.
- **NEVER mention label names, label removals, or label additions in your `explanation`.** The explanation must be purely written for the user (e.g., "Please provide your CLI version.") without exposing internal triage mechanics (e.g., do NOT say "Removing area/unknown to leave only area/core").
Categorization Guidelines (Priority):
P0 - Urgent Blocking Issues:
- DO NOT APPLY THE priority/p0 LABEL AUTOMATICALLY. Instead apply priority/p1 and status/manual-triage.
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
- Note: You must apply status/manual-triage instead of priority/p0.
- Note: You must apply priority/p1 and status/manual-triage instead of priority/p0.
P1 - Critical but Workable:
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
P2 - Significant Issues:
@@ -276,9 +297,11 @@ jobs:
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
REPOSITORY: '${{ github.repository }}'
AVAILABLE_LABELS: '${{ steps.get_labels.outputs.available_labels }}'
CLI_VERSION: '${{ steps.get_version.outputs.version }}'
GEMINI_CLI_TRUST_WORKSPACE: 'true'
GEMINI_EXP: 'gemini_exp.json'
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
GEMINI_MODEL: 'gemini-3-flash-preview'
with:
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
@@ -289,7 +312,7 @@ jobs:
use_gemini_code_assist: '${{ vars.GOOGLE_GENAI_USE_GCA }}'
settings: |-
{
"maxSessionTurns": 25,
"maxSessionTurns": 30,
"coreTools": [
"run_shell_command(echo)",
"grep_search",
@@ -297,7 +320,7 @@ jobs:
"read_file"
],
"telemetry": {
"enabled": true,
"enabled": false,
"target": "gcp"
}
}
@@ -383,29 +406,14 @@ jobs:
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
- When users report that they dont expect the model to change those would be categorized as feature requests.
- name: 'Apply Standard Labels to Issues'
- name: 'Apply Triaged Labels'
if: |-
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
always() &&
( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') ||
(steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') )
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
script: |-
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
await applyLabels({ github, context, core });
- name: 'Apply Effort Labels to Issues'
if: |-
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
env:
REPOSITORY: '${{ github.repository }}'
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
with:
github-token: '${{ steps.generate_token.outputs.token }}'
@@ -431,9 +439,12 @@ jobs:
GITHUB_REPOSITORY: '${{ github.repository }}'
run: |-
set -euo pipefail
echo '🧹 Finding issues that have both bot-triaged and need-triage labels...'
echo '🧹 Finding issues that have conflicting status labels...'
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > issues_to_cleanup.json
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > cleanup_1.json
gh issue list --repo "${GITHUB_REPOSITORY}" \
--search 'is:open is:issue label:status/bot-triaged label:status/manual-triage' --limit 50 --json number > cleanup_2.json
jq -c -s 'add | unique_by(.number)' cleanup_1.json cleanup_2.json > issues_to_cleanup.json
- name: 'Clean Up Triage Labels'
if: |-
+1
View File
@@ -68,3 +68,4 @@ temp_agents/
# conductor extension and planning directories
conductor/
simulator_workspace_*
+1
View File
@@ -23,3 +23,4 @@ Thumbs.db
**/SKILL.md
packages/sdk/test-data/*.json
*.mdx
packages/vscode-ide-companion/NOTICES.txt
+111
View File
@@ -0,0 +1,111 @@
# Running User Simulation in Docker with External Knowledge Source
This guide explains how to run the User Simulator in a Docker environment while
mounting an external knowledge base. This setup allows the simulator to "learn"
from its interactions and persist that knowledge back to your host machine.
We have provided an automated script that handles the entire setup, execution,
and cleanup process.
## Prerequisites
- **Docker** installed and running.
- **Gemini API Key** (standard `AIza...` key).
- Local checkout of the `gemini-cli` repository.
## Execution via Automation Script (Recommended)
The easiest and most reliable way to run the simulation is using the provided
bash script. This script automatically:
1. Creates a uniquely timestamped workspace folder on your host.
2. Generates a global `settings.json` file to natively bypass the CLI's
interactive Folder Trust and Authentication dialogs.
3. Builds the sandbox image from your current branch.
4. Mounts the workspace and runs the container with `--init` to gracefully
handle termination (e.g., `Ctrl+C`).
### Running the Script
Ensure your API key is exported:
```bash
export GEMINI_API_KEY="AIzaSy..."
```
Run the script from the root of the repository:
```bash
# Uses the default prompt ("make a snake game in python")
./scripts/run_simulator_docker.sh
# Or, provide a custom prompt:
./scripts/run_simulator_docker.sh "create a simple react counter component"
```
## Manual Execution Breakdown
If you need to run the simulation manually, here is exactly what the automated
script does under the hood:
### 1. Prepare Workspace & Knowledge Source
```bash
WORKSPACE_DIR="/tmp/gemini_docker_workspace"
mkdir -p "$WORKSPACE_DIR"
touch "$WORKSPACE_DIR/knowledge.md"
chmod -R 777 "$WORKSPACE_DIR"
```
### 2. Bypass Interactive Startup Dialogs
To prevent the simulator from getting stuck on the initial Auth or Folder Trust
screens, generate a global `settings.json` file.
```bash
mkdir -p "$WORKSPACE_DIR/.gemini"
echo '{
"security": {
"auth": { "selectedType": "gemini-api-key" },
"folderTrust": { "enabled": false }
}
}' > "$WORKSPACE_DIR/.gemini/settings.json"
chmod 777 "$WORKSPACE_DIR/.gemini/settings.json"
```
### 3. Build the Image
```bash
GEMINI_SANDBOX=docker npm run build:sandbox -- -i gemini-cli-simulator:latest
```
### 4. Run the Container
Notice the `--init` flag (for `Ctrl+C` support) and the explicit mount mapping
the `settings.json` file into `/home/node/.gemini/` inside the container.
```bash
docker run -it --rm --init \
-v "$WORKSPACE_DIR:/workspace" \
-v "$WORKSPACE_DIR/.gemini/settings.json:/home/node/.gemini/settings.json" \
-w /workspace \
-e GEMINI_API_KEY="$GEMINI_API_KEY" \
-e GEMINI_DEBUG_LOG_FILE="/workspace/debug.log" \
gemini-cli-simulator:latest \
gemini --prompt-interactive "make a snake game in python" \
--approval-mode plan \
--simulate-user \
--knowledge-source "/workspace/knowledge.md"
```
## Verification
Once the simulation completes, verify the results in your workspace folder:
1. **Generated Code:** Check for project files (e.g., `snake.py`).
2. **Persistent Knowledge:** Check `knowledge.md`. You should see new rules
dynamically appended by the simulator.
3. **Logs:**
- `debug.log`: Detailed internal LLM decision logic.
- `interactions_<timestamp>.txt`: Raw screen scrape frames seen by the
simulator's "eyes".
+31
View File
@@ -18,6 +18,37 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.43.0 - 2026-05-22
- **Surgical Code Edits:** Steered Gemini models to prefer the `edit` tool for
surgical modifications, improving speed and precision
([#26480](https://github.com/google-gemini/gemini-cli/pull/26480) by
@aishaneeshah).
- **Session Export and Import:** Added the ability to export sessions to files
and import them via a new flag, facilitating session portability
([#26514](https://github.com/google-gemini/gemini-cli/pull/26514) by
@cocosheng-g).
- **Adaptive Token Estimation:** Introduced an adaptive token calculator for
more accurate content size estimation, enhancing context management efficiency
([#26888](https://github.com/google-gemini/gemini-cli/pull/26888) by
@joshualitt).
## Announcements: v0.42.0 - 2026-05-12
- **Auto Memory Inbox:** Introduced a new inbox flow for Auto Memory with a
canonical-patch contract for seamless skill management
([#26338](https://github.com/google-gemini/gemini-cli/pull/26338) by
@SandyTao520).
- **Gemma 4 by Default:** Enabled Gemma 4 models by default via the Gemini API
for all users
([#26307](https://github.com/google-gemini/gemini-cli/pull/26307) by
@Abhijit-2592).
- **Voice Mode Enhancements:** Added wave animations and privacy/compliance UX
warnings for the Gemini Live backend
([#26284](https://github.com/google-gemini/gemini-cli/pull/26284) by
@devr0306, [#26454](https://github.com/google-gemini/gemini-cli/pull/26454) by
@cocosheng-g).
## Announcements: v0.41.0 - 2026-05-05
- **Real-time Voice Mode:** Implemented real-time voice mode with cloud and
+198 -109
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.41.0
# Latest stable release: v0.43.0
Released: May 05, 2026
Released: May 22, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,119 +11,208 @@ npm install -g @google/gemini-cli
## Highlights
- **Real-time Voice Mode:** Introduced support for real-time voice interaction
with both cloud-based and local processing backends.
- **Enhanced Security:** Implemented mandatory workspace trust for headless
environments and secured the loading of `.env` configuration files.
- **Advanced Shell Validation:** Added a robust shell command validation layer
and a core tools allowlist to prevent unauthorized execution.
- **Improved Context Management:** Integrated a new `ContextManager` and
`AgentChatHistory` to provide more reliable and efficient session handling.
- **Auto-Memory Persistence:** Enabled the persistence of the auto-memory
scratchpad, allowing for seamless skill extraction across turns.
- **Surgical Code Edits:** Gemini models are now steered to prefer the `edit`
tool for surgical modifications, leading to faster and more precise code
updates.
- **Session Portability:** Introduced features to export active sessions to
files and import them later via a CLI flag, allowing for easier session
sharing and resumption.
- **Adaptive Token Estimation:** A new adaptive token calculator provides more
accurate content size measurements, optimizing context window usage and
reducing API overhead.
- **Improved UI Rendering:** Core tools now utilize native `ToolDisplay`
properties, fixing various UI rendering issues and improving the experience in
ACP-compliant IDEs.
- **Enhanced Agent Architecture:** Introduced `LocalSubagentProtocol` and
`RemoteSubagentProtocol` behind a unified `AgentProtocol`, laying the
groundwork for more complex multi-agent interactions.
## What's Changed
- chore(release): bump version to 0.41.0-nightly.20260423.gaa05b4583 by
@gemini-cli-robot in
[#25847](https://github.com/google-gemini/gemini-cli/pull/25847)
- fix(core): only show `list` suggestion if the partial input is empty by
@cynthialong0-0 in
[#25821](https://github.com/google-gemini/gemini-cli/pull/25821)
- feat(cli): secure .env loading and enforce workspace trust in headless mode by
@ehedlund in [#25814](https://github.com/google-gemini/gemini-cli/pull/25814)
- fix: fatal hard-crash on loop detection via unhandled AbortError by @hsm207 in
[#20108](https://github.com/google-gemini/gemini-cli/pull/20108)
- update package-lock.json by @ehedlund in
[#25876](https://github.com/google-gemini/gemini-cli/pull/25876)
- feat(core): enhance shell command validation and add core tools allowlist by
@galz10 in [#25720](https://github.com/google-gemini/gemini-cli/pull/25720)
- fix(ui): corrected background color check in user message components by
@devr0306 in [#25880](https://github.com/google-gemini/gemini-cli/pull/25880)
- perf(core): fix slow boot by fetching experiments and quota asynchronously by
@spencer426 in
[#25758](https://github.com/google-gemini/gemini-cli/pull/25758)
- feat(core,cli): add support for Gemma 4 models (experimental) by @Abhijit-2592
in [#25604](https://github.com/google-gemini/gemini-cli/pull/25604)
- update FatalUntrustedWorkspaceError message to include doc link by @ehedlund
in [#25874](https://github.com/google-gemini/gemini-cli/pull/25874)
- docs: add Gemini CLI course link to README by @JayadityaGit in
[#25925](https://github.com/google-gemini/gemini-cli/pull/25925)
- feat(repo): add gemini-cli-bot metrics and workflows by @gundermanc in
[#25888](https://github.com/google-gemini/gemini-cli/pull/25888)
- fix(cli): allow output redirection for cli commands by @spencer426 in
[#25894](https://github.com/google-gemini/gemini-cli/pull/25894)
- fix(core): fail closed in YOLO mode when shell parsing fails for restricted
rules by @ehedlund in
[#25935](https://github.com/google-gemini/gemini-cli/pull/25935)
- fix(cli-ui): revert backspace handling to fix Windows regression by @scidomino
in [#25941](https://github.com/google-gemini/gemini-cli/pull/25941)
- feat(voice): implement real-time voice mode with cloud and local backends by
- feat(core): steer model to use edit tool for surgical edits, fix a typo by
@aishaneeshah in
[#26480](https://github.com/google-gemini/gemini-cli/pull/26480)
- docs: clarify Auto Memory proposes memory updates and skills by @SandyTao520
in [#26527](https://github.com/google-gemini/gemini-cli/pull/26527)
- fix(core): reject numeric project IDs in GOOGLE_CLOUD_PROJECT (#24695) by
@Abhijit-2592 in
[#24174](https://github.com/google-gemini/gemini-cli/pull/24174)
- Changelog for v0.39.0 by @gemini-cli-robot in
[#25848](https://github.com/google-gemini/gemini-cli/pull/25848)
- feat(memory): persist auto-memory scratchpad for skill extraction by
@SandyTao520 in
[#25873](https://github.com/google-gemini/gemini-cli/pull/25873)
- fix(cli): add missing response key to custom theme text schema by @gaurav0107
in [#25822](https://github.com/google-gemini/gemini-cli/pull/25822)
- fix(cli): provide manual update command when automatic update fails by
[#26532](https://github.com/google-gemini/gemini-cli/pull/26532)
- fix(core): remove unsafe type assertion suppressions in error utils by
@himanshu748 in
[#19881](https://github.com/google-gemini/gemini-cli/pull/19881)
- fix(core): allow redirection in YOLO and AUTO_EDIT modes without sandboxing by
@galz10 in [#26542](https://github.com/google-gemini/gemini-cli/pull/26542)
- ci(release): build and attach unsigned macOS binaries to releases by @ruomengz
in [#26462](https://github.com/google-gemini/gemini-cli/pull/26462)
- fix(core): Fix chat corruption bug in context manager. by @joshualitt in
[#26534](https://github.com/google-gemini/gemini-cli/pull/26534)
- fix(cli): provide JSON output for AgentExecutionStopped in non-interactive
mode by @cynthialong0-0 in
[#26504](https://github.com/google-gemini/gemini-cli/pull/26504)
- feat(evals): add shell command safety evals by @akh64bit in
[#26528](https://github.com/google-gemini/gemini-cli/pull/26528)
- fix(core): handle invalid custom plans directory gracefully by @cynthialong0-0
in [#26560](https://github.com/google-gemini/gemini-cli/pull/26560)
- fix(acp): move tool explanation from thought stream to tool call content by
@sripasg in [#26554](https://github.com/google-gemini/gemini-cli/pull/26554)
- fix(a2a-server): Resolve race condition in tool completion waiting by @kschaab
in [#26568](https://github.com/google-gemini/gemini-cli/pull/26568)
- fix(cli): randomize sandbox container names by @Kkartik14 in
[#26014](https://github.com/google-gemini/gemini-cli/pull/26014)
- fix(core): Fix hysteresis in async context management pipelines. by
@joshualitt in
[#26452](https://github.com/google-gemini/gemini-cli/pull/26452)
- Tighten private Auto Memory patch allowlist by @SandyTao520 in
[#26535](https://github.com/google-gemini/gemini-cli/pull/26535)
- fix(cli): hide read-only settings scopes by @cvan20191 in
[#26249](https://github.com/google-gemini/gemini-cli/pull/26249)
- fix(ci): preserve executable bit for mac binaries by @ruomengz in
[#26600](https://github.com/google-gemini/gemini-cli/pull/26600)
- fix(cli): improve mcp list UX in untrusted folders by @Adib234 in
[#26457](https://github.com/google-gemini/gemini-cli/pull/26457)
- fix(core): prevent silent hang during OAuth auth on headless Linux by
@RhysSullivan in
[#26571](https://github.com/google-gemini/gemini-cli/pull/26571)
- Changelog for v0.42.0-preview.0 by @gemini-cli-robot in
[#26537](https://github.com/google-gemini/gemini-cli/pull/26537)
- ci: fix Argument list too long in triage workflows by @cocosheng-g in
[#26603](https://github.com/google-gemini/gemini-cli/pull/26603)
- refactor(cli): migrate core tools to native ToolDisplay property and fix UI
rendering by @mbleigh in
[#25186](https://github.com/google-gemini/gemini-cli/pull/25186)
- don't wrap args unnecessarily by @scidomino in
[#26599](https://github.com/google-gemini/gemini-cli/pull/26599)
- fix(core): preserve system PATH in Git environment to fix ENOENT (#25034) by
@cocosheng-g in
[#26052](https://github.com/google-gemini/gemini-cli/pull/26052)
- test(cli): add unit tests for restore ACP command (#23402) by @cocosheng-g in
[#26053](https://github.com/google-gemini/gemini-cli/pull/26053)
- fix(ui): better error messages for ECONNRESET and ETIMEDOUT by @devr0306 in
[#26059](https://github.com/google-gemini/gemini-cli/pull/26059)
- feat(core): wire up the new ContextManager and AgentChatHistory by @joshualitt
in [#25409](https://github.com/google-gemini/gemini-cli/pull/25409)
- fix(cli): ensure sandbox proxy cleanup and remove handler leaks by @ehedlund
in [#26065](https://github.com/google-gemini/gemini-cli/pull/26065)
- fix(cli): correct alternate buffer warning logic for JetBrains by @Adib234 in
[#26067](https://github.com/google-gemini/gemini-cli/pull/26067)
- fix(cli): make MCP ping optional in list command and use configured timeout by
[#26587](https://github.com/google-gemini/gemini-cli/pull/26587)
- fix(routing): fix resolveClassifierModel argument mismatch in
ApprovalModeStrategy by @danielweis in
[#26658](https://github.com/google-gemini/gemini-cli/pull/26658)
- docs: add vi mode shortcuts and clarify MCP/custom sandbox setup by
@chrisjcthomas in
[#23853](https://github.com/google-gemini/gemini-cli/pull/23853)
- fix(ux): fixed issue with transcribed text not showing after releasing space
by @devr0306 in
[#26609](https://github.com/google-gemini/gemini-cli/pull/26609)
- ci: fix json parsing in scheduled triage workflow by @cocosheng-g in
[#26656](https://github.com/google-gemini/gemini-cli/pull/26656)
- fix(cli): hide /memory add subcommand when memoryV2 is enabled by @SandyTao520
in [#26605](https://github.com/google-gemini/gemini-cli/pull/26605)
- fix: prevent false command conflicts when launching from home directory by
@Br1an67 in [#23069](https://github.com/google-gemini/gemini-cli/pull/23069)
- fix(core): cache model routing decision in LocalAgentExecutor by @akh64bit in
[#26548](https://github.com/google-gemini/gemini-cli/pull/26548)
- Changelog for v0.42.0-preview.2 by @gemini-cli-robot in
[#26597](https://github.com/google-gemini/gemini-cli/pull/26597)
- skip broken test by @scidomino in
[#26705](https://github.com/google-gemini/gemini-cli/pull/26705)
- feat: export session to file and import via flag by @cocosheng-g in
[#26514](https://github.com/google-gemini/gemini-cli/pull/26514)
- Feat: Add Machine Hostname to CLI interface by @M-DEV-1 in
[#25637](https://github.com/google-gemini/gemini-cli/pull/25637)
- docs(extensions): refactor releasing guide and add update mechanisms by
@ruomengz in [#26595](https://github.com/google-gemini/gemini-cli/pull/26595)
- fix(ci): fix maintainer identification in lifecycle manager by @gundermanc in
[#26706](https://github.com/google-gemini/gemini-cli/pull/26706)
- fix(ui): added quotes around session id in resume tip by @devr0306 in
[#26669](https://github.com/google-gemini/gemini-cli/pull/26669)
- Changelog for v0.41.0 by @gemini-cli-robot in
[#26670](https://github.com/google-gemini/gemini-cli/pull/26670)
- refactor(core): agent session protocol changes by @adamfweidman in
[#26661](https://github.com/google-gemini/gemini-cli/pull/26661)
- fix(context): implement loose boundary policy for gc backstop. by @joshualitt
in [#26594](https://github.com/google-gemini/gemini-cli/pull/26594)
- fix(core): throw explicit error on dropped tool responses by @aishaneeshah in
[#26668](https://github.com/google-gemini/gemini-cli/pull/26668)
- fix: resolve "function response turn must come immediately after function
call" error by @danielweis in
[#26691](https://github.com/google-gemini/gemini-cli/pull/26691)
- fix(core): resolve parallel tool call streaming ID collision by @aishaneeshah
in [#26646](https://github.com/google-gemini/gemini-cli/pull/26646)
- feat(core): add LocalSubagentProtocol behind AgentProtocol by @adamfweidman in
[#25302](https://github.com/google-gemini/gemini-cli/pull/25302)
- fix(cli): remove noisy theme registration logs from terminal by @JayadityaGit
in [#25858](https://github.com/google-gemini/gemini-cli/pull/25858)
- ci: implement codebase-aware effort level triage by @cocosheng-g in
[#26666](https://github.com/google-gemini/gemini-cli/pull/26666)
- feat(acp/core): prefix tool call IDs with tool names to support tool rendering
in ACP compliant IDEs. by @sripasg in
[#26676](https://github.com/google-gemini/gemini-cli/pull/26676)
- fix(mcp): treat GET 404 as 405 in StreamableHTTPClientTransport by @krishdef7
in [#24847](https://github.com/google-gemini/gemini-cli/pull/24847)
- feat(core): add RemoteSubagentProtocol behind AgentProtocol by @adamfweidman
in [#25303](https://github.com/google-gemini/gemini-cli/pull/25303)
- feat(context): Improvements to the snapshotter. by @joshualitt in
[#26655](https://github.com/google-gemini/gemini-cli/pull/26655)
- fix(context): Change snapshotter model config. by @joshualitt in
[#26745](https://github.com/google-gemini/gemini-cli/pull/26745)
- fix(cli): allow installing extensions from ssh repo by @danielmundi in
[#26274](https://github.com/google-gemini/gemini-cli/pull/26274)
- fix(cli): prevent duplicate SessionStart systemMessage render by @dimssu in
[#25827](https://github.com/google-gemini/gemini-cli/pull/25827)
- fix(cli/acp): prevent infinite thought loop in ACP mode by disablig
nextSpeakerCheck by @sripasg in
[#26874](https://github.com/google-gemini/gemini-cli/pull/26874)
- fix(cli): use static tool name in confirmation prompt to avoid parsing errors
by @cocosheng-g in
[#26866](https://github.com/google-gemini/gemini-cli/pull/26866)
- fix(routing): Refactor tool turn handling for the conversation history in
NumericalClassifierStrategy to prevent 400 Bad Request by @danielweis in
[#26761](https://github.com/google-gemini/gemini-cli/pull/26761)
- fix(core): handle malformed projects.json in ProjectRegistry by @cocosheng-g
in [#26885](https://github.com/google-gemini/gemini-cli/pull/26885)
- fix(ui): added a gutter width to the input prompt width calculation by
@devr0306 in [#26882](https://github.com/google-gemini/gemini-cli/pull/26882)
- fix: prevent EISDIR crash when customIgnoreFilePaths contains directories
(#19868) by @suhaan-24 in
[#19898](https://github.com/google-gemini/gemini-cli/pull/19898)
- revert 6b9b778d821728427eea07b1b97ba07378137d0b by @danielweis in
[#26893](https://github.com/google-gemini/gemini-cli/pull/26893)
- Fix/vscode run current file ts by @Neil-N4 in
[#22894](https://github.com/google-gemini/gemini-cli/pull/22894)
- Allow Enter to select session while in search mode in /resume by @f-pieri in
[#21523](https://github.com/google-gemini/gemini-cli/pull/21523)
- fix(core): ignore .pak and .rpa game archive formats by default by @Eswar809
in [#26884](https://github.com/google-gemini/gemini-cli/pull/26884)
- fix(cli): enable adk non-interactive session by @adamfweidman in
[#26895](https://github.com/google-gemini/gemini-cli/pull/26895)
- fix(cli): restore resume for legacy sessions by @KurodaKayn in
[#26577](https://github.com/google-gemini/gemini-cli/pull/26577)
- fix: respect explicit model selection after Flash quota exhaustion (#26759) by
@cocosheng-g in
[#26068](https://github.com/google-gemini/gemini-cli/pull/26068)
- fix(core): better error message for failed cloudshell-gca auth by @devr0306 in
[#26079](https://github.com/google-gemini/gemini-cli/pull/26079)
- feat(cli): provide manual session UUID via command line arg by @cocosheng-g in
[#26060](https://github.com/google-gemini/gemini-cli/pull/26060)
- Changelog for v0.40.0-preview.2 by @gemini-cli-robot in
[#25846](https://github.com/google-gemini/gemini-cli/pull/25846)
- (docs) update sandboxing documentation by @g-samroberts in
[#25930](https://github.com/google-gemini/gemini-cli/pull/25930)
- fix(core): enforce parallel task tracker updates by @anj-s in
[#24477](https://github.com/google-gemini/gemini-cli/pull/24477)
- Update policy so transient errors are not marked terminal by @DavidAPierce in
[#26066](https://github.com/google-gemini/gemini-cli/pull/26066)
- Implement bot that performs time-series metric analysis and suggests repo
management improvements by @gundermanc in
[#25945](https://github.com/google-gemini/gemini-cli/pull/25945)
- fix(core): handle non-string model flags in resolution by @Adib234 in
[#26069](https://github.com/google-gemini/gemini-cli/pull/26069)
- fix(ux): added error message for ENOTDIR by @devr0306 in
[#26128](https://github.com/google-gemini/gemini-cli/pull/26128)
- Changelog for v0.40.0-preview.3 by @gemini-cli-robot in
[#25904](https://github.com/google-gemini/gemini-cli/pull/25904)
- fix(cli): prevent ACP stdout pollution from SessionEnd hooks by @cocosheng-g
in [#26125](https://github.com/google-gemini/gemini-cli/pull/26125)
- feat(cli): support boolean and number casting for env vars in settings.json by
[#26872](https://github.com/google-gemini/gemini-cli/pull/26872)
- feat(context): Introduce adaptive token calculator to more accurately
calculate content sizes. by @joshualitt in
[#26888](https://github.com/google-gemini/gemini-cli/pull/26888)
- chore: update checkout action configuration in workflows by @galz10 in
[#26897](https://github.com/google-gemini/gemini-cli/pull/26897)
- fix (telemetry): inject quota_project_id to prevent fallback to default oauth
client by @TNTCompany in
[#26698](https://github.com/google-gemini/gemini-cli/pull/26698)
- Exclude extension context from skill extraction agent by @SandyTao520 in
[#26879](https://github.com/google-gemini/gemini-cli/pull/26879)
- Enable NumericalRouter when using dynamic model configs by @kevinjwang1 in
[#26929](https://github.com/google-gemini/gemini-cli/pull/26929)
- ci: actively triage missing priority labels and intelligently clean up
conflicting labels by @cocosheng-g in
[#26865](https://github.com/google-gemini/gemini-cli/pull/26865)
- refactor(core): introduce SubagentState enum for progress by @adamfweidman in
[#26934](https://github.com/google-gemini/gemini-cli/pull/26934)
- fix(ci): replace brittle --no-tag with explicit staging-tmp tag by @scidomino
in [#26940](https://github.com/google-gemini/gemini-cli/pull/26940)
- Incremental refactor repo agent towards skills-based composition by
@gundermanc in
[#26717](https://github.com/google-gemini/gemini-cli/pull/26717)
- fix(ui): fixed line wrap padding for selection lists by @devr0306 in
[#26944](https://github.com/google-gemini/gemini-cli/pull/26944)
- fix(core): update read_file schema for v1 compatibility (#22183) by
@cocosheng-g in
[#26118](https://github.com/google-gemini/gemini-cli/pull/26118)
- fix(cli): preserve Request headers in DevTools activity logger by @Adib234 in
[#26078](https://github.com/google-gemini/gemini-cli/pull/26078)
- fix(patch): cherry-pick 2194da2 to release/v0.41.0-preview.0-pr-26153 to patch
version v0.41.0-preview.0 and create version 0.41.0-preview.1 by
@gemini-cli-robot in
[#26269](https://github.com/google-gemini/gemini-cli/pull/26269)
- fix(patch): cherry-pick 1d72a12 to release/v0.41.0-preview.1-pr-26479 to patch
version v0.41.0-preview.1 and create version 0.41.0-preview.2 by
@gemini-cli-robot in
[#26508](https://github.com/google-gemini/gemini-cli/pull/26508)
- fix(patch): cherry-pick 7cc19c2 to release/v0.41.0-preview.2-pr-26507 to patch
version v0.41.0-preview.2 and create version 0.41.0-preview.3 by
@gemini-cli-robot in
[#26530](https://github.com/google-gemini/gemini-cli/pull/26530)
[#26922](https://github.com/google-gemini/gemini-cli/pull/26922)
- fix(ci): configure git remote with token for authentication by @scidomino in
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
- fix(patch): cherry-pick 85566a7 to release/v0.43.0-preview.0-pr-27073
[CONFLICTS] by @gemini-cli-robot in
[#27256](https://github.com/google-gemini/gemini-cli/pull/27256)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.40.1...v0.41.0
https://github.com/google-gemini/gemini-cli/compare/v0.42.0...v0.43.0
+202 -227
View File
@@ -1,6 +1,6 @@
# Preview release: v0.42.0-preview.2
# Preview release: v0.44.0-preview.0
Released: May 06, 2026
Released: May 22, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -13,233 +13,208 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Auto Memory Enhancements:** Introduced an Auto Memory inbox flow with a
canonical-patch contract for better memory management.
- **Improved Voice Mode:** Added a wave animation, microphone icon updates, and
privacy/compliance UX warnings for the Gemini Live backend.
- **New CLI Commands & Flags:** Added a `--delete` flag to the `/exit` command
for session deletion, a `list` subcommand to `/commands`, and a `/bug-memory`
command for heap snapshots.
- **Expanded Model Support:** Gemma 4 models are now enabled by default via the
Gemini API.
- **Enhanced Core Resilience:** Improved API resilience with reduced timeouts,
automatic retries for stream errors, and better handling of invalid stream
events.
- **Simplified Modes:** Merged existing Auto modes into a single, unified Auto
mode for a more streamlined user experience.
- **Enhanced Agent Registration:** Improved agent registration logic to
prioritize project-specific agents using a first-wins strategy.
- **New Developer Skills:** Introduced `agent-tui` and `tui-tester` skills to
empower developers with better terminal UI testing and automation
capabilities.
- **Expanded Editor Support:** Added support for Sublime Text and Emacs Client,
providing more flexibility for external editing tasks.
- **Session Management:** Added new session invocation types
(`LocalSessionInvocation`, `RemoteSessionInvocation`) and improved context
recovery across sessions.
## What's Changed
- fix(cli): prevent automatic updates from switching to less stable channels in
[#26132](https://github.com/google-gemini/gemini-cli/pull/26132)
- chore(release): bump version to 0.42.0-nightly.20260428.g59b2dea0e in
[#26142](https://github.com/google-gemini/gemini-cli/pull/26142)
- fix(cli): pass node arguments via NODE_OPTIONS during relaunch to support SEA
in [#26130](https://github.com/google-gemini/gemini-cli/pull/26130)
- fix(cli): handle DECKPAM keypad Enter sequences in terminal in
[#26092](https://github.com/google-gemini/gemini-cli/pull/26092)
- docs(cli): point plan-mode session retention to actual /settings labels in
[#25978](https://github.com/google-gemini/gemini-cli/pull/25978)
- fix(core): add missing oauth fields support in subagent parsing in
[#26141](https://github.com/google-gemini/gemini-cli/pull/26141)
- fix(core): disconnect extension-backed MCP clients in stopExtension in
[#26136](https://github.com/google-gemini/gemini-cli/pull/26136)
- Update documentation workflows with workspace trust in
[#26150](https://github.com/google-gemini/gemini-cli/pull/26150)
- refactor(acp): modularize monolithic acpClient into specialized files in
[#26143](https://github.com/google-gemini/gemini-cli/pull/26143)
- test: fix failures due to antigravity environment leakage in
[#26162](https://github.com/google-gemini/gemini-cli/pull/26162)
- fix(core): add explicit empty log guard in A2A pushMessage in
[#26198](https://github.com/google-gemini/gemini-cli/pull/26198)
- feat(cli): add --delete flag to /exit command for session deletion in
[#19332](https://github.com/google-gemini/gemini-cli/pull/19332)
- test(core): add regression test for issue for ToolConfirmationResponse in
[#26194](https://github.com/google-gemini/gemini-cli/pull/26194)
- Add the ability to @ mention the gemini robot. in
[#26207](https://github.com/google-gemini/gemini-cli/pull/26207)
- test(evals): add EvalMetadata JSDoc annotations to older tests in
[#26147](https://github.com/google-gemini/gemini-cli/pull/26147)
- fix(core): reduce default API timeout to 60s and enable retries for undici
timeouts in [#26191](https://github.com/google-gemini/gemini-cli/pull/26191)
- fix(core): distinguish fallback chains and fix maxAttempts for auto vs
explicit model selection in
[#26163](https://github.com/google-gemini/gemini-cli/pull/26163)
- fix(cli): handle InvalidStream event gracefully without throwing in
[#26218](https://github.com/google-gemini/gemini-cli/pull/26218)
- ci(github-actions): switch to github app token and fix bot self-trigger in
[#26223](https://github.com/google-gemini/gemini-cli/pull/26223)
- Respect logPrompts flag for logging sensitive fields in
[#26153](https://github.com/google-gemini/gemini-cli/pull/26153)
- fix: correct API key validation logic in handleApiKeySubmit in
[#25453](https://github.com/google-gemini/gemini-cli/pull/25453)
- fix(agent): prevent exit_plan_mode from being called via shell in
[#26230](https://github.com/google-gemini/gemini-cli/pull/26230)
- # Fix: Inconsistent Case-Sensitivity in GrepTool in [#26235](https://github.com/google-gemini/gemini-cli/pull/26235)
- docs(core): add automated gemma setup guide in
[#26233](https://github.com/google-gemini/gemini-cli/pull/26233)
- Allow non-https proxy urls to support container environments in
[#26234](https://github.com/google-gemini/gemini-cli/pull/26234)
- fix(bot): productivity and backlog optimizations in
[#26236](https://github.com/google-gemini/gemini-cli/pull/26236)
- refactor(acp): delegate prompt turn processing logic to GeminiClient in
[#26222](https://github.com/google-gemini/gemini-cli/pull/26222)
- fix(cli): refine platform-specific undo/redo and smart bubbling for WSL in
[#26202](https://github.com/google-gemini/gemini-cli/pull/26202)
- fix: suppress duplicate extension warnings during startup in
[#26208](https://github.com/google-gemini/gemini-cli/pull/26208)
- fix(cli): use byte length instead of string length for readStdin size limits
in [#26224](https://github.com/google-gemini/gemini-cli/pull/26224)
- fix(ui): made shell tool header wrap on Ctrl+O in
[#26229](https://github.com/google-gemini/gemini-cli/pull/26229)
- Changelog for v0.41.0-preview.0 in
[#26244](https://github.com/google-gemini/gemini-cli/pull/26244)
- Skip binary CLI relaunch in
[#26261](https://github.com/google-gemini/gemini-cli/pull/26261)
- fix(cli): do not override GOOGLE_CLOUD_PROJECT in Cloud Shell when using
Vertex AI in [#24455](https://github.com/google-gemini/gemini-cli/pull/24455)
- docs(cli): add skill discovery troubleshooting checklist to tutorial in
[#26018](https://github.com/google-gemini/gemini-cli/pull/26018)
- docs(policy-engine): link to tools reference for tool names and args in
[#22081](https://github.com/google-gemini/gemini-cli/pull/22081)
- Fix posting invalid response to a comment in
[#26266](https://github.com/google-gemini/gemini-cli/pull/26266)
- fix(cli): prevent informational logs from polluting json output in
[#26264](https://github.com/google-gemini/gemini-cli/pull/26264)
- feat(ui): added microphone and updated placeholder for voice mode in
[#26270](https://github.com/google-gemini/gemini-cli/pull/26270)
- feat(cli): Add 'list' subcommand to '/commands' in
[#22324](https://github.com/google-gemini/gemini-cli/pull/22324)
- fix(core): ensure tool output cleanup on session deletion for legacy files in
[#26263](https://github.com/google-gemini/gemini-cli/pull/26263)
- Docs: Update Agent Skills documentation in
[#22388](https://github.com/google-gemini/gemini-cli/pull/22388)
- test(acp): add missing coverage for extensions command error paths in
[#25313](https://github.com/google-gemini/gemini-cli/pull/25313)
- Changelog for v0.40.0 in
[#26245](https://github.com/google-gemini/gemini-cli/pull/26245)
- fix: report AgentExecutionBlocked in non-interactive programmatic modes in
[#26262](https://github.com/google-gemini/gemini-cli/pull/26262)
- feat(extensions): add 'delete' as an alias for /extensions uninstall in
[#25660](https://github.com/google-gemini/gemini-cli/pull/25660)
- fix(core): silently skip GEMINI.md paths that are directories (EISDIR) in
[#25662](https://github.com/google-gemini/gemini-cli/pull/25662)
- fix(ci): checkout PR branch instead of main in bot workflow in
[#26289](https://github.com/google-gemini/gemini-cli/pull/26289)
- fix(cli): use resolved sandbox state for auto-update check in
[#26285](https://github.com/google-gemini/gemini-cli/pull/26285)
- # Metrics Integrity & Standardized Reporting (BT-01) in [#26240](https://github.com/google-gemini/gemini-cli/pull/26240)
- Add Star History section to README in
[#26290](https://github.com/google-gemini/gemini-cli/pull/26290)
- Add Star History section to README in
[#26308](https://github.com/google-gemini/gemini-cli/pull/26308)
- Remove Star History section from README in
[#26309](https://github.com/google-gemini/gemini-cli/pull/26309)
- test(evals): add behavioral eval for file creation and write_file tool
selection in [#26292](https://github.com/google-gemini/gemini-cli/pull/26292)
- feat(config): enable Gemma 4 models by default via Gemini API in
[#26307](https://github.com/google-gemini/gemini-cli/pull/26307)
- fix(cli): insert voice transcription at cursor position instead of ap… in
[#26287](https://github.com/google-gemini/gemini-cli/pull/26287)
- fix(ui): fix issue with box edges in
[#26148](https://github.com/google-gemini/gemini-cli/pull/26148)
- fix(cli): respect .env override for GOOGLE_CLOUD_PROJECT in
[#26288](https://github.com/google-gemini/gemini-cli/pull/26288)
- fix(ci): robust version checking in release verification in
[#26337](https://github.com/google-gemini/gemini-cli/pull/26337)
- fix(cli): enable daemon relaunch in binary and bundle keytar in
[#26333](https://github.com/google-gemini/gemini-cli/pull/26333)
- fix(core): discourage unprompted git add . in prompt snippets in
[#26220](https://github.com/google-gemini/gemini-cli/pull/26220)
- feat(ui): added wave animation for voice mode in
[#26284](https://github.com/google-gemini/gemini-cli/pull/26284)
- fix(cli): prevent Escape from clearing input buffer (#17083) in
[#26339](https://github.com/google-gemini/gemini-cli/pull/26339)
- fix(cli): undeprecate --prompt and correct positional query docs in
[#26329](https://github.com/google-gemini/gemini-cli/pull/26329)
- Metrics updates in
[#26348](https://github.com/google-gemini/gemini-cli/pull/26348)
- fix(core): remove "System: Please continue." injection on InvalidStream events
in [#26340](https://github.com/google-gemini/gemini-cli/pull/26340)
- docs(policy-engine): add tool argument keys reference and shell policy
cross-links in
[#25292](https://github.com/google-gemini/gemini-cli/pull/25292)
- fix(cli): resolve Ghostty/raw-mode False Cancellation in oauth flow in
[#25026](https://github.com/google-gemini/gemini-cli/pull/25026)
- fix(core): reset session-scoped state on resumption in
[#26342](https://github.com/google-gemini/gemini-cli/pull/26342)
- Fix bulk of remaining issues with generalist profile in
[#26073](https://github.com/google-gemini/gemini-cli/pull/26073)
- fix(core): make subagents aware of active approval modes in
[#23608](https://github.com/google-gemini/gemini-cli/pull/23608)
- fix(acp): resolve agent mode disconnect and improve mode awareness in
[#26332](https://github.com/google-gemini/gemini-cli/pull/26332)
- docs(sdk): add JSDoc to exported interfaces in packages/sdk/src/types.ts in
[#26441](https://github.com/google-gemini/gemini-cli/pull/26441)
- perf: skip redundant GEMINI.md loading in partialConfig in
[#26443](https://github.com/google-gemini/gemini-cli/pull/26443)
- Enhance React guidelines in
[#22667](https://github.com/google-gemini/gemini-cli/pull/22667)
- feat(core): reinforce Inquiry constraints to prevent unauthorized changes in
[#26310](https://github.com/google-gemini/gemini-cli/pull/26310)
- revert: fix(ci): robust version checking in release verification (#26337) in
[#26450](https://github.com/google-gemini/gemini-cli/pull/26450)
- refactor(UI): created constants file for ThemeDialog in
[#26446](https://github.com/google-gemini/gemini-cli/pull/26446)
- docs: fix GitHub capitalization in releases guide in
[#26379](https://github.com/google-gemini/gemini-cli/pull/26379)
- fix(cli): ensure branch indicator updates in sub-directories and worktrees in
[#26330](https://github.com/google-gemini/gemini-cli/pull/26330)
- feat: add minimal V8 heap snapshot utility for memory diagnostics in
[#26440](https://github.com/google-gemini/gemini-cli/pull/26440)
- fix(hooks): preserve non-text parts in fromHookLLMRequest in
[#26275](https://github.com/google-gemini/gemini-cli/pull/26275)
- fix(cli): allow early stdout when config is undefined in
[#26453](https://github.com/google-gemini/gemini-cli/pull/26453)
- fix(cli)#21297: clear skills consent dialog before reload in
[#26431](https://github.com/google-gemini/gemini-cli/pull/26431)
- fix(cli): render LaTeX-style output as Unicode in the TUI in
[#25802](https://github.com/google-gemini/gemini-cli/pull/25802)
- fix(core): use close event instead of exit in child_process fallback in
[#25695](https://github.com/google-gemini/gemini-cli/pull/25695)
- feat(voice): add privacy and compliance UX warning for Gemini Live backend in
[#26454](https://github.com/google-gemini/gemini-cli/pull/26454)
- feat(memory): add Auto Memory inbox flow with canonical-patch contract in
[#26338](https://github.com/google-gemini/gemini-cli/pull/26338)
- test(cleanup): fix temporary directory leaks in test suites in
[#26217](https://github.com/google-gemini/gemini-cli/pull/26217)
- feat: add ignoreLocalEnv setting and --ignore-env flag (#2493) in
[#26445](https://github.com/google-gemini/gemini-cli/pull/26445)
- docs(sdk): add JSDoc to all exported interfaces and types in
[#26277](https://github.com/google-gemini/gemini-cli/pull/26277)
- feat(cli): improve /agents refresh logging in
[#26442](https://github.com/google-gemini/gemini-cli/pull/26442)
- Fix: make Dockerfile self-contained with multi-stage build in
[#24277](https://github.com/google-gemini/gemini-cli/pull/24277)
- fix(core): filter unsupported multimodal types from tool responses in
[#26352](https://github.com/google-gemini/gemini-cli/pull/26352)
- fix(core): properly format markdown in AskUser tool by unescaping newlines in
[#26349](https://github.com/google-gemini/gemini-cli/pull/26349)
- feat(bot): add actions spend metric script in
[#26463](https://github.com/google-gemini/gemini-cli/pull/26463)
- feat(cli): add /bug-memory command and auto-capture heap snapshot in /bug in
[#25639](https://github.com/google-gemini/gemini-cli/pull/25639)
- fix(cli): make SkillInboxDialog fit and scroll in alternate buffer in
[#26455](https://github.com/google-gemini/gemini-cli/pull/26455)
- Robust Scale-Safe Lifecycle Consolidation in
[#26355](https://github.com/google-gemini/gemini-cli/pull/26355)
- fix(ci): respect exempt labels when closing stale items in
[#26475](https://github.com/google-gemini/gemini-cli/pull/26475)
- fix(cli): use os.homedir() for home directory warning check in
[#25890](https://github.com/google-gemini/gemini-cli/pull/25890)
- fix(a2a-server): resolve tool approval race condition and improve status
reporting in [#26479](https://github.com/google-gemini/gemini-cli/pull/26479)
- fix(cli): prevent settings dialog border clipping using maxHeight in
[#26507](https://github.com/google-gemini/gemini-cli/pull/26507)
- feat: allow queuing messages during compression (#24071) in
[#26506](https://github.com/google-gemini/gemini-cli/pull/26506)
- fix(core): retry on ERR_STREAM_PREMATURE_CLOSE errors in
[#26519](https://github.com/google-gemini/gemini-cli/pull/26519)
- fix(core): Minor fixes for generalist profile. in
[#26357](https://github.com/google-gemini/gemini-cli/pull/26357)
- chore(release): bump version to 0.44.0-nightly.20260512.g022e8baef by
@gemini-cli-robot in
[#26957](https://github.com/google-gemini/gemini-cli/pull/26957)
- Changelog for v0.42.0 by @gemini-cli-robot in
[#26958](https://github.com/google-gemini/gemini-cli/pull/26958)
- Refactor: Eliminate `no-unsafe-return` suppressions via strict type validation
by @M-DEV-1 in
[#20668](https://github.com/google-gemini/gemini-cli/pull/20668)
- Changelog for v0.43.0-preview.0 by @gemini-cli-robot in
[#26959](https://github.com/google-gemini/gemini-cli/pull/26959)
- feat(core): change agent registration to first-wins and prioritize project by
@adamfweidman in
[#26953](https://github.com/google-gemini/gemini-cli/pull/26953)
- feat(cli): merge Auto modes into a single Auto mode by @DavidAPierce in
[#26714](https://github.com/google-gemini/gemini-cli/pull/26714)
- fix(core): preserve OAuth refresh tokens during rotation and retrieval by
@cocosheng-g in
[#26924](https://github.com/google-gemini/gemini-cli/pull/26924)
- fix(cli): allow keychain auth for --list-sessions and non-interactive mode by
@cocosheng-g in
[#26921](https://github.com/google-gemini/gemini-cli/pull/26921)
- fix(core): handle EISDIR on virtual drives in memory discovery by @cocosheng-g
in [#26985](https://github.com/google-gemini/gemini-cli/pull/26985)
- fix(cli): auto-approve shell redirections in AUTO_EDIT mode by @cocosheng-g in
[#27003](https://github.com/google-gemini/gemini-cli/pull/27003)
- ci: suppress bot comments during standard triage maintenance by @cocosheng-g
in [#27006](https://github.com/google-gemini/gemini-cli/pull/27006)
- fix(core): refresh MCP OAuth token usage after re-auth by @sahilkirad in
[#26312](https://github.com/google-gemini/gemini-cli/pull/26312)
- fix(ui): clamped table column widths by @devr0306 in
[#26991](https://github.com/google-gemini/gemini-cli/pull/26991)
- fix(core): isolate subagent thread context by @akh64bit in
[#26449](https://github.com/google-gemini/gemini-cli/pull/26449)
- chore: add execution permission to scripts/review.sh by @scidomino in
[#27009](https://github.com/google-gemini/gemini-cli/pull/27009)
- fix(core): made context files append instead of replace by @devr0306 in
[#26950](https://github.com/google-gemini/gemini-cli/pull/26950)
- fix: add system PATH fallback for ripgrep resolution (#26777) by @cocosheng-g
in [#26868](https://github.com/google-gemini/gemini-cli/pull/26868)
- chore: clean up launched memory features by @SandyTao520 in
[#26941](https://github.com/google-gemini/gemini-cli/pull/26941)
- fix(core): throttle shell text output and bound live UI buffer by
@emersonbusson in
[#26955](https://github.com/google-gemini/gemini-cli/pull/26955)
- fix(cli): don't crash when an @-mention captures a non-path blob by @ifitisit
in [#25980](https://github.com/google-gemini/gemini-cli/pull/25980)
- fix(core): ensure stable fallback for restricted preview models by @galz10 in
[#26999](https://github.com/google-gemini/gemini-cli/pull/26999)
- feat(core): expose RAG snippets to local log file for debugging by @spencer426
in [#27016](https://github.com/google-gemini/gemini-cli/pull/27016)
- fix(acp/auth): prevent conflicting credentials on enterprise gateways and
support optional API keys natively by @sripasg in
[#27021](https://github.com/google-gemini/gemini-cli/pull/27021)
- fix(core): respect NO_PROXY for network-based MCP servers by @cocosheng-g in
[#27012](https://github.com/google-gemini/gemini-cli/pull/27012)
- fix(cli): resolve permission denied in sandbox on NixOS and other distros by
@cocosheng-g in
[#27004](https://github.com/google-gemini/gemini-cli/pull/27004)
- fix(ui): preserve new line at the end of edit window by @devr0306 in
[#27057](https://github.com/google-gemini/gemini-cli/pull/27057)
- fix(core): ensure Vertex AI sets hasAccessToPreviewModels and remove
aggressive 404 fallback revocation by @galz10 in
[#27067](https://github.com/google-gemini/gemini-cli/pull/27067)
- fix(core): ensure stable admin settings comparison across IPC to prevent
restart loop by @DavidAPierce in
[#27066](https://github.com/google-gemini/gemini-cli/pull/27066)
- fix(deps): update vulnerable dependencies by @scidomino in
[#27062](https://github.com/google-gemini/gemini-cli/pull/27062)
- fix(core): resolve EISDIR errors during file processing (#21527) by @ProthamD
in [#27041](https://github.com/google-gemini/gemini-cli/pull/27041)
- docs(extensions): clarify env var sanitization policy for MCP and ext… by
@galz10 in [#22854](https://github.com/google-gemini/gemini-cli/pull/22854)
- fix(ui): add ENAMETOOLONG and ENOTDIR to exceptions for file parsing errors by
@devr0306 in [#27069](https://github.com/google-gemini/gemini-cli/pull/27069)
- fix(cli): explicitly clear entrypoint when spawning sandbox container by
@cocosheng-g in
[#27059](https://github.com/google-gemini/gemini-cli/pull/27059)
- docs: update sandbox image command by @sjhddh in
[#26774](https://github.com/google-gemini/gemini-cli/pull/26774)
- fix(core): externalize https-proxy-agent to fix proxy support by @sotokisehiro
in [#26361](https://github.com/google-gemini/gemini-cli/pull/26361)
- security: update dependencies to fix critical and high vulnerabilities by
@scidomino in [#27077](https://github.com/google-gemini/gemini-cli/pull/27077)
- Fix/web fetch ctrl c abort by @ProthamD in
[#24320](https://github.com/google-gemini/gemini-cli/pull/24320)
- fix(core): add aliases and thinking config for gemini-3.1 models by
@anishs1207 in
[#27007](https://github.com/google-gemini/gemini-cli/pull/27007)
- fix(core): use hasAccessToPreview for auto model resolution and fix
disappearing models by @DavidAPierce in
[#27112](https://github.com/google-gemini/gemini-cli/pull/27112)
- feat(core): add adk.agentSessionSubagentEnabled flag by @adamfweidman in
[#26947](https://github.com/google-gemini/gemini-cli/pull/26947)
- fix(core): enforce compile-time exhaustiveness in content-utils by
@adamfweidman in
[#27207](https://github.com/google-gemini/gemini-cli/pull/27207)
- feat(skills): add agent-tui and tui-tester skills by @adamfweidman in
[#27121](https://github.com/google-gemini/gemini-cli/pull/27121)
- fix(context): Fix snapshot recovery across sessions. by @joshualitt in
[#26939](https://github.com/google-gemini/gemini-cli/pull/26939)
- fix(core): add unit tests for stableStringify by @devr0306 in
[#27212](https://github.com/google-gemini/gemini-cli/pull/27212)
- fix(core): prefer pwsh.exe over Windows PowerShell 5.1 (#25859) by @kaluchi in
[#25900](https://github.com/google-gemini/gemini-cli/pull/25900)
- feat(core): add LocalSessionInvocation by @adamfweidman in
[#26665](https://github.com/google-gemini/gemini-cli/pull/26665)
- refactor: decouple auto model description and configuration from
releaseChannel by @danielweis in
[#27227](https://github.com/google-gemini/gemini-cli/pull/27227)
- fix(core): prevent isBinary false-positive on Windows PTY streams by
@TirthNaik-99 in
[#26565](https://github.com/google-gemini/gemini-cli/pull/26565)
- fix(cli): Prevent unmapped keys in Vim Normal mode from inserting text into
prompt Input. by @Rajeshpatel07 in
[#25139](https://github.com/google-gemini/gemini-cli/pull/25139)
- fix(a2a-server): Implement default policy loading for parity with CLI by
@kschaab in [#27073](https://github.com/google-gemini/gemini-cli/pull/27073)
- feat(core): add RemoteSessionInvocation by @adamfweidman in
[#26937](https://github.com/google-gemini/gemini-cli/pull/26937)
- fix: allow configured MCP servers in non-interactive mode by @cocosheng-g in
[#27215](https://github.com/google-gemini/gemini-cli/pull/27215)
- fix(core): add exception handling to migrateFromFileStorage by @devr0306 in
[#27229](https://github.com/google-gemini/gemini-cli/pull/27229)
- fix(cli): bundle ink worker-entry.js by @rmedranollamas in
[#27249](https://github.com/google-gemini/gemini-cli/pull/27249)
- feat(core): wire AgentSession invocations into agent-tool by @adamfweidman in
[#26948](https://github.com/google-gemini/gemini-cli/pull/26948)
- fix(core): prevent path traversal in custome command file injection by
@ompatel-aiml in
[#27234](https://github.com/google-gemini/gemini-cli/pull/27234)
- fix(core): respect NO_PROXY in global fetch dispatcher by @cocosheng-g in
[#27216](https://github.com/google-gemini/gemini-cli/pull/27216)
- fix(core): correctly handle nullable array types in MCP tools by @devr0306 in
[#27228](https://github.com/google-gemini/gemini-cli/pull/27228)
- fix(cli): preserve proxy-agent named exports in ESM bundle by @ashishch432 in
[#27145](https://github.com/google-gemini/gemini-cli/pull/27145)
- Proposal: deterministic encoding for child-process I/O by @kaluchi in
[#27247](https://github.com/google-gemini/gemini-cli/pull/27247)
- feat(cli): add Sublime Text and Emacs Client editors, improve error messages
and documentation by @alberti42 in
[#21090](https://github.com/google-gemini/gemini-cli/pull/21090)
- Changelog for v0.43.0-preview.1 by @gemini-cli-robot in
[#27297](https://github.com/google-gemini/gemini-cli/pull/27297)
- fix(devtools): bundle devtools package to avoid resolution errors by
@rmedranollamas in
[#27250](https://github.com/google-gemini/gemini-cli/pull/27250)
- fix(cli): integrate PolicyEngine into ACP session to prevent deadlocks
(#23507) by @cocosheng-g in
[#27252](https://github.com/google-gemini/gemini-cli/pull/27252)
- fix: robust ripgrep path resolution and 1p hermetic execution support by
@cocosheng-g in
[#27253](https://github.com/google-gemini/gemini-cli/pull/27253)
- refactor: decouple stored session deletion from ChatRecordingService (#22920)
by @yuvrajangadsingh in
[#27039](https://github.com/google-gemini/gemini-cli/pull/27039)
- fix(core): improve Alpine shell compatibility by @dibyx in
[#26770](https://github.com/google-gemini/gemini-cli/pull/26770)
- fix(core): generalize MCP compliance fix for tool results by @cocosheng-g in
[#27045](https://github.com/google-gemini/gemini-cli/pull/27045)
- fix(scripts): scrub CI env vars in dev to keep interactive mode by @Hashaam101
in [#27159](https://github.com/google-gemini/gemini-cli/pull/27159)
- fix(core): Added date field for the GCal MCP by @devr0306 in
[#27251](https://github.com/google-gemini/gemini-cli/pull/27251)
- fix(core): centralize path validation to prevent crashes from malformed
prompts by @cocosheng-g in
[#27211](https://github.com/google-gemini/gemini-cli/pull/27211)
- fix(core): prevent SIGHUP kills in PTY environments (WSL2/Kitty/Alacritty) by
@ProthamD in [#27267](https://github.com/google-gemini/gemini-cli/pull/27267)
- fix(core): dynamic fallback routing for exhausted quota models by @cocosheng-g
in [#27315](https://github.com/google-gemini/gemini-cli/pull/27315)
- Auto detect pnpm global installation path for macOS and Windows by @tisonkun
in [#22748](https://github.com/google-gemini/gemini-cli/pull/22748)
- fix(windows): resolve interactive shell arrow-key navigation on Windows by
@KumarADITHYA123 in
[#23505](https://github.com/google-gemini/gemini-cli/pull/23505)
- ci: robust stale issue lifecycle and consolidated triage labels by
@cocosheng-g in
[#27015](https://github.com/google-gemini/gemini-cli/pull/27015)
- fix(context): Ensure last message is processed. by @joshualitt in
[#27232](https://github.com/google-gemini/gemini-cli/pull/27232)
- chore/release: bump version to 0.44.0-nightly.20260521.g57c42a5c4 by
@gemini-cli-robot in
[#27324](https://github.com/google-gemini/gemini-cli/pull/27324)
- fix(ui): added volta to auto update check by @devr0306 in
[#27353](https://github.com/google-gemini/gemini-cli/pull/27353)
- perf: optimize issue triage and lifecycle management by @cocosheng-g in
[#27346](https://github.com/google-gemini/gemini-cli/pull/27346)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.41.0-preview.3...v0.42.0-preview.2
https://github.com/google-gemini/gemini-cli/compare/v0.43.0-preview.1...v0.44.0-preview.0
+4 -5
View File
@@ -29,11 +29,10 @@ You'll use Auto Memory when you want to:
avoid them.
- **Bootstrap a skills library** without writing every `SKILL.md` by hand.
Auto Memory complements—but does not replace—the
[`save_memory` tool](../tools/memory.md), which captures single facts into
`GEMINI.md` when the agent explicitly calls it. Auto Memory infers candidates
from past sessions, writes reviewable patches or skill drafts, and never applies
them without your approval.
Auto Memory complements direct memory-file editing. The agent can still persist
explicit user instructions by editing the appropriate Markdown memory file; Auto
Memory infers candidates from past sessions, writes reviewable patches or skill
drafts, and never applies them without your approval.
## Prerequisites
-2
View File
@@ -65,8 +65,6 @@ You can interact with the loaded context files by using the `/memory` command.
being provided to the model.
- **`/memory reload`**: Forces a re-scan and reload of all `GEMINI.md` files
from all configured locations.
- **`/memory add <text>`**: Appends your text to your global
`~/.gemini/GEMINI.md` file. This lets you add persistent memories on the fly.
## Modularize context with imports
-1
View File
@@ -138,7 +138,6 @@ These are the only allowed tools:
[`replace`](../tools/file-system.md#6-replace-edit) only allowed for `.md`
files in the `~/.gemini/tmp/<project>/<session-id>/plans/` directory or your
[custom plans directory](#custom-plan-directory-and-policies).
- **Memory:** [`save_memory`](../tools/memory.md)
- **Skills:** [`activate_skill`](../cli/skills.md) (allows loading specialized
instructions and resources in a read-only manner)
+19 -19
View File
@@ -40,6 +40,7 @@ they appear in the UI.
| 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"` |
| Topic & Update Narration | `general.topicUpdateNarration` | Enable the Topic & Update communication model for reduced chattiness and structured progress reporting. | `true` |
| Log RAG Snippets | `general.logRagSnippets` | Log full Code Customization (RAG) retrieved snippets to a local file for debugging. | `false` |
### Output
@@ -162,25 +163,24 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `4000` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Memory v2 | `experimental.memoryV2` | Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool. | `true` |
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| UI Label | Setting | Description | Default |
| ---------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| Gemma Models | `experimental.gemma` | Enable access to Gemma 4 models via Gemini API. | `true` |
| Voice Mode | `experimental.voiceMode` | Enable experimental voice dictation and commands (/voice, /voice model). | `false` |
| Voice Activation Mode | `experimental.voice.activationMode` | How to trigger voice recording with the Space key. | `"push-to-talk"` |
| Voice Transcription Backend | `experimental.voice.backend` | The backend to use for voice transcription. Note: When using the Gemini Live backend, voice recordings are sent to Google Cloud for transcription. | `"gemini-live"` |
| Whisper Model | `experimental.voice.whisperModel` | The Whisper model to use for local transcription. | `"ggml-base.en.bin"` |
| Voice Stop Grace Period (ms) | `experimental.voice.stopGracePeriodMs` | How long to wait for final transcription after stopping recording. | `4000` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router (experimental). Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` |
| Auto-start LiteRT Server | `experimental.gemmaModelRouter.autoStartServer` | Automatically start the LiteRT-LM server when Gemini CLI starts and the Gemma router is enabled. | `false` |
| Auto Memory | `experimental.autoMemory` | Automatically extract memory patches and skills from past sessions in the background. Every change is written as a unified diff `.patch` file under `<projectMemoryDir>/.inbox/<kind>/` and held for review in /memory inbox; nothing is applied until you approve it. | `false` |
| Use the generalist profile to manage agent contexts. | `experimental.generalistProfile` | Suitable for general coding and software development tasks. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
### Skills
+2 -2
View File
@@ -71,8 +71,8 @@ Just tell the agent to remember something.
**Prompt:** `Remember that I prefer using 'const' over 'let' wherever possible.`
The agent will use the `save_memory` tool to store this fact in your global
memory file.
The agent will edit the appropriate memory Markdown file, so the fact is loaded
in future sessions.
**Prompt:** `Save the fact that the staging server IP is 10.0.0.5.`
+16
View File
@@ -210,6 +210,22 @@ To update an extension's settings:
gemini extensions config <name> [setting] [--scope <scope>]
```
#### Environment variable sanitization
For security reasons, sensitive environment variables are filtered out and not
passed to extensions or MCP servers by default.
Extensions **will not** inherit the user's full shell environment variables.
They will only have access to:
1. Standard safe variables (e.g., `HOME`, `PATH`, `TMPDIR`).
2. Variables explicitly declared and requested in the `gemini-extension.json`
manifest via the `settings` array (using the `envVar` property).
If your extension requires specific environment variables (like an API key,
custom host, or config path), you **must** declare them in the `settings` array
so the CLI can allowlist them for use within the extension.
### Custom commands
Provide [custom commands](../cli/custom-commands.md) by placing TOML files in a
+7
View File
@@ -159,6 +159,13 @@ When a user installs this extension, Gemini CLI will prompt them to enter the
`sensitive` is true) and injected into the MCP server's process as the
`MY_SERVICE_API_KEY` environment variable.
> **Important (Environment Variable Sanitization):** For security reasons,
> sensitive environment variables are filtered out and not passed to extensions
> or MCP servers by default. Extensions will _only_ have access to environment
> variables that are explicitly declared in the `settings` array using the
> `envVar` property, plus a few standard safe variables. Do not expect host
> environment variables to be available otherwise.
## Step 4: Link your extension
Link your extension to your Gemini CLI installation for local development.
+2 -2
View File
@@ -111,8 +111,8 @@ You can also run Gemini CLI using one of the following advanced methods:
directly. This is useful for environments where you only have Docker and want
to run the CLI.
```bash
# Run the published sandbox image
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1
# Run the published sandbox image for a specified CLI version
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e
```
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
(using the standard installation described above), you can instruct it to run
-3
View File
@@ -265,9 +265,6 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Manage the AI's instructional context (hierarchical memory
loaded from `GEMINI.md` files).
- **Sub-commands:**
- **`add`**:
- **Description:** Adds the following text to the AI's memory. Usage:
`/memory add <text to remember>`
- **`list`**:
- **Description:** Lists the paths of the GEMINI.md files in use for
hierarchical memory.
+102 -91
View File
@@ -105,9 +105,19 @@ their corresponding top-level category object in your `settings.json` file.
#### `general`
- **`general.preferredEditor`** (string):
- **Description:** The preferred editor to open files in.
- **`general.preferredEditor`** (enum):
- **Description:** The preferred editor to open files in. Must be one of the
built-in supported identifiers. Use /editor in the CLI to pick
interactively, or leave unset to use $VISUAL/$EDITOR.
- **Default:** `undefined`
- **Values:** `"vscode"`, `"vscodium"`, `"windsurf"`, `"cursor"`, `"zed"`,
`"antigravity"`, `"sublimetext"`, `"lapce"`, `"nova"`, `"bbedit"`, `"vim"`,
`"neovim"`, `"emacs"`, `"hx"`, `"emacsclient"`, `"micro"`
- **`general.openEditorInNewWindow`** (boolean):
- **Description:** Open VS Code-family editors in a new window when editing
files.
- **Default:** `false`
- **`general.vimMode`** (boolean):
- **Description:** Enable Vim keybindings
@@ -203,6 +213,11 @@ their corresponding top-level category object in your `settings.json` file.
chattiness and structured progress reporting.
- **Default:** `true`
- **`general.logRagSnippets`** (boolean):
- **Description:** Log full Code Customization (RAG) retrieved snippets to a
local file for debugging.
- **Default:** `false`
#### `output`
- **`output.format`** (enum):
@@ -545,6 +560,24 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-3-flash-preview"
}
},
"gemini-3.1-pro-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview"
}
},
"gemini-3.1-pro-preview-customtools": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-pro-preview-customtools"
}
},
"gemini-3.1-flash-lite-preview": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
}
},
"gemini-2.5-pro": {
"extends": "chat-base-2.5",
"modelConfig": {
@@ -563,6 +596,12 @@ their corresponding top-level category object in your `settings.json` file.
"model": "gemini-2.5-flash-lite"
}
},
"gemini-3.1-flash-lite": {
"extends": "chat-base-3",
"modelConfig": {
"model": "gemini-3.1-flash-lite"
}
},
"gemma-4-31b-it": {
"extends": "chat-base-3",
"modelConfig": {
@@ -590,7 +629,7 @@ their corresponding top-level category object in your `settings.json` file.
"classifier": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 1024,
"thinkingConfig": {
@@ -602,7 +641,7 @@ their corresponding top-level category object in your `settings.json` file.
"prompt-completion": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"temperature": 0.3,
"maxOutputTokens": 16000,
@@ -615,7 +654,7 @@ their corresponding top-level category object in your `settings.json` file.
"fast-ack-helper": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"temperature": 0.2,
"maxOutputTokens": 120,
@@ -628,7 +667,7 @@ their corresponding top-level category object in your `settings.json` file.
"edit-corrector": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"thinkingConfig": {
"thinkingBudget": 0
@@ -639,7 +678,7 @@ their corresponding top-level category object in your `settings.json` file.
"summarizer-default": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
@@ -648,7 +687,7 @@ their corresponding top-level category object in your `settings.json` file.
"summarizer-shell": {
"extends": "base",
"modelConfig": {
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"generateContentConfig": {
"maxOutputTokens": 2000
}
@@ -725,7 +764,7 @@ their corresponding top-level category object in your `settings.json` file.
},
"chat-compression-3.1-flash-lite": {
"modelConfig": {
"model": "gemini-3.1-flash-lite-preview"
"model": "gemini-3.1-flash-lite"
}
},
"chat-compression-2.5-pro": {
@@ -779,10 +818,10 @@ their corresponding top-level category object in your `settings.json` file.
```json
{
"gemini-3.1-flash-lite-preview": {
"gemini-3.1-flash-lite": {
"tier": "flash-lite",
"family": "gemini-3",
"isPreview": true,
"isPreview": false,
"isVisible": true,
"features": {
"thinking": false,
@@ -882,9 +921,10 @@ their corresponding top-level category object in your `settings.json` file.
}
},
"auto": {
"displayName": "Auto",
"tier": "auto",
"isPreview": true,
"isVisible": false,
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": false
@@ -918,26 +958,16 @@ their corresponding top-level category object in your `settings.json` file.
}
},
"auto-gemini-3": {
"displayName": "Auto (Gemini 3)",
"tier": "auto",
"family": "gemini-3",
"isPreview": true,
"isVisible": true,
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash",
"features": {
"thinking": true,
"multimodalToolUse": false
}
"isVisible": false
},
"auto-gemini-2.5": {
"displayName": "Auto (Gemini 2.5)",
"tier": "auto",
"family": "gemini-2.5",
"isPreview": false,
"isVisible": true,
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
"features": {
"thinking": false,
"multimodalToolUse": false
}
"isVisible": false
}
}
```
@@ -1020,30 +1050,6 @@ their corresponding top-level category object in your `settings.json` file.
}
]
},
"auto-gemini-3": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"auto": {
"default": "gemini-3-pro-preview",
"contexts": [
@@ -1092,19 +1098,8 @@ their corresponding top-level category object in your `settings.json` file.
}
]
},
"auto-gemini-2.5": {
"default": "gemini-2.5-pro"
},
"gemini-3.1-flash-lite-preview": {
"default": "gemini-3.1-flash-lite-preview",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": false
},
"target": "gemini-2.5-flash-lite"
}
]
"gemini-3.1-flash-lite": {
"default": "gemini-3.1-flash-lite"
},
"flash": {
"default": "gemini-3-flash-preview",
@@ -1118,15 +1113,34 @@ their corresponding top-level category object in your `settings.json` file.
]
},
"flash-lite": {
"default": "gemini-2.5-flash-lite",
"default": "gemini-3.1-flash-lite"
},
"auto-gemini-3": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"useGemini3_1FlashLite": true
"hasAccessToPreview": false
},
"target": "gemini-3.1-flash-lite-preview"
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"auto-gemini-2.5": {
"default": "gemini-2.5-pro"
}
}
```
@@ -1145,15 +1159,15 @@ their corresponding top-level category object in your `settings.json` file.
"contexts": [
{
"condition": {
"requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"]
"hasAccessToPreview": false
},
"target": "gemini-2.5-flash"
},
{
"condition": {
"requestedModels": ["auto-gemini-3", "gemini-3-pro-preview"]
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
},
"target": "gemini-3-flash-preview"
"target": "gemini-2.5-flash"
}
]
},
@@ -1162,7 +1176,13 @@ their corresponding top-level category object in your `settings.json` file.
"contexts": [
{
"condition": {
"requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"]
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
},
"target": "gemini-2.5-pro"
},
@@ -1333,7 +1353,7 @@ their corresponding top-level category object in your `settings.json` file.
],
"lite": [
{
"model": "gemini-2.5-flash-lite",
"model": "flash-lite",
"actions": {
"terminal": "silent",
"transient": "silent",
@@ -1821,6 +1841,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionSubagentEnabled`** (boolean):
- **Description:** Route subagent invocations through the AgentSession
protocol instead of legacy executors.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
@@ -1857,13 +1883,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading. Defaults to
true; set to false to opt out and load all GEMINI.md files into the system
instruction up-front.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
- **Description:** Use OSC 52 for pasting. This may be more robust than the
default system when using remote terminal sessions (if your terminal is
@@ -1926,19 +1945,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `"gemma3-1b-gpu-custom"`
- **Requires restart:** Yes
- **`experimental.memoryV2`** (boolean):
- **Description:** Disable the built-in save_memory tool and let the main
agent persist project context by editing markdown files directly with
edit/write_file. Route facts across four tiers: team-shared conventions go
to project GEMINI.md files, project-specific personal notes go to the
per-project private memory folder (MEMORY.md as index + sibling .md files
for detail), and cross-project personal preferences go to the global
~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit
— settings, credentials, etc. remain off-limits). Set to false to fall back
to the legacy save_memory tool.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.stressTestProfile`** (boolean):
- **Description:** Significantly lowers token limits to force early garbage
collection and distillation for testing purposes.
@@ -1958,6 +1964,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.powerUserProfile`** (boolean):
- **Description:** Less cache friendly version of the generalist profile.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **Default:** `false`
-2
View File
@@ -120,7 +120,6 @@ each tool.
| :----------------------------------------------- | :------ | :----------------------------------------------------------------------------------- |
| [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise from the `.gemini/skills` directory. |
| [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation for accurate answers about its capabilities. |
| [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file. |
### Planning
@@ -173,7 +172,6 @@ representation of each tool's arguments.
| `replace` | `file_path`, `old_string`, `new_string`, `instruction`, `allow_multiple` |
| `ask_user` | `questions` (array of `question`, `header`, `type`, `options`) |
| `write_todos` | `todos` (array of `description`, `status`) |
| `save_memory` | `fact` |
| `activate_skill` | `name` |
| `get_internal_docs` | `path` |
| `enter_plan_mode` | `reason` |
+24 -3
View File
@@ -221,8 +221,10 @@ spawning MCP server processes.
#### Automatic redaction
By default, the CLI redacts sensitive environment variables from the base
environment (inherited from the host process) to prevent unintended exposure to
third-party MCP servers. This includes:
environment (inherited from the host process). This prevents the accidental
leakage of sensitive host environment variables (like AWS keys or GitHub tokens)
to arbitrary third-party MCP servers that might execute malicious code or log
your environment. This includes:
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
@@ -232,7 +234,8 @@ third-party MCP servers. This includes:
#### Explicit overrides
If an environment variable must be passed to an MCP server, you must explicitly
state it in the `env` property of the server configuration in `settings.json`.
state it in the `env` property of the server configuration in `settings.json`
(or `mcp_config.json` if configuring standard MCP clients or remote skills).
Explicitly defined variables (including those from extensions) are trusted and
are **not** subjected to the automatic redaction process.
@@ -247,6 +250,24 @@ specific data with that server.
> (for example, `"MY_KEY": "$MY_KEY"`) to securely pull the value from your host
> environment at runtime.
**Example: Passing a GitHub Token securely to the
[official GitHub MCP server](https://github.com/github/github-mcp-server) via
`mcp_config.json`**
```json
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["-y", "@github/github-mcp-server"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PERSONAL_ACCESS_TOKEN"
}
}
}
}
```
### OAuth support for remote MCP servers
Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or
+10 -13
View File
@@ -1,25 +1,22 @@
# Memory tool (`save_memory`)
# Memory files
The `save_memory` tool allows the Gemini agent to persist specific facts, user
preferences, and project details across sessions.
Gemini CLI persists durable facts, user preferences, and project details by
editing Markdown memory files directly.
## Technical reference
This tool appends information to the `## Gemini Added Memories` section of your
global `GEMINI.md` file (typically located at `~/.gemini/GEMINI.md`).
### Arguments
- `fact` (string, required): A clear, self-contained statement in natural
language.
The agent routes memories to the appropriate Markdown file: shared project
instructions go in repository `GEMINI.md` files, private project notes go in the
per-project private memory folder, and cross-project personal preferences go in
the global `~/.gemini/GEMINI.md` file.
## Technical behavior
- **Storage:** Appends to the global context file in the user's home directory.
- **Storage:** Edits Markdown files with `write_file` or `replace`.
- **Loading:** The stored facts are automatically included in the hierarchical
context system for all future sessions.
- **Format:** Saves data as a bulleted list item within a dedicated Markdown
section.
- **Format:** Keeps durable instructions concise and avoids duplicating the same
fact across multiple memory tiers.
## Use cases
+41 -2
View File
@@ -63,7 +63,6 @@ const external = [
'@lydell/node-pty-win32-arm64',
'@lydell/node-pty-win32-x64',
'@github/keytar',
'@google/gemini-cli-devtools',
];
const baseConfig = {
@@ -102,11 +101,46 @@ const cliConfig = {
plugins: createWasmPlugins(),
alias: {
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
'https-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/https-proxy-agent.ts',
),
'http-proxy-agent': path.resolve(
__dirname,
'packages/cli/src/patches/http-proxy-agent.ts',
),
'@google/gemini-cli-devtools': path.resolve(
__dirname,
'packages/devtools/src/index.ts',
),
...commonAliases,
},
metafile: true,
};
const workerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
},
entryPoints: {
'worker/worker-entry': path.join(
path.dirname(require.resolve('ink')),
'worker/worker-entry.js',
),
},
outdir: 'bundle',
define: {
__filename: '__chunk_filename',
__dirname: '__chunk_dirname',
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'production',
),
},
plugins: createWasmPlugins(),
alias: commonAliases,
};
const a2aServerConfig = {
...baseConfig,
banner: {
@@ -133,13 +167,18 @@ Promise.allSettled([
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
}
}),
esbuild.build(workerConfig),
esbuild.build(a2aServerConfig),
]).then((results) => {
const [cliResult, a2aResult] = results;
const [cliResult, workerResult, a2aResult] = results;
if (cliResult.status === 'rejected') {
console.error('gemini.js build failed:', cliResult.reason);
process.exit(1);
}
if (workerResult.status === 'rejected') {
console.error('worker-entry.js build failed:', workerResult.reason);
process.exit(1);
}
// error in a2a-server bundling will not stop gemini.js bundling process
if (a2aResult.status === 'rejected') {
console.warn('a2a-server build failed:', a2aResult.reason);
+33
View File
@@ -0,0 +1,33 @@
import { evalTest } from './test-helper.js';
import { expect } from 'vitest';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: 'should create an all-day event using the optional date field',
prompt:
'Create an all-day event for 2026-05-20 titled "Company Retreat". Do not use a specific time.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
console.log('TOOL LOGS:', JSON.stringify(toolLogs, null, 2));
const createEventCall = toolLogs.find(
(log) =>
log.toolRequest.name === 'mcp_workspace-server_calendar.createEvent',
);
expect(createEventCall).toBeDefined();
const args = JSON.parse(createEventCall!.toolRequest.args);
expect(args?.start).toHaveProperty('date');
expect(args?.start).not.toHaveProperty('dateTime');
expect(args?.start?.date).toBe('2026-05-20');
expect(args?.end).toHaveProperty('date');
expect(args?.end).not.toHaveProperty('dateTime');
},
});
@@ -11,11 +11,7 @@ import {
loadConversationRecord,
SESSION_FILE_PREFIX,
} from '@google/gemini-cli-core';
import {
evalTest,
assertModelHasOutput,
checkModelOutputContent,
} from './test-helper.js';
import { evalTest, assertModelHasOutput } from './test-helper.js';
function findDir(base: string, name: string): string | null {
if (!fs.existsSync(base)) return null;
@@ -77,336 +73,13 @@ async function waitForSessionScratchpad(
return loadLatestSessionRecord(homeDir, sessionId);
}
describe('save_memory', () => {
const TEST_PREFIX = 'Save memory test: ';
const rememberingFavoriteColor = "Agent remembers user's favorite color";
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingFavoriteColor,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `remember that my favorite color is blue.
what is my favorite color? tell me that and surround it with $ symbol`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: 'blue',
testName: `${TEST_PREFIX}${rememberingFavoriteColor}`,
});
},
});
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandRestrictions,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I don't want you to ever run npm commands.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/not run npm commands|remember|ok/i],
testName: `${TEST_PREFIX}${rememberingCommandRestrictions}`,
});
},
});
const rememberingWorkflow = 'Agent remembers workflow preferences';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingWorkflow,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I want you to always lint after building.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/always|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingWorkflow}`,
});
},
});
const ignoringTemporaryInformation =
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: ignoringTemporaryInformation,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I'm going to get a coffee.`,
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const wasToolCalled = rig
.readToolLogs()
.some((log) => log.toolRequest.name === 'save_memory');
expect(
wasToolCalled,
'save_memory should not be called for temporary information',
).toBe(false);
assertModelHasOutput(result);
checkModelOutputContent(result, {
testName: `${TEST_PREFIX}${ignoringTemporaryInformation}`,
forbiddenContent: [/remember|will do/i],
});
},
});
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingPetName,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `Please remember that my dog's name is Buddy.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/Buddy/i],
testName: `${TEST_PREFIX}${rememberingPetName}`,
});
},
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCommandAlias,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `When I say 'start server', you should run 'npm run dev'.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/npm run dev|start server|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingCommandAlias}`,
});
},
});
const savingDbSchemaLocationAsProjectMemory =
'Agent saves workspace database schema location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: savingDbSchemaLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The database schema for this workspace is located in \`db/schema.sql\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingCodingStyle,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `I prefer to use tabs instead of spaces for indentation.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/tabs instead of spaces|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingCodingStyle}`,
});
},
});
const savingBuildArtifactLocationAsProjectMemory =
'Agent saves workspace build artifact location as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: savingBuildArtifactLocationAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `In this workspace, build artifacts are stored in the \`dist/artifacts\` directory.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const savingMainEntryPointAsProjectMemory =
'Agent saves workspace main entry point as project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: savingMainEntryPointAsProjectMemory,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `The main entry point for this workspace is \`src/index.js\`.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => {
try {
const params = JSON.parse(args);
return params.scope === 'project';
} catch {
return false;
}
},
);
expect(
wasToolCalled,
'Expected save_memory to be called with scope="project" for workspace-specific information',
).toBe(true);
assertModelHasOutput(result);
},
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: rememberingBirthday,
params: {
settings: {
experimental: { memoryV2: false },
},
},
prompt: `My birthday is on June 15th.`,
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory tool to be called').toBe(
true,
);
assertModelHasOutput(result);
checkModelOutputContent(result, {
expectedContent: [/June 15th|ok|remember|will do/i],
testName: `${TEST_PREFIX}${rememberingBirthday}`,
});
},
});
describe('memory persistence', () => {
const proactiveMemoryFromLongSession =
'Agent saves preference from earlier in conversation history';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: proactiveMemoryFromLongSession,
params: {
settings: {
experimental: { memoryV2: true },
},
},
messages: [
{
id: 'msg-1',
@@ -462,9 +135,9 @@ describe('save_memory', () => {
prompt:
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
// Under experimental.memoryV2, the agent persists memories by
// editing markdown files directly with write_file or replace — not via
// a save_memory subagent. The user said "I always prefer Vitest over
// The agent persists memories by editing markdown files directly with
// write_file or replace. The user said
// "I always prefer Vitest over
// Jest for testing in all my projects" — that matches the new
// cross-project cue phrase ("across all my projects"), so under the
// 4-tier model the correct destination is the global personal memory
@@ -522,17 +195,12 @@ describe('save_memory', () => {
},
});
const memoryV2RoutesTeamConventionsToProjectGemini =
const memoryRoutesTeamConventionsToProjectGemini =
'Agent routes team-shared project conventions to ./GEMINI.md';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2RoutesTeamConventionsToProjectGemini,
params: {
settings: {
experimental: { memoryV2: true },
},
},
name: memoryRoutesTeamConventionsToProjectGemini,
messages: [
{
id: 'msg-1',
@@ -573,11 +241,11 @@ describe('save_memory', () => {
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
// Under experimental.memoryV2, the prompt enforces an explicit
// one-tier-per-fact rule: team-shared project conventions (the team's
// test command, project-wide indentation rules) belong in the
// committed project-root ./GEMINI.md and must NOT be mirrored or
// cross-referenced into the private project memory folder
// The prompt enforces an explicit one-tier-per-fact rule: team-shared
// project conventions (the team's test command, project-wide
// indentation rules) belong in the committed project-root ./GEMINI.md
// and must NOT be mirrored or cross-referenced into the private project
// memory folder
// (~/.gemini/tmp/<hash>/memory/). The global ~/.gemini/GEMINI.md must
// never be touched in this mode either.
await rig.waitForToolCall('write_file').catch(() => {});
@@ -635,18 +303,13 @@ describe('save_memory', () => {
},
});
const memoryV2SessionScratchpad =
const memorySessionScratchpad =
'Session summary persists memory scratchpad for memory-saving sessions';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2SessionScratchpad,
name: memorySessionScratchpad,
sessionId: 'memory-scratchpad-eval',
params: {
settings: {
experimental: { memoryV2: true },
},
},
messages: [
{
id: 'msg-1',
@@ -695,7 +358,7 @@ describe('save_memory', () => {
expect(
writeCalls.length,
'Expected memoryV2 save flow to edit a markdown memory file',
'Expected memory save flow to edit a markdown memory file',
).toBeGreaterThan(0);
await rig.run({
@@ -732,17 +395,12 @@ describe('save_memory', () => {
},
});
const memoryV2RoutesUserProject =
const memoryRoutesUserProject =
'Agent routes personal-to-user project notes to user-project memory';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2RoutesUserProject,
params: {
settings: {
experimental: { memoryV2: true },
},
},
name: memoryRoutesUserProject,
prompt: `Please remember my personal local dev setup for THIS project's Postgres database. This is private to my machine — do NOT commit it to the repo.
Connection details:
@@ -761,11 +419,11 @@ Quirks to remember:
- The migrations runner sometimes hangs on my machine if I forget step 1; kill it with Ctrl+C and rerun.
- I keep an extra \`scratch\` schema for ad-hoc experiments — never reference it from project code.`,
assert: async (rig, result) => {
// Under experimental.memoryV2 with the Private Project Memory bullet
// surfaced in the prompt, a fact that is project-specific AND
// personal-to-the-user (must not be committed) should land in the
// private project memory folder under ~/.gemini/tmp/<hash>/memory/. The
// detailed note should be written to a sibling markdown file, with
// With the Private Project Memory bullet surfaced in the prompt, a fact
// that is project-specific AND personal-to-the-user (must not be
// committed) should land in the private project memory folder under
// ~/.gemini/tmp/<hash>/memory/. The detailed note should be written to a
// sibling markdown file, with
// MEMORY.md updated as the index. It must NOT go to committed
// ./GEMINI.md or the global ~/.gemini/GEMINI.md.
await rig.waitForToolCall('write_file').catch(() => {});
@@ -828,24 +486,19 @@ Quirks to remember:
},
});
const memoryV2RoutesCrossProjectToGlobal =
const memoryRoutesCrossProjectToGlobal =
'Agent routes cross-project personal preferences to ~/.gemini/GEMINI.md';
evalTest('USUALLY_PASSES', {
suiteName: 'default',
suiteType: 'behavioral',
name: memoryV2RoutesCrossProjectToGlobal,
params: {
settings: {
experimental: { memoryV2: true },
},
},
name: memoryRoutesCrossProjectToGlobal,
prompt:
'Please remember this about me in general: across all my projects I always prefer Prettier with single quotes and trailing commas, and I always prefer tabs over spaces for indentation. These are my personal coding-style defaults that follow me into every workspace.',
assert: async (rig, result) => {
// Under experimental.memoryV2 with the Global Personal Memory
// tier surfaced in the prompt, a fact that explicitly applies to the
// user "across all my projects" / "in every workspace" must land in
// the global ~/.gemini/GEMINI.md (the cross-project tier). It must
// With the Global Personal Memory tier surfaced in the prompt, a fact
// that explicitly applies to the user "across all my projects" / "in
// every workspace" must land in the global ~/.gemini/GEMINI.md (the
// cross-project tier). It must
// NOT be mirrored into a committed project-root ./GEMINI.md (that
// tier is for team-shared conventions) or into the per-project
// private memory folder (that tier is for project-specific personal
+1 -1
View File
@@ -32,7 +32,7 @@ export const EVAL_MODEL =
// Indicates the consistency expectation for this test.
// - ALWAYS_PASSES - Means that the test is expected to pass 100% of the time. These
// These tests are typically trivial and test basic functionality with unambiguous
// prompts. For example: "call save_memory to remember foo" should be fairly reliable.
// prompts. For example: "remember foo" should be fairly reliable.
// These are the first line of defense against regressions in key behaviors and run in
// every CI. You can run these locally with 'npm run test:always_passing_evals'.
//
+287
View File
@@ -0,0 +1,287 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core';
describe('Context Management Fidelity E2E', () => {
let rig: TestRig;
function generateRandomString(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length),
);
}
return result;
}
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it(
'should reproduce the exact context working buffer on resume',
{ timeout: 300000 },
async () => {
// Mock responses to trigger GC (summarization)
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
} as unknown as GenerateContentResponse,
};
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 1000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts: [{ text }], role: 'model' },
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
);
return filePath;
};
await rig.setup('context-fidelity', {
settings: {
experimental: {
stressTestProfile: true, // Lowers thresholds to trigger GC easily
},
},
});
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
// Ignore trace and response files to keep environment context clean and stable
fs.writeFileSync(
path.join(rig.testDir!, '.geminiignore'),
'traces/\nresp*.json\ndebug.log\n',
);
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_CONTEXT_TRACE_DIR: traceDir,
GEMINI_CONTEXT_TRACE_ENABLED: 'true',
GEMINI_DEBUG_LOG_FILE: path.join(rig.testDir!, 'debug.log'),
};
const runMocks: FakeResponse[] = [
streamResponse('Ack 1'),
streamResponse('Ack 2'),
streamResponse('Ack 3'),
streamResponse('Ack 4'),
streamResponse('Ack 5'),
streamResponse('Ack 6'),
streamResponse('Ack 7'),
streamResponse('Ack 8'),
streamResponse('Ack 9'),
streamResponse('Ack 10'),
streamResponse('Ack 11'),
streamResponse('Ack 12'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Turns 1-10: Build up history
for (let i = 1; i <= 10; i++) {
await rig.run({
args: [
'--debug',
i === 1 ? '' : '--resume',
i === 1 ? '' : 'latest',
'--fake-responses-non-strict',
setupResponses(`resp_init_${i}.json`, runMocks),
].filter(Boolean),
stdin: `Turn ${i}: ` + generateRandomString(900),
env: commonEnv,
});
}
// Turn 11: Penultimate turn
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 11: ' + generateRandomString(900),
env: commonEnv,
});
// Turn 12: Breach threshold and force GC
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 12: ' + generateRandomString(900),
env: commonEnv,
});
// Extract the rendered context asset from the log
const getRenderedContext = (logContent: string): HistoryTurn[] | null => {
const lines = logContent.split('\n');
const renderLines = lines.filter(
(l) =>
l.includes('[Render] Render Sanitized Context for LLM') ||
l.includes('[Render] Render Context for LLM'),
);
if (renderLines.length === 0) return null;
const lastRender = renderLines[renderLines.length - 1];
const detailsMatch = lastRender.match(/\| Details: (.*)$/);
if (!detailsMatch) return null;
const details = JSON.parse(detailsMatch[1]);
const assetInfo =
details.renderedContextSanitized || details.renderedContext;
if (assetInfo && assetInfo.$asset) {
const assetPath = path.join(traceDir, 'assets', assetInfo.$asset);
return JSON.parse(fs.readFileSync(assetPath, 'utf-8'));
}
return assetInfo;
};
const log1 = fs.readFileSync(traceLog, 'utf-8');
const contextBeforeExit = getRenderedContext(log1);
expect(contextBeforeExit).toBeDefined();
console.log(
'Context Before Exit (First 2 turns):',
JSON.stringify(contextBeforeExit!.slice(0, 2), null, 2),
);
// Turn 4: Resume and run a small command
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp4.json', runMocks),
'continue',
],
env: commonEnv,
});
const log2 = fs.readFileSync(traceLog, 'utf-8');
const contextAfterResume = getRenderedContext(log2);
expect(contextAfterResume).toBeDefined();
console.log(
'Context After Resume (First 2 turns):',
JSON.stringify(contextAfterResume!.slice(0, 2), null, 2),
);
expect(contextAfterResume!.length).toBeGreaterThanOrEqual(
contextBeforeExit!.length,
);
// The environment context is intentionally refreshed on resume to reflect
// the current state of the workspace (e.g. new files, current date).
// We allow its content to differ but ensure it's still an environment context.
const isEnvContext = (turn: HistoryTurn) =>
turn.content.parts?.some((p) => p.text?.includes('<session_context>'));
for (let i = 0; i < contextBeforeExit!.length; i++) {
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
const turnBefore = contextBeforeExit![i];
const turnAfter = contextAfterResume![i];
if (isEnvContext(turnBefore)) {
expect(isEnvContext(turnAfter)).toBe(true);
continue;
}
expect(turnAfter.content).toEqual(turnBefore.content);
}
// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurns.length).toBeGreaterThan(0);
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) =>
t.content.parts?.some((p) => p.text?.includes('active_tasks')) ||
(t.id && t.id.length === 32),
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
);
// Check if the first synthetic turn is identical (with relaxation for environment context)
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
if (isEnvContext(syntheticTurns[0])) {
expect(isEnvContext(syntheticTurnsAfter[0])).toBe(true);
} else {
expect(syntheticTurnsAfter[0].content).toEqual(
syntheticTurns[0].content,
);
}
},
);
});
+1 -1
View File
@@ -172,7 +172,7 @@ describe('file-system', () => {
).toBeDefined();
const newFileContent = rig.readFile(fileName);
expect(newFileContent).toBe('1.0.1');
expect(newFileContent.trimEnd()).toBe('1.0.1');
});
it.skip('should replace multiple instances of a string', async () => {
+2 -2
View File
@@ -12,7 +12,7 @@ if (process.env['NO_COLOR'] !== undefined) {
import { mkdir, readdir, rm, readFile } from 'node:fs/promises';
import { join, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
import { resolveRipgrepPath } from '../packages/core/src/tools/ripGrep.js';
import { disableMouseTracking } from '@google/gemini-cli-core';
import { isolateTestEnv } from '../packages/test-utils/src/env-setup.js';
import { createServer, type Server } from 'node:http';
@@ -93,7 +93,7 @@ export async function setup() {
isolateTestEnv(runDir);
// Download ripgrep to avoid race conditions in parallel tests
const available = await canUseRipgrep();
const available = await resolveRipgrepPath();
if (!available) {
throw new Error('Failed to download ripgrep binary');
}
+149
View File
@@ -0,0 +1,149 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { FinishReason, GenerateContentResponse } from '@google/genai';
import type { FakeResponse } from '@google/gemini-cli-core';
describe('Context Management Resume E2E', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
const snapshotResponse: FakeResponse = {
method: 'generateContent',
response: {
candidates: [
{
content: {
parts: [
{
text: JSON.stringify({
new_facts: ['GC Triggered.'],
new_constraints: [],
new_tasks: [],
resolved_task_ids: [],
obsolete_fact_indices: [],
obsolete_constraint_indices: [],
chronological_summary: 'Snapshot created.',
}),
},
],
role: 'model',
},
finishReason: FinishReason.STOP,
index: 0,
},
],
} as unknown as GenerateContentResponse,
};
const countTokensResponse: FakeResponse = {
method: 'countTokens',
response: { totalTokens: 50000 },
};
const streamResponse = (text: string): FakeResponse => ({
method: 'generateContentStream',
response: [
{
candidates: [
{
content: { parts: [{ text }], role: 'model' },
finishReason: FinishReason.STOP,
index: 0,
},
],
},
] as unknown as GenerateContentResponse[],
});
const setupResponses = (fileName: string, mocks: FakeResponse[]) => {
const filePath = path.join(rig.testDir!, fileName);
fs.writeFileSync(
filePath,
mocks.map((m) => JSON.stringify(m)).join('\n'),
);
return filePath;
};
await rig.setup('resume-gc-snapshot', {
settings: {
experimental: {
stressTestProfile: true,
},
},
});
const massivePayload = 'X'.repeat(40000);
const logFile = path.join(rig.testDir!, 'debug.log');
const traceDir = path.join(rig.testDir!, 'traces');
fs.mkdirSync(traceDir, { recursive: true });
const traceLog = path.join(traceDir, 'trace.log');
const commonEnv = {
GEMINI_API_KEY: 'mock-key',
GEMINI_DEBUG_LOG_FILE: logFile,
GEMINI_CONTEXT_TRACE_DIR: traceDir,
};
// Provide a massive pool of responses to prevent exhaustion
const runMocks: FakeResponse[] = [streamResponse('Acknowledged block.')];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Use stdin for the massive payload to avoid ENAMETOOLONG on Windows
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
const result3 = await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
'continue',
],
env: commonEnv,
});
expect(result3).toContain('Acknowledged block');
const traces = fs.readFileSync(traceLog, 'utf-8');
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
expect(traces).toContain('GC Triggered.');
});
});
+8 -1
View File
@@ -8,7 +8,10 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
import {
RipGrepTool,
resolveRipgrepPath,
} from '../packages/core/src/tools/ripGrep.js';
import { Config } from '../packages/core/src/config/config.js';
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
@@ -48,6 +51,10 @@ class MockConfig {
validatePathAccess() {
return null;
}
async getRipgrepPath() {
return resolveRipgrepPath();
}
}
describe('ripgrep-real-direct', () => {
+2 -2
View File
@@ -7,7 +7,7 @@
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
import { resolveRipgrepPath } from '../packages/core/src/tools/ripGrep.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
@@ -27,7 +27,7 @@ export async function setup() {
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
// Download ripgrep to avoid race conditions
const available = await canUseRipgrep();
const available = await resolveRipgrepPath();
if (!available) {
throw new Error('Failed to download ripgrep binary');
}
+782 -377
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.45.0-nightly.20260521.g854f811be",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -418,7 +418,7 @@ confirmations for tool calls (like executing a shell command), will be sent as
```proto
// Request to execute a specific slash command.
message ExecuteSlashCommandRequest {
// The path to the command, e.g., ["memory", "add"] for /memory add
// The path to the command, e.g., ["memory", "list"] for /memory list
repeated string command_path = 1;
// The arguments for the command as a single string.
string args = 2;
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -26,7 +26,7 @@
],
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
"@google-cloud/storage": "^7.19.0",
"@google/gemini-cli-core": "file:../core",
"express": "^5.1.0",
"fs-extra": "^11.3.0",
+8 -2
View File
@@ -93,10 +93,16 @@ export class CoderAgentExecutor implements AgentExecutor {
taskId: string,
): Promise<Config> {
const workspaceRoot = setTargetDir(agentSettings);
const isTrusted = agentSettings.isTrusted ?? false;
loadEnvironment(); // Will override any global env with workspace envs
const settings = loadSettings(workspaceRoot);
const settings = loadSettings(workspaceRoot, isTrusted);
const extensions = loadExtensions(workspaceRoot);
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
return loadConfig(
settings,
new SimpleExtensionLoader(extensions),
taskId,
isTrusted,
);
}
/**
@@ -294,6 +294,66 @@ describe('Task', () => {
]);
});
it('should capture usageMetadata on Finished event and include it in final status update', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
publish: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn(),
finished: vi.fn(),
};
// @ts-expect-error - Calling private constructor for test purposes.
const task = new Task(
'task-id',
'context-id',
mockConfig as Config,
mockEventBus,
);
const finishedEvent = {
type: GeminiEventType.Finished,
value: {
reason: 'STOP',
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
},
};
await task.acceptAgentMessage(finishedEvent);
expect(task.usageMetadata).toEqual({
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
});
task.setTaskStateAndPublishUpdate(
'input-required',
{ kind: CoderAgentEvent.StateChangeEvent },
undefined,
undefined,
true, // final
);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
final: true,
metadata: expect.objectContaining({
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 50,
totalTokenCount: 150,
},
}),
}),
);
});
it('should update modelInfo and reflect it in metadata and status updates', async () => {
const mockConfig = createMockConfig();
const mockEventBus: ExecutionEventBus = {
+21
View File
@@ -89,6 +89,12 @@ export class Task {
currentAgentMessageId = uuidv4();
promptCount = 0;
autoExecute: boolean;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
cachedContentTokenCount?: number;
};
private get isYoloMatch(): boolean {
return (
this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO
@@ -274,6 +280,7 @@ export class Task {
userTier?: UserTierId;
error?: string;
traceId?: string;
usageMetadata?: Task['usageMetadata'];
} = {
coderAgent: coderAgentMessage,
model: this.modelInfo || this.config.getModel(),
@@ -288,6 +295,10 @@ export class Task {
metadata.traceId = traceId;
}
if (final && this.usageMetadata) {
metadata.usageMetadata = this.usageMetadata;
}
return {
kind: 'status-update',
taskId: this.id,
@@ -857,8 +868,18 @@ export class Task {
break;
case GeminiEventType.Finished:
logger.info(`[Task ${this.id}] Agent finished its turn.`);
// Capture the usage metadata when the stream finishes
if (
event.value &&
typeof event.value === 'object' &&
'usageMetadata' in event.value
) {
this.usageMetadata = event.value
.usageMetadata as typeof this.usageMetadata;
}
break;
case GeminiEventType.ModelInfo:
this.usageMetadata = undefined;
this.modelInfo = event.value;
break;
case GeminiEventType.Retry:
@@ -5,17 +5,13 @@
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
type AnyDeclarativeTool,
type Config,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AddMemoryCommand,
ListMemoryCommand,
MemoryCommand,
RefreshMemoryCommand,
@@ -32,44 +28,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
showMemory: vi.fn(),
refreshMemory: vi.fn(),
listMemoryFiles: vi.fn(),
addMemory: vi.fn(),
};
});
const mockShowMemory = vi.mocked(showMemory);
const mockRefreshMemory = vi.mocked(refreshMemory);
const mockListMemoryFiles = vi.mocked(listMemoryFiles);
const mockAddMemory = vi.mocked(addMemory);
describe('a2a-server memory commands', () => {
let mockContext: CommandContext;
let mockConfig: Config;
let mockToolRegistry: ToolRegistry;
let mockSaveMemoryTool: AnyDeclarativeTool;
beforeEach(() => {
mockSaveMemoryTool = {
name: 'save_memory',
description: 'Saves memory',
buildAndExecute: vi.fn().mockResolvedValue(undefined),
} as unknown as AnyDeclarativeTool;
mockToolRegistry = {
getTool: vi.fn(),
} as unknown as ToolRegistry;
mockConfig = {
get toolRegistry() {
return mockToolRegistry;
},
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
} as unknown as Config;
mockConfig = {} as unknown as Config;
mockContext = {
config: mockConfig,
};
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool);
});
describe('MemoryCommand', () => {
@@ -136,76 +111,4 @@ describe('a2a-server memory commands', () => {
expect(response.data).toBe('file1.md\nfile2.md');
});
});
describe('AddMemoryCommand', () => {
it('returns message content if addMemory returns a message', async () => {
const command = new AddMemoryCommand();
mockAddMemory.mockReturnValue({
type: 'message',
messageType: 'error',
content: 'error message',
});
const response = await command.execute(mockContext, []);
expect(mockAddMemory).toHaveBeenCalledWith('');
expect(response.name).toBe('memory add');
expect(response.data).toBe('error message');
});
it('executes the save_memory tool if found', async () => {
const command = new AddMemoryCommand();
const fact = 'this is a new fact';
mockAddMemory.mockReturnValue({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
const response = await command.execute(mockContext, [
'this',
'is',
'a',
'new',
'fact',
]);
expect(mockAddMemory).toHaveBeenCalledWith(fact);
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('save_memory');
expect(mockSaveMemoryTool.buildAndExecute).toHaveBeenCalledWith(
{ fact },
expect.any(AbortSignal),
undefined,
{
shellExecutionConfig: {
sanitizationConfig: {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
},
sandboxManager: undefined,
},
},
);
expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config);
expect(response.name).toBe('memory add');
expect(response.data).toBe(`Added memory: "${fact}"`);
});
it('returns an error if the tool is not found', async () => {
const command = new AddMemoryCommand();
const fact = 'another fact';
mockAddMemory.mockReturnValue({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
vi.mocked(mockToolRegistry.getTool).mockReturnValue(undefined);
const response = await command.execute(mockContext, ['another', 'fact']);
expect(response.name).toBe('memory add');
expect(response.data).toBe('Error: Tool save_memory not found.');
});
});
});
@@ -5,7 +5,6 @@
*/
import {
addMemory,
listMemoryFiles,
refreshMemory,
showMemory,
@@ -15,13 +14,6 @@ import type {
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { AgentLoopContext } from '@google/gemini-cli-core';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
@@ -30,7 +22,6 @@ export class MemoryCommand implements Command {
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
];
readonly topLevel = true;
readonly requiresWorkspace = true;
@@ -81,43 +72,3 @@ export class ListMemoryCommand implements Command {
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 loopContext: AgentLoopContext = context.config;
const toolRegistry = loopContext.toolRegistry;
const tool = toolRegistry.getTool(result.toolName);
if (tool) {
const abortController = new AbortController();
const abortSignal = abortController.signal;
await tool.buildAndExecute(result.toolArgs, abortSignal, undefined, {
shellExecutionConfig: {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: loopContext.sandboxManager,
},
});
await refreshMemory(context.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
+109 -22
View File
@@ -10,7 +10,6 @@ import { loadConfig } from './config.js';
import type { Settings } from './settings.js';
import {
type ExtensionLoader,
FileDiscoveryService,
getCodeAssistServer,
Config,
ExperimentFlags,
@@ -20,7 +19,9 @@ import {
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
ApprovalMode,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -48,18 +49,38 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
isHeadlessMode: vi.fn().mockReturnValue(false),
FileDiscoveryService: vi.fn(),
getCodeAssistServer: vi.fn(),
fetchAdminControlsOnce: vi.fn(),
createPolicyEngineConfig: vi
.fn()
.mockImplementation(
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
rules:
mode === actual.ApprovalMode.YOLO
? [
{
toolName: '*',
decision: actual.PolicyDecision.ALLOW,
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
modes: [actual.ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [
{
toolName: 'read_file',
decision: actual.PolicyDecision.ALLOW,
priority: 1.05,
source: 'Default: read-only.toml',
},
],
checkers: [],
}),
),
coreEvents: {
emitAdminSettingsChanged: vi.fn(),
},
@@ -268,21 +289,82 @@ describe('loadConfig', () => {
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
});
it('should initialize FileDiscoveryService with correct options', async () => {
const testPath = '/tmp/ignore';
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
const settings: Settings = {
fileFiltering: {
respectGitIgnore: false,
},
};
describe('policy engine configuration', () => {
it('should merge V1 and V2 tool settings into policySettings', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
exclude: ['v2-exclude'],
core: ['v2-core'],
},
mcpServers: {
test: { command: 'test', args: [] },
},
policyPaths: ['/path/to/policy'],
adminPolicyPaths: ['/path/to/admin/policy'],
};
await loadConfig(settings, mockExtensionLoader, taskId);
await loadConfig(settings, mockExtensionLoader, taskId);
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
respectGitIgnore: false,
respectGeminiIgnore: undefined,
customIgnoreFilePaths: [testPath],
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: {
core: ['v2-core'],
exclude: ['v2-exclude'],
allowed: ['v1-allowed'],
},
mcpServers: settings.mcpServers,
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V2 tool settings when V1 is missing', async () => {
const settings: Settings = {
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v2-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
it('should use V1 tool settings when V2 is also present', async () => {
const settings: Settings = {
allowedTools: ['v1-allowed'],
tools: {
allowed: ['v2-allowed'],
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: ['v1-allowed'],
}),
}),
ApprovalMode.DEFAULT,
undefined,
true,
);
});
});
@@ -410,14 +492,19 @@ describe('loadConfig', () => {
);
});
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: [],
rules: expect.arrayContaining([
expect.objectContaining({
toolName: 'read_file',
decision: PolicyDecision.ALLOW,
}),
]),
}),
}),
);
+23 -36
View File
@@ -11,9 +11,7 @@ import * as dotenv from 'dotenv';
import {
AuthType,
Config,
FileDiscoveryService,
ApprovalMode,
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
startupProfiler,
@@ -25,8 +23,8 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
createPolicyEngineConfig,
type PolicySettings,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -40,6 +38,7 @@ export async function loadConfig(
settings: Settings,
extensionLoader: ExtensionLoader,
taskId: string,
trusted: boolean = false,
): Promise<Config> {
const workspaceDir = process.cwd();
@@ -65,6 +64,24 @@ export async function loadConfig(
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const policySettings: PolicySettings = {
mcpServers: settings.mcpServers,
tools: {
core: settings.coreTools || settings.tools?.core,
exclude: settings.excludeTools || settings.tools?.exclude,
allowed: settings.allowedTools || settings.tools?.allowed,
},
policyPaths: settings.policyPaths,
adminPolicyPaths: settings.adminPolicyPaths,
};
const policyEngineConfig = await createPolicyEngineConfig(
policySettings,
approvalMode,
undefined,
true,
);
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
@@ -80,20 +97,7 @@ export async function loadConfig(
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode,
policyEngineConfig: {
rules:
approvalMode === ApprovalMode.YOLO
? [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [],
},
policyEngineConfig,
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
@@ -120,7 +124,7 @@ export async function loadConfig(
},
ideMode: false,
folderTrust,
trustedFolder: true,
trustedFolder: trusted,
extensionLoader,
checkpointing,
interactive: true,
@@ -129,23 +133,6 @@ export async function loadConfig(
enableAgents: settings.experimental?.enableAgents ?? true,
};
const fileService = new FileDiscoveryService(workspaceDir, {
respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore,
respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore,
customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths,
});
const { memoryContent, fileCount, filePaths } =
await loadServerHierarchicalMemory(
workspaceDir,
[workspaceDir],
fileService,
extensionLoader,
folderTrust,
);
configParams.userMemory = memoryContent;
configParams.geminiMdFileCount = fileCount;
configParams.geminiMdFilePaths = filePaths;
// Set an initial config to use to get a code assist server.
// This is needed to fetch admin controls.
const initialConfig = new Config({
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
const mocks = vi.hoisted(() => {
const suffix = Math.random().toString(36).slice(2);
@@ -40,6 +40,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
},
getErrorMessage: (error: unknown) => String(error),
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
isHeadlessMode: vi.fn(() => true),
};
});
@@ -146,7 +148,7 @@ describe('loadSettings', () => {
);
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
const result = loadSettings(mockWorkspaceDir);
const result = loadSettings(mockWorkspaceDir, true);
// Primitive value overwritten
expect(result.showMemoryUsage).toBe(true);
@@ -154,4 +156,78 @@ describe('loadSettings', () => {
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
});
describe('security', () => {
it('should NOT load workspace settings if workspace is NOT trusted', () => {
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
// checkPathTrust is mocked to return isTrusted: false by default
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(false);
});
it('should load workspace settings if workspace IS trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = { showMemoryUsage: false };
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = { showMemoryUsage: true };
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
});
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
vi.mocked(checkPathTrust).mockReturnValueOnce({
isTrusted: true,
source: 'file',
});
const userSettings = {
adminPolicyPaths: ['/trusted/admin'],
policyPaths: ['/trusted/user'],
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
const workspaceSettings = {
adminPolicyPaths: ['./malicious/admin'],
policyPaths: ['./malicious/user'],
showMemoryUsage: true,
};
const workspaceSettingsPath = path.join(
mockGeminiWorkspaceDir,
'settings.json',
);
fs.writeFileSync(
workspaceSettingsPath,
JSON.stringify(workspaceSettings),
);
const result = loadSettings(mockWorkspaceDir);
expect(result.showMemoryUsage).toBe(true);
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
expect(result.policyPaths).toEqual(['/trusted/user']);
});
});
});
+44 -17
View File
@@ -14,6 +14,8 @@ import {
getErrorMessage,
type TelemetrySettings,
homedir,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
@@ -51,6 +53,8 @@ export interface Settings {
experimental?: {
enableAgents?: boolean;
};
policyPaths?: string[];
adminPolicyPaths?: string[];
}
export interface SettingsError {
@@ -64,13 +68,16 @@ export interface CheckpointingSettings {
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
* Project settings override user settings if the workspace is trusted.
*
* How is it different to gemini-cli/cli: Returns already merged settings rather
* than `LoadedSettings` (unnecessary since we are not modifying users
* settings.json).
*/
export function loadSettings(workspaceDir: string): Settings {
export function loadSettings(
workspaceDir: string,
isTrustedOverride?: boolean,
): Settings {
let userSettings: Settings = {};
let workspaceSettings: Settings = {};
const settingsErrors: SettingsError[] = [];
@@ -92,27 +99,39 @@ export function loadSettings(workspaceDir: string): Settings {
});
}
let isTrusted = isTrustedOverride;
if (isTrusted === undefined) {
const { isTrusted: trustResult } = checkPathTrust({
path: workspaceDir,
isFolderTrustEnabled: userSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
isTrusted = trustResult ?? false;
}
const workspaceSettingsPath = path.join(
workspaceDir,
GEMINI_DIR,
'settings.json',
);
// Load workspace settings
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
// Load workspace settings only if trusted
if (isTrusted) {
try {
if (fs.existsSync(workspaceSettingsPath)) {
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const parsedWorkspaceSettings = JSON.parse(
stripJsonComments(projectContent),
) as Settings;
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
} catch (error: unknown) {
settingsErrors.push({
message: getErrorMessage(error),
path: workspaceSettingsPath,
});
}
if (settingsErrors.length > 0) {
@@ -125,10 +144,18 @@ export function loadSettings(workspaceDir: string): Settings {
// If there are overlapping keys, the values of workspaceSettings will
// override values from userSettings
return {
const mergedSettings = {
...userSettings,
...workspaceSettings,
};
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
// configuration and cannot be overridden by workspace-level settings, even if the
// workspace is trusted.
mergedSettings.policyPaths = userSettings.policyPaths;
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
return mergedSettings;
}
function resolveEnvVarsInString(value: string): string {
+14 -1
View File
@@ -30,6 +30,8 @@ import {
debugLogger,
SimpleExtensionLoader,
GitService,
checkPathTrust,
isHeadlessMode,
} from '@google/gemini-cli-core';
import type { Command, CommandArgument } from '../commands/types.js';
@@ -197,12 +199,23 @@ export async function createApp() {
// Load the server configuration once on startup.
const workspaceRoot = setTargetDir(undefined);
loadEnvironment();
const settings = loadSettings(workspaceRoot);
// Use a temporary settings load to check if folder trust is enabled.
// This is similar to how the CLI handles the initial trust check.
const initialSettings = loadSettings(workspaceRoot, false);
const { isTrusted } = checkPathTrust({
path: workspaceRoot,
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
isHeadless: isHeadlessMode(),
});
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
const extensions = loadExtensions(workspaceRoot);
const config = await loadConfig(
settings,
new SimpleExtensionLoader(extensions),
'a2a-server',
isTrusted ?? false,
);
let git: GitService | undefined;
+1
View File
@@ -47,6 +47,7 @@ export interface AgentSettings {
kind: CoderAgentEvent.StateAgentSettingsEvent;
workspacePath: string;
autoExecute?: boolean;
isTrusted?: boolean;
}
export interface ToolCallConfirmation {
+1 -1
View File
@@ -126,7 +126,7 @@ async function run() {
while (true) {
try {
const exitCode = await runner();
if (exitCode !== RELAUNCH_EXIT_CODE) {
if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) {
process.exit(exitCode);
}
} catch (error: unknown) {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.42.0-nightly.20260428.g59b2dea0e",
"version": "0.45.0-nightly.20260521.g854f811be",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.42.0-nightly.20260428.g59b2dea0e"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.45.0-nightly.20260521.g854f811be"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -17,9 +17,8 @@ describe('CommandHandler', () => {
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 memList = parse('/memory list');
expect(memList.commandToExecute?.name).toBe('memory list');
const extList = parse('/extensions list');
expect(extList.commandToExecute?.name).toBe('extensions list');
+5 -2
View File
@@ -60,8 +60,11 @@ export class AcpFileSystemService implements FileSystemService {
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
const content: unknown = response.content;
if (typeof content !== 'string') {
throw new Error('content must be a string'); // replace with other response type formats when modified in the future
}
return content;
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
+5
View File
@@ -100,6 +100,11 @@ describe('GeminiAgent Session Resume', () => {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
getMessageBus: vi.fn().mockReturnValue({
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
}),
getApprovalMode: vi.fn().mockReturnValue('default'),
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
isPlanEnabled: vi.fn().mockReturnValue(true),
@@ -71,6 +71,7 @@ describe('GeminiAgent - RPC Dispatcher', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
+326
View File
@@ -26,6 +26,10 @@ import {
InvalidStreamError,
GeminiEventType,
type ServerGeminiStreamEvent,
PolicyDecision,
MessageBusType,
type ToolConfirmationRequest,
DiscoveredMCPTool,
} from '@google/gemini-cli-core';
import type { LoadedSettings } from '../config/settings.js';
import { type Part, FinishReason } from '@google/genai';
@@ -139,9 +143,13 @@ describe('Session', () => {
isPlanEnabled: vi.fn().mockReturnValue(true),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn().mockResolvedValue({} as GitService),
getPolicyEngine: vi.fn().mockReturnValue({
check: vi.fn(),
}),
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
waitForMcpInit: vi.fn(),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
@@ -707,4 +715,322 @@ describe('Session', () => {
}),
);
});
describe('Policy Handling', () => {
it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Trigger the subscription handler
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
expect(handler).toBeDefined();
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id',
confirmed: true,
requiresUserConfirmation: false,
}),
);
});
it('should request user confirmation when PolicyEngine returns ASK_USER', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ASK_USER,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-2',
toolCall: { name: 'rm', args: { path: '/' } },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-2',
confirmed: false,
requiresUserConfirmation: true,
}),
);
});
it('should deny tool calls when PolicyEngine returns DENY', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.DENY,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-3',
toolCall: { name: 'forbidden', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-3',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass subagent and trusted tool info to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry with trusted annotations
const trustedAnnotations = { safe: true };
mockToolRegistry.getTool.mockReturnValue({
name: 'ls',
toolAnnotations: trustedAnnotations,
});
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-trusted',
toolCall: { name: 'ls', args: {} },
subagent: 'restricted-subagent',
serverName: 'spoofed-server', // Should be ignored
toolAnnotations: { malicious: true }, // Should be ignored
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
undefined, // serverName for non-MCP tool
trustedAnnotations,
'restricted-subagent',
);
});
it('should handle exceptions in PolicyEngine by failing closed', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockRejectedValue(
new Error('Policy check failed'),
);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-error',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-error',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should fail closed when PolicyEngine is missing', async () => {
(mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-engine',
toolCall: { name: 'ls', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-engine',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should handle missing tool name in request by failing closed', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-no-name',
toolCall: { name: '', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-no-name',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should trim tool name before lookup and validation', async () => {
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-whitespace',
toolCall: { name: ' ', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-whitespace',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => {
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
check: Mock<
(
toolCall: { name: string; args: Record<string, unknown> },
serverName?: string,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
) => Promise<{ decision: PolicyDecision }>
>;
};
mockPolicyEngine.check.mockResolvedValue({
decision: PolicyDecision.ALLOW,
});
// Mock tool in registry as a DiscoveredMCPTool instance
const mcpTool = {
name: 'mcp_server_tool',
serverName: 'test-server',
toolAnnotations: { mcp: true },
};
Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype);
mockToolRegistry.getTool.mockReturnValue(mcpTool);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-mcp',
toolCall: { name: 'mcp_server_tool', args: {} },
});
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
expect.anything(),
'test-server',
{ mcp: true },
undefined,
);
});
it('should fail closed and deny unknown tools', async () => {
mockToolRegistry.getTool.mockReturnValue(undefined);
const handler = mockMessageBus.subscribe.mock.calls.find(
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
await handler({
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
correlationId: 'test-id-unknown',
toolCall: { name: 'unknown_tool', args: {} },
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'test-id-unknown',
confirmed: false,
requiresUserConfirmation: false,
}),
);
});
});
});
+210 -88
View File
@@ -34,6 +34,11 @@ import {
isNodeError,
REFERENCE_CONTENT_START,
InvalidStreamError,
MessageBusType,
PolicyDecision,
type ToolConfirmationRequest,
resolveAtCommandPath,
type ResolvedAtCommandPath,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import type { Part, FunctionCall } from '@google/genai';
@@ -61,6 +66,7 @@ export class Session {
private pendingPrompt: AbortController | null = null;
private commandHandler = new CommandHandler();
private callIdCounter = 0;
private readonly disposeController = new AbortController();
private generateCallId(name: string): string {
return `${name}-${Date.now()}-${++this.callIdCounter}`;
@@ -77,8 +83,98 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
// Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands)
this.context.config
.getMessageBus()
?.subscribe(
MessageBusType.TOOL_CONFIRMATION_REQUEST,
this.handleToolConfirmationRequest,
{ signal: this.disposeController.signal },
);
}
private handleToolConfirmationRequest = async (
request: ToolConfirmationRequest,
) => {
try {
const policyEngine = this.context.config.getPolicyEngine?.();
const messageBus = this.context.config.getMessageBus();
if (!messageBus) {
return;
}
if (!policyEngine) {
debugLogger.warn(
'Policy engine missing. Denying tool confirmation request.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const toolName = request.toolCall.name?.trim();
if (!toolName) {
debugLogger.warn(
'Tool confirmation request missing tool name. Denying.',
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const tool = this.context.toolRegistry.getTool(toolName);
if (!tool) {
debugLogger.warn(
`Tool confirmation request for unknown tool: ${toolName}. Denying.`,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
return;
}
const serverName =
tool instanceof DiscoveredMCPTool ? tool.serverName : undefined;
const toolAnnotations = tool.toolAnnotations;
const result = await policyEngine.check(
request.toolCall,
serverName,
toolAnnotations,
request.subagent,
);
await messageBus.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: result.decision === PolicyDecision.ALLOW,
requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER,
});
} catch (error) {
debugLogger.error('Error handling tool confirmation request:', error);
// Fail closed on exception
await this.context.config.getMessageBus()?.publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: request.correlationId,
confirmed: false,
requiresUserConfirmation: false,
});
}
};
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
if (payload.sessionId === this.id) {
void this.sendUpdate({
@@ -96,6 +192,7 @@ export class Session {
CoreEvent.ApprovalModeChanged,
this.handleApprovalModeChanged,
);
this.disposeController.abort();
}
async cancelPendingPrompt(): Promise<void> {
@@ -928,99 +1025,120 @@ export class Session {
let currentPathSpec = pathName;
let resolvedSuccessfully = false;
let readDirectly = false;
try {
const absolutePath = path.resolve(
const result = await resolveAtCommandPath(
pathName,
this.context.config,
(msg) => this.debug(msg),
);
let validationError: string | null = null;
let absolutePath: string;
let resolved: ResolvedAtCommandPath | undefined;
if (result.status === 'resolved') {
resolved = result.resolved;
absolutePath = resolved.absolutePath;
} else if (result.status === 'unauthorized') {
absolutePath = result.absolutePath;
validationError = result.error;
} else if (result.status === 'invalid') {
// Already logged in resolveAtCommandPath
continue;
} else {
// Result is not_found.
// We still check if it's an unauthorized absolute path that we can ask permission for,
// specifically for paths that are completely outside the root and not even in any workspace directory.
// For relative paths not found anywhere, we resolve relative to targetDir for permission check.
absolutePath = path.resolve(
this.context.config.getTargetDir(),
pathName,
);
}
let validationError = this.context.config.validatePathAccess(
absolutePath,
'read',
);
// We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized).
if (
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
if (
!resolved &&
validationError &&
!isWithinRoot(absolutePath, this.context.config.getTargetDir())
) {
try {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`;
const params = {
sessionId: this.id,
options: [
{
optionId: ToolConfirmationOutcome.ProceedOnce,
name: 'Allow once',
kind: 'allow_once',
},
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
{
optionId: ToolConfirmationOutcome.Cancel,
name: 'Deny',
kind: 'reject_once',
},
] as acp.PermissionOption[],
toolCall: {
toolCallId: syntheticCallId,
status: 'pending',
title: `Allow access to absolute path: ${pathName}`,
content: [
{
type: 'content',
content: {
type: 'text',
text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`,
},
},
],
locations: [],
kind: 'read',
},
});
}
}
};
const output = RequestPermissionResponseSchema.parse(
await this.connection.requestPermission(params),
);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
if (outcome === ToolConfirmationOutcome.ProceedOnce) {
this.context.config
.getWorkspaceContext()
.addReadOnlyPath(absolutePath);
validationError = null;
} else {
this.debug(
`Direct read authorization denied for absolute path ${pathName}`,
);
directContents.push({
spec: pathName,
content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`,
});
continue;
}
}
} catch (error) {
this.debug(
`Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`,
);
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`,
},
});
}
}
try {
if (!validationError) {
// If it's an absolute path that is authorized (e.g. added via readOnlyPaths),
// read it directly to avoid ReadManyFilesTool absolute path resolution issues.
@@ -1033,7 +1151,9 @@ export class Session {
!readDirectly
) {
try {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isFile()) {
const fileReadResult = await processSingleFileContent(
absolutePath,
@@ -1092,7 +1212,9 @@ export class Session {
}
if (!readDirectly) {
const stats = await fs.stat(absolutePath);
const stats = resolved
? resolved.stats
: await fs.stat(absolutePath);
if (stats.isDirectory()) {
currentPathSpec = pathName.endsWith('/')
? `${pathName}**`
+7 -11
View File
@@ -19,6 +19,7 @@ import type * as acp from '@agentclientprotocol/sdk';
import {
AuthType,
type Config,
GEMINI_MODEL_ALIAS_AUTO,
type MessageBus,
type Storage,
} from '@google/gemini-cli-core';
@@ -78,6 +79,7 @@ describe('AcpSessionManager', () => {
validatePathAccess: vi.fn().mockReturnValue(null),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
getDirectories: vi.fn().mockReturnValue(['/tmp']),
}),
getPolicyEngine: vi.fn().mockReturnValue({
addRule: vi.fn(),
@@ -208,20 +210,19 @@ describe('AcpSessionManager', () => {
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'auto-gemini-3',
modelId: GEMINI_MODEL_ALIAS_AUTO,
name: expect.stringContaining('Auto'),
}),
]),
);
});
it('should include gemini-3.1-flash-lite when useGemini31FlashLite is true', async () => {
it('should NOT include retired preview models (none) in available models', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
});
mockConfig.getHasAccessToPreviewModel = vi.fn().mockReturnValue(true);
mockConfig.getGemini31LaunchedSync = vi.fn().mockReturnValue(true);
mockConfig.getGemini31FlashLiteLaunchedSync = vi.fn().mockReturnValue(true);
const response = await manager.newSession(
{
@@ -231,14 +232,9 @@ describe('AcpSessionManager', () => {
{},
);
expect(response.models?.availableModels).toEqual(
expect.arrayContaining([
expect.objectContaining({
modelId: 'gemini-3.1-flash-lite-preview',
name: 'gemini-3.1-flash-lite-preview',
}),
]),
);
const modelIds =
response.models?.availableModels?.map((m) => m.modelId) ?? [];
expect(modelIds).not.toContain('none');
});
it('should return modes with plan mode when plan is enabled', async () => {
+10 -2
View File
@@ -69,7 +69,10 @@ export class AcpSessionManager {
);
const authType =
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
loadedSettings.merged.security.auth.selectedType ||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
? AuthType.GATEWAY
: AuthType.USE_GEMINI);
let isAuthenticated = false;
let authErrorMessage = '';
@@ -231,7 +234,12 @@ export class AcpSessionManager {
mcpServers: acp.McpServer[],
authDetails: AuthDetails,
): Promise<Config> {
const selectedAuthType = this.settings.merged.security.auth.selectedType;
const selectedAuthType =
this.settings.merged.security.auth.selectedType ||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
? AuthType.GATEWAY
: undefined);
if (!selectedAuthType) {
throw acp.RequestError.authRequired();
}
+13 -24
View File
@@ -10,8 +10,7 @@ import {
type ToolCallConfirmationDetails,
Kind,
ApprovalMode,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
GEMINI_MODEL_ALIAS_AUTO,
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_FLASH_LITE_MODEL,
@@ -19,10 +18,11 @@ import {
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
PREVIEW_GEMINI_FLASH_LITE_MODEL,
getDisplayString,
AuthType,
ToolConfirmationOutcome,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import { z } from 'zod';
@@ -262,11 +262,9 @@ export function buildAvailableModels(
}>;
currentModelId: string;
} {
const preferredModel = config.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
const preferredModel = config.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config.getHasAccessToPreviewModel();
const useGemini31 = config.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const selectedAuthType = settings.merged.security.auth.selectedType;
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
@@ -278,7 +276,6 @@ export function buildAvailableModels(
) {
const options = config.getModelConfigService().getAvailableModelOptions({
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
});
@@ -292,23 +289,15 @@ export function buildAvailableModels(
// --- LEGACY PATH ---
const mainOptions = [
{
value: DEFAULT_GEMINI_MODEL_AUTO,
title: getDisplayString(DEFAULT_GEMINI_MODEL_AUTO),
description:
'Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash',
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
},
];
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,
@@ -344,10 +333,10 @@ export function buildAvailableModels(
},
];
if (useGemini31FlashLite) {
if (PREVIEW_GEMINI_FLASH_LITE_MODEL !== 'none') {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
value: PREVIEW_GEMINI_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_FLASH_LITE_MODEL),
});
}
-50
View File
@@ -5,7 +5,6 @@
*/
import {
addMemory,
listInboxMemoryPatches,
listInboxSkills,
listInboxPatches,
@@ -19,12 +18,6 @@ import type {
CommandExecutionResponse,
} from './types.js';
const DEFAULT_SANITIZATION_CONFIG = {
allowedEnvironmentVariables: [],
blockedEnvironmentVariables: [],
enableEnvironmentVariableRedaction: false,
};
export class MemoryCommand implements Command {
readonly name = 'memory';
readonly description = 'Manage memory.';
@@ -32,7 +25,6 @@ export class MemoryCommand implements Command {
new ShowMemoryCommand(),
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
new InboxMemoryCommand(),
];
readonly requiresWorkspace = true;
@@ -85,48 +77,6 @@ export class ListMemoryCommand implements Command {
}
}
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.agentContext.toolRegistry;
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, {
shellExecutionConfig: {
sanitizationConfig: DEFAULT_SANITIZATION_CONFIG,
sandboxManager: context.agentContext.sandboxManager,
},
});
await refreshMemory(context.agentContext.config);
return {
name: this.name,
data: `Added memory: "${textToAdd}"`,
};
} else {
return {
name: this.name,
data: `Error: Tool ${result.toolName} not found.`,
};
}
}
}
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
@@ -27,7 +27,7 @@ export interface ConfigLogger {
export type RequestSettingCallback = (
setting: ExtensionSetting,
) => Promise<string>;
) => Promise<string | undefined>;
export type RequestConfirmationCallback = (message: string) => Promise<boolean>;
const defaultLogger: ConfigLogger = {
@@ -47,8 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async (
message,
initial: false,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.confirm;
return typeof response.confirm === 'boolean' ? response.confirm : false;
};
export async function getExtensionManager() {
+11 -2
View File
@@ -8,6 +8,15 @@ import { AuthType } from '@google/gemini-cli-core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAuthMethod } from './auth.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
loadApiKey: vi.fn().mockResolvedValue(null),
};
});
vi.mock('./settings.js', () => ({
loadEnvironment: vi.fn(),
loadSettings: vi.fn().mockReturnValue({
@@ -90,10 +99,10 @@ describe('validateAuthMethod', () => {
envs: {},
expected: 'Invalid auth method selected.',
},
])('$description', ({ authType, envs, expected }) => {
])('$description', async ({ authType, envs, expected }) => {
for (const [key, value] of Object.entries(envs)) {
vi.stubEnv(key, value as string);
}
expect(validateAuthMethod(authType)).toBe(expected);
expect(await validateAuthMethod(authType)).toBe(expected);
});
});
+6 -3
View File
@@ -4,10 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { AuthType } from '@google/gemini-cli-core';
import { AuthType, loadApiKey } from '@google/gemini-cli-core';
import { loadEnvironment, loadSettings } from './settings.js';
export function validateAuthMethod(authMethod: string): string | null {
export async function validateAuthMethod(
authMethod: string,
): Promise<string | null> {
loadEnvironment(loadSettings().merged, process.cwd());
if (
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
@@ -17,7 +19,8 @@ export function validateAuthMethod(authMethod: string): string | null {
}
if (authMethod === AuthType.USE_GEMINI) {
if (!process.env['GEMINI_API_KEY']) {
const key = process.env['GEMINI_API_KEY'] || (await loadApiKey());
if (!key) {
return (
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
'Update your environment and try again (no reload needed if using .env)!'
+37 -169
View File
@@ -15,7 +15,6 @@ import {
EDIT_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
ASK_USER_TOOL_NAME,
type ExtensionLoader,
debugLogger,
ApprovalMode,
type MCPServerConfig,
@@ -112,27 +111,6 @@ vi.mock('@google/gemini-cli-core', async () => {
}),
},
loadEnvironment: vi.fn(),
loadServerHierarchicalMemory: vi.fn(
(
cwd,
dirs,
fileService,
extensionLoader: ExtensionLoader,
_folderTrust,
_importFormat,
_fileFilteringOptions,
_maxDirs,
) => {
const extensionPaths =
extensionLoader?.getExtensions?.()?.flatMap((e) => e.contextFiles) ||
[];
return Promise.resolve({
memoryContent: extensionPaths.join(',') || '',
fileCount: extensionPaths?.length || 0,
filePaths: extensionPaths,
});
},
),
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS: {
respectGitIgnore: false,
respectGeminiIgnore: true,
@@ -312,6 +290,22 @@ describe('parseArguments', () => {
});
});
describe('knowledgeSource', () => {
it('should parse --knowledge-source flag with a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'mykb.md'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe('mykb.md');
});
it('should default to ~/.agents/kb.md when --knowledge-source is provided without a path', async () => {
process.argv = ['node', 'script.js', '--knowledge-source'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
expect(argv.knowledgeSource).toBe(path.join(os.homedir(), '.agents', 'kb.md'));
});
});
it.each([
{
description: 'long flags',
@@ -1030,6 +1024,25 @@ describe('loadCliConfig', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should enable simulateUser when knowledgeSource is provided', async () => {
process.argv = ['node', 'script.js', '--knowledge-source', 'k.txt'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
expect(config.getKnowledgeSource()).toBe(
path.resolve(process.cwd(), 'k.txt'),
);
});
it('should enable simulateUser when simulateUser flag is provided', async () => {
process.argv = ['node', 'script.js', '--simulate-user'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getSimulateUser()).toBe(true);
});
it('should be non-interactive when isCommand is set', async () => {
process.argv = ['node', 'script.js', 'mcp', 'list'];
const argv = await parseArguments(createTestMergedSettings());
@@ -1067,151 +1080,6 @@ describe('loadCliConfig', () => {
});
});
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
// Restore ExtensionManager mocks that were reset
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
ExtensionManager.prototype.loadExtensions = vi
.fn()
.mockResolvedValue(undefined);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
// Other common mocks would be reset here.
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass extension context file paths to loadServerHierarchicalMemory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
});
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
contextFiles: ['/path/to/ext1/GEMINI.md'],
isActive: true,
},
{
path: '/path/to/ext2',
name: 'ext2',
id: 'ext2-id',
version: '1.0.0',
contextFiles: [],
isActive: true,
},
{
path: '/path/to/ext3',
name: 'ext3',
id: 'ext3-id',
version: '1.0.0',
contextFiles: [
'/path/to/ext3/context1.md',
'/path/to/ext3/context2.md',
],
isActive: true,
},
]);
const argv = await parseArguments(createTestMergedSettings());
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200, // maxDirs
['.git'], // boundaryMarkers
);
});
it('should pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is true', async () => {
process.argv = ['node', 'script.js'];
const includeDir = path.resolve(path.sep, 'path', 'to', 'include');
const settings = createTestMergedSettings({
experimental: { jitContext: false },
context: {
includeDirectories: [includeDir],
loadMemoryFromIncludeDirectories: true,
},
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[includeDir],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
it('should NOT pass includeDirectories to loadServerHierarchicalMemory when loadMemoryFromIncludeDirectories is false', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
context: {
includeDirectories: ['/path/to/include'],
loadMemoryFromIncludeDirectories: false,
},
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String),
[],
expect.any(Object),
expect.any(ExtensionManager),
true,
'tree',
expect.objectContaining({
respectGitIgnore: true,
respectGeminiIgnore: true,
}),
200,
['.git'], // boundaryMarkers
);
});
it('should NOT call loadServerHierarchicalMemory when skipMemoryLoad is true', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { jitContext: false },
});
const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv, {
skipMemoryLoad: true,
});
expect(ServerConfig.loadServerHierarchicalMemory).not.toHaveBeenCalled();
});
});
describe('mergeMcpServers', () => {
it('should not modify the original settings object', async () => {
const settings = createTestMergedSettings({
@@ -2058,7 +1926,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('auto-gemini-3');
expect(config.getModel()).toBe('auto');
});
it('always prefers model from argv', async () => {
@@ -2102,7 +1970,7 @@ describe('loadCliConfig model selection', () => {
argv,
);
expect(config.getModel()).toBe('auto-gemini-3');
expect(config.getModel()).toBe('auto');
});
});
+47 -52
View File
@@ -8,6 +8,7 @@ import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import * as os from 'node:os';
import { execa } from 'execa';
import { mcpCommand } from '../commands/mcp.js';
import { extensionsCommand } from '../commands/extensions.js';
@@ -16,22 +17,19 @@ import { hooksCommand } from '../commands/hooks.js';
import { gemmaCommand } from '../commands/gemma.js';
import {
setGeminiMdFilename as setServerGeminiMdFilename,
getCurrentGeminiMdFilename,
resetGeminiMdFilename,
DEFAULT_CONTEXT_FILENAME,
ApprovalMode,
DEFAULT_GEMINI_EMBEDDING_MODEL,
DEFAULT_FILE_FILTERING_OPTIONS,
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
FileDiscoveryService,
resolveTelemetrySettings,
FatalConfigError,
getErrorMessage,
getPty,
debugLogger,
loadServerHierarchicalMemory,
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
@@ -82,6 +80,7 @@ export interface CliArgs {
model: string | undefined;
sandbox: boolean | string | undefined;
debug: boolean | undefined;
disableStreaming?: boolean;
prompt: string | undefined;
promptInteractive: string | undefined;
worktree?: string;
@@ -106,12 +105,15 @@ export interface CliArgs {
useWriteTodos: boolean | undefined;
outputFormat: string | undefined;
fakeResponses: string | undefined;
fakeResponsesNonStrict?: string | undefined;
recordResponses: string | undefined;
startupMessages?: string[];
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
skipTrust: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
knowledgeSource: string | undefined;
}
/**
@@ -465,6 +467,10 @@ export async function parseArguments(
type: 'boolean',
description: 'Enable screen reader mode for accessibility.',
})
.option('disable-streaming', {
type: 'boolean',
description: 'Disable streaming responses from the model',
})
.option('output-format', {
alias: 'o',
type: 'string',
@@ -477,6 +483,12 @@ export async function parseArguments(
description: 'Path to a file with fake model responses for testing.',
hidden: true,
})
.option('fake-responses-non-strict', {
type: 'string',
description:
'Path to a file with fake model responses for testing (non-strict mode).',
hidden: true,
})
.option('record-responses', {
type: 'string',
description: 'Path to a file to record model responses for testing.',
@@ -490,6 +502,24 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('simulate-user', {
type: 'boolean',
description:
'Run the user simulation agent in the background for evaluation purposes.',
})
.option('knowledge-source', {
type: 'string',
skipValidation: true,
description:
'A file path to load into the user simulator context and update with new knowledge. Defaults to ~/.agents/kb.md if passed without a value.',
coerce: (value: string): string => {
const trimmed = value.trim();
if (trimmed === '') {
return path.join(os.homedir(), '.agents', 'kb.md');
}
return trimmed;
},
}),
)
.version(await getVersion()) // This will enable the --version flag based on package.json
@@ -572,7 +602,6 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
skipMemoryLoad?: boolean;
}
export async function loadCliConfig(
@@ -581,12 +610,7 @@ export async function loadCliConfig(
argv: CliArgs,
options: LoadCliConfigOptions = {},
): Promise<Config> {
const {
cwd = process.cwd(),
projectHooks,
skipExtensions = false,
skipMemoryLoad = false,
} = options;
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
const debugMode = isDebugMode(argv);
const worktreeSettings =
@@ -596,7 +620,6 @@ export async function loadCliConfig(
process.env['GEMINI_SANDBOX'] = 'true';
}
const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
const ideMode = settings.ide?.enabled ?? false;
@@ -612,7 +635,7 @@ export async function loadCliConfig(
query: argv.query,
})?.isTrusted ?? false;
// Set the context filename in the server's memoryTool module BEFORE loading memory
// Set the context filename in the server's memory file helpers before loading memory
// TODO(b/343434939): This is a bit of a hack. The contextFileName should ideally be passed
// directly to the Config constructor in core, and have core handle setGeminiMdFilename.
// However, loadHierarchicalGeminiMemory is called *before* createServerConfig.
@@ -620,16 +643,11 @@ export async function loadCliConfig(
setServerGeminiMdFilename(settings.context.fileName);
} else {
// Reset to default if not provided in settings.
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
resetGeminiMdFilename(DEFAULT_CONTEXT_FILENAME);
}
const fileService = new FileDiscoveryService(cwd);
const memoryFileFiltering = {
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
...settings.context?.fileFiltering,
};
const fileFiltering = {
...DEFAULT_FILE_FILTERING_OPTIONS,
...settings.context?.fileFiltering,
@@ -680,8 +698,6 @@ export async function loadCliConfig(
?.getExtensions()
?.find((ext) => ext.isActive && ext.plan?.directory)?.plan;
const experimentalJitContext = settings.experimental.jitContext ?? true;
let extensionRegistryURI =
process.env['GEMINI_CLI_EXTENSION_REGISTRY_URI'] ??
(trustedFolder ? settings.experimental?.extensionRegistryURI : undefined);
@@ -692,33 +708,9 @@ export async function loadCliConfig(
);
}
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
const finalExtensionLoader =
extensionManager ?? new SimpleExtensionLoader([]);
if (!experimentalJitContext && !skipMemoryLoad) {
// Call the (now wrapper) loadHierarchicalGeminiMemory which calls the server's version
const result = await loadServerHierarchicalMemory(
cwd,
settings.context?.loadMemoryFromIncludeDirectories || false
? includeDirectories
: [],
fileService,
finalExtensionLoader,
trustedFolder,
memoryImportFormat,
memoryFileFiltering,
settings.context?.discoveryMaxDirs,
settings.context?.memoryBoundaryMarkers,
);
memoryContent = result.memoryContent;
fileCount = result.fileCount;
filePaths = result.filePaths;
}
const question = argv.promptInteractive || argv.prompt || '';
// Determine approval mode with backward compatibility
@@ -866,7 +858,7 @@ export async function loadCliConfig(
interactive,
);
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
const defaultModel = GEMINI_MODEL_ALIAS_AUTO;
const rawModel =
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
@@ -970,6 +962,8 @@ export async function loadCliConfig(
let profileSelector: string | undefined = undefined;
if (settings.experimental?.stressTestProfile) {
profileSelector = 'stressTestProfile';
} else if (settings.experimental?.powerUserProfile) {
profileSelector = 'powerUserProfile';
} else if (
settings.experimental?.generalistProfile ||
settings.experimental?.contextManagement
@@ -984,6 +978,7 @@ export async function loadCliConfig(
return new Config({
acpMode: isAcpMode,
clientName,
disableStreaming: argv.disableStreaming,
sessionId,
clientVersion: await getVersion(),
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
@@ -1030,12 +1025,13 @@ export async function loadCliConfig(
settings.security?.environmentVariableRedaction?.allowed,
enableEnvironmentVariableRedaction:
settings.security?.environmentVariableRedaction?.enabled,
userMemory: memoryContent,
geminiMdFileCount: fileCount,
geminiMdFilePaths: filePaths,
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
simulateUser: !!argv.simulateUser || !!argv.knowledgeSource,
knowledgeSource: argv.knowledgeSource
? path.resolve(cwd, resolvePath(argv.knowledgeSource))
: undefined,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
@@ -1077,8 +1073,6 @@ export async function loadCliConfig(
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
experimentalJitContext,
experimentalMemoryV2: settings.experimental?.memoryV2,
experimentalAutoMemory: settings.experimental?.autoMemory,
experimentalGemma: settings.experimental?.gemma,
contextManagement,
@@ -1120,6 +1114,7 @@ export async function loadCliConfig(
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
fakeResponsesNonStrict: argv.fakeResponsesNonStrict,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
billing: settings.billing,
@@ -109,6 +109,7 @@ describe('ExtensionManager theme loading', () => {
getFileExclusions: () => ({
isIgnored: () => false,
}),
getMemoryContextManager: () => undefined,
getGeminiMdFilePaths: () => [],
getMcpServers: () => ({}),
getAllowedMcpServers: () => [],
@@ -185,6 +186,7 @@ describe('ExtensionManager theme loading', () => {
getWorkspaceContext: () => ({
getDirectories: () => [],
}),
getMemoryContextManager: () => undefined,
getDebugMode: () => false,
getFileService: () => ({
findFiles: async () => [],
+5 -3
View File
@@ -88,7 +88,9 @@ interface ExtensionManagerParams {
enabledExtensionOverrides?: string[];
settings: MergedSettings;
requestConsent: (consent: string) => Promise<boolean>;
requestSetting: ((setting: ExtensionSetting) => Promise<string>) | null;
requestSetting:
| ((setting: ExtensionSetting) => Promise<string | undefined>)
| null;
workspaceDir: string;
eventEmitter?: EventEmitter<ExtensionEvents>;
clientVersion?: string;
@@ -106,7 +108,7 @@ export class ExtensionManager extends ExtensionLoader {
private settings: MergedSettings;
private requestConsent: (consent: string) => Promise<boolean>;
private requestSetting:
| ((setting: ExtensionSetting) => Promise<string>)
| ((setting: ExtensionSetting) => Promise<string | undefined>)
| undefined;
private telemetryConfig: Config;
private workspaceDir: string;
@@ -161,7 +163,7 @@ export class ExtensionManager extends ExtensionLoader {
}
setRequestSetting(
requestSetting?: (setting: ExtensionSetting) => Promise<string>,
requestSetting?: (setting: ExtensionSetting) => Promise<string | undefined>,
): void {
this.requestSetting = requestSetting;
}
@@ -94,9 +94,8 @@ export class ExtensionRegistryClient {
fuzzy: true,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(query);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return results.map((r: { item: RegistryExtension }) => r.item);
const results: Array<{ item: RegistryExtension }> = await fzf.find(query);
return results.map((r) => r.item);
}
async getExtension(id: string): Promise<RegistryExtension | undefined> {
@@ -8,6 +8,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { coreEvents, type GeminiCLIExtension } from '@google/gemini-cli-core';
import { ExtensionStorage } from './storage.js';
import { z } from 'zod';
export interface ExtensionEnablementConfig {
overrides: string[];
@@ -179,8 +180,12 @@ export class ExtensionEnablementManager {
readConfig(): AllExtensionsEnablementConfig {
try {
const content = fs.readFileSync(this.configFilePath, 'utf-8');
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return JSON.parse(content);
const parsed: unknown = JSON.parse(content);
const schema = z.record(
z.string(),
z.object({ overrides: z.array(z.string()) }),
);
return schema.parse(parsed);
} catch (error) {
if (
error instanceof Error &&
@@ -62,7 +62,7 @@ export const getEnvFilePath = (
export async function maybePromptForSettings(
extensionConfig: ExtensionConfig,
extensionId: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
requestSetting: (setting: ExtensionSetting) => Promise<string | undefined>,
previousExtensionConfig?: ExtensionConfig,
previousSettings?: Record<string, string>,
): Promise<void> {
@@ -106,7 +106,9 @@ export async function maybePromptForSettings(
settingsChanges.promptForEnv,
)) {
const answer = await requestSetting(setting);
allSettings[setting.envVar] = answer;
if (answer !== undefined) {
allSettings[setting.envVar] = answer;
}
}
const nonSensitiveSettings: Record<string, string> = {};
@@ -159,14 +161,13 @@ function formatEnvContent(settings: Record<string, string>): string {
export async function promptForSetting(
setting: ExtensionSetting,
): Promise<string> {
): Promise<string | undefined> {
const response = await prompts({
type: setting.sensitive ? 'password' : 'text',
name: 'value',
message: `${setting.name}\n${setting.description}`,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.value;
return typeof response.value === 'string' ? response.value : undefined;
}
export async function getScopedEnvContents(
@@ -230,7 +231,7 @@ export async function updateSetting(
extensionConfig: ExtensionConfig,
extensionId: string,
settingKey: string,
requestSetting: (setting: ExtensionSetting) => Promise<string>,
requestSetting: (setting: ExtensionSetting) => Promise<string | undefined>,
scope: ExtensionSettingScope,
workspaceDir: string,
): Promise<void> {
@@ -250,6 +251,10 @@ export async function updateSetting(
}
const newValue = await requestSetting(settingToUpdate);
if (newValue === undefined) {
return;
}
const keychain = new KeychainTokenStorage(
getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
);
@@ -67,8 +67,7 @@ export function recursivelyHydrateStrings<T>(
}
if (Array.isArray(obj)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return obj.map((item) =>
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (obj as unknown[]).map((item) =>
recursivelyHydrateStrings(item, values),
) as unknown as T;
}
@@ -571,6 +571,18 @@ describe('SettingsSchema', () => {
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
const agentSessionSubagentEnabled =
adk.properties.agentSessionSubagentEnabled;
expect(agentSessionSubagentEnabled).toBeDefined();
expect(agentSessionSubagentEnabled.type).toBe('boolean');
expect(agentSessionSubagentEnabled.category).toBe('Experimental');
expect(agentSessionSubagentEnabled.default).toBe(false);
expect(agentSessionSubagentEnabled.requiresRestart).toBe(true);
expect(agentSessionSubagentEnabled.showInDialog).toBe(false);
expect(agentSessionSubagentEnabled.description).toBe(
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
);
});
});
+52 -23
View File
@@ -12,6 +12,7 @@
import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
EDITOR_OPTIONS,
AuthProviderType,
type MCPServerConfig,
type RequiredMcpServerConfig,
@@ -192,12 +193,27 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
properties: {
preferredEditor: {
type: 'string',
type: 'enum',
label: 'Preferred Editor',
category: 'General',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The preferred editor to open files in.',
description: oneLine`
The preferred editor to open files in. Must be one of the built-in
supported identifiers. Use /editor in the CLI to pick interactively,
or leave unset to use $VISUAL/$EDITOR.
`,
showInDialog: false,
options: EDITOR_OPTIONS,
},
openEditorInNewWindow: {
type: 'boolean',
label: 'Open Editor in New Window',
category: 'General',
requiresRestart: false,
default: false,
description:
'Open VS Code-family editors in a new window when editing files.',
showInDialog: false,
},
vimMode: {
@@ -429,6 +445,16 @@ const SETTINGS_SCHEMA = {
'Enable the Topic & Update communication model for reduced chattiness and structured progress reporting.',
showInDialog: true,
},
logRagSnippets: {
type: 'boolean',
label: 'Log RAG Snippets',
category: 'General',
requiresRestart: false,
default: false,
description:
'Log full Code Customization (RAG) retrieved snippets to a local file for debugging.',
showInDialog: true,
},
},
},
output: {
@@ -2184,6 +2210,16 @@ const SETTINGS_SCHEMA = {
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
agentSessionSubagentEnabled: {
type: 'boolean',
label: 'Agent Session Subagent Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
showInDialog: false,
},
},
},
enableAgents: {
@@ -2252,16 +2288,6 @@ const SETTINGS_SCHEMA = {
'Enables extension loading/unloading within the CLI session.',
showInDialog: false,
},
jitContext: {
type: 'boolean',
label: 'JIT Context Loading',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
showInDialog: false,
},
useOSC52Paste: {
type: 'boolean',
label: 'Use OSC 52 Paste',
@@ -2392,16 +2418,6 @@ const SETTINGS_SCHEMA = {
},
},
},
memoryV2: {
type: 'boolean',
label: 'Memory v2',
category: 'Experimental',
requiresRestart: true,
default: true,
description:
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
showInDialog: true,
},
stressTestProfile: {
type: 'boolean',
label:
@@ -2433,6 +2449,15 @@ const SETTINGS_SCHEMA = {
'Suitable for general coding and software development tasks.',
showInDialog: true,
},
powerUserProfile: {
type: 'boolean',
label: 'Use the power user profile to manage agent contexts.',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Less cache friendly version of the generalist profile.',
showInDialog: false,
},
contextManagement: {
type: 'boolean',
label: 'Enable Context Management',
@@ -3471,7 +3496,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
family: { type: 'string' },
isPreview: { type: 'boolean' },
isVisible: { type: 'boolean' },
dialogDescription: { type: 'string' },
dialogDescription: {
type: 'string',
description:
"A description of the model to display in the model selection dialog. For the 'auto' alias, this value is dynamically generated and any value provided here will be ignored.",
},
features: {
type: 'object',
properties: {
@@ -26,11 +26,6 @@ vi.mock('@google/gemini-cli-core', async () => {
);
return {
...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: '',
fileCount: 0,
filePaths: [],
}),
createPolicyEngineConfig: vi.fn().mockResolvedValue({
rules: [],
checkers: [],
+46
View File
@@ -275,6 +275,10 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'),
}));
vi.mock('./config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));
describe('gemini.tsx main function', () => {
let originalIsTTY: boolean | undefined;
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
@@ -563,6 +567,8 @@ describe('gemini.tsx main function kitty protocol', () => {
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
@@ -623,6 +629,8 @@ describe('gemini.tsx main function kitty protocol', () => {
acceptRawOutputRisk: undefined,
isCommand: undefined,
skipTrust: undefined,
simulateUser: undefined,
knowledgeSource: undefined,
});
await act(async () => {
@@ -1276,6 +1284,44 @@ describe('gemini.tsx main function exit codes', () => {
}
});
it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
createMockSandboxConfig({
command: 'docker',
image: 'test-image',
}),
);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockResolvedValue(undefined),
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
const authModule = await import('./config/auth.js');
vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce(
'Auth method invalid',
);
try {
await main();
expect.fail('Should have thrown MockProcessExitError');
} catch (e) {
expect(e).toBeInstanceOf(MockProcessExitError);
expect((e as MockProcessExitError).code).toBe(41);
}
});
it('should exit with 41 for auth failure during sandbox setup', async () => {
vi.stubEnv('SANDBOX', '');
vi.mocked(loadSandboxConfig).mockResolvedValue(
+1 -2
View File
@@ -499,7 +499,6 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
skipExtensions: true,
skipMemoryLoad: true,
});
adminControlsListner.setConfig(partialConfig);
@@ -514,7 +513,7 @@ export async function main() {
partialConfig.isInteractive() &&
settings.merged.security.auth.selectedType
) {
const err = validateAuthMethod(
const err = await validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (err) {
+59 -19
View File
@@ -14,6 +14,14 @@ import {
removeCleanup,
setupTtyCheck,
} from './utils/cleanup.js';
import { UserSimulator } from './services/UserSimulator.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -86,11 +94,14 @@ export async function startInteractiveUI(
const version = await getVersion();
setWindowTitle(basename(workspaceRoot), settings);
const simulateUser = config.getSimulateUser();
const consolePatcher = new ConsolePatcher({
onNewMessage: (msg) => {
coreEvents.emitConsoleLog(msg.type, msg.content);
},
debugMode: config.getDebugMode(),
interactive: !simulateUser,
});
consolePatcher.patch();
@@ -135,6 +146,10 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -146,12 +161,20 @@ export async function startInteractiveUI(
{
stdout: inkStdout,
stderr: inkStderr,
stdin: process.stdin,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, Math.round(renderTime));
onRender: (metrics: RenderMetrics) => {
lastFrame = metrics.output;
if (metrics.staticOutput) {
staticHistory.push(metrics.staticOutput);
if (staticHistory.length > 50) {
staticHistory.shift();
}
}
if (metrics.renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, Math.round(metrics.renderTime));
}
profiler.reportFrameRendered();
},
@@ -177,25 +200,42 @@ export async function startInteractiveUI(
registerCleanup(cleanupLineWrapping);
}
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(
info,
settings,
config.getProjectRoot(),
config.getSandboxEnabled(),
);
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
if (!simulateUser) {
checkForUpdates(settings)
.then((info) => {
handleAutoUpdate(
info,
settings,
config.getProjectRoot(),
config.getSandboxEnabled(),
);
})
.catch((err) => {
// Silently ignore update check errors.
if (config.getDebugMode()) {
debugLogger.warn('Update check failed:', err);
}
});
}
const cleanupUnmount = () => instance.unmount();
registerCleanup(cleanupUnmount);
if (simulateUser) {
const simulator = new UserSimulator(
config,
() => {
if (lastFrame === undefined) return undefined;
// Combine history with latest frame for the simulator
const historyText = staticHistory.join('\n');
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
},
simulatedStdin,
);
simulator.start();
registerCleanup(() => simulator.stop());
}
const cleanupTtyCheck = setupTtyCheck();
registerCleanup(cleanupTtyCheck);
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpProxyAgent } from '../../../../node_modules/http-proxy-agent/dist/index.js';
@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// eslint-disable-next-line import/no-relative-packages
export { HttpsProxyAgent } from '../../../../node_modules/https-proxy-agent/dist/index.js';
@@ -0,0 +1,308 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { UserSimulator } from './UserSimulator.js';
import { Writable } from 'node:stream';
import {
type Config,
MessageBusType,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
describe('UserSimulator', () => {
let mockConfig: Config;
let mockGetScreen: Mock<() => string | undefined>;
let mockStdinBuffer: Writable;
let mockContentGenerator: {
generateContent: Mock;
};
let mockMessageBus: {
subscribe: Mock;
unsubscribe: Mock;
};
beforeEach(() => {
mockContentGenerator = {
generateContent: vi
.fn()
.mockResolvedValue({ text: JSON.stringify({ action: 'y\r' }) }),
};
mockMessageBus = {
subscribe: vi.fn(),
unsubscribe: vi.fn(),
};
mockConfig = {
getContentGenerator: () => mockContentGenerator,
getSimulateUser: () => true,
getQuestion: () => 'test goal',
getKnowledgeSource: () => undefined,
getHasAccessToPreviewModel: () => true,
getMessageBus: () => mockMessageBus,
} as unknown as Config;
mockGetScreen = vi.fn();
mockStdinBuffer = new Writable({
write(chunk, encoding, callback) {
callback();
},
});
vi.spyOn(mockStdinBuffer, 'write');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should include interactive prompts in its vision even when timers are present', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
// Mock a screen with a timer and a confirmation prompt
mockGetScreen.mockReturnValue(
'Thinking... (0s)\n\nAction Required: Allow pip execution? [Y/n]',
);
// Start simulator to initialize isRunning and subscribers, but clear interval immediately
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// Directly run the private tick method synchronously
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalled();
const lastCall = mockContentGenerator.generateContent.mock.calls[0];
const prompt = lastCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
'STATE 2: The agent is waiting for you to authorize a tool',
);
expect(prompt).toContain('[Y/n]');
expect(prompt).toContain('RULE 1: If there is a clear confirmation prompt');
simulator.stop();
});
it('should not wait if a prompt is visible even if a spinner is present', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
// Mock a screen with a spinner and a prompt
mockGetScreen.mockReturnValue('⠋ Working...\n> Type your message');
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalled();
const lastCall = mockContentGenerator.generateContent.mock.calls[0];
const prompt = lastCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
'Only <WAIT> (Rule 1 fallback) if the agent is truly mid-process',
);
simulator.stop();
});
it('should submit keys with reliable delays', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('> Prompt');
mockContentGenerator.generateContent.mockResolvedValue({
text: JSON.stringify({ action: 'abc' }),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
await simulator['tick']();
expect(mockStdinBuffer.write).toHaveBeenCalledWith('a');
expect(mockStdinBuffer.write).toHaveBeenCalledWith('b');
expect(mockStdinBuffer.write).toHaveBeenCalledWith('c');
simulator.stop();
});
it('should inject internal tool state into the prompt', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('Responding...');
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// Simulate tool call update
const handler = mockMessageBus.subscribe.mock.calls[0][1];
handler({
type: MessageBusType.TOOL_CALLS_UPDATE,
toolCalls: [
{
status: CoreToolCallStatus.AwaitingApproval,
request: { name: 'test_tool' },
},
],
});
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalled();
const lastCall = mockContentGenerator.generateContent.mock.calls[0];
const prompt = lastCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
'INTERNAL SYSTEM STATE: The system is currently BLOCKED',
);
expect(prompt).toContain('test_tool');
expect(prompt).toContain("Ignore any 'Responding' indicators");
simulator.stop();
});
it('should terminate if terminal state does not change after 10 consecutive inputs', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
return undefined as never;
});
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('Static Screen');
mockContentGenerator.generateContent.mockResolvedValue({
text: JSON.stringify({ action: 'y\r' }),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// Run 10 ticks manually. All of them fall through to generateContent.
for (let i = 0; i < 10; i++) {
await simulator['tick']();
}
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(10);
// Run the 11th tick, which should trigger stall termination
await simulator['tick']();
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
simulator.stop();
});
it('should capture session notes and inject them into subsequent prompts', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
mockGetScreen.mockReturnValue('> Prompt 1');
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({
action: 'ls\r',
session_notes: 'I listed the directory contents.',
}),
});
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
// First tick: captures note
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(1);
// Second tick: different screen
mockGetScreen.mockReturnValue('> Prompt 2');
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({ action: 'pwd\r' }),
});
await simulator['tick']();
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(2);
const secondCall = mockContentGenerator.generateContent.mock.calls[1];
const prompt = secondCall[0].contents[0].parts[0].text;
expect(prompt).toContain(
"Your Session Memory (Key facts you've recorded):",
);
expect(prompt).toContain('1. I listed the directory contents.');
simulator.stop();
});
it('should trigger background compression when memory exceeds threshold and merge correctly', async () => {
const simulator = new UserSimulator(
mockConfig,
mockGetScreen,
mockStdinBuffer,
);
simulator.start();
if (simulator['timer']) clearInterval(simulator['timer']);
for (let i = 0; i < 5; i++) {
mockGetScreen.mockReturnValue(`> Prompt ${i}`);
mockContentGenerator.generateContent.mockResolvedValueOnce({
text: JSON.stringify({
action: 'wait\r',
session_notes: `Note ${i}`,
}),
});
await simulator['tick']();
}
expect(mockContentGenerator.generateContent).toHaveBeenCalledTimes(5);
// Resolve the compression call
mockContentGenerator.generateContent.mockImplementation(async (req, id) => {
if (id === 'simulator-compression') {
return { text: 'Compressed Summary' };
}
return { text: JSON.stringify({ action: 'y\r' }) };
});
// Wait for the background task to complete using Vitest waitFor
await vi.waitFor(() => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
const memory = (simulator as any).sessionMemory as string[];
return memory.length > 0 && memory[0] === 'Compressed Summary';
});
// Trigger one more tick to see if the compressed memory is used
mockGetScreen.mockReturnValue('> Final Prompt');
await simulator['tick']();
const finalCall = mockContentGenerator.generateContent.mock.calls.find(
(call) =>
call[0].contents[0].parts[0].text.includes('> Final Prompt') &&
call[1] === 'simulator-prompt',
);
expect(finalCall).toBeDefined();
if (finalCall) {
const finalPrompt = finalCall[0].contents[0].parts[0].text;
expect(finalPrompt).toContain('1. Compressed Summary');
}
simulator.stop();
});
});
+554
View File
@@ -0,0 +1,554 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
debugLogger,
LlmRole,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
MessageBusType,
CoreToolCallStatus,
type Config,
type ToolCall,
type ToolCallsUpdateMessage,
} from '@google/gemini-cli-core';
import type { Writable } from 'node:stream';
import * as fs from 'node:fs';
import * as path from 'node:path';
interface SimulatorResponse {
action?: string;
thought?: string;
used_knowledge?: boolean;
new_rule?: string;
session_notes?: string;
}
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastStateKey = '';
private isProcessing = false;
private isCompressingMemory = false;
private consecutiveStallCount = 0;
private staleCycleCount = 0;
private interactionsFile: string | null = null;
private knowledgeBase = '';
private editableKnowledgeFile: string | null = null;
private actionHistory: string[] = [];
private sessionMemory: string[] = [];
private pendingToolCalls: ToolCall[] = [];
private messageBusHandler: ((msg: ToolCallsUpdateMessage) => void) | null =
null;
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
if (!this.config.getSimulateUser()) {
return;
}
this.messageBusHandler = (msg: ToolCallsUpdateMessage) => {
this.pendingToolCalls = msg.toolCalls.filter(
(tc) => tc.status === CoreToolCallStatus.AwaitingApproval,
);
};
this.config
.getMessageBus()
.subscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusHandler);
const source = this.config.getKnowledgeSource?.();
if (source) {
if (!fs.existsSync(source)) {
try {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.writeFileSync(source, '', 'utf8');
} catch (e) {
debugLogger.error(`Failed to create knowledge file at ${source}`, e);
}
}
this.editableKnowledgeFile = source;
this.loadKnowledge(source);
}
this.interactionsFile = `interactions_${Date.now()}.txt`;
this.isRunning = true;
this.timer = setInterval(() => this.tick(), 1000);
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
if (this.messageBusHandler) {
this.config
.getMessageBus()
.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, this.messageBusHandler);
this.messageBusHandler = null;
}
debugLogger.log('User simulator stopped');
}
private loadKnowledge(p: string) {
try {
if (!fs.existsSync(p)) return;
const stats = fs.statSync(p);
if (stats.isFile()) {
const content = fs.readFileSync(p, 'utf-8');
if (content.trim()) {
this.knowledgeBase = content + '\n';
}
}
} catch (e) {
debugLogger.error(`Failed to load knowledge from ${p}`, e);
}
}
private async tick() {
if (!this.isRunning || this.isProcessing) return;
try {
this.isProcessing = true;
// Patient refresh cycle: Attempt up to 3 SIGWINCH refreshes with increasing delays if screen is blank
let screen = this.getScreen();
let strippedScreen = '';
let normalizedScreen = '';
const refreshDelays = [1500, 3000, 5000];
for (let attempt = 0; attempt < refreshDelays.length; attempt++) {
if (!screen) break;
strippedScreen = screen
.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
'',
)
.replace(/\n([ \t]*\n)+/g, '\n\n');
normalizedScreen = strippedScreen
.replace(/[\u2800-\u28FF]/g, '') // Braille patterns
.replace(/[|/-\\]/g, '') // Spinners
.replace(/\b\d+(\.\d+)?s\b/g, '') // Timers (seconds)
.replace(/\b\d+m(\s+\d+s)?\b/g, '') // Timers (minutes)
.replace(/\b\d+%\b/g, '') // Percentages
.replace(/\b\d+\/\d+\b/g, '') // Progress ratios (e.g. 1/10)
.replace(/\(\s*\)/g, '')
.trim();
// If screen is not blank, or we are not blocked, proceed immediately
if (normalizedScreen.length > 0 || this.pendingToolCalls.length === 0) {
break;
}
// Screen is blank and we are blocked: Try a patient refresh
debugLogger.log(
`[SIMULATOR] Screen blank and BLOCKED. Attempting SIGWINCH refresh ${attempt + 1}/${refreshDelays.length} with ${refreshDelays[attempt]}ms delay.`,
);
try {
process.kill(0, 'SIGWINCH');
} catch {
process.kill(process.pid, 'SIGWINCH');
}
await new Promise((resolve) =>
setTimeout(resolve, refreshDelays[attempt]),
);
screen = this.getScreen();
}
if (!screen) return;
// Create a composite key representing the full state (Vision + Internal State)
const pendingIds = this.pendingToolCalls
.map((tc) => tc.request.callId)
.join(',');
const currentStateKey = `${normalizedScreen}::${pendingIds}`;
if (currentStateKey === this.lastStateKey) {
const lastAction = this.actionHistory[this.actionHistory.length - 1];
if (lastAction && lastAction !== '<WAIT>') {
this.consecutiveStallCount++;
// Increased limit to 10 for high-load environments.
if (this.consecutiveStallCount >= 10) {
const errorMsg = `[SIMULATOR] CRITICAL STALL DETECTED: Terminal state has not changed after ${this.consecutiveStallCount} consecutive inputs. Terminating to prevent loop.`;
debugLogger.error(errorMsg);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[ERROR] ${errorMsg}\n\n`,
);
}
// eslint-disable-next-line no-console
console.error(`\n${errorMsg}`);
this.stop();
process.exit(1);
}
// RECOVERY: If screen is blank and we are stalled, try a terminal refresh.
if (
normalizedScreen.length === 0 &&
this.pendingToolCalls.length > 0
) {
debugLogger.log(
'[SIMULATOR] Screen is blank but system is BLOCKED. Sending SIGWINCH refresh.',
);
try {
process.kill(0, 'SIGWINCH');
} catch {
process.kill(process.pid, 'SIGWINCH');
}
return;
}
} else {
// If it was a <WAIT> action or no action yet, we still want the 10s fallback for internal state sync
if (this.pendingToolCalls.length > 0) {
this.staleCycleCount++;
if (this.staleCycleCount % 10 !== 0) {
return;
}
} else {
return;
}
}
} else {
this.consecutiveStallCount = 0;
this.staleCycleCount = 0;
}
this.lastStateKey = currentStateKey;
debugLogger.log(
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
);
}
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const originalGoal = this.config.getQuestion();
const goalInstruction = originalGoal
? `\nThe original goal was: "${originalGoal}"\n`
: '';
const knowledgeInstruction = this.knowledgeBase
? `\nUser Knowledge Base:\nUse this information to answer questions if applicable. If the answer is not here, respond as you normally would.\n${this.knowledgeBase}\n`
: '';
const historyInstruction =
this.actionHistory.length > 0
? `\nRecent Simulator Actions (last 10):\n${this.actionHistory
.slice(-10)
.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`)
.join('\n')}\n`
: '';
const pendingToolInstruction =
this.pendingToolCalls.length > 0
? `\nINTERNAL SYSTEM STATE: The system is currently BLOCKED awaiting user approval for the following tool(s): ${this.pendingToolCalls.map((tc) => tc.request.name).join(', ')}.
Ignore any 'Responding' indicators, spinners, or timers. You MUST provide a response (e.g., 'y\\r', '2\\r') to unblock the tool execution NOW.\n`
: '';
const sessionInstruction =
this.sessionMemory.length > 0
? `\nYour Session Memory (Key facts you've recorded):
${this.sessionMemory.map((m, i) => `${i + 1}. ${m}`).join('\n')}\n`
: '';
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
Look carefully at the screen and determine the CLI's current state:
STATE 1: The agent is busy (e.g., streaming a response, executing a tool, or showing a progress message). It is actively working and NOT waiting for text input or user approval.
- In this case, your action MUST be exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options, "[Y/n]").
- In this case, your action MUST be the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool. This state takes precedence even if timers or background messages are visible.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
- If the task is indeed fully complete, your action should be "Thank you\\r" to graciously finish the simulation.
- If you have already said thank you, your action MUST be exactly: <DONE>
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
- Your action should be the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
CRITICAL RULES:
- RULE 1: If there is a clear confirmation prompt (e.g. "[Y/n]", "1) Allow Once") or an input cursor (">"), YOU MUST RESPOND (State 2 or 3). Detect these states aggressively. Only <WAIT> (Rule 1 fallback) if the agent is truly mid-process with no interactive markers visible.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: If prompted to allow execution of a command with options like 'Allow once' and 'Allow for this session', you MUST choose the option for 'Allow for this session' (typically by sending '2\\r').
- RULE 4: Use the "session_notes" field to record important facts that are scrolling off the screen (e.g., test results, proposed plans, file names, errors). Keep notes extremely brief. DO NOT record transient states like "Agent is thinking". This memory helps you maintain context across the session.
- RULE 5: You MUST output a strictly formatted JSON object with no markdown wrappers or extra text.
JSON FORMAT:
{
"action": "<The exact raw characters to send, <WAIT>, or <DONE>>",
"session_notes": "<Brief factual note to remember for future turns, if applicable>",
"used_knowledge": <true if you used the User Knowledge Base below to answer this prompt, false otherwise>
}
${goalInstruction}${knowledgeInstruction}${sessionInstruction}${historyInstruction}${pendingToolInstruction}
Here is the current terminal screen output:
<screen>
${strippedScreen}
</screen>`;
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
);
}
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-prompt',
LlmRole.UTILITY_SIMULATOR,
);
let responseText = '';
let parsedJson: SimulatorResponse = {};
try {
let cleanJson = response.text || '';
const startIdx = cleanJson.indexOf('{');
const endIdx = cleanJson.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
cleanJson = cleanJson.substring(startIdx, endIdx + 1);
} else {
cleanJson = cleanJson
.replace(/^\`\`\`json\s*|\s*\`\`\`$/gm, '')
.trim();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsedJson = JSON.parse(cleanJson) as SimulatorResponse;
responseText = parsedJson.action || '';
if (parsedJson.session_notes) {
this.sessionMemory.push(parsedJson.session_notes);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Recorded session note: ${JSON.stringify(parsedJson.session_notes)}\n\n`,
);
}
}
} catch (err) {
debugLogger.error('Failed to parse simulator response as JSON', err);
const text = (response.text || '').trim();
if (
text === '<WAIT>' ||
text === '<DONE>' ||
/^\d+\\r$/.test(text) ||
text === '\\r'
) {
responseText = text.replace(/^[\`\"']+|[\`\"']+$/g, '');
} else {
responseText = ''; // Prevent typing broken JSON string
}
}
const trimmedResponse = responseText.trim();
debugLogger.log(
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
);
}
debugLogger.log(
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
);
if (trimmedResponse === '<DONE>') {
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
debugLogger.log(msg);
if (this.interactionsFile) {
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
}
// eslint-disable-next-line no-console
console.log(`\n${msg}`);
this.stop();
process.exit(0);
}
if (trimmedResponse === '<WAIT>') {
debugLogger.log(
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
);
this.actionHistory.push('<WAIT>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<WAIT>"\n\n`,
);
}
return;
}
if (responseText) {
const keys = responseText
.replace(/\\n|\n/g, '\r')
.replace(/\\r/g, '\r');
debugLogger.log(
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
);
this.actionHistory.push(keys);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: ${JSON.stringify(keys)}\n\n`,
);
}
if (false) /* Disabled dynamic knowledge generation for evaluation stability */ {
const newKnowledge = `- ${parsedJson.new_rule}\n`;
this.knowledgeBase += newKnowledge;
const file = this.editableKnowledgeFile;
const logFile = this.interactionsFile;
if (file !== null) {
try {
fs.appendFileSync(file!, newKnowledge);
debugLogger.log(
`[SIMULATOR] Saved new knowledge to ${file}`,
);
if (logFile !== null) {
fs.appendFileSync(
logFile!,
`[LOG] [SIMULATOR] Saved new knowledge to ${file}\n\n`,
);
}
} catch (e) {
debugLogger.error(`Failed to append knowledge`, e);
}
}
}
// Wait a bit to ensure the terminal is ready for input
await new Promise((resolve) => setTimeout(resolve, 100));
for (const char of keys) {
if (char === '\r') {
// Wait a bit to ensure the previous character is rendered before submitting
await new Promise((resolve) => setTimeout(resolve, 50));
}
this.stdinBuffer.write(char);
// Small delay to ensure Ink processes each keypress event individually
// while preventing UI state collisions during long simulated inputs.
await new Promise((resolve) => setTimeout(resolve, 10));
}
// Wait a bit to ensure Ink has processed the full input
await new Promise((resolve) => setTimeout(resolve, 100));
} else {
debugLogger.log('[SIMULATOR] Skipping (empty response)');
this.actionHistory.push('<EMPTY>');
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Action History updated with: "<EMPTY>"\n\n`,
);
}
}
if (this.sessionMemory.length >= 5 && !this.isCompressingMemory) {
// Trigger background compression (do not await)
this.compressMemory().catch((err) => {
debugLogger.error('Failed to compress simulator memory', err);
});
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
private async compressMemory() {
this.isCompressingMemory = true;
try {
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const memoryToCompress = [...this.sessionMemory];
const prompt = `Summarize the following chronological session notes into a single, concise list of key facts, preserving specific technical details like file paths, proposed plans, and test results. Drop transient or obsolete observations.
Notes:
${memoryToCompress.map((m, i) => `${i + 1}. ${m}`).join('\n')}`;
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-compression',
LlmRole.UTILITY_SIMULATOR,
);
const summary = response.text?.trim();
if (summary) {
debugLogger.log(`[SIMULATOR] Memory compressed. Summary: ${summary}`);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Memory compressed. Summary: ${summary}\n\n`,
);
}
// Replace the older items with the new summary string, while preserving any new notes
// that arrived while the compression was running.
const newNotes = this.sessionMemory.slice(memoryToCompress.length);
this.sessionMemory = [summary, ...newNotes];
}
} finally {
this.isCompressingMemory = false;
}
}
}
@@ -112,5 +112,11 @@ export const createMockCommandContext = (
return output;
};
return merge(defaultMocks, overrides);
const merged: unknown = merge(defaultMocks, overrides);
const isCommandContext = (val: unknown): val is CommandContext =>
typeof val === 'object' && val !== null;
if (isCommandContext(merged)) {
return merged;
}
throw new Error('Unreachable');
};
+1 -2
View File
@@ -38,7 +38,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
isMemoryV2Enabled: vi.fn(() => false),
isAutoMemoryEnabled: vi.fn(() => false),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
@@ -66,6 +65,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
getSimulateUser: vi.fn(() => false),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
@@ -166,7 +166,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getExperimentalGemma: vi.fn().mockReturnValue(false),
getMemoryBoundaryMarkers: vi.fn().mockReturnValue(['.git']),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
+34
View File
@@ -150,6 +150,9 @@ vi.mock('./hooks/useQuotaAndFallback.js');
vi.mock('./hooks/useHistoryManager.js');
vi.mock('./hooks/useThemeCommand.js');
vi.mock('./auth/useAuth.js');
vi.mock('../config/auth.js', () => ({
validateAuthMethod: vi.fn().mockResolvedValue(null),
}));
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
vi.mock('./hooks/useModelCommand.js');
@@ -217,6 +220,7 @@ vi.mock('../utils/cleanup.js');
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { useAuthCommand } from './auth/useAuth.js';
import { validateAuthMethod } from '../config/auth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
@@ -576,6 +580,36 @@ describe('AppContainer State Management', () => {
});
describe('State Initialization', () => {
it('calls validateAuthMethod and onAuthError if validation fails', async () => {
const mockOnAuthError = vi.fn();
mockedUseAuthCommand.mockReturnValue({
authState: 'authenticated',
setAuthState: vi.fn(),
authError: null,
onAuthError: mockOnAuthError,
});
vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed');
const { unmount } = await act(async () =>
renderAppContainer({
settings: createMockSettings({
merged: {
security: {
auth: { selectedType: 'oauth-personal', useExternal: false },
},
},
}),
}),
);
await waitFor(() => {
expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal');
expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed');
});
unmount();
});
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
+32 -47
View File
@@ -47,7 +47,6 @@ import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
type Config,
type IdeInfo,
type IdeContext,
@@ -68,9 +67,9 @@ import {
ShellExecutionService,
saveApiKey,
debugLogger,
isValidEditorType,
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
@@ -255,7 +254,6 @@ export const AppContainer = (props: AppContainerProps) => {
}, [mouseMode, setOptions]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
const [quittingMessages, setQuittingMessages] = useState<
HistoryItem[] | null
@@ -610,11 +608,10 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const getPreferredEditor = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
);
const getPreferredEditor = useCallback(() => {
const val = settings.merged.general.preferredEditor;
return isValidEditorType(val) ? val : undefined;
}, [settings.merged.general.preferredEditor]);
const buffer = useTextBuffer({
initialText: '',
@@ -912,12 +909,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
const error = validateAuthMethod(
settings.merged.security.auth.selectedType,
);
if (error) {
onAuthError(error);
}
const authMethod = settings.merged.security.auth.selectedType;
void (async () => {
try {
const error = await validateAuthMethod(authMethod);
if (
error &&
authMethod === settings.merged.security.auth.selectedType
) {
onAuthError(error);
}
} catch (e) {
if (authMethod === settings.merged.security.auth.selectedType) {
onAuthError(getErrorMessage(e));
}
}
})();
}
}, [
settings.merged.security.auth.selectedType,
@@ -1065,19 +1072,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
Date.now(),
);
try {
let flattenedMemory: string;
let fileCount: number;
if (config.isJitContextEnabled()) {
await config.getMemoryContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
flattenedMemory = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
const result = await refreshServerHierarchicalMemory(config);
flattenedMemory = flattenMemory(result.memoryContent);
fileCount = result.fileCount;
}
await config.getMemoryContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
const flattenedMemory = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount();
historyManager.addItem(
{
@@ -1409,10 +1407,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
debugLogger.log(
`[AppContainer] handleFinalSubmit: streamingState=${streamingState}, isIdle=${isIdle}, isSlash=${isSlash}`,
);
if (
(isSlash && isConfigInitialized) ||
(!isCompressing && isIdle && isMcpOrConfigReady)
) {
debugLogger.log(
`[AppContainer] handleFinalSubmit: condition met, calling submitQuery`,
);
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1688,8 +1692,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
} = useIdeTrustListener();
const isInitialMount = useRef(true);
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -1742,8 +1744,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { handleSuspend } = useSuspend({
handleWarning,
setRawMode,
refreshStatic,
setForceRerenderKey,
shouldUseAlternateScreen,
});
@@ -1754,21 +1754,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [ideNeedsRestart]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
const handler = setTimeout(() => {
refreshStatic();
}, 300);
return () => {
clearTimeout(handler);
};
}, [terminalWidth, refreshStatic]);
useEffect(() => {
const unsubscribe = ideContextStore.subscribe(setIdeContextState);
setIdeContextState(ideContextStore.get());
@@ -2873,7 +2858,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
<App />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
+11 -11
View File
@@ -215,11 +215,11 @@ describe('AuthDialog', () => {
describe('handleAuthSelect', () => {
it('calls onAuthError if validation fails', async () => {
mockedValidateAuthMethod.mockReturnValue('Invalid method');
mockedValidateAuthMethod.mockResolvedValue('Invalid method');
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
handleAuthSelect(AuthType.USE_GEMINI);
await handleAuthSelect(AuthType.USE_GEMINI);
expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
@@ -231,7 +231,7 @@ describe('AuthDialog', () => {
});
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -245,7 +245,7 @@ describe('AuthDialog', () => {
it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -259,7 +259,7 @@ describe('AuthDialog', () => {
it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', '');
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -270,7 +270,7 @@ describe('AuthDialog', () => {
});
it('sets auth context with empty object for other auth types', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
mockedRadioButtonSelect.mock.calls[0][0];
@@ -281,7 +281,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog even when env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
@@ -297,7 +297,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog even when env var is empty string', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
@@ -313,7 +313,7 @@ describe('AuthDialog', () => {
});
it('shows API key dialog on initial setup if no env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
// process.env['GEMINI_API_KEY'] is not set
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
@@ -329,7 +329,7 @@ describe('AuthDialog', () => {
});
it('always shows API key dialog on re-auth even if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate switching from a different auth method (e.g., Google Login → API key)
props.settings.merged.security.auth.selectedType =
@@ -353,7 +353,7 @@ describe('AuthDialog', () => {
.mockImplementation(() => undefined as never);
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
mockedValidateAuthMethod.mockReturnValue(null);
mockedValidateAuthMethod.mockResolvedValue(null);
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
const { onSelect: handleAuthSelect } =
+5 -2
View File
@@ -154,8 +154,11 @@ export function AuthDialog({
[settings, config, setAuthState, exiting, setAuthContext],
);
const handleAuthSelect = (authMethod: AuthType) => {
const error = validateAuthMethodWithSettings(authMethod, settings);
const handleAuthSelect = async (authMethod: AuthType) => {
const error = await validateAuthMethodWithSettings(
authMethod,
settings,
).catch((e) => (e instanceof Error ? e.message : String(e)));
if (error) {
onAuthError(error);
} else {
+10 -10
View File
@@ -45,7 +45,7 @@ describe('useAuth', () => {
});
describe('validateAuthMethodWithSettings', () => {
it('should return error if auth type is enforced and does not match', () => {
it('should return error if auth type is enforced and does not match', async () => {
const settings = {
merged: {
security: {
@@ -56,14 +56,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toContain('Authentication is enforced to be oauth');
});
it('should return null if useExternal is true', () => {
it('should return null if useExternal is true', async () => {
const settings = {
merged: {
security: {
@@ -74,14 +74,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
expect(error).toBeNull();
});
it('should return null if authType is USE_GEMINI', () => {
it('should return null if authType is USE_GEMINI', async () => {
const settings = {
merged: {
security: {
@@ -90,14 +90,14 @@ describe('useAuth', () => {
},
} as LoadedSettings;
const error = validateAuthMethodWithSettings(
const error = await validateAuthMethodWithSettings(
AuthType.USE_GEMINI,
settings,
);
expect(error).toBeNull();
});
it('should call validateAuthMethod for other auth types', () => {
it('should call validateAuthMethod for other auth types', async () => {
const settings = {
merged: {
security: {
@@ -106,8 +106,8 @@ describe('useAuth', () => {
},
} as LoadedSettings;
mockValidateAuthMethod.mockReturnValue('Validation Error');
const error = validateAuthMethodWithSettings(
mockValidateAuthMethod.mockResolvedValue('Validation Error');
const error = await validateAuthMethodWithSettings(
AuthType.LOGIN_WITH_GOOGLE,
settings,
);
@@ -265,7 +265,7 @@ describe('useAuth', () => {
});
it('should set error if validation fails', async () => {
mockValidateAuthMethod.mockReturnValue('Validation Failed');
mockValidateAuthMethod.mockResolvedValue('Validation Failed');
const { result } = await renderHook(() =>
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
);
+7 -3
View File
@@ -18,10 +18,10 @@ import { getErrorMessage } from '@google/gemini-cli-core';
import { AuthState } from '../types.js';
import { validateAuthMethod } from '../../config/auth.js';
export function validateAuthMethodWithSettings(
export async function validateAuthMethodWithSettings(
authType: AuthType,
settings: LoadedSettings,
): string | null {
): Promise<string | null> {
const enforcedType = settings.merged.security.auth.enforcedType;
if (enforcedType && enforcedType !== authType) {
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
@@ -111,7 +111,11 @@ export const useAuthCommand = (
}
}
const error = validateAuthMethodWithSettings(authType, settings);
const error = await validateAuthMethodWithSettings(
authType,
settings,
).catch((e: unknown) => getErrorMessage(e));
if (error) {
onAuthError(error);
return;
@@ -80,6 +80,7 @@ describe('directoryCommand', () => {
}),
getWorkingDir: () => path.resolve('/test/dir'),
shouldLoadMemoryFromIncludeDirectories: () => false,
getMemoryContextManager: vi.fn(),
getDebugMode: () => false,
getFileService: () => ({}),
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
@@ -15,10 +15,7 @@ import {
type CommandContext,
} from './types.js';
import { MessageType, type HistoryItem } from '../types.js';
import {
refreshServerHierarchicalMemory,
type Config,
} from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import {
expandHomeDir,
getDirectorySuggestions,
@@ -47,7 +44,7 @@ async function finishAddingDirectories(
if (added.length > 0) {
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
await config.getMemoryContextManager()?.refresh();
}
addItem({
type: MessageType.INFO,
@@ -11,13 +11,10 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
import { MessageType } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import {
type Config,
refreshMemory,
refreshServerHierarchicalMemory,
SimpleExtensionLoader,
type FileDiscoveryService,
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';
@@ -32,46 +29,28 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return String(error);
}),
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
messageType: 'info',
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
};
}
await config.getMemoryContextManager()?.refresh();
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
messageType: 'info',
content: 'Memory reloaded successfully.',
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
};
}),
showMemory: vi.fn(),
addMemory: vi.fn(),
listMemoryFiles: vi.fn(),
refreshServerHierarchicalMemory: vi.fn(),
};
});
const mockRefreshMemory = refreshMemory as Mock;
const mockRefreshServerHierarchicalMemory =
refreshServerHierarchicalMemory as Mock;
describe('memoryCommand', () => {
let mockContext: CommandContext;
const buildMemoryCommand = (isMemoryV2 = false): SlashCommand => {
const config: Pick<Config, 'isMemoryV2Enabled'> = {
isMemoryV2Enabled: () => isMemoryV2,
};
return memoryCommand(config as Config);
};
const buildMemoryCommand = (): SlashCommand => memoryCommand(null);
const getSubCommand = (
name: 'show' | 'add' | 'reload' | 'list',
): SlashCommand => {
const getSubCommand = (name: 'show' | 'reload' | 'list'): SlashCommand => {
const subCommand = buildMemoryCommand().subCommands?.find(
(cmd) => cmd.name === name,
);
@@ -81,23 +60,11 @@ describe('memoryCommand', () => {
return subCommand;
};
describe('Memory v2', () => {
it('omits the /memory add subcommand when memoryV2 is enabled', () => {
const command = buildMemoryCommand(true);
describe('subcommands', () => {
it('does not include the legacy add subcommand', () => {
const command = buildMemoryCommand();
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).not.toContain('add');
});
it('includes the /memory add subcommand by default', () => {
const command = buildMemoryCommand(false);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
});
it('includes the /memory add subcommand when no config is provided', () => {
const command = memoryCommand(null);
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
expect(names).toContain('add');
expect(names).toEqual(['show', 'reload', 'list', 'inbox']);
});
});
@@ -178,63 +145,6 @@ describe('memoryCommand', () => {
});
});
describe('/memory add', () => {
let addCommand: SlashCommand;
beforeEach(() => {
addCommand = getSubCommand('add');
vi.mocked(addMemory).mockImplementation((args) => {
if (!args || args.trim() === '') {
return {
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
};
}
return {
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact: args.trim() },
};
});
mockContext = createMockCommandContext();
});
it('should return an error message if no arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const result = addCommand.action(mockContext, ' ');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Usage: /memory add <text to remember>',
});
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should return a tool action and add an info message when arguments are provided', () => {
if (!addCommand.action) throw new Error('Command has no action');
const fact = 'remember this';
const result = addCommand.action(mockContext, ` ${fact} `);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${fact}"`,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'tool',
toolName: 'save_memory',
toolArgs: { fact },
});
});
});
describe('/memory reload', () => {
let reloadCommand: SlashCommand;
let mockSetUserMemory: Mock;
@@ -270,8 +180,7 @@ describe('memoryCommand', () => {
updateSystemInstructionIfInitialized: vi
.fn()
.mockResolvedValue(undefined),
isJitContextEnabled: vi.fn().mockReturnValue(false),
getContextManager: vi.fn().mockReturnValue({
getMemoryContextManager: vi.fn().mockReturnValue({
refresh: mockContextManagerRefresh,
}),
getUserMemory: vi.fn().mockReturnValue(''),
@@ -294,21 +203,18 @@ describe('memoryCommand', () => {
mockRefreshMemory.mockClear();
});
it('should use ContextManager.refresh when JIT is enabled', async () => {
it('should use MemoryContextManager.refresh', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
// Enable JIT in mock config
const config = mockContext.services.agentContext?.config;
if (!config) throw new Error('Config is undefined');
vi.mocked(config.isJitContextEnabled).mockReturnValue(true);
vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content');
vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3);
await reloadCommand.action(mockContext, '');
expect(mockContextManagerRefresh).toHaveBeenCalledOnce();
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
@@ -319,7 +225,7 @@ describe('memoryCommand', () => {
);
});
it('should display success message when memory is reloaded with content (Legacy)', async () => {
it('should display success message when memory is reloaded with content', async () => {
if (!reloadCommand.action) throw new Error('Command has no action');
const successMessage = {
+1 -31
View File
@@ -6,7 +6,6 @@
import React from 'react';
import {
addMemory,
type Config,
listMemoryFiles,
refreshMemory,
@@ -41,30 +40,6 @@ const showSubCommand: SlashCommand = {
},
};
const addSubCommand: SlashCommand = {
name: 'add',
description: 'Add content to the memory',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: (context, args): SlashCommandActionReturn | void => {
const result = addMemory(args);
if (result.type === 'message') {
return result;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: `Attempting to save to memory: "${args.trim()}"`,
},
Date.now(),
);
return result;
},
};
const reloadSubCommand: SlashCommand = {
name: 'reload',
altNames: ['refresh'],
@@ -170,14 +145,9 @@ const inboxSubCommand: SlashCommand = {
},
};
export const memoryCommand = (config: Config | null): SlashCommand => {
// The `add` subcommand depends on the `save_memory` tool, which is not
// registered when Memory v2 is enabled. Omit it in that case.
const isMemoryV2 = config?.isMemoryV2Enabled() ?? false;
export const memoryCommand = (_config: Config | null): SlashCommand => {
const subCommands: SlashCommand[] = [
showSubCommand,
...(isMemoryV2 ? [] : [addSubCommand]),
reloadSubCommand,
listSubCommand,
inboxSubCommand,
@@ -14,7 +14,6 @@ import { type HistoryItem } from '../types.js';
import { convertSessionToHistoryFormats } from '../hooks/useSessionBrowser.js';
import { revertFileChanges } from '../utils/rewindFileOps.js';
import { RewindOutcome } from '../components/RewindConfirmation.js';
import type { Content } from '@google/genai';
import {
checkExhaustive,
coreEvents,
@@ -58,7 +57,7 @@ async function rewindConversation(
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
const clientHistory = convertSessionToClientHistory(conversation.messages);
client.setHistory(clientHistory as Content[]);
client.setHistory(clientHistory);
// Reset context manager as we are rewinding history
await context.services.agentContext?.config

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