Compare commits

...

35 Commits

Author SHA1 Message Date
Jack Wotherspoon a69d422a83 docs(resources): add guide for migrating to Antigravity CLI 2026-05-19 14:54:49 -04: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
224 changed files with 9328 additions and 6030 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

+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.
+29 -33
View File
@@ -203,6 +203,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 +550,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": {
@@ -1014,12 +1037,6 @@ their corresponding top-level category object in your `settings.json` file.
"auto": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"releaseChannel": "stable"
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"hasAccessToPreview": false
@@ -1163,13 +1180,6 @@ their corresponding top-level category object in your `settings.json` file.
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"releaseChannel": "stable",
"requestedModels": ["auto"]
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"requestedModels": ["gemini-2.5-pro", "auto-gemini-2.5"]
@@ -1831,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`
@@ -1867,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
@@ -1936,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.
-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` |
@@ -0,0 +1,367 @@
# Migrating to Antigravity CLI
If youre an existing Gemini CLI user looking to migrate your workflow to
Antigravity CLI, youve come to the right place. This guide will help you get
familiar and up and running quickly in Antigravity CLI.
## TL;DR
Antigravity CLI supports the majority of Gemini CLIs features. While there
isnt 100% feature parity, workflow-defining features like _Gemini CLI
extensions_ (Antigravity plugins), _Agent Skills_, _MCP servers_, _hooks_, and
_subagents_ are supported in Antigravity CLI.
On the first launch of Antigravity CLI you should see **Migration Options**
where you can choose to migrate your existing Gemini CLI extensions to the
equivalent _Antigravity Plugins_.
![](/docs/assets/migration-options.png)
_Note: Some Gemini CLI extensions can not be migrated 1:1 to Antigravity plugins
as some components (e.g. custom themes, etc) may not be supported._
For the majority of users, you can now get started using Antigravity CLI with
the workflows youve come to love in Gemini CLI. Antigravity CLI loads in the
same context files, global Agent Skills, etc, as Gemini CLI does.
If you notice something not working the way it should or how you expect, come
back to this guide for **specific details below**.
## Extensions → Antigravity Plugins
Since Gemini CLI launched extensions (a way to extend Gemini CLI by bundling and
sharing capabilities), the industry has standardized on the term **plugins**.
[Antigravity Plugins](https://antigravity.google/docs/plugins) are supported in
Antigravity CLI.
Users should be prompted on first launch of Antigravity CLI to have their
extensions automatically migrated to plugins.
There is also an explicit command that can be run from the terminal to migrate
them:
```shell
agy plugin import gemini
```
Running the above `agy plugin import` command will find each locally installed
extension and convert them to an Antigravity plugin:
```shell
[ok] conductor
- skills : skipped (not found)
- agents : skipped (not found)
✔ commands : 6 processed (converted to skills)
- mcpServers : skipped (not found)
- hooks : skipped (not found)
[ok] google-workspace
✔ skills : 6 processed
- agents : skipped (not found)
✔ commands : 4 processed (converted to skills)
✔ mcpServers : 1 processed
- hooks : skipped (not found)
```
## Context Files (Rules)
Antigravity CLI supports the same context files as Gemini CLI. It supports
reading both `GEMINI.md` and `AGENTS.md` from your workspace and allows you to
have a global context file located at `~/.gemini/GEMINI.md`.
## Agent Skills
Agent Skills work in Antigravity CLI just as they do in Gemini CLI. They can be
managed with the same `/skills` command and are also converted into slash
commands allowing them to be manually invoked.
Global skills for Gemini CLI were located in `~/.gemini/skills/` and are shared
with Antigravity CLI across all workspaces. No action is needed for global
skills, they are picked up automatically.
Workspace specific skills for Antigravity CLI are stored in `.agents/skills`
which means if you have project/workspace skills in a given project within
the`.gemini/skills` folder they will need to be moved from to `.agents/skills`
| | Gemini CLI | Antigravity CLI |
| :------------- | :----------------------------------------------------------------------- | :----------------------------------------------------------------------------------------- |
| **Location** | Global: \~/.gemini/skills/ Workspace: .gemini/skills/ or .agents/skills/ | Global: \~/.gemini/antigravity-cli/skills/ or \~/.gemini/skills Workspace: .agents/skills/ |
| **Management** | /skills | /skills |
| **Behavior** | Skills become slash commands | Skills become slash commands |
_Note: Antigravity CLI does not currently have an equivalent to the
`gemini skills` commands for managing Agent Skills. You can create your own
skills files or use `npx skills install`._
## MCP Servers
Antigravity CLI supports both local and remote MCP servers and provides the same
`/mcp` command to manage MCP servers. The main difference from Gemini CLI is the
file location where `mcpServers` are defined.
![](/docs/assets/mcp-config.png)
Antigravity and Antigravity CLI store MCP server configurations in a distinct
`mcp_config.json` file whereas Gemini CLI stores them inline in your
`settings.json`.
_Note:_ Antigravity CLI uses `serverUrl` field instead of `url` (or deprecated
`httpUrl`) for remote MCP servers.
| | Gemini CLI | Antigravity CLI |
| :------------- | :---------------------------------------------------------------- | :------------------------------------------------------------------------------------ |
| **Location** | Global: \~/.gemini/settings.json Workspace: .gemini/settings.json | Global: \~/.gemini/antigravity-cli/mcp_config.json Workspace: .agents/mcp_config.json |
| **Management** | /mcp | /mcp |
### Local MCP server example
For local MCP servers, your `mcpServers` fields should be the same from Gemini
CLIs `settings.json` as they are in Antigravitys `mcp_config.json`.
For example, to configure the
[Firebase MCP server](https://firebase.google.com/docs/ai-assistance/mcp-server),
put the following in \~/.gemini/antigravity-cli/mcp_config.json to use across
all workspaces (global) or in .agents/mcp_config.json for a single workspace.
```json
{
"mcpServers": {
"firebase": {
"command": "npx",
"args": ["-y", "firebase-tools@latest", "mcp"]
}
}
}
```
### Remote MCP server example
For remote MCP servers you can copy your `mcpServers` field but need to switch
`url` (or deprecated `httpUrl`) to be `serverUrl` which is the field Antigravity
CLI uses.
For example, here is the
[Google Developer Knowledge MCP server](https://developers.google.com/knowledge/mcp)
configuration for your `mcp_config.json` using the `serverUrl` field for
Antigravity CLI:
```json
{
"mcpServers": {
"google-developer-knowledge": {
"serverUrl": "https://developerknowledge.googleapis.com/mcp",
"authProviderType": "google_credentials",
"oauth": {
"scopes": ["https://www.googleapis.com/auth/cloud-platform"]
},
"headers": {
"X-goog-user-project": "$GOOGLE_CLOUD_PROJECT"
}
}
}
}
```
## Subagents
[Subagents in Antigravity CLI](https://antigravity.google/docs/subagents) serve
the same fundamental purpose as in Gemini CLI, acting as **specialized expert
personas** with their _own contexts_ and _tailored toolsets_.
The most critical difference between Gemini CLI and Antigravity CLI is execution
flow:
- **Gemini CLI:** Subagent delegation is **synchronous**. When the main agent
delegates a task to a subagent, your active conversation is blocked until the
subagent completes its work and reports back.
- **Antigravity CLI:** Subagents are **asynchronous by default**. The main agent
can delegate parallel workloads or run background research without blocking
your prompt interface. You can continue conversing with the main agent while
subagents work in the background.
Antigravity CLI also has a `define_subagent` tool. This allows you to spin up
specialized assistants dynamically during an active session.
- _Example Prompt:_
`"Create a frontend specialist subagent that excels at modern website design and accessibility audits."`
### Configuring Custom Subagents
Gemini CLI defines subagents using a single Markdown file containing YAML
frontmatter. Antigravity CLI uses a directory structure separating metadata
manifest (`agent.json`) from declarative configuration (`config.yaml`).
```
agents/
└── frontend-specialist/
├── agent.json # Required marker file (metadata & routing manifest)
└── config.yaml # Declarative configuration (prompts, tools, MCP servers)
```
| Feature | Gemini CLI | Antigravity CLI |
| :--------------------- | :------------------------------ | :---------------------------------------------- |
| **Global Location** | `~/.gemini/agents/*.md` | `~/.gemini/antigravity-cli/agents/*/agent.json` |
| **Workspace Location** | `.gemini/agents/*.md` | `.agents/agents/*/agent.json` |
| **Config Format** | Single `.md` (YAML Frontmatter) | Split: `agent.json` \+ `config.yaml` |
| **Execution Mode** | Synchronous (Blocking) | Asynchronous (Non-blocking) |
| **CLI Management** | `/agents` | `/agents` |
### Example Migration: `frontend-specialist`
Below is a complete walkthrough demonstrating how to migrate a custom Gemini CLI
subagent to Antigravity CLI.
#### Gemini CLI
The subagent for Gemini CLI is configured in
`.gemini/agents/frontend-specialist.md`.
```
---
name: frontend-specialist
description: Frontend specialist in building high-performance, accessible, and
scalable web applications using modern frameworks and standards.
tools:
- read_file
- grep_search
- glob
- list_directory
- web_fetch
- google_web_search
model: inherit
---
You are a Senior Frontend Specialist Your goal is to...
<instructions>
```
#### Antigravity CLI
To migrate this agent, create a new directory named `frontend-specialist` inside
your workspace directory (`.agents/agents/frontend-specialist/`) or your global
directory.
##### 1\. Manifest File (`agent.json`)
This file acts as the discovery marker and registers the agent's metadata.
```json
{
"name": "frontend-specialist",
"description": "Frontend specialist in building high-performance, accessible, and scalable web applications.",
"configPath": {
"relativePathToConfig": "config.yaml"
}
}
```
##### 2\. Declarative Config (`config.yaml`)
This file contains the core instructions and tools available. Notice that
Antigravity CLI uses updated tool names (e.g., `read_file` becomes `view_file`).
```
custom_agent:
system_prompt_sections:
- title: "Role and Core Principles"
content: |
You are a Senior Frontend Specialist Your goal is to...<instructions>
tool_names:
- view_file # Migrated from read_file
- grep_search # Identical
- find_by_name # Migrated from glob
- list_dir # Migrated from list_directory
- read_url_content # Migrated from web_fetch
- search_web # Migrated from google_web_search
# Append standard CLI context sections for smooth execution
system_prompt_config:
include_sections:
- user_information
- messaging
- artifacts
```
#### Notable Tool Mappings During Migration
When migrating your tool lists from Gemini CLI to Antigravity CLI, ensure you
update them to match the new tool names:
| Gemini CLI Tool | Antigravity CLI Equivalent | Description |
| :------------------ | :------------------------- | :--------------------------------------- |
| `read_file` | `view_file` | Reads local file contents. |
| `grep_search` | `grep_search` | Pattern searching within files. |
| `glob` | `find_by_name` | Directory and file discovery by pattern. |
| `list_directory` | `list_dir` | Lists directory structures. |
| `web_fetch` | `read_url_content` | Fetches raw web page content. |
| `google_web_search` | `search_web` | Performs web searches. |
## Hooks
Hooks in Antigravity CLI serve the same core purpose as in Gemini CLI, allowing
you to intercept the agentic loop to customize it to your liking and inject
context, block tools, have the agent continue, etc.
Migrating from Gemini CLI involves moving to a standalone configuration file,
adopting a simplified event model, and utilizing the interactive `/hooks`
command.
| Feature | Gemini CLI | Antigravity CLI |
| :--------------------- | :--------------------------- | :------------------------------------- |
| **Global Location** | `~/.gemini/settings.json` | `~/.gemini/antigravity-cli/hooks.json` |
| **Workspace Location** | `.gemini/settings.json` | `.agents/hooks.json` |
| **Config Format** | Embedded in `settings.json` | Standalone `hooks.json` |
| **Top-Level Grouping** | By Event Type (`BeforeTool`) | By Hook Name (`my-hook-suite`) |
| **Timeout Units** | Milliseconds (e.g., `5000`) | Seconds (e.g., `5`) |
| **CLI Management** | `/hooks` | `/hooks` |
### Supported Hook Events
Here are the supported hook event types in Antigravity and their Gemini CLI
equivalent:
| Antigravity CLI Event | Gemini CLI Equivalent | Lifecycle Timing & Behavior |
| :-------------------- | :-------------------- | :------------------------------------ |
| `PreToolUse` | `BeforeTool` | Executes before a tool call. |
| `PostToolUse` | `AfterTool` | Executes after a tool call completes. |
| `PreInvocation` | `BeforeModel` | Executes before every model call. |
| `PostInvocation` | `AfterModel` | Executes after every model call . |
| `Stop` | `AfterAgent` | Executes when the agent finishes. |
_Note: Antigravity CLI currently supports fewer hook types than Gemini CLI._
If you are migrating custom hook scripts, update them to match Antigravity's
updated JSON contract:
1. **Input Naming:** Payloads use `camelCase` (e.g., `toolCall.name`,
`toolCall.args`) instead of `snake_case`.
2. **Output Flags (`PreToolUse`):** To block a tool, output
`{"allowTool": false, "denyReason": "..."}` instead of
`{"decision": "deny", "reason": "..."}`.
### Interactive Management (`/hooks`)
Rather than manually authoring JSON files and managing syntax formatting,
Antigravity CLI provides a built-in interactive terminal interface to view,
create, and manage hooks through the `/hooks` command.
![](/docs/assets/hooks-ui.png)
1. **Open the Panel:** Type **`/hooks`** into your prompt and press Enter.
2. **Browse Events:** Use the arrow keys to navigate through the available hook
events (`PreToolUse`, `PreInvocation`, etc.).
3. **Create & Edit:** Select an event to add a new regex matcher, assign shell
commands, and set timeout limits directly within the UI dialog.
4. **Toggle State:** Press **`e`** on any configured hook to instantly toggle it
enabled or disabled without losing your configuration data.
5. **Save & Apply:** Changes made in the dialog are automatically validated and
saved back to your `hooks.json` file.
## Migration FAQs
**Q: Will AGY CLI work in headless mode?**
_A:_ Yes, just run `agy -p “Your awesome prompt”`
**Q: Will my chat history be migrated? How will my user context be preserved?**
_A:_ No, Gemini CLI chat sessions and history will not be migrated.
**Q: Will the policies I installed in Gemini CLI be migrated to AGY CLI?**
_A:_ No, Antigravity does not use the same policy engine as Gemini CLI. It uses
its own permissions system which lets you set tools and commands as
`allow|deny|ask` in your Antigravity CLI `settings.json` file.
+4
View File
@@ -261,6 +261,10 @@
"label": "Resources",
"items": [
{ "label": "FAQ", "slug": "docs/resources/faq" },
{
"label": "Migrating to Antigravity CLI",
"slug": "docs/resources/migrating-to-antigravity-cli"
},
{
"label": "Quota and pricing",
"slug": "docs/resources/quota-and-pricing"
+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
@@ -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'.
//
+232
View File
@@ -0,0 +1,232 @@
/**
* @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;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should reproduce the exact context working buffer on resume', 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: 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('context-fidelity', {
settings: {
experimental: {
stressTestProfile: true, // Lowers thresholds to trigger GC easily
},
},
});
const massivePayload = 'X'.repeat(50000);
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_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'),
];
for (let i = 0; i < 50; i++) {
runMocks.push(snapshotResponse);
runMocks.push(countTokensResponse);
}
// Turn 1: Initial massive payload to put pressure
await rig.run({
args: [
'--debug',
'--fake-responses-non-strict',
setupResponses('resp1.json', runMocks),
],
stdin: 'Turn 1: ' + massivePayload,
env: commonEnv,
});
// Turn 2: Another turn, resuming Turn 1
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp2.json', runMocks),
],
stdin: 'Turn 2: ' + massivePayload,
env: commonEnv,
});
// Turn 3: Third turn to force GC, resuming Turn 2
await rig.run({
args: [
'--debug',
'--resume',
'latest',
'--fake-responses-non-strict',
setupResponses('resp3.json', runMocks),
],
stdin: 'Turn 3: ' + massivePayload,
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,
);
for (let i = 0; i < contextBeforeExit!.length; i++) {
expect(contextAfterResume![i].id).toBe(contextBeforeExit![i].id);
expect(contextAfterResume![i].content).toEqual(
contextBeforeExit![i].content,
);
}
// Most importantly, synthetic IDs (like summaries) must be stable.
const syntheticTurns = contextBeforeExit!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
); // deriveStableId produces 32-char hex
expect(syntheticTurns.length).toBeGreaterThan(0);
const syntheticTurnsAfter = contextAfterResume!.filter(
(t: HistoryTurn) => t.id && t.id.length === 32,
);
expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual(
syntheticTurns.length,
);
// Check if the first synthetic turn is identical
expect(syntheticTurnsAfter[0].id).toBe(syntheticTurns[0].id);
expect(syntheticTurnsAfter[0].content).toEqual(syntheticTurns[0].content);
});
});
+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');
}
+364 -348
View File
File diff suppressed because it is too large Load Diff
@@ -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;
+1 -1
View File
@@ -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",
@@ -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.`,
};
}
}
}
@@ -10,7 +10,6 @@ import { loadConfig } from './config.js';
import type { Settings } from './settings.js';
import {
type ExtensionLoader,
FileDiscoveryService,
getCodeAssistServer,
Config,
ExperimentFlags,
@@ -48,16 +47,10 @@ 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(),
coreEvents: {
@@ -268,24 +261,6 @@ 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,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
respectGitIgnore: false,
respectGeminiIgnore: undefined,
customIgnoreFilePaths: [testPath],
});
});
describe('tool configuration', () => {
it('should pass V1 allowedTools to Config properly', async () => {
const settings: Settings = {
-19
View File
@@ -11,9 +11,7 @@ import * as dotenv from 'dotenv';
import {
AuthType,
Config,
FileDiscoveryService,
ApprovalMode,
loadServerHierarchicalMemory,
GEMINI_DIR,
DEFAULT_GEMINI_EMBEDDING_MODEL,
startupProfiler,
@@ -129,23 +127,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({
@@ -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');
+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();
}
+4 -5
View File
@@ -22,7 +22,6 @@ import {
getDisplayString,
AuthType,
ToolConfirmationOutcome,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
@@ -272,8 +271,6 @@ export function buildAvailableModels(
const useCustomToolModel =
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
const releaseChannel = getChannelFromVersion(config.clientVersion);
// --- DYNAMIC PATH ---
if (
config.getExperimentalDynamicModelConfiguration?.() === true &&
@@ -284,7 +281,6 @@ export function buildAvailableModels(
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
releaseChannel,
});
return {
@@ -298,7 +294,10 @@ export function buildAvailableModels(
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
},
];
-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 =
-167
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,
@@ -1067,151 +1045,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({
+13 -50
View File
@@ -16,21 +16,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,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
@@ -105,6 +103,7 @@ export interface CliArgs {
useWriteTodos: boolean | undefined;
outputFormat: string | undefined;
fakeResponses: string | undefined;
fakeResponsesNonStrict?: string | undefined;
recordResponses: string | undefined;
startupMessages?: string[];
rawOutput: boolean | undefined;
@@ -476,6 +475,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.',
@@ -571,7 +576,6 @@ export interface LoadCliConfigOptions {
};
worktreeSettings?: WorktreeSettings;
skipExtensions?: boolean;
skipMemoryLoad?: boolean;
}
export async function loadCliConfig(
@@ -580,12 +584,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 =
@@ -595,7 +594,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;
@@ -611,7 +609,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.
@@ -619,16 +617,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,
@@ -679,8 +672,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);
@@ -691,33 +682,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
@@ -1029,9 +996,6 @@ 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,
@@ -1076,8 +1040,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,
@@ -1119,6 +1081,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 () => [],
@@ -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.',
);
});
});
+20 -20
View File
@@ -429,6 +429,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 +2194,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 +2272,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 +2402,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:
@@ -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: [],
-1
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);
@@ -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(() => []),
@@ -166,7 +165,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),
+4 -14
View File
@@ -70,7 +70,6 @@ import {
debugLogger,
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
@@ -1075,19 +1074,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(
{
@@ -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
@@ -154,6 +154,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
vimEnabled={vimEnabled}
vimMode={vimMode}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
@@ -1962,8 +1962,8 @@ describe('InputPrompt', () => {
},
{
name: 'should NOT trigger completion when cursor is after space following /',
text: '/memory add',
cursor: [0, 11],
text: '/memory list',
cursor: [0, 12],
showSuggestions: false,
},
{
@@ -4898,6 +4898,60 @@ describe('InputPrompt', () => {
unmount();
});
it('should NOT open shortcuts help with ? in vim NORMAL mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(true);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="NORMAL"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
expect(setShortcutsHelpVisible).not.toHaveBeenCalled();
expect(vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
it('should open shortcuts help with ? in vim INSERT mode', async () => {
const setShortcutsHelpVisible = vi.fn();
const vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = await renderWithProviders(
<TestInputPrompt
{...props}
vimEnabled={true}
vimMode="INSERT"
vimHandleInput={vimHandleInput}
/>,
{
uiActions: { setShortcutsHelpVisible },
},
);
await act(async () => {
stdin.write('?');
});
await waitFor(() => {
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(true);
});
unmount();
});
it.each([
{
name: 'terminal paste event occurs',
+14 -2
View File
@@ -92,6 +92,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import type { VimMode } from '../contexts/VimModeContext.js';
const SCROLLBAR_GUTTER_WIDTH = 1;
@@ -126,6 +127,8 @@ export interface InputPromptProps {
onEscapePromptChange?: (showPrompt: boolean) => void;
onSuggestionsVisibilityChange?: (visible: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
vimEnabled?: boolean;
vimMode?: VimMode;
isEmbeddedShellFocused?: boolean;
setQueueErrorMessage: (message: string | null) => void;
streamingState: StreamingState;
@@ -214,6 +217,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onEscapePromptChange,
onSuggestionsVisibilityChange,
vimHandleInput,
vimEnabled,
vimMode,
isEmbeddedShellFocused,
setQueueErrorMessage,
streamingState,
@@ -859,7 +864,11 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
if (shortcutsHelpVisible) {
if (key.sequence === '?' && key.insertable) {
if (
key.sequence === '?' &&
key.insertable &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(false);
buffer.handleInput(key);
return true;
@@ -879,7 +888,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
key.sequence === '?' &&
key.insertable &&
!shortcutsHelpVisible &&
buffer.text.length === 0
buffer.text.length === 0 &&
(!vimEnabled || vimMode === 'INSERT')
) {
setShortcutsHelpVisible(true);
return true;
@@ -1374,6 +1384,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState,
resetEscapeState,
vimHandleInput,
vimEnabled,
vimMode,
reverseSearchActive,
textBeforeReverseSearch,
cursorPosition,
@@ -34,6 +34,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getAutoModelDescription: (
hasAccessToPreview: boolean,
useGemini3_1?: boolean,
) =>
`Auto Model Description (preview: ${hasAccessToPreview}, 3.1: ${useGemini3_1})`,
getDisplayString: (val: string) => mockGetDisplayString(val),
logModelSlashCommand: (config: Config, event: ModelSlashCommandEvent) =>
mockLogModelSlashCommand(config, event),
+5 -13
View File
@@ -26,7 +26,6 @@ import {
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
getChannelFromVersion,
getAutoModelDescription,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -66,7 +65,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
// Determine the Preferred Model (read once when the dialog opens).
const preferredModel = config?.getModel() || GEMINI_MODEL_ALIAS_AUTO;
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel() ?? false;
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
const useGemini31FlashLite =
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
@@ -122,12 +121,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
{ isActive: true },
);
const releaseChannel = useMemo(
() => getChannelFromVersion(config?.clientVersion ?? ''),
[config?.clientVersion],
);
const mainOptions = useMemo(() => {
// --- DYNAMIC PATH ---
if (
@@ -142,7 +135,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
const list = allOptions
@@ -170,7 +162,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
{
value: GEMINI_MODEL_ALIAS_AUTO,
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
description: getAutoModelDescription(releaseChannel, useGemini31),
description: getAutoModelDescription(
shouldShowPreviewModels,
useGemini31,
),
key: GEMINI_MODEL_ALIAS_AUTO,
},
{
@@ -192,7 +187,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
]);
const manualOptions = useMemo(() => {
@@ -209,7 +203,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useCustomTools: useCustomToolModel,
hasAccessToPreview: shouldShowPreviewModels,
hasAccessToProModel,
releaseChannel,
});
return allOptions
@@ -302,7 +295,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
useGemini31FlashLite,
useCustomToolModel,
hasAccessToProModel,
releaseChannel,
config,
]);
@@ -12,7 +12,6 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
isWindows,
type WorktreeSettings,
} from '@google/gemini-cli-core';
@@ -22,7 +21,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getShellConfiguration: vi.fn(),
isWindows: vi.fn(),
};
});
@@ -45,7 +43,6 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
};
});
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
const isWindowsMock = vi.mocked(isWindows);
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
@@ -104,11 +101,6 @@ describe('<SessionSummaryDisplay />', () => {
};
beforeEach(() => {
getShellConfigurationMock.mockReturnValue({
executable: 'bash',
argsPrefix: ['-c'],
shell: 'bash',
});
isWindowsMock.mockReturnValue(false);
});
@@ -173,11 +165,6 @@ describe('<SessionSummaryDisplay />', () => {
it('renders a standard UUID-formatted session ID in the footer (powershell) on Windows', async () => {
isWindowsMock.mockReturnValue(true);
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
const uuidSessionId = '1234-abcd-5678-efgh';
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -192,11 +179,7 @@ describe('<SessionSummaryDisplay />', () => {
});
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
getShellConfigurationMock.mockReturnValue({
executable: 'powershell.exe',
argsPrefix: ['-NoProfile', '-Command'],
shell: 'powershell',
});
isWindowsMock.mockReturnValue(true);
const maliciousSessionId = "'; rm -rf / #";
const { lastFrame, unmount } = await renderWithMockedStats(
@@ -10,8 +10,8 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
escapeShellArg,
getShellConfiguration,
isWindows,
type ShellType,
} from '@google/gemini-cli-core';
interface SessionSummaryDisplayProps {
@@ -23,7 +23,7 @@ export const SessionSummaryDisplay: React.FC<SessionSummaryDisplayProps> = ({
}) => {
const { stats } = useSessionStats();
const config = useConfig();
const { shell } = getShellConfiguration();
const shell: ShellType = isWindows() ? 'powershell' : 'bash';
const worktreeSettings = config.getWorktreeSettings();
@@ -174,27 +174,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = `
"▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
> [Pasted Text: 10 lines]
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
> hello
-1
View File
@@ -144,7 +144,6 @@ export const INFORMATIVE_TIPS = [
'Authenticate with an OAuth-enabled MCP server with /mcp auth',
'Reload MCP servers with /mcp reload',
'See the current instructional context with /memory show',
'Add content to the instructional memory with /memory add',
'Reload instructional context from GEMINI.md files with /memory reload',
'List the paths of the GEMINI.md files in use with /memory list',
'Choose your Gemini model with /model',
@@ -14,6 +14,7 @@ import {
type Mock,
} from 'vitest';
import {
checkPermissions,
handleAtCommand,
escapeAtSymbols,
unescapeLiteralAt,
@@ -35,6 +36,7 @@ import {
import * as core from '@google/gemini-cli-core';
import * as os from 'node:os';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import * as fs from 'node:fs';
import * as fsPromises from 'node:fs/promises';
import * as path from 'node:path';
@@ -94,6 +96,7 @@ describe('handleAtCommand', () => {
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
getDirectories: () => [testRootDir],
}),
getMemoryContextManager: () => undefined,
storage: {
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
},
@@ -1540,3 +1543,57 @@ describe('unescapeLiteralAt', () => {
expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input);
});
});
describe('checkPermissions', () => {
let testRootDir: string;
let mockConfig: Config;
beforeEach(async () => {
vi.restoreAllMocks();
testRootDir = await fsPromises.mkdtemp(
path.join(os.tmpdir(), 'check-permissions-test-'),
);
mockConfig = {
getTargetDir: () => testRootDir,
getAgentRegistry: () => ({
getDefinition: () => undefined,
}),
getResourceRegistry: () => ({
findResourceByUri: () => undefined,
getAllResources: () => [],
}),
validatePathAccess: () => null,
} as unknown as Config;
});
afterEach(async () => {
await fsPromises.rm(testRootDir, { recursive: true, force: true });
});
// Regression for #22029 (and related #25910 / #25923): when a user pastes
// a JSON-like blob after an @, the @-command regex greedily captures it.
// The resolved string is longer than NAME_MAX, so fs.realpathSync throws
// ENAMETOOLONG. Previously this bubbled up as an unhandled rejection and
// crashed the CLI.
it('skips @-mentions whose path is too long to be a real filesystem entry', async () => {
const longSegment = 'a'.repeat(8192);
const query = `@${longSegment}`;
await expect(checkPermissions(query, mockConfig)).resolves.toEqual([]);
});
it('still surfaces real @-mentioned files when a sibling @-mention is unresolvable', async () => {
// A real file alongside a giant pasted-blob mention: the bogus mention
// should be skipped, the real one should still appear in the result.
const realFile = path.join(testRootDir, 'real.txt');
await fsPromises.writeFile(realFile, 'hello');
const resolvedRealFile = fs.realpathSync(realFile);
mockConfig.validatePathAccess = () =>
'permission required' as unknown as null;
const longSegment = 'b'.repeat(8192);
const query = `@real.txt and @${longSegment}`;
await expect(checkPermissions(query, mockConfig)).resolves.toEqual([
resolvedRealFile,
]);
});
});
@@ -188,9 +188,15 @@ export async function checkPermissions(
const pathName = part.content.substring(1);
if (!pathName) continue;
const resolvedPathName = resolveToRealPath(
path.resolve(config.getTargetDir(), pathName),
);
let resolvedPathName: string;
try {
resolvedPathName = resolveToRealPath(
path.resolve(config.getTargetDir(), pathName),
);
} catch {
// skip if resolveToRealPath errors out
continue;
}
if (config.validatePathAccess(resolvedPathName, 'read')) {
if (await fileExists(resolvedPathName)) {
@@ -352,7 +352,6 @@ describe('useGeminiStream', () => {
isInteractive: () => false,
getExperiments: () => {},
getMaxSessionTurns: vi.fn(() => 100),
isJitContextEnabled: vi.fn(() => false),
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getMessageBus: vi.fn(() => mockMessageBus),
@@ -1951,23 +1950,23 @@ describe('useGeminiStream', () => {
it('should schedule a tool call when the command processor returns a schedule_tool action', async () => {
const clientToolRequest: SlashCommandProcessorResult = {
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
toolName: 'activate_skill',
toolArgs: { name: 'test-skill' },
};
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
const { result } = await renderTestHook();
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
await result.current.submitQuery('/memory show');
});
await waitFor(() => {
expect(mockScheduleToolCalls).toHaveBeenCalledWith(
[
expect.objectContaining({
name: 'save_memory',
args: { fact: 'test fact' },
name: 'activate_skill',
args: { name: 'test-skill' },
isClientInitiated: true,
}),
],
@@ -2195,25 +2194,25 @@ describe('useGeminiStream', () => {
});
});
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
it('should NOT record other client-initiated tool calls in history', async () => {
const { result, client: mockGeminiClient } = await renderTestHook();
mockHandleSlashCommand.mockResolvedValue({
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
toolName: 'write_todos',
toolArgs: { todos: [] },
});
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
await result.current.submitQuery('/todos');
});
// Simulate tool completion
const completedTool = {
request: {
callId: 'test-call-id',
name: 'save_memory',
args: { fact: 'test fact' },
name: 'write_todos',
args: { todos: [] },
isClientInitiated: true,
},
status: CoreToolCallStatus.Success,
@@ -2227,7 +2226,7 @@ describe('useGeminiStream', () => {
responseParts: [
{
functionResponse: {
name: 'save_memory',
name: 'write_todos',
response: { success: true },
},
},
@@ -2246,91 +2245,6 @@ describe('useGeminiStream', () => {
});
});
describe('Memory Refresh on save_memory', () => {
it('should call performMemoryRefresh when a save_memory tool call completes successfully', async () => {
const mockPerformMemoryRefresh = vi.fn();
const completedToolCall: TrackedCompletedToolCall = {
request: {
callId: 'save-mem-call-1',
name: 'save_memory',
args: { fact: 'test' },
isClientInitiated: true,
prompt_id: 'prompt-id-6',
},
status: CoreToolCallStatus.Success,
responseSubmittedToGemini: false,
response: {
callId: 'save-mem-call-1',
responseParts: [{ text: 'Memory saved' }],
resultDisplay: 'Success: Memory saved',
error: undefined,
errorType: undefined, // FIX: Added missing property
},
tool: {
name: 'save_memory',
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
};
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
mockPerformMemoryRefresh,
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with the completed save_memory tool
await act(async () => {
if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete([completedToolCall]);
}
});
await waitFor(() => {
expect(mockPerformMemoryRefresh).toHaveBeenCalledTimes(1);
});
});
});
describe('Error Handling', () => {
it('should call parseAndFormatApiError with the correct authType on stream initialization failure', async () => {
// 1. Setup
+3 -22
View File
@@ -226,7 +226,7 @@ export const useGeminiStream = (
shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
onAuthError: (error: string) => void,
performMemoryRefresh: () => Promise<void>,
_performMemoryRefresh: () => Promise<void>,
modelSwitchedFromQuotaError: boolean,
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
onCancelSubmit: (
@@ -266,7 +266,6 @@ export const useGeminiStream = (
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const logger = useLogger(config);
const gitService = useMemo(() => {
@@ -1897,8 +1896,8 @@ export const useGeminiStream = (
if (geminiClient) {
for (const tool of clientTools) {
// Only manually record skill activations in the chat history.
// Other client-initiated tools (like save_memory) update the system
// prompt/context and don't strictly need to be in the history.
// Other client-initiated tools update context and don't strictly
// need to be in the history.
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
continue;
}
@@ -1925,14 +1924,6 @@ export const useGeminiStream = (
}
}
// Identify new, successful save_memory calls that we haven't processed yet.
const newSuccessfulMemorySaves = completedAndReadyToSubmitTools.filter(
(t) =>
t.request.name === 'save_memory' &&
t.status === 'success' &&
!processedMemoryToolsRef.current.has(t.request.callId),
);
for (const toolCall of completedAndReadyToSubmitTools) {
const backgroundedTool = getBackgroundedToolInfo(toolCall);
if (backgroundedTool) {
@@ -1944,15 +1935,6 @@ export const useGeminiStream = (
}
}
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
void performMemoryRefresh();
// Mark them as processed so we don't do this again on the next render.
newSuccessfulMemorySaves.forEach((t) =>
processedMemoryToolsRef.current.add(t.request.callId),
);
}
const geminiTools = completedAndReadyToSubmitTools.filter(
(t) => !t.request.isClientInitiated,
);
@@ -2076,7 +2058,6 @@ export const useGeminiStream = (
submitQuery,
markToolsAsSubmitted,
geminiClient,
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundTask,
@@ -80,6 +80,8 @@ describe('useIncludeDirsTrust', () => {
clearPendingIncludeDirectories: vi.fn(),
getFolderTrust: vi.fn().mockReturnValue(true),
getWorkspaceContext: () => mockWorkspaceContext,
shouldLoadMemoryFromIncludeDirectories: vi.fn().mockReturnValue(false),
getMemoryContextManager: vi.fn(),
getGeminiClient: vi
.fn()
.mockReturnValue({ addDirectoryContext: vi.fn() }),
@@ -8,10 +8,7 @@ import { useEffect } from 'react';
import { type Config } from '@google/gemini-cli-core';
import { loadTrustedFolders } from '../../config/trustedFolders.js';
import { expandHomeDir, batchAddDirectories } from '../utils/directoryUtils.js';
import {
debugLogger,
refreshServerHierarchicalMemory,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType, type HistoryItem } from '../types.js';
@@ -35,7 +32,7 @@ async function finishAddingDirectories(
try {
if (config.shouldLoadMemoryFromIncludeDirectories()) {
await refreshServerHierarchicalMemory(config);
await config.getMemoryContextManager()?.refresh();
}
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -194,14 +194,16 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(2);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Hello' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [{ text: 'Hi there' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Hello' }],
},
{
role: 'model',
parts: [{ text: 'Hi there' }],
},
]);
});
it('should convert thinking tokens (thoughts) to thinking history items', () => {
@@ -254,10 +256,12 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Expanded content' }],
});
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Expanded content' }],
},
]);
});
it('should filter out slash commands from client history but keep in UI', () => {
@@ -316,33 +320,35 @@ describe('convertSessionToHistoryFormats', () => {
const clientHistory = convertSessionToClientHistory(messages);
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'What time is it?' }],
});
expect(clientHistory[1]).toEqual({
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
expect(clientHistory.map((h) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'What time is it?' }],
},
{
role: 'model',
parts: [
{
functionCall: {
name: 'get_time',
args: {},
id: 'call_1',
},
},
},
],
});
expect(clientHistory[2]).toEqual({
role: 'user',
parts: [
{
functionResponse: {
id: 'call_1',
name: 'get_time',
response: { output: '12:00' },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
name: 'get_time',
response: { output: '12:00' },
id: 'call_1',
},
},
},
],
});
],
},
]);
});
});
+10 -4
View File
@@ -12,22 +12,28 @@ import {
convertSessionToClientHistory,
uiTelemetryService,
loadConversationRecord,
type Config,
type ResumedSessionData,
} from '@google/gemini-cli-core';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import {
convertSessionToHistoryFormats,
type SessionInfo,
} from '../../utils/sessionUtils.js';
import type { Part } from '@google/genai';
export { convertSessionToHistoryFormats };
import type { Part } from '@google/genai';
export const useSessionBrowser = (
config: Config,
onLoadHistory: (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedSessionData: ResumedSessionData,
) => Promise<void>,
) => {
@@ -13,6 +13,7 @@ import type {
ResumedSessionData,
ConversationRecord,
MessageRecord,
HistoryTurn,
} from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { HistoryItemWithoutId } from '../types.js';
@@ -527,10 +528,12 @@ describe('useSessionResume', () => {
// Should only have the non-slash-command message
expect(clientHistory).toHaveLength(1);
expect(clientHistory[0]).toEqual({
role: 'user',
parts: [{ text: 'Regular message' }],
});
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
{
role: 'user',
parts: [{ text: 'Regular message' }],
},
]);
// But UI history should have both
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
@@ -7,14 +7,17 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
coreEvents,
type Config,
type ResumedSessionData,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
import type {
HistoryTurn,
Config,
ResumedSessionData,
} from '@google/gemini-cli-core';
import type { HistoryItemWithoutId } from '../types.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { convertSessionToHistoryFormats } from './useSessionBrowser.js';
import type { Part } from '@google/genai';
interface UseSessionResumeParams {
config: Config;
@@ -54,7 +57,9 @@ export function useSessionResume({
const loadHistoryForResume = useCallback(
async (
uiHistory: HistoryItemWithoutId[],
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
clientHistory: Array<
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
>,
resumedData: ResumedSessionData,
) => {
// Wait for the client.
+74
View File
@@ -2258,6 +2258,80 @@ describe('useVim hook', async () => {
});
});
describe('should handle unmapped keys in Normal mode', () => {
type UnmappedKeyCase = {
char: string;
insertable: boolean;
};
it.each<UnmappedKeyCase>([
{ char: 'm', insertable: true },
{ char: 'n', insertable: true },
{ char: 'p', insertable: true },
{ char: 'q', insertable: true },
{ char: 's', insertable: true },
{ char: 'v', insertable: true },
{ char: 'y', insertable: true },
{ char: 'z', insertable: true },
{ char: 'H', insertable: true },
{ char: 'J', insertable: true },
{ char: 'K', insertable: true },
{ char: 'L', insertable: true },
{ char: 'M', insertable: true },
{ char: 'N', insertable: true },
{ char: 'P', insertable: true },
{ char: 'Q', insertable: true },
{ char: 'R', insertable: true },
{ char: 'S', insertable: true },
{ char: 'U', insertable: true },
{ char: 'V', insertable: true },
{ char: 'Y', insertable: true },
{ char: 'Z', insertable: true },
{ char: '/', insertable: true },
{ char: '#', insertable: true },
{ char: '%', insertable: true },
{ char: '&', insertable: true },
{ char: "'", insertable: true },
{ char: '(', insertable: true },
{ char: ')', insertable: true },
{ char: '*', insertable: true },
{ char: '+', insertable: true },
{ char: '-', insertable: true },
{ char: '/', insertable: true },
{ char: ':', insertable: true },
{ char: '<', insertable: true },
{ char: '=', insertable: true },
{ char: '>', insertable: true },
{ char: '@', insertable: true },
{ char: '[', insertable: true },
{ char: '\\', insertable: true },
{ char: ']', insertable: true },
{ char: '_', insertable: true },
{ char: '`', insertable: true },
{ char: '{', insertable: true },
{ char: '|', insertable: true },
{ char: '}', insertable: true },
])(
'$char: should be swallowed and do nothing in Normal mode',
async ({ char, insertable }) => {
const { result } = await renderVimHook();
exitInsertMode(result);
let handled = false;
act(() => {
handled = result.current.handleInput(
createKey({ sequence: char, name: char, insertable }),
);
});
expect(handled).toBe(true);
expect(mockVimContext.setVimMode).not.toHaveBeenCalledWith('INSERT');
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
},
);
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', async () => {
it('df{char}: executes delete-to-char, not a dangling operator', async () => {
const { result } = await renderVimHook();
+5
View File
@@ -1486,6 +1486,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
// Ignore any Insertable key in Normal Mode
if (normalizedKey.insertable) {
return true;
}
// Not handled by vim so allow other handlers to process it.
return false;
}
@@ -17,11 +17,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
return {
...original,
homedir: () => mockHomeDir,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: 'mock memory',
fileCount: 10,
filePaths: ['/a/b/c.md'],
}),
};
});
+14 -14
View File
@@ -28,8 +28,8 @@ const mockCommands: readonly SlashCommand[] = [
altNames: ['mem'],
subCommands: [
{
name: 'add',
description: 'Add to memory',
name: 'list',
description: 'List memory files',
action: async () => {},
kind: CommandKind.BUILT_IN,
},
@@ -64,27 +64,27 @@ describe('parseSlashCommand', () => {
});
it('should parse a subcommand', () => {
const result = parseSlashCommand('/memory add', mockCommands);
expect(result.commandToExecute?.name).toBe('add');
const result = parseSlashCommand('/memory list', mockCommands);
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should parse a subcommand with arguments', () => {
const result = parseSlashCommand(
'/memory add some important data',
'/memory list some important data',
mockCommands,
);
expect(result.commandToExecute?.name).toBe('add');
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some important data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should handle a command alias', () => {
const result = parseSlashCommand('/mem add some data', mockCommands);
expect(result.commandToExecute?.name).toBe('add');
const result = parseSlashCommand('/mem list some data', mockCommands);
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should handle a subcommand alias', () => {
@@ -113,12 +113,12 @@ describe('parseSlashCommand', () => {
it('should handle extra whitespace', () => {
const result = parseSlashCommand(
' /memory add some data ',
' /memory list some data ',
mockCommands,
);
expect(result.commandToExecute?.name).toBe('add');
expect(result.commandToExecute?.name).toBe('list');
expect(result.args).toBe('some data');
expect(result.canonicalPath).toEqual(['memory', 'add']);
expect(result.canonicalPath).toEqual(['memory', 'list']);
});
it('should return undefined if query does not start with a slash', () => {
+1 -1
View File
@@ -16,7 +16,7 @@ export type ParsedSlashCommand = {
* Parses a raw slash command string into its command, arguments, and canonical path.
* If no valid command is found, the `commandToExecute` property will be `undefined`.
*
* @param query The raw input string, e.g., "/memory add some data" or "/help".
* @param query The raw input string, e.g., "/memory show" or "/help".
* @param commands The list of available top-level slash commands.
* @returns An object containing the resolved command, its arguments, and its canonical path.
*/
+67 -5
View File
@@ -336,7 +336,14 @@ describe('sandbox', () => {
await expect(promise).resolves.toBe(0);
expect(spawn).toHaveBeenCalledWith(
'docker',
expect.arrayContaining(['run', '-i', '--rm', '--init']),
expect.arrayContaining([
'run',
'-i',
'--rm',
'--init',
'--entrypoint',
'',
]),
expect.objectContaining({ stdio: 'inherit' }),
);
@@ -787,12 +794,67 @@ describe('sandbox', () => {
expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/user']),
expect.any(Object),
);
// Check that the entrypoint command includes useradd/groupadd
// Check that the entrypoint command includes the defensive useradd check
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
const entrypointCmd = args[args.length - 1];
expect(entrypointCmd).toContain('groupadd');
expect(entrypointCmd).toContain('useradd');
expect(entrypointCmd).toContain('su -p gemini');
expect(entrypointCmd).toContain('if command -v useradd');
expect(entrypointCmd).toContain('groupadd -g 1000 -o gemini');
expect(entrypointCmd).toContain('id 1000');
expect(entrypointCmd).toContain('useradd -o -u 1000');
expect(entrypointCmd).toContain('USER_NAME=$(id -nu 1000 2>/dev/null);');
expect(entrypointCmd).toContain('if [ -n "$USER_NAME" ]; then');
expect(entrypointCmd).toContain('su -p "$USER_NAME"');
expect(entrypointCmd).toContain('else');
expect(entrypointCmd).toContain('Error: Failed to map host UID 1000');
expect(entrypointCmd).toContain('exit 1');
expect(entrypointCmd).toContain("Error: 'useradd' not found");
});
it('should correctly escape home directory with spaces and special characters', async () => {
const config: SandboxConfig = createMockSandboxConfig({
command: 'docker',
image: 'gemini-cli-sandbox',
});
process.env['SANDBOX_SET_UID_GID'] = 'true';
vi.mocked(os.platform).mockReturnValue('linux');
const specialHome = '/home/user name `$(id)`';
mockedHomedir.mockReturnValue(specialHome);
mockedGetContainerPath.mockImplementation((p: string) => p);
// Mock image check to return true
interface MockProcessWithStdout extends EventEmitter {
stdout: EventEmitter;
}
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
mockImageCheckProcess.stdout = new EventEmitter();
vi.mocked(spawn).mockImplementationOnce(() => {
setTimeout(() => {
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
mockImageCheckProcess.emit('close', 0);
}, 1);
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
});
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
typeof spawn
>;
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
if (event === 'close') {
setTimeout(() => cb(0), 10);
}
return mockSpawnProcess;
});
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
await start_sandbox(config);
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
const entrypointCmd = args[args.length - 1];
// Verify that the special home directory is properly quoted/escaped
// The quote tool should handle spaces and backticks
expect(entrypointCmd).toContain("'/home/user name `$(id)`'");
});
it('should register and unregister proxy exit handlers', async () => {
+28 -10
View File
@@ -314,6 +314,10 @@ export async function start_sandbox(
// run init binary inside container to forward signals & reap zombies
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
// explicitly clear the entrypoint to prevent the container's default
// entrypoint from interfering with the CLI's spawn command.
args.push('--entrypoint', '');
// add runsc runtime if using runsc
if (config.command === 'runsc') {
args.push('--runtime=runsc');
@@ -676,22 +680,34 @@ export async function start_sandbox(
// container's /etc/passwd file, which is required by os.userInfo().
const username = 'gemini';
const homeDir = getContainerPath(homedir());
const setupUserCommands = [
// Use -f with groupadd to avoid errors if the group already exists.
`groupadd -f -g ${gid} ${username}`,
// Create user only if it doesn't exist. Use -o for non-unique UID.
`id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`,
].join(' && ');
const quotedHomeDir = quote([homeDir]);
const originalCommand = finalEntrypoint[2];
const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''");
// Use `su -p` to preserve the environment.
const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`;
// Use defensive entrypoint logic that checks for useradd availability.
// This ensures we can support UID/GID mapping on distros that have these
// tools. If useradd is missing (e.g. on minimal images), we fail explicitly
// to avoid insecurely falling back to root execution with host mounts.
const defensiveEntrypoint = [
`if command -v useradd >/dev/null 2>&1; then`,
` (groupadd -g ${gid} -o ${username} 2>/dev/null || true) &&`,
` (id ${uid} >/dev/null 2>&1 || useradd -o -u ${uid} -g ${gid} -d ${quotedHomeDir} -s /bin/bash ${username} 2>/dev/null || true) &&`,
` USER_NAME=$(id -nu ${uid} 2>/dev/null);`,
` if [ -n "$USER_NAME" ]; then`,
` su -p "$USER_NAME" -c '${escapedOriginalCommand}';`,
` else`,
` echo "Error: Failed to map host UID ${uid} to a user in the container." >&2;`,
` exit 1;`,
` fi`,
`else`,
` echo "Error: 'useradd' not found in container. UID/GID mapping is required for Linux distros like NixOS/Arch to avoid permission issues. Please use a container image that includes standard user management tools (like 'ubuntu' or 'debian')." >&2;`,
` exit 1;`,
`fi`,
].join('\n');
// The entrypoint is always `['bash', '-c', '<command>']`, so we modify the command part.
finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`;
finalEntrypoint[2] = defensiveEntrypoint;
// We still need userFlag for the simpler proxy container, which does not have this issue.
userFlag = `--user ${uid}:${gid}`;
@@ -716,6 +732,8 @@ export async function start_sandbox(
'run',
'--rm',
'--init',
'--entrypoint',
'',
...(userFlag ? userFlag.split(' ') : []),
'--name',
SANDBOX_PROXY_NAME,
@@ -143,6 +143,95 @@ describe('sandboxUtils', () => {
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return true on NixOS', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID=nixos\n');
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return true on NixOS with quotes', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID="nixos"\n');
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return true on Ubuntu with single quotes', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue("ID='ubuntu'\n");
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return true on Arch Linux', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID=arch\n');
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return false on unrecognized Linux and warn on UID mismatch', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
vi.mocked(os.userInfo).mockReturnValue({
uid: 1234,
username: 'test',
gid: 1234,
shell: '/bin/bash',
homedir: '/home/test',
});
const { debugLogger } = await import('@google/gemini-cli-core');
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Host UID mismatch detected (current UID: 1234)',
),
);
});
it('should return true on Pop!_OS (via ID_LIKE)', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue(
'ID=pop\nID_LIKE="ubuntu debian"\n',
);
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
});
it('should return false and NOT warn for host root user (UID 0)', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
vi.mocked(os.userInfo).mockReturnValue({
uid: 0,
username: 'root',
gid: 0,
shell: '/bin/bash',
homedir: '/root',
});
const { debugLogger } = await import('@google/gemini-cli-core');
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
expect(debugLogger.warn).not.toHaveBeenCalledWith(
expect.stringContaining('Host UID mismatch detected'),
);
});
it('should warn and return false if /etc/os-release is unreadable', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('linux');
vi.mocked(readFile).mockRejectedValue(new Error('EACCES'));
const { debugLogger } = await import('@google/gemini-cli-core');
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
expect(debugLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Could not read /etc/os-release'),
);
});
it('should return false on non-Linux', async () => {
delete process.env['SANDBOX_SET_UID_GID'];
vi.mocked(os.platform).mockReturnValue('darwin');
+21 -8
View File
@@ -49,22 +49,35 @@ export async function shouldUseCurrentUserInSandbox(): Promise<boolean> {
if (os.platform() === 'linux') {
try {
const osReleaseContent = await readFile('/etc/os-release', 'utf8');
if (
osReleaseContent.includes('ID=debian') ||
osReleaseContent.includes('ID=ubuntu') ||
osReleaseContent.match(/^ID_LIKE=.*debian.*/m) || // Covers derivatives
osReleaseContent.match(/^ID_LIKE=.*ubuntu.*/m) // Covers derivatives
) {
const isSupportedDistro =
osReleaseContent.match(
/^ID=["']?(?:debian|ubuntu|nixos|arch|fedora|suse|opensuse)/m,
) ||
osReleaseContent.match(
/^ID_LIKE=["']?.*(?:debian|ubuntu|arch|fedora|suse).*/m,
);
if (isSupportedDistro) {
debugLogger.log(
'Defaulting to use current user UID/GID for Debian/Ubuntu-based Linux.',
'Defaulting to use current user UID/GID for supported Linux distribution.',
);
return true;
}
// If we're on Linux but the distro is unrecognized, check for a UID mismatch
// that might cause permission issues in the sandbox.
const uid = os.userInfo().uid;
if (uid !== 1000 && uid !== 0) {
debugLogger.warn(
`Warning: Host UID mismatch detected (current UID: ${uid}). ` +
'If you encounter permission errors in the sandbox, try setting SANDBOX_SET_UID_GID=true.',
);
}
} catch {
// Silently ignore if /etc/os-release is not found or unreadable.
// The default (false) will be applied in this case.
debugLogger.warn(
'Warning: Could not read /etc/os-release to auto-detect Debian/Ubuntu for UID/GID default.',
'Warning: Could not read /etc/os-release to auto-detect Linux distribution for UID/GID default.',
);
}
}
+17 -16
View File
@@ -33,22 +33,22 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
"@opentelemetry/core": "^2.5.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.211.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.211.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
"@opentelemetry/instrumentation-http": "^0.211.0",
"@opentelemetry/otlp-exporter-base": "^0.211.0",
"@opentelemetry/resources": "^2.5.0",
"@opentelemetry/sdk-logs": "^0.211.0",
"@opentelemetry/sdk-metrics": "^2.5.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/sdk-trace-node": "^2.5.0",
"@opentelemetry/api-logs": "^0.218.0",
"@opentelemetry/core": "^2.7.1",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.218.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.218.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.218.0",
"@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/otlp-exporter-base": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-logs": "^0.218.0",
"@opentelemetry/sdk-metrics": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/sdk-trace-base": "^2.7.1",
"@opentelemetry/sdk-trace-node": "^2.7.1",
"@opentelemetry/semantic-conventions": "^1.39.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
@@ -67,6 +67,7 @@
"glob": "^12.0.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"ignore": "^7.0.0",
"ipaddr.js": "^1.9.1",
+4 -1
View File
@@ -93,13 +93,16 @@ export function contentPartsToGeminiParts(content: ContentPart[]): Part[] {
// References are converted to text for the model
result.push({ text: part.text });
break;
default:
default: {
const _exhaustiveCheck: never = part;
void _exhaustiveCheck;
debugLogger.warn(
`Unhandled ContentPart type: ${JSON.stringify(part)} fallback to serialization`,
);
// Serialize unknown ContentPart variants instead of dropping them
result.push({ text: JSON.stringify(part) });
break;
}
}
}
return result;
@@ -9,16 +9,21 @@ import {
supersedeStaleSnapshots,
SNAPSHOT_SUPERSEDED_PLACEHOLDER,
} from './snapshotSuperseder.js';
import type { GeminiChat } from '../../core/geminiChat.js';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Content } from '@google/genai';
import { randomUUID } from 'node:crypto';
/** Builds a minimal mock GeminiChat around a mutable history array. */
function createMockChat(history: Content[]): GeminiChat {
const getTurns = () => history.map((c) => ({ id: randomUUID(), content: c }));
return {
getHistory: vi.fn(() => [...history]),
setHistory: vi.fn((newHistory: readonly Content[]) => {
getHistoryTurns: vi.fn(() => getTurns()),
setHistory: vi.fn((newHistory: ReadonlyArray<Content | HistoryTurn>) => {
history.length = 0;
history.push(...newHistory);
for (const item of newHistory) {
history.push('content' in item ? item.content : item);
}
}),
} as unknown as GeminiChat;
}
@@ -14,8 +14,8 @@
* model call so the model only ever sees the most recent snapshot in full.
*/
import type { GeminiChat } from '../../core/geminiChat.js';
import type { Content, Part } from '@google/genai';
import type { GeminiChat, HistoryTurn } from '../../core/geminiChat.js';
import type { Part } from '@google/genai';
import { debugLogger } from '../../utils/debugLogger.js';
const TAKE_SNAPSHOT_TOOL_NAME = 'take_snapshot';
@@ -39,7 +39,7 @@ export const SNAPSHOT_SUPERSEDED_PLACEHOLDER =
* Uses {@link GeminiChat.setHistory} to apply the modified history.
*/
export function supersedeStaleSnapshots(chat: GeminiChat): void {
const history = chat.getHistory();
const history = chat.getHistoryTurns();
// Locate all (contentIndex, partIndex) tuples for take_snapshot responses.
const snapshotLocations: Array<{
@@ -48,7 +48,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}> = [];
for (let i = 0; i < history.length; i++) {
const parts = history[i].parts;
const parts = history[i].content.parts;
if (!parts) continue;
for (let j = 0; j < parts.length; j++) {
const part = parts[j];
@@ -71,7 +71,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
const staleLocations = snapshotLocations.slice(0, -1);
const needsUpdate = staleLocations.some(({ contentIdx, partIdx }) => {
const output = getResponseOutput(
history[contentIdx].parts![partIdx].functionResponse?.response,
history[contentIdx].content.parts![partIdx].functionResponse?.response,
);
return !output.includes(SNAPSHOT_SUPERSEDED_PLACEHOLDER);
});
@@ -81,15 +81,18 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
}
// Shallow-copy the history and replace stale snapshots.
const newHistory: Content[] = history.map((content) => ({
...content,
parts: content.parts ? [...content.parts] : undefined,
const newHistory: HistoryTurn[] = history.map((turn) => ({
id: turn.id,
content: {
...turn.content,
parts: turn.content.parts ? [...turn.content.parts] : undefined,
},
}));
let replacedCount = 0;
for (const { contentIdx, partIdx } of staleLocations) {
const originalPart = newHistory[contentIdx].parts![partIdx];
const originalPart = newHistory[contentIdx].content.parts![partIdx];
if (!originalPart.functionResponse) continue;
// Check if already superseded
@@ -106,7 +109,7 @@ export function supersedeStaleSnapshots(chat: GeminiChat): void {
},
};
newHistory[contentIdx].parts![partIdx] = replacementPart;
newHistory[contentIdx].content.parts![partIdx] = replacementPart;
replacedCount++;
}
+26 -40
View File
@@ -756,12 +756,19 @@ describe('LocalAgentExecutor', () => {
expect(startHistory).toBeDefined();
expect(startHistory).toHaveLength(2);
const history = startHistory!;
// Perform checks on defined objects to satisfy TS
const firstPart = startHistory?.[0]?.parts?.[0];
const firstPart =
'content' in history[0]
? history[0].content.parts?.[0]
: history[0].parts?.[0];
expect(firstPart?.text).toBe('Goal: TestGoal');
const secondPart = startHistory?.[1]?.parts?.[0];
const secondPart =
'content' in history[1]
? history[1].content.parts?.[0]
: history[1].parts?.[0];
expect(secondPart?.text).toBe('OK, starting on TestGoal.');
});
@@ -3601,7 +3608,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
it('should pass hasFailedCompressionAttempt=true to compression after a failure', async () => {
@@ -3706,7 +3720,14 @@ describe('LocalAgentExecutor', () => {
expect(mockCompress.mock.calls[2][5]).toBe(false);
expect(mockSetHistory).toHaveBeenCalledTimes(1);
expect(mockSetHistory).toHaveBeenCalledWith(compressedHistory);
// History turns are now wrapped with IDs
expect(mockSetHistory).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.objectContaining({ role: 'user' }),
}),
]),
);
});
});
@@ -4132,40 +4153,7 @@ describe('LocalAgentExecutor', () => {
expect(systemInstruction).toContain('<loaded_context>');
});
it('should inject environment memory into the first message when JIT is disabled', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
definition,
mockConfig,
onActivity,
);
const mockMemory = 'Project memory rule';
vi.spyOn(mockConfig, 'getEnvironmentMemory').mockReturnValue(
mockMemory,
);
vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(false);
mockModelResponse([
{
name: COMPLETE_TASK_TOOL_NAME,
args: { finalResult: 'done' },
id: 'call1',
},
]);
await executor.run({ goal: 'test' }, signal);
const { message } = getMockMessageParams(0);
const parts = message as Part[];
expect(parts).toBeDefined();
const memoryPart = parts.find((p) => p.text?.includes(mockMemory));
expect(memoryPart).toBeDefined();
expect(memoryPart?.text).toBe(mockMemory);
});
it('should inject session memory into the first message when JIT is enabled', async () => {
it('should inject session memory into the first message', async () => {
const definition = createTestDefinition();
const executor = await LocalAgentExecutor.create(
definition,
@@ -4176,7 +4164,6 @@ describe('LocalAgentExecutor', () => {
const mockMemory =
'<loaded_context>\nExtension memory rule\n</loaded_context>';
vi.spyOn(mockConfig, 'getSessionMemory').mockReturnValue(mockMemory);
vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(true);
mockModelResponse([
{
@@ -4216,7 +4203,6 @@ describe('LocalAgentExecutor', () => {
? '<loaded_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>'
: '<loaded_context>\n<extension_context>\nExtension memory rule\n</extension_context>\n<project_context>\nProject memory rule\n</project_context>\n</loaded_context>',
);
vi.spyOn(mockConfig, 'isJitContextEnabled').mockReturnValue(true);
mockModelResponse([
{
+16 -13
View File
@@ -643,17 +643,12 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
// Inject loaded memory files. Some background agents opt out of
// extension memory while still retaining project session context.
let environmentMemory: string;
if (this.context.config.isJitContextEnabled?.()) {
environmentMemory =
this.definition.includeExtensionContext === false
? this.context.config.getSessionMemory({
includeExtensionContext: false,
})
: this.context.config.getSessionMemory();
} else {
environmentMemory = this.context.config.getEnvironmentMemory();
}
const environmentMemory =
this.definition.includeExtensionContext === false
? this.context.config.getSessionMemory({
includeExtensionContext: false,
})
: this.context.config.getSessionMemory();
const initialParts: Part[] = [];
if (environmentMemory) {
@@ -924,12 +919,20 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
this.hasFailedCompressionAttempt = true;
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
this.hasFailedCompressionAttempt = false;
}
} else if (info.compressionStatus === CompressionStatus.CONTENT_TRUNCATED) {
if (newHistory) {
chat.setHistory(newHistory);
const turns = newHistory.map((c) => ({
id: randomUUID(),
content: c,
}));
chat.setHistory(turns);
// Do NOT reset hasFailedCompressionAttempt.
// We only truncated content because summarization previously failed.
// We want to keep avoiding expensive summarization calls.
@@ -121,7 +121,7 @@ describe('LocalSubagentInvocation', () => {
);
});
it('should truncate long input values', () => {
it('should not truncate long input values', () => {
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSubagentInvocation(
@@ -131,13 +131,12 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default INPUT_PREVIEW_MAX_LENGTH is 50
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(50)} }`,
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should truncate the overall description if it exceeds the limit', () => {
it('should not truncate the overall description', () => {
// Create a definition and inputs that result in a very long description
const longNameDef: LocalAgentDefinition = {
...testDefinition,
@@ -154,8 +153,7 @@ describe('LocalSubagentInvocation', () => {
mockMessageBus,
);
const description = invocation.getDescription();
// Default DESCRIPTION_MAX_LENGTH is 200
expect(description.length).toBe(200);
expect(description.length).toBeGreaterThan(300);
expect(
description.startsWith(
"Running subagent 'VeryLongAgentNameThatTakesUpSpace'",
+35 -42
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -35,9 +35,6 @@ import {
} from '../utils/agent-sanitization-utils.js';
import { debugLogger } from '../utils/debugLogger.js';
const INPUT_PREVIEW_MAX_LENGTH = 50;
const DESCRIPTION_MAX_LENGTH = 200;
/**
* Represents a validated, executable instance of a subagent tool.
*
@@ -80,14 +77,10 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(
([key, value]) =>
`${key}: ${String(value).slice(0, INPUT_PREVIEW_MAX_LENGTH)}`,
)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
const description = `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
return description.slice(0, DESCRIPTION_MAX_LENGTH);
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
@@ -168,8 +161,11 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
const args = JSON.stringify(
sanitizeToolArgs(activity.data['args']),
);
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: randomUUID(),
id: callId,
type: 'tool_call',
content: name,
displayName,
@@ -186,23 +182,28 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
break;
}
case 'TOOL_CALL_END': {
const name = String(activity.data['name']);
const data = activity.data['data'];
const isError = isToolActivityError(data);
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === name &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
this.publishActivity(recentActivity[i]);
break;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
@@ -218,31 +219,23 @@ export class LocalSubagentInvocation extends BaseToolInvocation<
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const toolName = activity.data['name']
? String(activity.data['name'])
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (toolName && (isCancellation || isRejection)) {
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.CANCELLED;
updated = true;
break;
}
}
} else if (toolName) {
// Mark non-rejection/non-cancellation errors as 'error'
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].content === toolName &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = SubagentState.ERROR;
recentActivity[i].status = targetStatus;
updated = true;
break;
}
@@ -0,0 +1,817 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
type SubagentProgress,
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
} from './types.js';
import { LocalSessionInvocation } from './local-session-invocation.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
vi.mock('./local-subagent-protocol.js');
const MockLocalSubagentSession = vi.mocked(LocalSubagentSession);
let capturedActivityCallback:
| ((activity: SubagentActivityEvent) => void)
| undefined;
const testDefinition: LocalAgentDefinition = {
kind: 'local',
name: 'MockAgent',
description: 'A mock agent for testing.',
inputConfig: {
inputSchema: {
type: 'object',
properties: { task: { type: 'string' } },
},
},
modelConfig: { model: 'test-model', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
function setupMockSession(config: {
output?: { result: string; terminate_reason: AgentTerminateMode };
error?: Error;
}) {
const mockSession = {
send: vi.fn().mockResolvedValue({ streamId: 'stream-1' }),
getResult: config.error
? vi.fn().mockRejectedValue(config.error)
: vi.fn().mockResolvedValue(
config.output ?? {
result: 'done',
terminate_reason: AgentTerminateMode.GOAL,
},
),
abort: vi.fn(),
subscribe: vi.fn().mockReturnValue(vi.fn()),
};
MockLocalSubagentSession.mockImplementation(
(
_def: LocalAgentDefinition,
_ctx: AgentLoopContext,
_bus: MessageBus,
rawCallback?: (activity: SubagentActivityEvent) => void,
) => {
capturedActivityCallback = rawCallback;
return mockSession as unknown as LocalSubagentSession;
},
);
return mockSession;
}
describe('LocalSessionInvocation', () => {
let mockContext: AgentLoopContext;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.clearAllMocks();
capturedActivityCallback = undefined;
mockContext = makeFakeConfig() as unknown as AgentLoopContext;
mockMessageBus = createMockMessageBus();
});
it('should pass the messageBus to the parent constructor', () => {
setupMockSession({});
const params = { task: 'Analyze data' };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
expect(
(invocation as unknown as { messageBus: MessageBus }).messageBus,
).toBe(mockMessageBus);
});
describe('getDescription', () => {
it('should format the description with inputs', () => {
setupMockSession({});
const params = { task: 'Analyze data', priority: 5 };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
"Running subagent 'MockAgent' with inputs: { task: Analyze data, priority: 5 }",
);
});
it('should not truncate long input values', () => {
setupMockSession({});
const longTask = 'A'.repeat(100);
const params = { task: longTask };
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description).toBe(
`Running subagent 'MockAgent' with inputs: { task: ${'A'.repeat(100)} }`,
);
});
it('should not truncate the overall description', () => {
setupMockSession({});
const longNameDef: LocalAgentDefinition = {
...testDefinition,
name: 'VeryLongAgentNameThatTakesUpSpace',
};
const params: AgentInputs = {};
for (let i = 0; i < 20; i++) {
params[`input${i}`] = `value${i}`;
}
const invocation = new LocalSessionInvocation(
longNameDef,
mockContext,
params,
mockMessageBus,
);
const description = invocation.getDescription();
expect(description.length).toBeGreaterThan(300);
});
});
describe('execute', () => {
it('should create session and run successfully', async () => {
const mockOutput = {
result: 'Analysis complete.',
terminate_reason: AgentTerminateMode.GOAL,
};
const mockSession = setupMockSession({ output: mockOutput });
const params = { query: 'Execute task' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(MockLocalSubagentSession).toHaveBeenCalledWith(
testDefinition,
mockContext,
mockMessageBus,
expect.any(Function),
);
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Execute task' }] },
});
expect(result.llmContent).toEqual([
{
text: expect.stringContaining(
"Subagent 'MockAgent' finished.\nTermination Reason: GOAL\nResult:\nAnalysis complete.",
),
},
]);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('completed');
expect(display.result).toBe('Analysis complete.');
expect(display.terminateReason).toBe(AgentTerminateMode.GOAL);
});
it('should stream THOUGHT_CHUNK activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'think' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
// Wait for send to be called so the activity callback is wired
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a thought chunk via captured callback
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Analyzing...' },
});
await executePromise;
// Find an updateOutput call containing the thought
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasThought = progressCalls.some(
(p) =>
p.recentActivity &&
p.recentActivity.some(
(a) => a.type === 'thought' && a.content === 'Analyzing...',
),
);
expect(hasThought).toBe(true);
});
it('should stream TOOL_CALL_START and TOOL_CALL_END', async () => {
const mockSession = setupMockSession({});
const params = { query: 'run tool' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'ls', args: {}, callId: 'call-123' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'ls', data: {}, id: 'call-123' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// After TOOL_CALL_START, the immediate updateOutput call should show running
const runningCalls = progressCalls.filter((p) => p.state === 'running');
// The first running call with a tool_call should show 'running'
const firstToolCall = runningCalls.find((p) =>
p.recentActivity?.some(
(a) => a.type === 'tool_call' && a.content === 'ls',
),
);
expect(firstToolCall).toBeDefined();
// After TOOL_CALL_END, the tool should be completed
const hasCompleted = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'ls' &&
a.status === 'completed',
),
);
expect(hasCompleted).toBe(true);
});
it('should handle ERROR activity', async () => {
const mockSession = setupMockSession({});
const params = { query: 'fail' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: { error: 'Something broke' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'thought' &&
a.content === 'Error: Something broke' &&
a.status === 'error',
),
);
expect(hasError).toBe(true);
});
it('should handle cancelled errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'cancel' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
error: SUBAGENT_CANCELLED_ERROR_MESSAGE,
errorType: SubagentActivityErrorType.CANCELLED,
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) => a.type === 'thought' && a.status === 'cancelled',
),
);
expect(hasCancelled).toBe(true);
});
it('should handle rejected errors', async () => {
const mockSession = setupMockSession({});
const params = { query: 'reject' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'dangerous_tool', args: {}, callId: 'call-rej' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'ERROR',
data: {
name: 'dangerous_tool',
error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Rethink approach.`,
errorType: SubagentActivityErrorType.REJECTED,
callId: 'call-rej',
},
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Tool call should be marked cancelled
const hasToolCancelled = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'dangerous_tool' &&
a.status === 'cancelled',
),
);
expect(hasToolCancelled).toBe(true);
});
it('should trim recentActivity to MAX_RECENT_ACTIVITY', async () => {
const mockSession = setupMockSession({});
const params = { query: 'trim' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit 4+ activities to exceed MAX_RECENT_ACTIVITY (3)
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool1', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool2', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool3', args: {} },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'tool4', args: {} },
});
await executePromise;
// After the 4th activity, the last updateOutput call before completion
// should have only 3 items in recentActivity
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// Find the call right after the 4th activity (before completion)
const afterFourthActivity = progressCalls.filter(
(p) => p.state === 'running' && p.recentActivity.length > 0,
);
const lastRunning = afterFourthActivity[afterFourthActivity.length - 1];
expect(lastRunning.recentActivity.length).toBeLessThanOrEqual(3);
// Should contain tool4 (the latest)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool4'),
).toBe(true);
// Should NOT contain tool1 (trimmed away)
expect(
lastRunning.recentActivity.some((a) => a.content === 'tool1'),
).toBe(false);
});
it('should handle executor errors', async () => {
const error = new Error('Model failed during execution.');
setupMockSession({ error });
const params = { query: 'fail hard' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
expect(result.llmContent).toBe(
`Subagent 'MockAgent' failed. Error: ${error.message}`,
);
const display = result.returnDisplay as SubagentProgress;
expect(display.isSubagentProgress).toBe(true);
expect(display.state).toBe('error');
expect(display.recentActivity).toContainEqual(
expect.objectContaining({
type: 'thought',
content: `Error: ${error.message}`,
status: 'error',
}),
);
});
it('should handle abort', async () => {
const mockOutput = {
result: '',
terminate_reason: AgentTerminateMode.ABORTED,
};
setupMockSession({ output: mockOutput });
const params = { query: 'abort me' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await expect(
invocation.execute({ abortSignal: signal, updateOutput }),
).rejects.toThrow('Operation cancelled by user');
// Verify cancelled state was published
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasCancelledState = progressCalls.some(
(p) => p.state === 'cancelled',
);
expect(hasCancelledState).toBe(true);
});
it('should wire abort signal to session.abort', async () => {
const mockSession = setupMockSession({});
const params = { query: 'abort wire' };
const controller = new AbortController();
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
// Trigger abort
controller.abort();
// The execute should complete (getResult returned GOAL by default)
await executePromise.catch(() => {
/* abort may throw */
});
expect(mockSession.abort).toHaveBeenCalled();
});
it('should send non-query params as config update before query', async () => {
const mockSession = setupMockSession({});
const params = { query: 'Do something', extra_config: 'value123' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({ abortSignal: signal, updateOutput });
// First send: config update with non-query params
expect(mockSession.send).toHaveBeenCalledWith({
update: { config: { extra_config: 'value123' } },
});
// Second send: message with query
expect(mockSession.send).toHaveBeenCalledWith({
message: { content: [{ type: 'text', text: 'Do something' }] },
});
// Config update should come before message
const sendCalls = mockSession.send.mock.calls;
const configIdx = sendCalls.findIndex((c) => c[0]?.update?.config);
const messageIdx = sendCalls.findIndex((c) => c[0]?.message);
expect(configIdx).toBeLessThan(messageIdx);
});
it('should publish SUBAGENT_ACTIVITY on messageBus', async () => {
const mockSession = setupMockSession({});
const params = { query: 'publish test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'THOUGHT_CHUNK',
data: { text: 'Thinking...' },
});
await executePromise;
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: 'MockAgent',
activity: expect.objectContaining({
type: 'thought',
content: 'Thinking...',
}),
}),
);
});
it('should clean up abort listener in finally', async () => {
setupMockSession({});
const params = { query: 'cleanup' };
const controller = new AbortController();
const removeEventListenerSpy = vi.spyOn(
controller.signal,
'removeEventListener',
);
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
await invocation.execute({
abortSignal: controller.signal,
updateOutput,
});
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'abort',
expect.any(Function),
);
});
it('should unsubscribe parent observer in finally', async () => {
const unsubscribeFn = vi.fn();
const mockSession = setupMockSession({});
mockSession.subscribe.mockReturnValue(unsubscribeFn);
const params = { query: 'unsub test' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const onAgentEvent = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
{ onAgentEvent },
);
await invocation.execute({ abortSignal: signal, updateOutput });
expect(mockSession.subscribe).toHaveBeenCalledWith(onAgentEvent);
expect(unsubscribeFn).toHaveBeenCalled();
});
it('should handle TOOL_CALL_END with error data', async () => {
const mockSession = setupMockSession({});
const params = { query: 'tool error' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'failing_tool', args: {}, callId: 'call-err' },
});
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_END',
data: { name: 'failing_tool', data: { isError: true }, id: 'call-err' },
});
await executePromise;
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
const hasToolError = progressCalls.some((p) =>
p.recentActivity?.some(
(a) =>
a.type === 'tool_call' &&
a.content === 'failing_tool' &&
a.status === 'error',
),
);
expect(hasToolError).toBe(true);
});
it('should mark running items as cancelled on abort', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
const mockSession = setupMockSession({ error: abortError });
const params = { query: 'mark cancelled' };
const signal = new AbortController().signal;
const updateOutput = vi.fn();
const invocation = new LocalSessionInvocation(
testDefinition,
mockContext,
params,
mockMessageBus,
);
const executePromise = invocation.execute({
abortSignal: signal,
updateOutput,
});
await vi.waitFor(() => expect(mockSession.send).toHaveBeenCalled());
// Emit a running tool call before the abort
capturedActivityCallback!({
isSubagentActivityEvent: true,
agentName: 'MockAgent',
type: 'TOOL_CALL_START',
data: { name: 'running_tool', args: {} },
});
await expect(executePromise).rejects.toThrow('Aborted');
const progressCalls = updateOutput.mock.calls.map(
(c) => c[0] as SubagentProgress,
);
// The final progress should show the tool as cancelled
const lastProgress = progressCalls[progressCalls.length - 1];
expect(lastProgress.state).toBe('cancelled');
expect(lastProgress.recentActivity).toContainEqual(
expect.objectContaining({
type: 'tool_call',
content: 'running_tool',
status: 'cancelled',
}),
);
});
});
});
@@ -0,0 +1,416 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type AgentLoopContext } from '../config/agent-loop-context.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
BaseToolInvocation,
type ToolResult,
type ExecuteOptions,
} from '../tools/tools.js';
import {
type LocalAgentDefinition,
type AgentInputs,
type SubagentActivityEvent,
type SubagentProgress,
type SubagentActivityItem,
AgentTerminateMode,
SubagentActivityErrorType,
SUBAGENT_REJECTED_ERROR_PREFIX,
SUBAGENT_CANCELLED_ERROR_MESSAGE,
isToolActivityError,
SubagentState,
} from './types.js';
import { randomUUID } from 'node:crypto';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
sanitizeThoughtContent,
sanitizeToolArgs,
sanitizeErrorMessage,
} from '../utils/agent-sanitization-utils.js';
import { checkExhaustive } from '../utils/checks.js';
import { LocalSubagentSession } from './local-subagent-protocol.js';
import type { AgentEvent } from '../agent/types.js';
const MAX_RECENT_ACTIVITY = 3;
/** Optional configuration for subagent invocations. */
export interface SubagentInvocationOptions {
toolName?: string;
toolDisplayName?: string;
onAgentEvent?: (event: AgentEvent) => void;
}
/**
* Session-based local subagent invocation.
*
* This class orchestrates the execution of a defined agent by:
* 1. Using {@link LocalSubagentSession} as the execution engine.
* 2. Bridging the agent's streaming activity (e.g., thoughts) to the tool's
* live output stream via the session's rawActivityCallback.
* 3. Formatting the final result into a {@link ToolResult}.
*/
export class LocalSessionInvocation extends BaseToolInvocation<
AgentInputs,
ToolResult
> {
private readonly _onAgentEvent?: (event: AgentEvent) => void;
/**
* @param definition The definition object that configures the agent.
* @param context The agent loop context.
* @param params The validated input parameters for the agent.
* @param messageBus Message bus for policy enforcement.
* @param options Optional overrides for tool name, display name, and event callback.
*/
constructor(
private readonly definition: LocalAgentDefinition,
private readonly context: AgentLoopContext,
params: AgentInputs,
messageBus: MessageBus,
options?: SubagentInvocationOptions,
) {
super(
params,
messageBus,
options?.toolName ?? definition.name,
options?.toolDisplayName ?? definition.displayName,
);
this._onAgentEvent = options?.onAgentEvent;
}
/**
* Returns a concise, human-readable description of the invocation.
* Used for logging and display purposes.
*/
getDescription(): string {
const inputSummary = Object.entries(this.params)
.map(([key, value]) => `${key}: ${String(value)}`)
.join(', ');
return `Running subagent '${this.definition.name}' with inputs: { ${inputSummary} }`;
}
private publishActivity(activity: SubagentActivityItem): void {
void this.messageBus.publish({
type: MessageBusType.SUBAGENT_ACTIVITY,
subagentName: this.definition.displayName ?? this.definition.name,
activity,
});
}
/**
* Executes the subagent.
*
* @param options Options for tool execution including signal and output updates.
* @returns A `Promise` that resolves with the final `ToolResult`.
*/
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
let recentActivity: SubagentActivityItem[] = [];
// Raw SubagentActivityEvent handler — preserves all existing progress display logic.
// Passed as rawActivityCallback to LocalSubagentSession so the protocol can call it
// before translating to AgentEvents.
const onActivity = (activity: SubagentActivityEvent): void => {
if (!updateOutput) return;
let updated = false;
switch (activity.type) {
case 'THOUGHT_CHUNK': {
const rawText = activity.data['text'];
const text = typeof rawText === 'string' ? rawText : '';
const lastItem = recentActivity[recentActivity.length - 1];
if (
lastItem &&
lastItem.type === 'thought' &&
lastItem.status === SubagentState.RUNNING
) {
lastItem.content = sanitizeThoughtContent(text);
} else {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: sanitizeThoughtContent(text),
status: SubagentState.RUNNING,
});
}
updated = true;
const latestThought = recentActivity[recentActivity.length - 1];
if (latestThought) {
this.publishActivity(latestThought);
}
break;
}
case 'TOOL_CALL_START': {
const rawName = activity.data['name'];
const name = typeof rawName === 'string' ? rawName.trim() : '';
const displayName = activity.data['displayName']
? sanitizeErrorMessage(String(activity.data['displayName']).trim())
: undefined;
const description = activity.data['description']
? sanitizeErrorMessage(String(activity.data['description']))
: undefined;
const args = JSON.stringify(sanitizeToolArgs(activity.data['args']));
const callId = activity.data['callId']
? String(activity.data['callId'])
: randomUUID();
recentActivity.push({
id: callId,
type: 'tool_call',
content: name,
displayName,
description,
args,
status: SubagentState.RUNNING,
});
updated = true;
const latestTool = recentActivity[recentActivity.length - 1];
if (latestTool) {
this.publishActivity(latestTool);
}
break;
}
case 'TOOL_CALL_END': {
const data = activity.data['data'];
const isError = isToolActivityError(data);
const callId = activity.data['id']
? String(activity.data['id'])
: undefined;
if (callId) {
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = isError
? SubagentState.ERROR
: SubagentState.COMPLETED;
updated = true;
this.publishActivity(recentActivity[i]);
break;
}
}
}
break;
}
case 'ERROR': {
const rawError = activity.data['error'];
const error = typeof rawError === 'string' ? rawError.trim() : '';
const errorType = activity.data['errorType'];
const sanitizedError = sanitizeErrorMessage(error);
const isCancellation =
errorType === SubagentActivityErrorType.CANCELLED ||
error === SUBAGENT_CANCELLED_ERROR_MESSAGE;
const isRejection =
errorType === SubagentActivityErrorType.REJECTED ||
error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX);
const callId = activity.data['callId']
? String(activity.data['callId'])
: undefined;
if (callId) {
const targetStatus =
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR;
for (let i = recentActivity.length - 1; i >= 0; i--) {
if (
recentActivity[i].type === 'tool_call' &&
recentActivity[i].id === callId &&
recentActivity[i].status === SubagentState.RUNNING
) {
recentActivity[i].status = targetStatus;
updated = true;
break;
}
}
}
recentActivity.push({
id: randomUUID(),
type: 'thought',
content:
isCancellation || isRejection
? sanitizedError
: `Error: ${sanitizedError}`,
status:
isCancellation || isRejection
? SubagentState.CANCELLED
: SubagentState.ERROR,
});
updated = true;
break;
}
default:
checkExhaustive(activity.type);
break;
}
if (updated) {
// Keep only the last N items
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity], // Copy to avoid mutation issues
state: SubagentState.RUNNING,
};
updateOutput(progress);
}
};
// Create session with the raw activity callback for rich progress display
const session = new LocalSubagentSession(
this.definition,
this.context,
this.messageBus,
onActivity,
);
// Subscribe for parent session observability
let unsubscribeParent: (() => void) | undefined;
if (this._onAgentEvent) {
unsubscribeParent = session.subscribe(this._onAgentEvent);
}
// Wire external abort signal to session abort
const abortListener = () => void session.abort();
signal.addEventListener('abort', abortListener, { once: true });
try {
if (updateOutput) {
const initialProgress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [],
state: SubagentState.RUNNING,
};
updateOutput(initialProgress);
}
// Buffer non-query params, then send query as message to start execution
const query = String(this.params['query'] ?? '');
const otherParams = { ...this.params } as Record<string, unknown>;
delete otherParams['query'];
if (Object.keys(otherParams).length > 0) {
await session.send({ update: { config: otherParams } });
}
await session.send({
message: { content: [{ type: 'text', text: query }] },
});
const output = await session.getResult();
if (output.terminate_reason === AgentTerminateMode.ABORTED) {
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.CANCELLED,
};
if (updateOutput) {
updateOutput(progress);
}
const cancelError = new Error('Operation cancelled by user');
cancelError.name = 'AbortError';
throw cancelError;
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: SubagentState.COMPLETED,
result: output.result,
terminateReason: output.terminate_reason,
};
if (updateOutput) {
updateOutput(progress);
}
const resultContent = `Subagent '${this.definition.name}' finished.
Termination Reason: ${output.terminate_reason}
Result:
${output.result}`;
return {
llmContent: [{ text: resultContent }],
returnDisplay: progress,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const isAbort =
(error instanceof Error && error.name === 'AbortError') ||
errorMessage.includes('Aborted');
// Mark any running items as error/cancelled
for (const item of recentActivity) {
if (item.status === SubagentState.RUNNING) {
item.status = isAbort ? SubagentState.CANCELLED : SubagentState.ERROR;
}
}
// Ensure the error is reflected in the recent activity for display
if (!isAbort) {
const lastActivity = recentActivity[recentActivity.length - 1];
if (!lastActivity || lastActivity.status !== SubagentState.ERROR) {
recentActivity.push({
id: randomUUID(),
type: 'thought',
content: `Error: ${errorMessage}`,
status: SubagentState.ERROR,
});
if (recentActivity.length > MAX_RECENT_ACTIVITY) {
recentActivity = recentActivity.slice(-MAX_RECENT_ACTIVITY);
}
}
}
const progress: SubagentProgress = {
isSubagentProgress: true,
agentName: this.definition.name,
recentActivity: [...recentActivity],
state: isAbort ? SubagentState.CANCELLED : SubagentState.ERROR,
};
if (updateOutput) {
updateOutput(progress);
}
if (isAbort) {
throw error;
}
return {
llmContent: `Subagent '${this.definition.name}' failed. Error: ${errorMessage}`,
returnDisplay: progress,
};
} finally {
signal.removeEventListener('abort', abortListener);
unsubscribeParent?.();
}
}
}
@@ -29,6 +29,9 @@ describe('Auto Routing Fallback Integration', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(Config.prototype, 'getHasAccessToPreviewModel').mockReturnValue(
true,
);
// Mock fs to avoid real file system access
vi.mocked(fs.existsSync).mockReturnValue(true);
@@ -28,6 +28,7 @@ describe('Fallback Integration', () => {
getActiveModel: () => PREVIEW_GEMINI_MODEL_AUTO,
setActiveModel: vi.fn(),
getUserTier: () => undefined,
getHasAccessToPreviewModel: () => true,
getModelAvailabilityService: () => availabilityService,
modelConfigService: undefined as unknown as ModelConfigService,
} as unknown as Config;
@@ -220,18 +220,7 @@ describe('policyHelpers', () => {
];
testCases.forEach(
({
name,
model,
useGemini31,
hasAccess,
authType,
wrapsAround,
...rest
}) => {
const releaseChannel = (rest as Record<string, unknown>)[
'releaseChannel'
] as string | undefined;
({ name, model, useGemini31, hasAccess, authType, wrapsAround }) => {
it(`achieves parity for: ${name}`, () => {
const createBaseConfig = (dynamic: boolean) =>
createMockConfig({
@@ -241,7 +230,7 @@ describe('policyHelpers', () => {
getGemini31FlashLiteLaunchedSync: () => false,
getHasAccessToPreviewModel: () => hasAccess ?? true,
getContentGeneratorConfig: () => ({ authType }),
getReleaseChannel: () => releaseChannel ?? 'preview',
getReleaseChannel: () => 'preview',
modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS),
});
@@ -55,7 +55,10 @@ export function resolvePolicyChain(
const useGemini31FlashLite =
config.getGemini31FlashLiteLaunchedSync?.() ?? false;
const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true;
const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? false;
// Capture the original family intent before any normalization or early downgrade.
const isOriginallyGemini3 = isGemini3Model(modelFromConfig, config);
const resolvedModel = normalizeModelId(
resolveModel(
@@ -75,10 +78,7 @@ export function resolvePolicyChain(
// We always wrap around for Gemini 3 chains to ensure maximum availability
// between models in the same family (e.g. fallback to Pro if Flash is exhausted).
const effectiveWrapsAround =
wrapsAround ||
isAutoPreferred ||
isAutoConfigured ||
isGemini3Model(resolvedModel, config);
wrapsAround || isAutoPreferred || isAutoConfigured || isOriginallyGemini3;
// --- DYNAMIC PATH ---
if (config.getExperimentalDynamicModelConfiguration?.() === true) {
@@ -86,16 +86,11 @@ export function resolvePolicyChain(
useGemini3_1: useGemini31,
useGemini3_1FlashLite: useGemini31FlashLite,
useCustomTools: useCustomToolModel,
releaseChannel: config.getReleaseChannel?.(),
};
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = config.modelConfigService.resolveChain('lite', context);
} else if (
isGemini3Model(normalizeModelId(resolvedModel), config) ||
isAutoPreferred ||
isAutoConfigured
) {
} else if (isOriginallyGemini3 || isAutoPreferred || isAutoConfigured) {
// 1. Try to find a chain specifically for the current configured alias
if (
isAutoConfigured &&
@@ -132,15 +127,11 @@ export function resolvePolicyChain(
if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) {
chain = getFlashLitePolicyChain();
} else if (
isGemini3Model(resolvedModel, config) ||
isAutoPreferred ||
isAutoConfigured
) {
} else if (isOriginallyGemini3 || isAutoPreferred || isAutoConfigured) {
const isAutoSelection = isAutoPreferred || isAutoConfigured;
if (hasAccessToPreview) {
const previewEnabled =
isGemini3Model(resolvedModel, config) ||
isOriginallyGemini3 ||
normalizedPreferredModel === PREVIEW_GEMINI_MODEL_AUTO ||
configuredModel === PREVIEW_GEMINI_MODEL_AUTO;
chain = getModelPolicyChain({
@@ -87,7 +87,9 @@ export function sanitizeAdminSettings(
mcpSetting: {
mcpEnabled: sanitized.mcpSetting?.mcpEnabled ?? false,
mcpConfig: mcpConfig ?? {},
requiredMcpConfig: mcpConfig?.requiredMcpServers,
...(mcpConfig?.requiredMcpServers && {
requiredMcpConfig: mcpConfig.requiredMcpServers,
}),
},
};
}
@@ -228,6 +228,7 @@ describe('setupUser', () => {
});
it('should throw InvalidNumericProjectIdError when GOOGLE_CLOUD_PROJECT_ID is numeric', async () => {
vi.stubEnv('GOOGLE_CLOUD_PROJECT', '');
vi.stubEnv('GOOGLE_CLOUD_PROJECT_ID', '1234567890');
await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
InvalidNumericProjectIdError,
+11 -65
View File
@@ -11,7 +11,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
addMemory,
applyInboxMemoryPatch,
dismissInboxSkill,
dismissInboxMemoryPatch,
@@ -25,11 +24,6 @@ import {
refreshMemory,
showMemory,
} from './memory.js';
import * as memoryDiscovery from '../utils/memoryDiscovery.js';
vi.mock('../utils/memoryDiscovery.js', () => ({
refreshServerHierarchicalMemory: vi.fn(),
}));
vi.mock('../config/storage.js', () => ({
Storage: {
@@ -38,17 +32,19 @@ vi.mock('../config/storage.js', () => ({
},
}));
const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory);
describe('memory commands', () => {
let mockConfig: Config;
let mockMemoryContextRefresh: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockMemoryContextRefresh = vi.fn().mockResolvedValue(undefined);
mockConfig = {
getUserMemory: vi.fn(),
getGeminiMdFileCount: vi.fn(),
getGeminiMdFilePaths: vi.fn(),
isJitContextEnabled: vi.fn(),
getMemoryContextManager: vi.fn().mockReturnValue({
refresh: mockMemoryContextRefresh,
}),
updateSystemInstructionIfInitialized: vi
.fn()
.mockResolvedValue(undefined),
@@ -92,63 +88,16 @@ describe('memory commands', () => {
});
});
describe('addMemory', () => {
it('should return a tool action to save memory', () => {
const result = addMemory('new memory');
expect(result.type).toBe('tool');
if (result.type === 'tool') {
expect(result.toolName).toBe('save_memory');
expect(result.toolArgs).toEqual({ fact: 'new memory' });
}
});
it('should trim the arguments', () => {
const result = addMemory(' new memory ');
expect(result.type).toBe('tool');
if (result.type === 'tool') {
expect(result.toolArgs).toEqual({ fact: 'new memory' });
}
});
it('should return an error if args are empty', () => {
const result = addMemory('');
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.messageType).toBe('error');
expect(result.content).toBe('Usage: /memory add <text to remember>');
}
});
it('should return an error if args are just whitespace', () => {
const result = addMemory(' ');
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.messageType).toBe('error');
expect(result.content).toBe('Usage: /memory add <text to remember>');
}
});
it('should return an error if args are undefined', () => {
const result = addMemory(undefined);
expect(result.type).toBe('message');
if (result.type === 'message') {
expect(result.messageType).toBe('error');
expect(result.content).toBe('Usage: /memory add <text to remember>');
}
});
});
describe('refreshMemory', () => {
it('should refresh memory and show success message', async () => {
mockRefresh.mockResolvedValue({
memoryContent: { project: 'refreshed content' },
fileCount: 2,
filePaths: [],
vi.mocked(mockConfig.getUserMemory).mockReturnValue({
project: 'refreshed content',
});
vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(2);
const result = await refreshMemory(mockConfig);
expect(mockRefresh).toHaveBeenCalledWith(mockConfig);
expect(mockMemoryContextRefresh).toHaveBeenCalled();
expect(
mockConfig.updateSystemInstructionIfInitialized,
).toHaveBeenCalled();
@@ -162,11 +111,8 @@ describe('memory commands', () => {
});
it('should show a message if no memory content is found after refresh', async () => {
mockRefresh.mockResolvedValue({
memoryContent: { project: '' },
fileCount: 0,
filePaths: [],
});
vi.mocked(mockConfig.getUserMemory).mockReturnValue({ project: '' });
vi.mocked(mockConfig.getGeminiMdFileCount).mockReturnValue(0);
const result = await refreshMemory(mockConfig);
expect(result.type).toBe('message');
+4 -31
View File
@@ -31,8 +31,7 @@ import {
validateParsedSkillPatchHeaders,
} from '../services/memoryPatchUtils.js';
import { readExtractionState } from '../services/memoryService.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';
import type { MessageActionReturn } from './types.js';
export type { InboxMemoryPatchKind } from '../services/memoryPatchUtils.js';
export { getAllowedMemoryPatchRoots } from '../services/memoryPatchUtils.js';
@@ -55,38 +54,12 @@ export function showMemory(config: Config): MessageActionReturn {
};
}
export function addMemory(
args?: string,
): MessageActionReturn | ToolActionReturn {
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() },
};
}
export async function refreshMemory(
config: Config,
): Promise<MessageActionReturn> {
let memoryContent = '';
let fileCount = 0;
if (config.isJitContextEnabled()) {
await config.getMemoryContextManager()?.refresh();
memoryContent = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
const result = await refreshServerHierarchicalMemory(config);
memoryContent = flattenMemory(result.memoryContent);
fileCount = result.fileCount;
}
await config.getMemoryContextManager()?.refresh();
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount();
config.updateSystemInstructionIfInitialized();
let content: string;
+45 -98
View File
@@ -52,7 +52,7 @@ import { ShellTool } from '../tools/shell.js';
import { AgentTool } from '../agents/agent-tool.js';
import { ReadFileTool } from '../tools/read-file.js';
import { GrepTool } from '../tools/grep.js';
import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js';
import { RipGrepTool, resolveRipgrepPath } from '../tools/ripGrep.js';
import {
logRipgrepFallback,
logApprovalModeDuration,
@@ -89,6 +89,22 @@ vi.mock('fs', async (importOriginal) => {
};
});
vi.mock('../utils/paths.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../utils/paths.js')>();
return {
...actual,
resolveToRealPath: vi.fn((p) => p),
};
});
vi.mock('../utils/fileUtils.js', () => ({
fileExists: vi.fn(),
}));
vi.mock('../utils/shell-utils.js', () => ({
resolveExecutable: vi.fn(),
}));
// Mock dependencies that might be called during Config construction or createServerConfig
vi.mock('../tools/tool-registry', () => {
const ToolRegistryMock = vi.fn();
@@ -111,16 +127,12 @@ vi.mock('../tools/mcp-client-manager.js', () => ({
})),
}));
vi.mock('../utils/memoryDiscovery.js', () => ({
loadServerHierarchicalMemory: vi.fn(),
}));
// Mock individual tools if their constructors are complex or have side effects
vi.mock('../tools/ls');
vi.mock('../tools/read-file');
vi.mock('../tools/grep.js');
vi.mock('../tools/ripGrep.js', () => ({
canUseRipgrep: vi.fn(),
resolveRipgrepPath: vi.fn(),
RipGrepTool: class MockRipGrepTool {},
}));
vi.mock('../tools/glob');
@@ -129,13 +141,15 @@ vi.mock('../tools/shell');
vi.mock('../tools/write-file');
vi.mock('../tools/web-fetch');
vi.mock('../tools/read-many-files');
vi.mock('../tools/memoryTool', () => ({
MemoryTool: vi.fn(),
setGeminiMdFilename: vi.fn(),
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'), // Mock the original filename
DEFAULT_CONTEXT_FILENAME: 'GEMINI.md',
GEMINI_DIR: '.gemini',
}));
vi.mock('../tools/memoryTool', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../tools/memoryTool.js')>();
return {
...actual,
setGeminiMdFilename: vi.fn(),
getCurrentGeminiMdFilename: vi.fn(() => 'GEMINI.md'),
};
});
vi.mock('../core/contentGenerator.js');
@@ -2005,7 +2019,6 @@ describe('Server Config (config.ts)', () => {
expect(configInternal.lastEmittedQuotaRemaining).toBeUndefined();
expect(configInternal.lastEmittedQuotaLimit).toBeUndefined();
expect(configInternal.lastQuotaFetchTime).toBe(0);
expect(configInternal.hasAccessToPreviewModel).toBeNull();
// Event emission
expect(emitQuotaSpy).toHaveBeenCalledWith(undefined, undefined, undefined);
@@ -2288,7 +2301,7 @@ describe('setApprovalMode with folder trust', () => {
});
it('should register RipGrepTool when useRipgrep is true and it is available', async () => {
vi.mocked(canUseRipgrep).mockResolvedValue(true);
vi.mocked(resolveRipgrepPath).mockResolvedValue('/mock/rg');
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
@@ -2306,7 +2319,7 @@ describe('setApprovalMode with folder trust', () => {
});
it('should register GrepTool as a fallback when useRipgrep is true but it is not available', async () => {
vi.mocked(canUseRipgrep).mockResolvedValue(false);
vi.mocked(resolveRipgrepPath).mockResolvedValue(null);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
@@ -2330,7 +2343,7 @@ describe('setApprovalMode with folder trust', () => {
it('should register GrepTool as a fallback when canUseRipgrep throws an error', async () => {
const error = new Error('ripGrep check failed');
vi.mocked(canUseRipgrep).mockRejectedValue(error);
vi.mocked(resolveRipgrepPath).mockRejectedValue(error);
const config = new Config({ ...baseParams, useRipgrep: true });
await config.initialize();
@@ -2366,7 +2379,7 @@ describe('setApprovalMode with folder trust', () => {
expect(wasRipGrepRegistered).toBe(false);
expect(wasGrepRegistered).toBe(true);
expect(canUseRipgrep).not.toHaveBeenCalled();
expect(resolveRipgrepPath).not.toHaveBeenCalled();
expect(logRipgrepFallback).not.toHaveBeenCalled();
});
});
@@ -3237,8 +3250,8 @@ describe('Config Quota & Preview Model Access', () => {
vi.mocked(getCodeAssistServer).mockReturnValue(undefined);
const result = await config.refreshUserQuota();
expect(result).toBeUndefined();
// Never set => stays null (unknown); getter returns true so UI shows preview
expect(config.getHasAccessToPreviewModel()).toBe(true);
// Never set => stays null (unknown); getter returns false by default
expect(config.getHasAccessToPreviewModel()).toBe(false);
});
it('should return undefined if retrieveUserQuota fails', async () => {
@@ -3247,8 +3260,8 @@ describe('Config Quota & Preview Model Access', () => {
);
const result = await config.refreshUserQuota();
expect(result).toBeUndefined();
// Never set => stays null (unknown); getter returns true so UI shows preview
expect(config.getHasAccessToPreviewModel()).toBe(true);
// Never set => stays null (unknown); getter returns false by default
expect(config.getHasAccessToPreviewModel()).toBe(false);
});
it('should derive quota from remainingFraction when remainingAmount is missing', async () => {
mockCodeAssistServer.retrieveUserQuota.mockResolvedValue({
@@ -3487,13 +3500,12 @@ describe('Config JIT Initialization', () => {
);
});
it('should initialize MemoryContextManager, load memory, and delegate to it when experimentalJitContext is enabled', async () => {
it('should initialize MemoryContextManager, load memory, and delegate to it', async () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
experimentalJitContext: true,
userMemory: 'Initial Memory',
cwd: '/tmp/test',
};
@@ -3540,26 +3552,11 @@ describe('Config JIT Initialization', () => {
expect(config.getGeminiMdFilePaths()).toEqual(['/path/to/GEMINI.md']);
});
it('should NOT initialize MemoryContextManager when experimentalJitContext is disabled', async () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
experimentalJitContext: false,
userMemory: 'Initial Memory',
cwd: '/tmp/test',
};
config = new Config(params);
await config.initialize();
expect(MemoryContextManager).not.toHaveBeenCalled();
expect(config.getUserMemory()).toBe('Initial Memory');
});
describe('isMemoryV2Enabled', () => {
it('should default to true', () => {
describe('memory path access', () => {
it('should NOT add the global ~/.gemini directory to the workspace', async () => {
// Memory does not broaden the workspace to include the global ~/.gemini/
// directory. Cross-project personal preferences are routed to
// ~/.gemini/GEMINI.md via the surgical isPathAllowed allowlist instead.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
@@ -3568,52 +3565,6 @@ describe('Config JIT Initialization', () => {
cwd: '/tmp/test',
};
config = new Config(params);
expect(config.isMemoryV2Enabled()).toBe(true);
});
it('should return false when experimentalMemoryV2 is explicitly false', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: false,
};
config = new Config(params);
expect(config.isMemoryV2Enabled()).toBe(false);
});
it('should return true when experimentalMemoryV2 is true', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
expect(config.isMemoryV2Enabled()).toBe(true);
});
it('should NOT add the global ~/.gemini directory to the workspace when enabled', async () => {
// The prompt-driven memoryV2 mode does not broaden the workspace
// to include the global ~/.gemini/ directory. Cross-project personal
// preferences are routed to ~/.gemini/GEMINI.md via the surgical
// isPathAllowed allowlist instead — see the next two tests.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
await config.initialize();
@@ -3622,16 +3573,15 @@ describe('Config JIT Initialization', () => {
});
it('should allow isPathAllowed to write the global ~/.gemini/GEMINI.md file', async () => {
// Surgical allowlist: when memoryV2 is on, the prompt routes
// cross-project personal preferences to ~/.gemini/GEMINI.md, so the
// agent must be able to edit that exact file via edit/write_file.
// Surgical allowlist: the prompt routes cross-project personal
// preferences to ~/.gemini/GEMINI.md, so the agent must be able to edit
// that exact file via edit/write_file.
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
@@ -3653,7 +3603,6 @@ describe('Config JIT Initialization', () => {
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
@@ -3945,18 +3894,16 @@ describe('Config JIT Initialization', () => {
expect(config.getExperimentalGemma()).toBe(true);
});
it('should be independent of experimentalMemoryV2', () => {
it('should default to disabled', () => {
const params: ConfigParameters = {
sessionId: 'test-session',
targetDir: '/tmp/test',
debugMode: false,
model: 'test-model',
cwd: '/tmp/test',
experimentalMemoryV2: true,
};
config = new Config(params);
expect(config.isMemoryV2Enabled()).toBe(true);
expect(config.isAutoMemoryEnabled()).toBe(false);
});
});

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