mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 03:30:59 -07:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e31c3a444 | |||
| 8047123225 | |||
| 29481a1562 | |||
| 2c85f57402 | |||
| 124539b5cc | |||
| ec4910f0bb | |||
| 57c42a5c40 | |||
| 8997488ea6 | |||
| 6589cdf11b | |||
| 9de4289287 | |||
| 37f3a4c90a | |||
| fcc8c62b8b | |||
| c4758ba820 | |||
| 96a8d1d069 | |||
| 3494fda2cf | |||
| 1a024f30a3 | |||
| 5650fa90d7 | |||
| 85566a73f6 | |||
| 7478859502 | |||
| f09d45d133 | |||
| 792654c88b | |||
| 6973b963ae | |||
| dc47aaa2d9 | |||
| 7ff60809ef | |||
| 055e0f6452 | |||
| 9d01958cdb | |||
| 84423e6ea1 | |||
| 5611ff40e7 | |||
| 77e65c0db5 | |||
| b36788eb2a | |||
| d32c9b77df | |||
| b213fd68ec | |||
| 928a311fb0 | |||
| f6494f3862 | |||
| b7f2067dd7 | |||
| 7a5a8183bf | |||
| 0c0d88d90b | |||
| 2151653133 | |||
| a6ed2cc5e3 | |||
| 5159b081bd | |||
| 918d6b6085 | |||
| 6fee663ddc | |||
| 456d1aec74 | |||
| e3f2d3e1ef | |||
| b705505dae | |||
| 488d71b8c9 | |||
| 77078b3e8a | |||
| 1814c7f358 | |||
| 724981baf8 | |||
| 7504259d72 | |||
| 0750b01fe4 | |||
| 41599ce29f | |||
| 74e9079e5b | |||
| 9da30b8831 | |||
| 71a2c0264e | |||
| fd01cc03bf | |||
| fc4054446f | |||
| 08abe4542d | |||
| 63b4bbfb5d | |||
| 1e7063bb0b |
@@ -3,7 +3,6 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true,
|
||||
"adk": {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -104,6 +104,51 @@ module.exports = async ({ github, context, core }) => {
|
||||
labelsToAdd = [...new Set(labelsToAdd)];
|
||||
labelsToRemove = [...new Set(labelsToRemove)];
|
||||
|
||||
// Fetch existing labels to auto-resolve conflicts
|
||||
try {
|
||||
const { data: issueData } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const existingLabels = issueData.labels.map((l) =>
|
||||
typeof l === 'string' ? l : l.name,
|
||||
);
|
||||
|
||||
const hasNewArea = labelsToAdd.some((l) => l.startsWith('area/'));
|
||||
if (hasNewArea) {
|
||||
const existingAreas = existingLabels.filter((l) =>
|
||||
l.startsWith('area/'),
|
||||
);
|
||||
labelsToRemove.push(...existingAreas);
|
||||
}
|
||||
|
||||
const hasNewPriority = labelsToAdd.some((l) => l.startsWith('priority/'));
|
||||
if (hasNewPriority) {
|
||||
const existingPriorities = existingLabels.filter((l) =>
|
||||
l.startsWith('priority/'),
|
||||
);
|
||||
labelsToRemove.push(...existingPriorities);
|
||||
}
|
||||
|
||||
const hasNewKind = labelsToAdd.some((l) => l.startsWith('kind/'));
|
||||
if (hasNewKind) {
|
||||
const existingKinds = existingLabels.filter((l) =>
|
||||
l.startsWith('kind/'),
|
||||
);
|
||||
labelsToRemove.push(...existingKinds);
|
||||
}
|
||||
|
||||
// Re-deduplicate and filter out labels we are trying to add
|
||||
labelsToRemove = [...new Set(labelsToRemove)].filter(
|
||||
(l) => !labelsToAdd.includes(l),
|
||||
);
|
||||
} catch (e) {
|
||||
core.warning(
|
||||
`Failed to fetch existing labels for #${issueNumber}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce mutually exclusive area labels
|
||||
const areaLabelsToAdd = labelsToAdd.filter((l) => l.startsWith('area/'));
|
||||
if (areaLabelsToAdd.length > 1) {
|
||||
@@ -166,9 +211,12 @@ module.exports = async ({ github, context, core }) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (entry.explanation || entry.effort_analysis) {
|
||||
if (
|
||||
(entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') ||
|
||||
entry.effort_analysis
|
||||
) {
|
||||
let commentBody = '';
|
||||
if (entry.explanation) {
|
||||
if (entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') {
|
||||
commentBody += entry.explanation;
|
||||
}
|
||||
if (entry.effort_analysis) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
name: '📋 Gemini Scheduled Issue Triage'
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- 'opened'
|
||||
- 'reopened'
|
||||
schedule:
|
||||
- cron: '0 * * * *' # Runs every hour
|
||||
workflow_dispatch:
|
||||
@@ -391,6 +387,7 @@ jobs:
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
|
||||
SUPPRESS_COMMENT: 'true'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
|
||||
@@ -23,3 +23,4 @@ Thumbs.db
|
||||
**/SKILL.md
|
||||
packages/sdk/test-data/*.json
|
||||
*.mdx
|
||||
packages/vscode-ide-companion/NOTICES.txt
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.43.0-preview.0
|
||||
# Preview release: v0.43.0-preview.1
|
||||
|
||||
Released: May 12, 2026
|
||||
Released: May 19, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -26,6 +26,9 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(patch): cherry-pick 85566a7 to release/v0.43.0-preview.0-pr-27073
|
||||
[CONFLICTS] by @gemini-cli-robot in
|
||||
[#27256](https://github.com/google-gemini/gemini-cli/pull/27256)
|
||||
- feat(core): steer model to use edit tool for surgical edits, fix a typo in
|
||||
[#26480](https://github.com/google-gemini/gemini-cli/pull/26480)
|
||||
- docs: clarify Auto Memory proposes memory updates and skills in
|
||||
@@ -193,4 +196,4 @@ npm install -g @google/gemini-cli@preview
|
||||
[#26949](https://github.com/google-gemini/gemini-cli/pull/26949)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.1
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -105,9 +105,19 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **`general.preferredEditor`** (enum):
|
||||
- **Description:** The preferred editor to open files in. Must be one of the
|
||||
built-in supported identifiers. Use /editor in the CLI to pick
|
||||
interactively, or leave unset to use $VISUAL/$EDITOR.
|
||||
- **Default:** `undefined`
|
||||
- **Values:** `"vscode"`, `"vscodium"`, `"windsurf"`, `"cursor"`, `"zed"`,
|
||||
`"antigravity"`, `"sublimetext"`, `"lapce"`, `"nova"`, `"bbedit"`, `"vim"`,
|
||||
`"neovim"`, `"emacs"`, `"hx"`, `"emacsclient"`, `"micro"`
|
||||
|
||||
- **`general.openEditorInNewWindow`** (boolean):
|
||||
- **Description:** Open VS Code-family editors in a new window when editing
|
||||
files.
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.vimMode`** (boolean):
|
||||
- **Description:** Enable Vim keybindings
|
||||
@@ -203,6 +213,11 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
chattiness and structured progress reporting.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`general.logRagSnippets`** (boolean):
|
||||
- **Description:** Log full Code Customization (RAG) retrieved snippets to a
|
||||
local file for debugging.
|
||||
- **Default:** `false`
|
||||
|
||||
#### `output`
|
||||
|
||||
- **`output.format`** (enum):
|
||||
@@ -545,6 +560,24 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
"model": "gemini-3-flash-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-pro-preview"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-preview-customtools": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-pro-preview-customtools"
|
||||
}
|
||||
},
|
||||
"gemini-3.1-flash-lite-preview": {
|
||||
"extends": "chat-base-3",
|
||||
"modelConfig": {
|
||||
"model": "gemini-3.1-flash-lite-preview"
|
||||
}
|
||||
},
|
||||
"gemini-2.5-pro": {
|
||||
"extends": "chat-base-2.5",
|
||||
"modelConfig": {
|
||||
@@ -1014,12 +1047,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 +1190,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 +1851,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 +1893,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 +1955,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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
@@ -1,25 +1,22 @@
|
||||
# Memory tool (`save_memory`)
|
||||
# Memory files
|
||||
|
||||
The `save_memory` tool allows the Gemini agent to persist specific facts, user
|
||||
preferences, and project details across sessions.
|
||||
Gemini CLI persists durable facts, user preferences, and project details by
|
||||
editing Markdown memory files directly.
|
||||
|
||||
## Technical reference
|
||||
|
||||
This tool appends information to the `## Gemini Added Memories` section of your
|
||||
global `GEMINI.md` file (typically located at `~/.gemini/GEMINI.md`).
|
||||
|
||||
### Arguments
|
||||
|
||||
- `fact` (string, required): A clear, self-contained statement in natural
|
||||
language.
|
||||
The agent routes memories to the appropriate Markdown file: shared project
|
||||
instructions go in repository `GEMINI.md` files, private project notes go in the
|
||||
per-project private memory folder, and cross-project personal preferences go in
|
||||
the global `~/.gemini/GEMINI.md` file.
|
||||
|
||||
## Technical behavior
|
||||
|
||||
- **Storage:** Appends to the global context file in the user's home directory.
|
||||
- **Storage:** Edits Markdown files with `write_file` or `replace`.
|
||||
- **Loading:** The stored facts are automatically included in the hierarchical
|
||||
context system for all future sessions.
|
||||
- **Format:** Saves data as a bulleted list item within a dedicated Markdown
|
||||
section.
|
||||
- **Format:** Keeps durable instructions concise and avoids duplicating the same
|
||||
fact across multiple memory tiers.
|
||||
|
||||
## Use cases
|
||||
|
||||
|
||||
+41
-2
@@ -63,7 +63,6 @@ const external = [
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'@github/keytar',
|
||||
'@google/gemini-cli-devtools',
|
||||
];
|
||||
|
||||
const baseConfig = {
|
||||
@@ -102,11 +101,46 @@ const cliConfig = {
|
||||
plugins: createWasmPlugins(),
|
||||
alias: {
|
||||
'is-in-ci': path.resolve(__dirname, 'packages/cli/src/patches/is-in-ci.ts'),
|
||||
'https-proxy-agent': path.resolve(
|
||||
__dirname,
|
||||
'packages/cli/src/patches/https-proxy-agent.ts',
|
||||
),
|
||||
'http-proxy-agent': path.resolve(
|
||||
__dirname,
|
||||
'packages/cli/src/patches/http-proxy-agent.ts',
|
||||
),
|
||||
'@google/gemini-cli-devtools': path.resolve(
|
||||
__dirname,
|
||||
'packages/devtools/src/index.ts',
|
||||
),
|
||||
...commonAliases,
|
||||
},
|
||||
metafile: true,
|
||||
};
|
||||
|
||||
const workerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
js: `const require = (await import('node:module')).createRequire(import.meta.url); const __chunk_filename = (await import('node:url')).fileURLToPath(import.meta.url); const __chunk_dirname = (await import('node:path')).dirname(__chunk_filename);`,
|
||||
},
|
||||
entryPoints: {
|
||||
'worker/worker-entry': path.join(
|
||||
path.dirname(require.resolve('ink')),
|
||||
'worker/worker-entry.js',
|
||||
),
|
||||
},
|
||||
outdir: 'bundle',
|
||||
define: {
|
||||
__filename: '__chunk_filename',
|
||||
__dirname: '__chunk_dirname',
|
||||
'process.env.NODE_ENV': JSON.stringify(
|
||||
process.env.NODE_ENV || 'production',
|
||||
),
|
||||
},
|
||||
plugins: createWasmPlugins(),
|
||||
alias: commonAliases,
|
||||
};
|
||||
|
||||
const a2aServerConfig = {
|
||||
...baseConfig,
|
||||
banner: {
|
||||
@@ -133,13 +167,18 @@ Promise.allSettled([
|
||||
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
|
||||
}
|
||||
}),
|
||||
esbuild.build(workerConfig),
|
||||
esbuild.build(a2aServerConfig),
|
||||
]).then((results) => {
|
||||
const [cliResult, a2aResult] = results;
|
||||
const [cliResult, workerResult, a2aResult] = results;
|
||||
if (cliResult.status === 'rejected') {
|
||||
console.error('gemini.js build failed:', cliResult.reason);
|
||||
process.exit(1);
|
||||
}
|
||||
if (workerResult.status === 'rejected') {
|
||||
console.error('worker-entry.js build failed:', workerResult.reason);
|
||||
process.exit(1);
|
||||
}
|
||||
// error in a2a-server bundling will not stop gemini.js bundling process
|
||||
if (a2aResult.status === 'rejected') {
|
||||
console.warn('a2a-server build failed:', a2aResult.reason);
|
||||
|
||||
@@ -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
|
||||
@@ -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'.
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe('file-system', () => {
|
||||
).toBeDefined();
|
||||
|
||||
const newFileContent = rig.readFile(fileName);
|
||||
expect(newFileContent).toBe('1.0.1');
|
||||
expect(newFileContent.trimEnd()).toBe('1.0.1');
|
||||
});
|
||||
|
||||
it.skip('should replace multiple instances of a string', async () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Generated
+364
-355
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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -93,10 +93,16 @@ export class CoderAgentExecutor implements AgentExecutor {
|
||||
taskId: string,
|
||||
): Promise<Config> {
|
||||
const workspaceRoot = setTargetDir(agentSettings);
|
||||
const isTrusted = agentSettings.isTrusted ?? false;
|
||||
loadEnvironment(); // Will override any global env with workspace envs
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
const settings = loadSettings(workspaceRoot, isTrusted);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
|
||||
return loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
taskId,
|
||||
isTrusted,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
@@ -20,7 +19,9 @@ import {
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
createPolicyEngineConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -48,18 +49,38 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
};
|
||||
return mockConfig;
|
||||
}),
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: { global: '', extension: '', project: '' },
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
startupProfiler: {
|
||||
flush: vi.fn(),
|
||||
},
|
||||
isHeadlessMode: vi.fn().mockReturnValue(false),
|
||||
FileDiscoveryService: vi.fn(),
|
||||
getCodeAssistServer: vi.fn(),
|
||||
fetchAdminControlsOnce: vi.fn(),
|
||||
createPolicyEngineConfig: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(_settings, mode, _defaultPoliciesDir, _interactive) => ({
|
||||
rules:
|
||||
mode === actual.ApprovalMode.YOLO
|
||||
? [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: actual.PolicyDecision.ALLOW,
|
||||
priority: actual.PRIORITY_YOLO_ALLOW_ALL,
|
||||
modes: [actual.ApprovalMode.YOLO],
|
||||
allowRedirection: true,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
toolName: 'read_file',
|
||||
decision: actual.PolicyDecision.ALLOW,
|
||||
priority: 1.05,
|
||||
source: 'Default: read-only.toml',
|
||||
},
|
||||
],
|
||||
checkers: [],
|
||||
}),
|
||||
),
|
||||
coreEvents: {
|
||||
emitAdminSettingsChanged: vi.fn(),
|
||||
},
|
||||
@@ -268,21 +289,82 @@ describe('loadConfig', () => {
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should initialize FileDiscoveryService with correct options', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
describe('policy engine configuration', () => {
|
||||
it('should merge V1 and V2 tool settings into policySettings', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-allowed'],
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
exclude: ['v2-exclude'],
|
||||
core: ['v2-core'],
|
||||
},
|
||||
mcpServers: {
|
||||
test: { command: 'test', args: [] },
|
||||
},
|
||||
policyPaths: ['/path/to/policy'],
|
||||
adminPolicyPaths: ['/path/to/admin/policy'],
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: undefined,
|
||||
customIgnoreFilePaths: [testPath],
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: {
|
||||
core: ['v2-core'],
|
||||
exclude: ['v2-exclude'],
|
||||
allowed: ['v1-allowed'],
|
||||
},
|
||||
mcpServers: settings.mcpServers,
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use V2 tool settings when V1 is missing', async () => {
|
||||
const settings: Settings = {
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: ['v2-allowed'],
|
||||
}),
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use V1 tool settings when V2 is also present', async () => {
|
||||
const settings: Settings = {
|
||||
allowedTools: ['v1-allowed'],
|
||||
tools: {
|
||||
allowed: ['v2-allowed'],
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
expect(createPolicyEngineConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
allowed: ['v1-allowed'],
|
||||
}),
|
||||
}),
|
||||
ApprovalMode.DEFAULT,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,14 +492,19 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
|
||||
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
|
||||
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
|
||||
await loadConfig(mockSettings, mockExtensionLoader, taskId);
|
||||
expect(Config).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
approvalMode: 'default',
|
||||
policyEngineConfig: expect.objectContaining({
|
||||
rules: [],
|
||||
rules: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
toolName: 'read_file',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,9 +11,7 @@ import * as dotenv from 'dotenv';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
ApprovalMode,
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
startupProfiler,
|
||||
@@ -25,8 +23,8 @@ import {
|
||||
ExperimentFlags,
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
PolicyDecision,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
createPolicyEngineConfig,
|
||||
type PolicySettings,
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
@@ -40,6 +38,7 @@ export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
|
||||
@@ -65,6 +64,24 @@ export async function loadConfig(
|
||||
? ApprovalMode.YOLO
|
||||
: ApprovalMode.DEFAULT;
|
||||
|
||||
const policySettings: PolicySettings = {
|
||||
mcpServers: settings.mcpServers,
|
||||
tools: {
|
||||
core: settings.coreTools || settings.tools?.core,
|
||||
exclude: settings.excludeTools || settings.tools?.exclude,
|
||||
allowed: settings.allowedTools || settings.tools?.allowed,
|
||||
},
|
||||
policyPaths: settings.policyPaths,
|
||||
adminPolicyPaths: settings.adminPolicyPaths,
|
||||
};
|
||||
|
||||
const policyEngineConfig = await createPolicyEngineConfig(
|
||||
policySettings,
|
||||
approvalMode,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
clientName: 'a2a-server',
|
||||
@@ -80,20 +97,7 @@ export async function loadConfig(
|
||||
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
|
||||
showMemoryUsage: settings.showMemoryUsage || false,
|
||||
approvalMode,
|
||||
policyEngineConfig: {
|
||||
rules:
|
||||
approvalMode === ApprovalMode.YOLO
|
||||
? [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_YOLO_ALLOW_ALL,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
allowRedirection: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
policyEngineConfig,
|
||||
mcpServers: settings.mcpServers,
|
||||
cwd: workspaceDir,
|
||||
telemetry: {
|
||||
@@ -120,7 +124,7 @@ export async function loadConfig(
|
||||
},
|
||||
ideMode: false,
|
||||
folderTrust,
|
||||
trustedFolder: true,
|
||||
trustedFolder: trusted,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
interactive: true,
|
||||
@@ -129,23 +133,6 @@ export async function loadConfig(
|
||||
enableAgents: settings.experimental?.enableAgents ?? true,
|
||||
};
|
||||
|
||||
const fileService = new FileDiscoveryService(workspaceDir, {
|
||||
respectGitIgnore: configParams?.fileFiltering?.respectGitIgnore,
|
||||
respectGeminiIgnore: configParams?.fileFiltering?.respectGeminiIgnore,
|
||||
customIgnoreFilePaths: configParams?.fileFiltering?.customIgnoreFilePaths,
|
||||
});
|
||||
const { memoryContent, fileCount, filePaths } =
|
||||
await loadServerHierarchicalMemory(
|
||||
workspaceDir,
|
||||
[workspaceDir],
|
||||
fileService,
|
||||
extensionLoader,
|
||||
folderTrust,
|
||||
);
|
||||
configParams.userMemory = memoryContent;
|
||||
configParams.geminiMdFileCount = fileCount;
|
||||
configParams.geminiMdFilePaths = filePaths;
|
||||
|
||||
// Set an initial config to use to get a code assist server.
|
||||
// This is needed to fetch admin controls.
|
||||
const initialConfig = new Config({
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { loadSettings, USER_SETTINGS_PATH } from './settings.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { debugLogger, checkPathTrust } from '@google/gemini-cli-core';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
@@ -40,6 +40,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
},
|
||||
getErrorMessage: (error: unknown) => String(error),
|
||||
homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`),
|
||||
checkPathTrust: vi.fn(() => ({ isTrusted: false })),
|
||||
isHeadlessMode: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -146,7 +148,7 @@ describe('loadSettings', () => {
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
const result = loadSettings(mockWorkspaceDir, true);
|
||||
// Primitive value overwritten
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
|
||||
@@ -154,4 +156,78 @@ describe('loadSettings', () => {
|
||||
expect(result.fileFiltering?.respectGitIgnore).toBe(false);
|
||||
expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined();
|
||||
});
|
||||
|
||||
describe('security', () => {
|
||||
it('should NOT load workspace settings if workspace is NOT trusted', () => {
|
||||
const userSettings = { showMemoryUsage: false };
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = { showMemoryUsage: true };
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
// checkPathTrust is mocked to return isTrusted: false by default
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(false);
|
||||
});
|
||||
|
||||
it('should load workspace settings if workspace IS trusted', () => {
|
||||
vi.mocked(checkPathTrust).mockReturnValueOnce({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
const userSettings = { showMemoryUsage: false };
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = { showMemoryUsage: true };
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => {
|
||||
vi.mocked(checkPathTrust).mockReturnValueOnce({
|
||||
isTrusted: true,
|
||||
source: 'file',
|
||||
});
|
||||
const userSettings = {
|
||||
adminPolicyPaths: ['/trusted/admin'],
|
||||
policyPaths: ['/trusted/user'],
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
adminPolicyPaths: ['./malicious/admin'],
|
||||
policyPaths: ['./malicious/user'],
|
||||
showMemoryUsage: true,
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
workspaceSettingsPath,
|
||||
JSON.stringify(workspaceSettings),
|
||||
);
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
expect(result.adminPolicyPaths).toEqual(['/trusted/admin']);
|
||||
expect(result.policyPaths).toEqual(['/trusted/user']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
getErrorMessage,
|
||||
type TelemetrySettings,
|
||||
homedir,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
@@ -51,6 +53,8 @@ export interface Settings {
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
policyPaths?: string[];
|
||||
adminPolicyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -64,13 +68,16 @@ export interface CheckpointingSettings {
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings.
|
||||
* Project settings override user settings if the workspace is trusted.
|
||||
*
|
||||
* How is it different to gemini-cli/cli: Returns already merged settings rather
|
||||
* than `LoadedSettings` (unnecessary since we are not modifying users
|
||||
* settings.json).
|
||||
*/
|
||||
export function loadSettings(workspaceDir: string): Settings {
|
||||
export function loadSettings(
|
||||
workspaceDir: string,
|
||||
isTrustedOverride?: boolean,
|
||||
): Settings {
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
@@ -92,27 +99,39 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
});
|
||||
}
|
||||
|
||||
let isTrusted = isTrustedOverride;
|
||||
if (isTrusted === undefined) {
|
||||
const { isTrusted: trustResult } = checkPathTrust({
|
||||
path: workspaceDir,
|
||||
isFolderTrustEnabled: userSettings.folderTrust ?? true,
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
isTrusted = trustResult ?? false;
|
||||
}
|
||||
|
||||
const workspaceSettingsPath = path.join(
|
||||
workspaceDir,
|
||||
GEMINI_DIR,
|
||||
'settings.json',
|
||||
);
|
||||
|
||||
// Load workspace settings
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
// Load workspace settings only if trusted
|
||||
if (isTrusted) {
|
||||
try {
|
||||
if (fs.existsSync(workspaceSettingsPath)) {
|
||||
const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const parsedWorkspaceSettings = JSON.parse(
|
||||
stripJsonComments(projectContent),
|
||||
) as Settings;
|
||||
workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsErrors.length > 0) {
|
||||
@@ -125,10 +144,18 @@ export function loadSettings(workspaceDir: string): Settings {
|
||||
|
||||
// If there are overlapping keys, the values of workspaceSettings will
|
||||
// override values from userSettings
|
||||
return {
|
||||
const mergedSettings = {
|
||||
...userSettings,
|
||||
...workspaceSettings,
|
||||
};
|
||||
|
||||
// Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level
|
||||
// configuration and cannot be overridden by workspace-level settings, even if the
|
||||
// workspace is trusted.
|
||||
mergedSettings.policyPaths = userSettings.policyPaths;
|
||||
mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths;
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
function resolveEnvVarsInString(value: string): string {
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
debugLogger,
|
||||
SimpleExtensionLoader,
|
||||
GitService,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
|
||||
@@ -197,12 +199,23 @@ export async function createApp() {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
|
||||
// Use a temporary settings load to check if folder trust is enabled.
|
||||
// This is similar to how the CLI handles the initial trust check.
|
||||
const initialSettings = loadSettings(workspaceRoot, false);
|
||||
const { isTrusted } = checkPathTrust({
|
||||
path: workspaceRoot,
|
||||
isFolderTrustEnabled: initialSettings.folderTrust ?? true,
|
||||
isHeadless: isHeadlessMode(),
|
||||
});
|
||||
|
||||
const settings = loadSettings(workspaceRoot, isTrusted ?? false);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface AgentSettings {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent;
|
||||
workspacePath: string;
|
||||
autoExecute?: boolean;
|
||||
isTrusted?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCallConfirmation {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -100,6 +100,11 @@ describe('GeminiAgent Session Resume', () => {
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
},
|
||||
getMessageBus: vi.fn().mockReturnValue({
|
||||
publish: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
unsubscribe: vi.fn(),
|
||||
}),
|
||||
getApprovalMode: vi.fn().mockReturnValue('default'),
|
||||
isAutoMemoryEnabled: vi.fn().mockReturnValue(false),
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
InvalidStreamError,
|
||||
GeminiEventType,
|
||||
type ServerGeminiStreamEvent,
|
||||
PolicyDecision,
|
||||
MessageBusType,
|
||||
type ToolConfirmationRequest,
|
||||
DiscoveredMCPTool,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { type Part, FinishReason } from '@google/genai';
|
||||
@@ -139,6 +143,9 @@ describe('Session', () => {
|
||||
isPlanEnabled: vi.fn().mockReturnValue(true),
|
||||
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
|
||||
getGitService: vi.fn().mockResolvedValue({} as GitService),
|
||||
getPolicyEngine: vi.fn().mockReturnValue({
|
||||
check: vi.fn(),
|
||||
}),
|
||||
validatePathAccess: vi.fn().mockReturnValue(null),
|
||||
getWorkspaceContext: vi.fn().mockReturnValue({
|
||||
addReadOnlyPath: vi.fn(),
|
||||
@@ -707,4 +714,322 @@ describe('Session', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('Policy Handling', () => {
|
||||
it('should auto-approve tool calls when PolicyEngine returns ALLOW', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
});
|
||||
|
||||
// Trigger the subscription handler
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id',
|
||||
toolCall: { name: 'ls', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id',
|
||||
confirmed: true,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should request user confirmation when PolicyEngine returns ASK_USER', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ASK_USER,
|
||||
});
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-2',
|
||||
toolCall: { name: 'rm', args: { path: '/' } },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-2',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should deny tool calls when PolicyEngine returns DENY', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.DENY,
|
||||
});
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-3',
|
||||
toolCall: { name: 'forbidden', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-3',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass subagent and trusted tool info to PolicyEngine', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
});
|
||||
|
||||
// Mock tool in registry with trusted annotations
|
||||
const trustedAnnotations = { safe: true };
|
||||
mockToolRegistry.getTool.mockReturnValue({
|
||||
name: 'ls',
|
||||
toolAnnotations: trustedAnnotations,
|
||||
});
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-trusted',
|
||||
toolCall: { name: 'ls', args: {} },
|
||||
subagent: 'restricted-subagent',
|
||||
serverName: 'spoofed-server', // Should be ignored
|
||||
toolAnnotations: { malicious: true }, // Should be ignored
|
||||
});
|
||||
|
||||
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
undefined, // serverName for non-MCP tool
|
||||
trustedAnnotations,
|
||||
'restricted-subagent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle exceptions in PolicyEngine by failing closed', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockRejectedValue(
|
||||
new Error('Policy check failed'),
|
||||
);
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-error',
|
||||
toolCall: { name: 'ls', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-error',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail closed when PolicyEngine is missing', async () => {
|
||||
(mockConfig.getPolicyEngine as Mock).mockReturnValue(undefined);
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-no-engine',
|
||||
toolCall: { name: 'ls', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-no-engine',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing tool name in request by failing closed', async () => {
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-no-name',
|
||||
toolCall: { name: '', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-no-name',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should trim tool name before lookup and validation', async () => {
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-whitespace',
|
||||
toolCall: { name: ' ', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-whitespace',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass serverName from DiscoveredMCPTool to PolicyEngine', async () => {
|
||||
const mockPolicyEngine = mockConfig.getPolicyEngine() as unknown as {
|
||||
check: Mock<
|
||||
(
|
||||
toolCall: { name: string; args: Record<string, unknown> },
|
||||
serverName?: string,
|
||||
toolAnnotations?: Record<string, unknown>,
|
||||
subagent?: string,
|
||||
) => Promise<{ decision: PolicyDecision }>
|
||||
>;
|
||||
};
|
||||
mockPolicyEngine.check.mockResolvedValue({
|
||||
decision: PolicyDecision.ALLOW,
|
||||
});
|
||||
|
||||
// Mock tool in registry as a DiscoveredMCPTool instance
|
||||
const mcpTool = {
|
||||
name: 'mcp_server_tool',
|
||||
serverName: 'test-server',
|
||||
toolAnnotations: { mcp: true },
|
||||
};
|
||||
Object.setPrototypeOf(mcpTool, DiscoveredMCPTool.prototype);
|
||||
mockToolRegistry.getTool.mockReturnValue(mcpTool);
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-mcp',
|
||||
toolCall: { name: 'mcp_server_tool', args: {} },
|
||||
});
|
||||
|
||||
expect(mockPolicyEngine.check).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'test-server',
|
||||
{ mcp: true },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail closed and deny unknown tools', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(undefined);
|
||||
|
||||
const handler = mockMessageBus.subscribe.mock.calls.find(
|
||||
(call) => call[0] === MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
)?.[1] as (request: ToolConfirmationRequest) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
correlationId: 'test-id-unknown',
|
||||
toolCall: { name: 'unknown_tool', args: {} },
|
||||
});
|
||||
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: 'test-id-unknown',
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
isNodeError,
|
||||
REFERENCE_CONTENT_START,
|
||||
InvalidStreamError,
|
||||
MessageBusType,
|
||||
PolicyDecision,
|
||||
type ToolConfirmationRequest,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import type { Part, FunctionCall } from '@google/genai';
|
||||
@@ -61,6 +64,7 @@ export class Session {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
private commandHandler = new CommandHandler();
|
||||
private callIdCounter = 0;
|
||||
private readonly disposeController = new AbortController();
|
||||
|
||||
private generateCallId(name: string): string {
|
||||
return `${name}-${Date.now()}-${++this.callIdCounter}`;
|
||||
@@ -77,8 +81,98 @@ export class Session {
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
|
||||
// Subscribe to tool confirmation requests to handle policy checks (e.g. auto-allowing safe shell commands)
|
||||
this.context.config
|
||||
.getMessageBus()
|
||||
?.subscribe(
|
||||
MessageBusType.TOOL_CONFIRMATION_REQUEST,
|
||||
this.handleToolConfirmationRequest,
|
||||
{ signal: this.disposeController.signal },
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolConfirmationRequest = async (
|
||||
request: ToolConfirmationRequest,
|
||||
) => {
|
||||
try {
|
||||
const policyEngine = this.context.config.getPolicyEngine?.();
|
||||
const messageBus = this.context.config.getMessageBus();
|
||||
|
||||
if (!messageBus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!policyEngine) {
|
||||
debugLogger.warn(
|
||||
'Policy engine missing. Denying tool confirmation request.',
|
||||
);
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: request.correlationId,
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = request.toolCall.name?.trim();
|
||||
if (!toolName) {
|
||||
debugLogger.warn(
|
||||
'Tool confirmation request missing tool name. Denying.',
|
||||
);
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: request.correlationId,
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tool = this.context.toolRegistry.getTool(toolName);
|
||||
if (!tool) {
|
||||
debugLogger.warn(
|
||||
`Tool confirmation request for unknown tool: ${toolName}. Denying.`,
|
||||
);
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: request.correlationId,
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const serverName =
|
||||
tool instanceof DiscoveredMCPTool ? tool.serverName : undefined;
|
||||
const toolAnnotations = tool.toolAnnotations;
|
||||
|
||||
const result = await policyEngine.check(
|
||||
request.toolCall,
|
||||
serverName,
|
||||
toolAnnotations,
|
||||
request.subagent,
|
||||
);
|
||||
|
||||
await messageBus.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: request.correlationId,
|
||||
confirmed: result.decision === PolicyDecision.ALLOW,
|
||||
requiresUserConfirmation: result.decision === PolicyDecision.ASK_USER,
|
||||
});
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling tool confirmation request:', error);
|
||||
// Fail closed on exception
|
||||
await this.context.config.getMessageBus()?.publish({
|
||||
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
|
||||
correlationId: request.correlationId,
|
||||
confirmed: false,
|
||||
requiresUserConfirmation: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private handleApprovalModeChanged = (payload: ApprovalModeChangedPayload) => {
|
||||
if (payload.sessionId === this.id) {
|
||||
void this.sendUpdate({
|
||||
@@ -96,6 +190,7 @@ export class Session {
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
this.disposeController.abort();
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -8,6 +8,15 @@ import { AuthType } from '@google/gemini-cli-core';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { validateAuthMethod } from './auth.js';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
loadApiKey: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./settings.js', () => ({
|
||||
loadEnvironment: vi.fn(),
|
||||
loadSettings: vi.fn().mockReturnValue({
|
||||
@@ -90,10 +99,10 @@ describe('validateAuthMethod', () => {
|
||||
envs: {},
|
||||
expected: 'Invalid auth method selected.',
|
||||
},
|
||||
])('$description', ({ authType, envs, expected }) => {
|
||||
])('$description', async ({ authType, envs, expected }) => {
|
||||
for (const [key, value] of Object.entries(envs)) {
|
||||
vi.stubEnv(key, value as string);
|
||||
}
|
||||
expect(validateAuthMethod(authType)).toBe(expected);
|
||||
expect(await validateAuthMethod(authType)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@google/gemini-cli-core';
|
||||
import { AuthType, loadApiKey } from '@google/gemini-cli-core';
|
||||
import { loadEnvironment, loadSettings } from './settings.js';
|
||||
|
||||
export function validateAuthMethod(authMethod: string): string | null {
|
||||
export async function validateAuthMethod(
|
||||
authMethod: string,
|
||||
): Promise<string | null> {
|
||||
loadEnvironment(loadSettings().merged, process.cwd());
|
||||
if (
|
||||
authMethod === AuthType.LOGIN_WITH_GOOGLE ||
|
||||
@@ -17,7 +19,8 @@ export function validateAuthMethod(authMethod: string): string | null {
|
||||
}
|
||||
|
||||
if (authMethod === AuthType.USE_GEMINI) {
|
||||
if (!process.env['GEMINI_API_KEY']) {
|
||||
const key = process.env['GEMINI_API_KEY'] || (await loadApiKey());
|
||||
if (!key) {
|
||||
return (
|
||||
'When using Gemini API, you must specify the GEMINI_API_KEY environment variable.\n' +
|
||||
'Update your environment and try again (no reload needed if using .env)!'
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
EDITOR_OPTIONS,
|
||||
AuthProviderType,
|
||||
type MCPServerConfig,
|
||||
type RequiredMcpServerConfig,
|
||||
@@ -192,12 +193,27 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
type: 'enum',
|
||||
label: 'Preferred Editor',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
description: 'The preferred editor to open files in.',
|
||||
description: oneLine`
|
||||
The preferred editor to open files in. Must be one of the built-in
|
||||
supported identifiers. Use /editor in the CLI to pick interactively,
|
||||
or leave unset to use $VISUAL/$EDITOR.
|
||||
`,
|
||||
showInDialog: false,
|
||||
options: EDITOR_OPTIONS,
|
||||
},
|
||||
openEditorInNewWindow: {
|
||||
type: 'boolean',
|
||||
label: 'Open Editor in New Window',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Open VS Code-family editors in a new window when editing files.',
|
||||
showInDialog: false,
|
||||
},
|
||||
vimMode: {
|
||||
@@ -429,6 +445,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the Topic & Update communication model for reduced chattiness and structured progress reporting.',
|
||||
showInDialog: true,
|
||||
},
|
||||
logRagSnippets: {
|
||||
type: 'boolean',
|
||||
label: 'Log RAG Snippets',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description:
|
||||
'Log full Code Customization (RAG) retrieved snippets to a local file for debugging.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
output: {
|
||||
@@ -2184,6 +2210,16 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable the agent session implementation for the interactive CLI.',
|
||||
showInDialog: false,
|
||||
},
|
||||
agentSessionSubagentEnabled: {
|
||||
type: 'boolean',
|
||||
label: 'Agent Session Subagent Enabled',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Route subagent invocations through the AgentSession protocol instead of legacy executors.',
|
||||
showInDialog: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
enableAgents: {
|
||||
@@ -2252,16 +2288,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Enables extension loading/unloading within the CLI session.',
|
||||
showInDialog: false,
|
||||
},
|
||||
jitContext: {
|
||||
type: 'boolean',
|
||||
label: 'JIT Context Loading',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Enable Just-In-Time (JIT) context loading. Defaults to true; set to false to opt out and load all GEMINI.md files into the system instruction up-front.',
|
||||
showInDialog: false,
|
||||
},
|
||||
useOSC52Paste: {
|
||||
type: 'boolean',
|
||||
label: 'Use OSC 52 Paste',
|
||||
@@ -2392,16 +2418,6 @@ const SETTINGS_SCHEMA = {
|
||||
},
|
||||
},
|
||||
},
|
||||
memoryV2: {
|
||||
type: 'boolean',
|
||||
label: 'Memory v2',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description:
|
||||
'Disable the built-in save_memory tool and let the main agent persist project context by editing markdown files directly with edit/write_file. Route facts across four tiers: team-shared conventions go to project GEMINI.md files, project-specific personal notes go to the per-project private memory folder (MEMORY.md as index + sibling .md files for detail), and cross-project personal preferences go to the global ~/.gemini/GEMINI.md (the only file under ~/.gemini/ that the agent can edit — settings, credentials, etc. remain off-limits). Set to false to fall back to the legacy save_memory tool.',
|
||||
showInDialog: true,
|
||||
},
|
||||
stressTestProfile: {
|
||||
type: 'boolean',
|
||||
label:
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -275,6 +275,10 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({
|
||||
validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'),
|
||||
}));
|
||||
|
||||
vi.mock('./config/auth.js', () => ({
|
||||
validateAuthMethod: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe('gemini.tsx main function', () => {
|
||||
let originalIsTTY: boolean | undefined;
|
||||
let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] =
|
||||
@@ -1276,6 +1280,44 @@ describe('gemini.tsx main function exit codes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(
|
||||
createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'test-image',
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadCliConfig).mockResolvedValue(
|
||||
createMockConfig({
|
||||
refreshAuth: vi.fn().mockResolvedValue(undefined),
|
||||
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
|
||||
isInteractive: vi.fn().mockReturnValue(true),
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadSettings).mockReturnValue(
|
||||
createMockSettings({
|
||||
merged: {
|
||||
security: { auth: { selectedType: 'google', useExternal: false } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
|
||||
|
||||
const authModule = await import('./config/auth.js');
|
||||
vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce(
|
||||
'Auth method invalid',
|
||||
);
|
||||
|
||||
try {
|
||||
await main();
|
||||
expect.fail('Should have thrown MockProcessExitError');
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(MockProcessExitError);
|
||||
expect((e as MockProcessExitError).code).toBe(41);
|
||||
}
|
||||
});
|
||||
|
||||
it('should exit with 41 for auth failure during sandbox setup', async () => {
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
vi.mocked(loadSandboxConfig).mockResolvedValue(
|
||||
|
||||
@@ -499,7 +499,6 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
@@ -514,7 +513,7 @@ export async function main() {
|
||||
partialConfig.isInteractive() &&
|
||||
settings.merged.security.auth.selectedType
|
||||
) {
|
||||
const err = validateAuthMethod(
|
||||
const err = await validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
if (err) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
export { HttpProxyAgent } from '../../../../node_modules/http-proxy-agent/dist/index.js';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
export { HttpsProxyAgent } from '../../../../node_modules/https-proxy-agent/dist/index.js';
|
||||
@@ -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),
|
||||
|
||||
@@ -150,6 +150,9 @@ vi.mock('./hooks/useQuotaAndFallback.js');
|
||||
vi.mock('./hooks/useHistoryManager.js');
|
||||
vi.mock('./hooks/useThemeCommand.js');
|
||||
vi.mock('./auth/useAuth.js');
|
||||
vi.mock('../config/auth.js', () => ({
|
||||
validateAuthMethod: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock('./hooks/useEditorSettings.js');
|
||||
vi.mock('./hooks/useSettingsCommand.js');
|
||||
vi.mock('./hooks/useModelCommand.js');
|
||||
@@ -217,6 +220,7 @@ vi.mock('../utils/cleanup.js');
|
||||
import { useHistory } from './hooks/useHistoryManager.js';
|
||||
import { useThemeCommand } from './hooks/useThemeCommand.js';
|
||||
import { useAuthCommand } from './auth/useAuth.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
import { useEditorSettings } from './hooks/useEditorSettings.js';
|
||||
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
|
||||
import { useModelCommand } from './hooks/useModelCommand.js';
|
||||
@@ -576,6 +580,36 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('State Initialization', () => {
|
||||
it('calls validateAuthMethod and onAuthError if validation fails', async () => {
|
||||
const mockOnAuthError = vi.fn();
|
||||
mockedUseAuthCommand.mockReturnValue({
|
||||
authState: 'authenticated',
|
||||
setAuthState: vi.fn(),
|
||||
authError: null,
|
||||
onAuthError: mockOnAuthError,
|
||||
});
|
||||
vi.mocked(validateAuthMethod).mockResolvedValueOnce('Validation Failed');
|
||||
|
||||
const { unmount } = await act(async () =>
|
||||
renderAppContainer({
|
||||
settings: createMockSettings({
|
||||
merged: {
|
||||
security: {
|
||||
auth: { selectedType: 'oauth-personal', useExternal: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(validateAuthMethod).toHaveBeenCalledWith('oauth-personal');
|
||||
expect(mockOnAuthError).toHaveBeenCalledWith('Validation Failed');
|
||||
});
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
|
||||
mockedUseFocusState.mockReturnValue({
|
||||
isFocused: false,
|
||||
@@ -2127,68 +2161,6 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
|
||||
describe('CTRL+C', () => {
|
||||
it('should not trigger quit flow when input buffer has text', async () => {
|
||||
const setText = vi.fn();
|
||||
mockedUseTextBuffer.mockReturnValue({
|
||||
text: 'hello world',
|
||||
setText,
|
||||
lines: ['hello world'],
|
||||
cursor: [0, 11],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
});
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).not.toHaveBeenCalled();
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should clear buffer and reset quit count when Ctrl+C is pressed with text', async () => {
|
||||
const setText = vi.fn();
|
||||
const bufferWithText = {
|
||||
text: 'hello world',
|
||||
setText,
|
||||
lines: ['hello world'],
|
||||
cursor: [0, 11],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
const bufferEmpty = {
|
||||
text: '',
|
||||
setText,
|
||||
lines: [''],
|
||||
cursor: [0, 0],
|
||||
handleInput: vi.fn().mockReturnValue(false),
|
||||
};
|
||||
|
||||
// First press with text
|
||||
mockedUseTextBuffer.mockReturnValue(bufferWithText);
|
||||
await setupKeypressTest();
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
|
||||
expect(mockCancelOngoingRequest).not.toHaveBeenCalled();
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Second press with empty buffer (simulating clear happened)
|
||||
// Since we reset the count, this should be treated as the "first" press for quit flow
|
||||
mockedUseTextBuffer.mockReturnValue(bufferEmpty);
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||
|
||||
// Third press with empty buffer should finally quit
|
||||
pressKey('\x03'); // Ctrl+C
|
||||
expect(mockCancelOngoingRequest).toHaveBeenCalledTimes(2);
|
||||
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
|
||||
'/quit',
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should cancel ongoing request on first press', async () => {
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
|
||||
@@ -47,7 +47,6 @@ import { MouseProvider } from './contexts/MouseContext.js';
|
||||
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
@@ -68,9 +67,9 @@ import {
|
||||
ShellExecutionService,
|
||||
saveApiKey,
|
||||
debugLogger,
|
||||
isValidEditorType,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
flattenMemory,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
@@ -610,11 +609,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const getPreferredEditor = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
() => settings.merged.general.preferredEditor as EditorType,
|
||||
[settings.merged.general.preferredEditor],
|
||||
);
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const val = settings.merged.general.preferredEditor;
|
||||
return isValidEditorType(val) ? val : undefined;
|
||||
}, [settings.merged.general.preferredEditor]);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
@@ -912,12 +910,22 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return;
|
||||
}
|
||||
|
||||
const error = validateAuthMethod(
|
||||
settings.merged.security.auth.selectedType,
|
||||
);
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
}
|
||||
const authMethod = settings.merged.security.auth.selectedType;
|
||||
void (async () => {
|
||||
try {
|
||||
const error = await validateAuthMethod(authMethod);
|
||||
if (
|
||||
error &&
|
||||
authMethod === settings.merged.security.auth.selectedType
|
||||
) {
|
||||
onAuthError(error);
|
||||
}
|
||||
} catch (e) {
|
||||
if (authMethod === settings.merged.security.auth.selectedType) {
|
||||
onAuthError(getErrorMessage(e));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [
|
||||
settings.merged.security.auth.selectedType,
|
||||
@@ -1065,19 +1073,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(
|
||||
{
|
||||
@@ -1649,14 +1648,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
[config, handleSlashCommand],
|
||||
);
|
||||
|
||||
const {
|
||||
pressCount: ctrlCPressCount,
|
||||
handlePress: handleCtrlCPress,
|
||||
resetCount: resetCtrlCPress,
|
||||
} = useRepeatedKeyPress({
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
});
|
||||
const { pressCount: ctrlCPressCount, handlePress: handleCtrlCPress } =
|
||||
useRepeatedKeyPress({
|
||||
windowMs: WARNING_PROMPT_DURATION_MS,
|
||||
onRepeat: handleExitRepeat,
|
||||
});
|
||||
|
||||
const { pressCount: ctrlDPressCount, handlePress: handleCtrlDPress } =
|
||||
useRepeatedKeyPress({
|
||||
@@ -1833,28 +1829,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
keyMatchers[Command.QUIT](key) &&
|
||||
isInputActive &&
|
||||
streamingState === StreamingState.Idle &&
|
||||
bufferRef.current.text.length > 0
|
||||
) {
|
||||
// Prevent quit flow when there is text.
|
||||
resetCtrlCPress();
|
||||
|
||||
if (keyMatchers[Command.CLEAR_INPUT](key)) {
|
||||
// Let InputPrompt handle it as edit.clear.
|
||||
// This preserves documented behavior and allows prompt-level state
|
||||
// (completions/history) to reset correctly.
|
||||
return false;
|
||||
}
|
||||
|
||||
// If QUIT is not also CLEAR_INPUT, we should still clear the buffer
|
||||
// to match user expectations for Ctrl+C (or equivalent).
|
||||
bufferRef.current.setText('');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
|
||||
// This should happen regardless of the count.
|
||||
@@ -2087,9 +2061,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
startRecording,
|
||||
stopRecording,
|
||||
mouseMode,
|
||||
isInputActive,
|
||||
streamingState,
|
||||
resetCtrlCPress,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -215,11 +215,11 @@ describe('AuthDialog', () => {
|
||||
|
||||
describe('handleAuthSelect', () => {
|
||||
it('calls onAuthError if validation fails', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue('Invalid method');
|
||||
mockedValidateAuthMethod.mockResolvedValue('Invalid method');
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
handleAuthSelect(AuthType.USE_GEMINI);
|
||||
await handleAuthSelect(AuthType.USE_GEMINI);
|
||||
|
||||
expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
|
||||
AuthType.USE_GEMINI,
|
||||
@@ -231,7 +231,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
@@ -245,7 +245,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', 'true');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
@@ -259,7 +259,7 @@ describe('AuthDialog', () => {
|
||||
|
||||
it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
|
||||
vi.stubEnv('CLOUD_SHELL', '');
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
@@ -270,7 +270,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('sets auth context with empty object for other auth types', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
mockedRadioButtonSelect.mock.calls[0][0];
|
||||
@@ -281,7 +281,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('always shows API key dialog even when env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('always shows API key dialog even when env var is empty string', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
|
||||
// props.settings.merged.security.auth.selectedType is undefined here
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('shows API key dialog on initial setup if no env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
// process.env['GEMINI_API_KEY'] is not set
|
||||
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
|
||||
|
||||
@@ -329,7 +329,7 @@ describe('AuthDialog', () => {
|
||||
});
|
||||
|
||||
it('always shows API key dialog on re-auth even if env var is present', async () => {
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
|
||||
// Simulate switching from a different auth method (e.g., Google Login → API key)
|
||||
props.settings.merged.security.auth.selectedType =
|
||||
@@ -353,7 +353,7 @@ describe('AuthDialog', () => {
|
||||
.mockImplementation(() => undefined as never);
|
||||
const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
|
||||
vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
|
||||
mockedValidateAuthMethod.mockReturnValue(null);
|
||||
mockedValidateAuthMethod.mockResolvedValue(null);
|
||||
|
||||
const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
|
||||
const { onSelect: handleAuthSelect } =
|
||||
|
||||
@@ -154,8 +154,11 @@ export function AuthDialog({
|
||||
[settings, config, setAuthState, exiting, setAuthContext],
|
||||
);
|
||||
|
||||
const handleAuthSelect = (authMethod: AuthType) => {
|
||||
const error = validateAuthMethodWithSettings(authMethod, settings);
|
||||
const handleAuthSelect = async (authMethod: AuthType) => {
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
authMethod,
|
||||
settings,
|
||||
).catch((e) => (e instanceof Error ? e.message : String(e)));
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
} else {
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('useAuth', () => {
|
||||
});
|
||||
|
||||
describe('validateAuthMethodWithSettings', () => {
|
||||
it('should return error if auth type is enforced and does not match', () => {
|
||||
it('should return error if auth type is enforced and does not match', async () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
@@ -56,14 +56,14 @@ describe('useAuth', () => {
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toContain('Authentication is enforced to be oauth');
|
||||
});
|
||||
|
||||
it('should return null if useExternal is true', () => {
|
||||
it('should return null if useExternal is true', async () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
@@ -74,14 +74,14 @@ describe('useAuth', () => {
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null if authType is USE_GEMINI', () => {
|
||||
it('should return null if authType is USE_GEMINI', async () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
@@ -90,14 +90,14 @@ describe('useAuth', () => {
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
const error = validateAuthMethodWithSettings(
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
AuthType.USE_GEMINI,
|
||||
settings,
|
||||
);
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
|
||||
it('should call validateAuthMethod for other auth types', () => {
|
||||
it('should call validateAuthMethod for other auth types', async () => {
|
||||
const settings = {
|
||||
merged: {
|
||||
security: {
|
||||
@@ -106,8 +106,8 @@ describe('useAuth', () => {
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Error');
|
||||
const error = validateAuthMethodWithSettings(
|
||||
mockValidateAuthMethod.mockResolvedValue('Validation Error');
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
settings,
|
||||
);
|
||||
@@ -265,7 +265,7 @@ describe('useAuth', () => {
|
||||
});
|
||||
|
||||
it('should set error if validation fails', async () => {
|
||||
mockValidateAuthMethod.mockReturnValue('Validation Failed');
|
||||
mockValidateAuthMethod.mockResolvedValue('Validation Failed');
|
||||
const { result } = await renderHook(() =>
|
||||
useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig),
|
||||
);
|
||||
|
||||
@@ -18,10 +18,10 @@ import { getErrorMessage } from '@google/gemini-cli-core';
|
||||
import { AuthState } from '../types.js';
|
||||
import { validateAuthMethod } from '../../config/auth.js';
|
||||
|
||||
export function validateAuthMethodWithSettings(
|
||||
export async function validateAuthMethodWithSettings(
|
||||
authType: AuthType,
|
||||
settings: LoadedSettings,
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
const enforcedType = settings.merged.security.auth.enforcedType;
|
||||
if (enforcedType && enforcedType !== authType) {
|
||||
return `Authentication is enforced to be ${enforcedType}, but you are currently using ${authType}.`;
|
||||
@@ -111,7 +111,11 @@ export const useAuthCommand = (
|
||||
}
|
||||
}
|
||||
|
||||
const error = validateAuthMethodWithSettings(authType, settings);
|
||||
const error = await validateAuthMethodWithSettings(
|
||||
authType,
|
||||
settings,
|
||||
).catch((e: unknown) => getErrorMessage(e));
|
||||
|
||||
if (error) {
|
||||
onAuthError(error);
|
||||
return;
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('directoryCommand', () => {
|
||||
}),
|
||||
getWorkingDir: () => path.resolve('/test/dir'),
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getMemoryContextManager: vi.fn(),
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}),
|
||||
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
|
||||
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
type CommandContext,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import {
|
||||
refreshServerHierarchicalMemory,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
expandHomeDir,
|
||||
getDirectorySuggestions,
|
||||
@@ -47,7 +44,7 @@ async function finishAddingDirectories(
|
||||
if (added.length > 0) {
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
|
||||
@@ -11,13 +11,10 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js
|
||||
import { MessageType } from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import {
|
||||
type Config,
|
||||
refreshMemory,
|
||||
refreshServerHierarchicalMemory,
|
||||
SimpleExtensionLoader,
|
||||
type FileDiscoveryService,
|
||||
showMemory,
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
flattenMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -32,46 +29,28 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return String(error);
|
||||
}),
|
||||
refreshMemory: vi.fn(async (config) => {
|
||||
if (config.isJitContextEnabled()) {
|
||||
await config.getContextManager()?.refresh();
|
||||
const memoryContent = original.flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
};
|
||||
}
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
const memoryContent = original.flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Memory reloaded successfully.',
|
||||
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
};
|
||||
}),
|
||||
showMemory: vi.fn(),
|
||||
addMemory: vi.fn(),
|
||||
listMemoryFiles: vi.fn(),
|
||||
refreshServerHierarchicalMemory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockRefreshMemory = refreshMemory as Mock;
|
||||
const mockRefreshServerHierarchicalMemory =
|
||||
refreshServerHierarchicalMemory as Mock;
|
||||
|
||||
describe('memoryCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
const buildMemoryCommand = (isMemoryV2 = false): SlashCommand => {
|
||||
const config: Pick<Config, 'isMemoryV2Enabled'> = {
|
||||
isMemoryV2Enabled: () => isMemoryV2,
|
||||
};
|
||||
return memoryCommand(config as Config);
|
||||
};
|
||||
const buildMemoryCommand = (): SlashCommand => memoryCommand(null);
|
||||
|
||||
const getSubCommand = (
|
||||
name: 'show' | 'add' | 'reload' | 'list',
|
||||
): SlashCommand => {
|
||||
const getSubCommand = (name: 'show' | 'reload' | 'list'): SlashCommand => {
|
||||
const subCommand = buildMemoryCommand().subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
);
|
||||
@@ -81,23 +60,11 @@ describe('memoryCommand', () => {
|
||||
return subCommand;
|
||||
};
|
||||
|
||||
describe('Memory v2', () => {
|
||||
it('omits the /memory add subcommand when memoryV2 is enabled', () => {
|
||||
const command = buildMemoryCommand(true);
|
||||
describe('subcommands', () => {
|
||||
it('does not include the legacy add subcommand', () => {
|
||||
const command = buildMemoryCommand();
|
||||
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
|
||||
expect(names).not.toContain('add');
|
||||
});
|
||||
|
||||
it('includes the /memory add subcommand by default', () => {
|
||||
const command = buildMemoryCommand(false);
|
||||
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
|
||||
expect(names).toContain('add');
|
||||
});
|
||||
|
||||
it('includes the /memory add subcommand when no config is provided', () => {
|
||||
const command = memoryCommand(null);
|
||||
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
|
||||
expect(names).toContain('add');
|
||||
expect(names).toEqual(['show', 'reload', 'list', 'inbox']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,63 +145,6 @@ describe('memoryCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory add', () => {
|
||||
let addCommand: SlashCommand;
|
||||
|
||||
beforeEach(() => {
|
||||
addCommand = getSubCommand('add');
|
||||
vi.mocked(addMemory).mockImplementation((args) => {
|
||||
if (!args || args.trim() === '') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add <text to remember>',
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: args.trim() },
|
||||
};
|
||||
});
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return an error message if no arguments are provided', () => {
|
||||
if (!addCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const result = addCommand.action(mockContext, ' ');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Usage: /memory add <text to remember>',
|
||||
});
|
||||
|
||||
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return a tool action and add an info message when arguments are provided', () => {
|
||||
if (!addCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const fact = 'remember this';
|
||||
const result = addCommand.action(mockContext, ` ${fact} `);
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Attempting to save to memory: "${fact}"`,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'tool',
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/memory reload', () => {
|
||||
let reloadCommand: SlashCommand;
|
||||
let mockSetUserMemory: Mock;
|
||||
@@ -270,8 +180,7 @@ describe('memoryCommand', () => {
|
||||
updateSystemInstructionIfInitialized: vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
isJitContextEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManager: vi.fn().mockReturnValue({
|
||||
getMemoryContextManager: vi.fn().mockReturnValue({
|
||||
refresh: mockContextManagerRefresh,
|
||||
}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
@@ -294,21 +203,18 @@ describe('memoryCommand', () => {
|
||||
mockRefreshMemory.mockClear();
|
||||
});
|
||||
|
||||
it('should use ContextManager.refresh when JIT is enabled', async () => {
|
||||
it('should use MemoryContextManager.refresh', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
// Enable JIT in mock config
|
||||
const config = mockContext.services.agentContext?.config;
|
||||
if (!config) throw new Error('Config is undefined');
|
||||
|
||||
vi.mocked(config.isJitContextEnabled).mockReturnValue(true);
|
||||
vi.mocked(config.getUserMemory).mockReturnValue('JIT Memory Content');
|
||||
vi.mocked(config.getGeminiMdFileCount).mockReturnValue(3);
|
||||
|
||||
await reloadCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContextManagerRefresh).toHaveBeenCalledOnce();
|
||||
expect(mockRefreshServerHierarchicalMemory).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
@@ -319,7 +225,7 @@ describe('memoryCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is reloaded with content (Legacy)', async () => {
|
||||
it('should display success message when memory is reloaded with content', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
@@ -72,10 +71,6 @@ export function EditorSettingsDialog({
|
||||
)
|
||||
: 0;
|
||||
if (editorIndex === -1) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Editor is not supported: ${currentPreference}`,
|
||||
);
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
@@ -131,10 +126,7 @@ export function EditorSettingsDialog({
|
||||
isEditorAvailable(settings.merged.general.preferredEditor)
|
||||
) {
|
||||
mergedEditorName =
|
||||
EDITOR_DISPLAY_NAMES[
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings.merged.general.preferredEditor as EditorType
|
||||
];
|
||||
EDITOR_DISPLAY_NAMES[settings.merged.general.preferredEditor];
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -161,6 +153,7 @@ export function EditorSettingsDialog({
|
||||
onSelect={handleEditorSelect}
|
||||
isFocused={focusedSection === 'editor'}
|
||||
key={selectedScope}
|
||||
maxItemsToShow={editorItems.length}
|
||||
/>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,17 @@ import { renderHook } from '../../../test-utils/render.js';
|
||||
import { useTextBuffer } from './text-buffer.js';
|
||||
import { parseInputForHighlighting } from '../../utils/highlight.js';
|
||||
|
||||
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
merged: { general: { openEditorInNewWindow: false } },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe('text-buffer performance', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
|
||||
@@ -44,6 +44,17 @@ import { cpLen } from '../../utils/textUtils.js';
|
||||
import { type Key } from '../../hooks/useKeypress.js';
|
||||
import { escapePath } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/SettingsContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useSettings: () => ({
|
||||
merged: { general: { openEditorInNewWindow: false } },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultVisualLayout: VisualLayout = {
|
||||
visualLines: [''],
|
||||
logicalToVisualMap: [[[0, 0]]],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LRUCache } from 'mnemonist';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
unescapePath,
|
||||
type EditorType,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -30,6 +31,7 @@ import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||
import { openFileInEditor } from '../../utils/editorUtils.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
export const LARGE_PASTE_LINE_THRESHOLD = 5;
|
||||
@@ -2840,6 +2842,7 @@ export function useTextBuffer({
|
||||
singleLine = false,
|
||||
getPreferredEditor,
|
||||
}: UseTextBufferProps): TextBuffer {
|
||||
const settings = useSettings();
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const initialState = useMemo((): TextBufferState => {
|
||||
const lines = initialText.split('\n');
|
||||
@@ -3325,6 +3328,7 @@ export function useTextBuffer({
|
||||
stdin,
|
||||
setRawMode,
|
||||
getPreferredEditor?.(),
|
||||
settings.merged.general.openEditorInNewWindow,
|
||||
);
|
||||
|
||||
let newText = fs.readFileSync(filePath, 'utf8');
|
||||
@@ -3342,11 +3346,7 @@ export function useTextBuffer({
|
||||
|
||||
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
|
||||
} catch (err) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'[useTextBuffer] external editor error',
|
||||
err,
|
||||
);
|
||||
coreEvents.emitFeedback('error', getErrorMessage(err), err);
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
@@ -3359,7 +3359,14 @@ export function useTextBuffer({
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
|
||||
}, [
|
||||
text,
|
||||
pastedContent,
|
||||
stdin,
|
||||
setRawMode,
|
||||
getPreferredEditor,
|
||||
settings.merged.general.openEditorInNewWindow,
|
||||
]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
SHELL_TOOL_NAME,
|
||||
MCPDiscoveryState,
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
@@ -351,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),
|
||||
@@ -1950,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,
|
||||
}),
|
||||
],
|
||||
@@ -2194,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,
|
||||
@@ -2226,7 +2226,7 @@ describe('useGeminiStream', () => {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'save_memory',
|
||||
name: 'write_todos',
|
||||
response: { success: true },
|
||||
},
|
||||
},
|
||||
@@ -2245,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
|
||||
@@ -2449,6 +2364,44 @@ describe('useGeminiStream', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-approve shell commands with redirection when switching to AUTO_EDIT mode', async () => {
|
||||
const shellCall = createMockToolCall(
|
||||
SHELL_TOOL_NAME,
|
||||
'call-shell',
|
||||
'info',
|
||||
);
|
||||
shellCall.request.args = { command: 'ls > files.txt' };
|
||||
|
||||
const { result } = await renderTestHook([shellCall]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
|
||||
// Shell command with redirection should be auto-approved
|
||||
expect(mockMessageBus.publish).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ correlationId: 'corr-call-shell' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT auto-approve shell commands without redirection when switching to AUTO_EDIT mode', async () => {
|
||||
const shellCall = createMockToolCall(
|
||||
SHELL_TOOL_NAME,
|
||||
'call-shell',
|
||||
'info',
|
||||
);
|
||||
shellCall.request.args = { command: 'ls -la' };
|
||||
|
||||
const { result } = await renderTestHook([shellCall]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleApprovalModeChange(ApprovalMode.AUTO_EDIT);
|
||||
});
|
||||
|
||||
// Regular shell command should NOT be auto-approved
|
||||
expect(mockMessageBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not auto-approve any tools when switching to REQUIRE_CONFIRMATION mode', async () => {
|
||||
const awaitingApprovalToolCalls: TrackedToolCall[] = [
|
||||
createMockToolCall('replace', 'call1', 'edit'),
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
debugLogger,
|
||||
runInDevTraceSpan,
|
||||
EDIT_TOOL_NAMES,
|
||||
SHELL_TOOL_NAME,
|
||||
hasRedirection,
|
||||
processRestorableToolCalls,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
@@ -224,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: (
|
||||
@@ -264,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(() => {
|
||||
@@ -1820,10 +1821,21 @@ export const useGeminiStream = (
|
||||
);
|
||||
|
||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||
// or shell commands with redirection (which act as edits).
|
||||
if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
|
||||
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
|
||||
EDIT_TOOL_NAMES.has(call.request.name),
|
||||
);
|
||||
awaitingApprovalCalls = awaitingApprovalCalls.filter((call) => {
|
||||
if (EDIT_TOOL_NAMES.has(call.request.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (call.request.name === SHELL_TOOL_NAME) {
|
||||
const command = (call.request.args as { command?: string })
|
||||
.command;
|
||||
return command && hasRedirection(command);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Process pending tool calls sequentially to reduce UI chaos
|
||||
@@ -1884,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;
|
||||
}
|
||||
@@ -1912,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) {
|
||||
@@ -1931,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,
|
||||
);
|
||||
@@ -2063,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',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,24 @@ describe('TableRenderer', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('handles extremely small terminal widths without crashing', async () => {
|
||||
const headers = ['Col 1', 'Col 2'];
|
||||
const rows = [['Data 1', 'Data 2']];
|
||||
// This width is much smaller than the overhead, which could lead to negative column widths
|
||||
const terminalWidth = 1;
|
||||
|
||||
const renderResult = await renderWithProviders(
|
||||
<TableRenderer
|
||||
headers={headers}
|
||||
rows={rows}
|
||||
terminalWidth={terminalWidth}
|
||||
/>,
|
||||
);
|
||||
const { unmount } = renderResult;
|
||||
// If it didn't throw RangeError: Invalid count value, the test passes
|
||||
unmount();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'handles non-ASCII characters (emojis and Asian scripts) correctly',
|
||||
|
||||
@@ -250,7 +250,9 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
|
||||
};
|
||||
|
||||
const char = chars[type];
|
||||
const borderParts = adjustedWidths.map((w) => char.horizontal.repeat(w));
|
||||
const borderParts = adjustedWidths.map((w) =>
|
||||
char.horizontal.repeat(Math.max(0, w || 0)),
|
||||
);
|
||||
const border = char.left + borderParts.join(char.middle) + char.right;
|
||||
|
||||
return <Text color={theme.border.default}>{border}</Text>;
|
||||
|
||||
@@ -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'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user