mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-29 11:10:59 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad5e01ceaf | |||
| 957195be4c | |||
| ca91aab74e | |||
| 651ad55357 | |||
| fc8c1bee0c | |||
| ac81288e1e | |||
| 6cfef6d15d | |||
| ccd1854c66 | |||
| 3fdc43734b |
@@ -3,6 +3,7 @@
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"autoMemory": true,
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true,
|
||||
"voiceMode": true,
|
||||
"adk": {
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -5,176 +5,178 @@
|
||||
*/
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const rawLabels = process.env.LABELS_OUTPUT;
|
||||
core.info(`Raw labels JSON: ${rawLabels}`);
|
||||
let parsedLabels;
|
||||
try {
|
||||
// First, try to parse the raw output as JSON.
|
||||
parsedLabels = JSON.parse(rawLabels);
|
||||
} catch (jsonError) {
|
||||
// If that fails, check for a markdown code block.
|
||||
core.warning(
|
||||
`Direct JSON parsing failed: ${jsonError.message}. Trying to extract from a markdown block.`,
|
||||
);
|
||||
const jsonMatch = rawLabels.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonMatch && jsonMatch[1]) {
|
||||
try {
|
||||
parsedLabels = JSON.parse(jsonMatch[1].trim());
|
||||
} catch (markdownError) {
|
||||
core.setFailed(
|
||||
`Failed to parse JSON even after extracting from markdown block: ${markdownError.message}\nRaw output: ${rawLabels}`,
|
||||
);
|
||||
return;
|
||||
const extractJson = (raw) => {
|
||||
if (!raw || raw === '[]' || raw === '') return [];
|
||||
try {
|
||||
// First, try to parse the raw output as JSON.
|
||||
return JSON.parse(raw);
|
||||
} catch (jsonError) {
|
||||
// If that fails, check for a markdown code block.
|
||||
core.info('Direct JSON parsing failed. Trying to extract from a markdown block.');
|
||||
const jsonMatch = raw.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
if (jsonMatch && jsonMatch[1]) {
|
||||
try {
|
||||
return JSON.parse(jsonMatch[1].trim());
|
||||
} catch (markdownError) {
|
||||
core.warning(`Failed to parse extracted JSON from markdown block: ${markdownError.message}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no markdown block, try to find a raw JSON array in the output.
|
||||
// The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
|
||||
// before the actual JSON response.
|
||||
const jsonArrayMatch = rawLabels.match(
|
||||
/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/,
|
||||
);
|
||||
|
||||
// Try to find a raw JSON array in the output.
|
||||
const jsonArrayMatch = raw.match(/\[\s*\{\s*"issue_number"[\s\S]*\}\s*\]/);
|
||||
if (jsonArrayMatch) {
|
||||
try {
|
||||
parsedLabels = JSON.parse(jsonArrayMatch[0]);
|
||||
return JSON.parse(jsonArrayMatch[0]);
|
||||
} catch (extractError) {
|
||||
// It's possible the regex matched from a `[STARTUP]` log all the way to the end
|
||||
// of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
|
||||
core.warning(
|
||||
`Strict array match failed: ${extractError.message}. Attempting to clean leading noisy brackets.`,
|
||||
);
|
||||
const fallbackMatch = rawLabels.match(
|
||||
/(\[\s*\{\s*"issue_number"[\s\S]*)/,
|
||||
);
|
||||
const fallbackMatch = raw.match(/(\[\s*\{\s*"issue_number"[\s\S]*)/);
|
||||
if (fallbackMatch) {
|
||||
try {
|
||||
// We might have grabbed trailing noise too, so we find the last closing bracket
|
||||
const cleaned = fallbackMatch[0].substring(
|
||||
0,
|
||||
fallbackMatch[0].lastIndexOf(']') + 1,
|
||||
);
|
||||
parsedLabels = JSON.parse(cleaned);
|
||||
const cleaned = fallbackMatch[0].substring(0, fallbackMatch[0].lastIndexOf(']') + 1);
|
||||
return JSON.parse(cleaned);
|
||||
} catch (fallbackError) {
|
||||
core.setFailed(
|
||||
`Found JSON-like content but failed to parse: ${fallbackError.message}\nRaw output: ${rawLabels}`,
|
||||
);
|
||||
return;
|
||||
core.warning(`Failed to parse extracted JSON using fallback regex: ${fallbackError.message}`);
|
||||
}
|
||||
} else {
|
||||
core.setFailed(
|
||||
`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawLabels}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
core.setFailed(
|
||||
`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawLabels}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
core.info(`Parsed labels JSON: ${JSON.stringify(parsedLabels)}`);
|
||||
core.warning('No valid JSON could be extracted from input.');
|
||||
return [];
|
||||
};
|
||||
|
||||
for (const entry of parsedLabels) {
|
||||
const issueNumber = entry.issue_number;
|
||||
if (!issueNumber) {
|
||||
core.info(
|
||||
`Skipping entry with no issue number: ${JSON.stringify(entry)}`,
|
||||
);
|
||||
continue;
|
||||
// Collect all outputs from environment variables
|
||||
// Prioritize EFFORT results over STANDARD results by processing Effort FIRST
|
||||
// so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic)
|
||||
const effortRaw = process.env.LABELS_OUTPUT_EFFORT;
|
||||
const standardRaw = process.env.LABELS_OUTPUT_STANDARD;
|
||||
const genericRaw = process.env.LABELS_OUTPUT;
|
||||
|
||||
const resultsByIssue = new Map();
|
||||
|
||||
const processResults = (results, sourceName) => {
|
||||
for (const entry of results) {
|
||||
const issueNumber = entry.issue_number;
|
||||
if (!issueNumber) continue;
|
||||
|
||||
if (!resultsByIssue.has(issueNumber)) {
|
||||
resultsByIssue.set(issueNumber, {
|
||||
issue_number: issueNumber,
|
||||
labels_to_add: [...(entry.labels_to_add || [])],
|
||||
labels_to_remove: [...(entry.labels_to_remove || [])],
|
||||
explanation: entry.explanation || '',
|
||||
effort_analysis: entry.effort_analysis || '',
|
||||
});
|
||||
} else {
|
||||
const existing = resultsByIssue.get(issueNumber);
|
||||
// Combine labels
|
||||
existing.labels_to_add = [...new Set([...existing.labels_to_add, ...(entry.labels_to_add || [])])];
|
||||
existing.labels_to_remove = [...new Set([...existing.labels_to_remove, ...(entry.labels_to_remove || [])])];
|
||||
|
||||
// Combine explanations (if different)
|
||||
if (entry.explanation && !existing.explanation.includes(entry.explanation)) {
|
||||
existing.explanation = existing.explanation
|
||||
? `${existing.explanation}\n\n${entry.explanation}`
|
||||
: entry.explanation;
|
||||
}
|
||||
|
||||
// Take effort analysis if present
|
||||
if (entry.effort_analysis && !existing.effort_analysis) {
|
||||
existing.effort_analysis = entry.effort_analysis;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Order matters: Effort first so its labels win in conflict resolution
|
||||
processResults(extractJson(effortRaw), 'EFFORT');
|
||||
processResults(extractJson(standardRaw), 'STANDARD');
|
||||
processResults(extractJson(genericRaw), 'GENERIC');
|
||||
|
||||
const finalResults = Array.from(resultsByIssue.values());
|
||||
core.info(`Aggregated triage results for ${finalResults.length} issues.`);
|
||||
|
||||
for (const entry of finalResults) {
|
||||
const issueNumber = entry.issue_number;
|
||||
let labelsToAdd = entry.labels_to_add || [];
|
||||
let labelsToRemove = entry.labels_to_remove || [];
|
||||
let existingLabels = [];
|
||||
|
||||
labelsToRemove.push('status/need-triage');
|
||||
|
||||
if (labelsToAdd.includes('status/manual-triage')) {
|
||||
// If the AI flagged it for manual triage, remove bot-triaged if it exists
|
||||
labelsToRemove.push('status/bot-triaged');
|
||||
// Ensure we don't accidentally try to add bot-triaged if the AI returned it
|
||||
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
|
||||
} else {
|
||||
// Standard successful bot triage
|
||||
labelsToAdd.push('status/bot-triaged');
|
||||
}
|
||||
|
||||
// Deduplicate arrays
|
||||
labelsToAdd = [...new Set(labelsToAdd)];
|
||||
labelsToRemove = [...new Set(labelsToRemove)];
|
||||
|
||||
// Fetch existing labels to auto-resolve conflicts
|
||||
// Fetch existing labels early
|
||||
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) =>
|
||||
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) {
|
||||
core.warning(
|
||||
`Issue #${issueNumber} has multiple area labels to add: ${areaLabelsToAdd.join(', ')}. Keeping only the first one.`,
|
||||
);
|
||||
const firstArea = areaLabelsToAdd[0];
|
||||
labelsToAdd = labelsToAdd.filter(
|
||||
(l) => !l.startsWith('area/') || l === firstArea,
|
||||
);
|
||||
// Programmatic Priority Downgrade Logic
|
||||
if (labelsToAdd.includes('status/need-information')) {
|
||||
const targetPriority = labelsToAdd.find((l) => l.startsWith('priority/'));
|
||||
if (targetPriority) {
|
||||
let downgradedPriority = null;
|
||||
if (targetPriority === 'priority/p0') downgradedPriority = 'priority/p1';
|
||||
if (targetPriority === 'priority/p1') downgradedPriority = 'priority/p2';
|
||||
|
||||
if (downgradedPriority) {
|
||||
core.info(`Programmatically downgrading ${targetPriority} to ${downgradedPriority} due to status/need-information`);
|
||||
labelsToAdd = labelsToAdd.filter((l) => l !== targetPriority);
|
||||
labelsToAdd.push(downgradedPriority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce mutually exclusive priority labels
|
||||
const priorityLabelsToAdd = labelsToAdd.filter((l) =>
|
||||
l.startsWith('priority/'),
|
||||
labelsToRemove.push('status/need-triage');
|
||||
|
||||
if (
|
||||
labelsToAdd.includes('status/manual-triage') ||
|
||||
existingLabels.includes('status/manual-triage')
|
||||
) {
|
||||
labelsToRemove.push('status/bot-triaged');
|
||||
labelsToAdd = labelsToAdd.filter((l) => l !== 'status/bot-triaged');
|
||||
} else {
|
||||
labelsToAdd.push('status/bot-triaged');
|
||||
}
|
||||
|
||||
// Resolve internal conflicts (e.g., adding P1 and P2)
|
||||
// We already resolved these by putting Effort first in the combined list
|
||||
|
||||
// Resolve external conflicts with existing labels
|
||||
if (labelsToAdd.some((l) => l.startsWith('area/'))) {
|
||||
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('area/')));
|
||||
}
|
||||
if (labelsToAdd.some((l) => l.startsWith('priority/'))) {
|
||||
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('priority/')));
|
||||
}
|
||||
if (labelsToAdd.some((l) => l.startsWith('kind/'))) {
|
||||
labelsToRemove.push(...existingLabels.filter((l) => l.startsWith('kind/')));
|
||||
}
|
||||
|
||||
// Enforce mutual exclusivity in the TO-ADD list (Architect wins)
|
||||
const exclusivePrefixes = ['area/', 'priority/', 'kind/'];
|
||||
for (const prefix of exclusivePrefixes) {
|
||||
const filtered = labelsToAdd.filter(l => l.startsWith(prefix));
|
||||
if (filtered.length > 1) {
|
||||
const winner = filtered[0]; // First one wins
|
||||
core.info(`Issue #${issueNumber} has multiple ${prefix} labels suggested. Keeping "${winner}" and discarding others.`);
|
||||
labelsToAdd = labelsToAdd.filter(l => !l.startsWith(prefix) || l === winner);
|
||||
}
|
||||
}
|
||||
|
||||
// Final deduplication and cleanup
|
||||
labelsToRemove = [...new Set(labelsToRemove)].filter(
|
||||
(l) => !labelsToAdd.includes(l) && existingLabels.includes(l),
|
||||
);
|
||||
if (priorityLabelsToAdd.length > 1) {
|
||||
core.warning(
|
||||
`Issue #${issueNumber} has multiple priority labels to add: ${priorityLabelsToAdd.join(', ')}. Keeping only the first one.`,
|
||||
);
|
||||
const firstPriority = priorityLabelsToAdd[0];
|
||||
labelsToAdd = labelsToAdd.filter(
|
||||
(l) => !l.startsWith('priority/') || l === firstPriority,
|
||||
);
|
||||
}
|
||||
labelsToAdd = [...new Set(labelsToAdd)].filter((l) => !existingLabels.includes(l));
|
||||
|
||||
// Batch label operations
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -182,11 +184,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToAdd,
|
||||
});
|
||||
|
||||
const explanation = entry.explanation ? ` - ${entry.explanation}` : '';
|
||||
core.info(
|
||||
`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}${explanation}`,
|
||||
);
|
||||
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
|
||||
}
|
||||
|
||||
if (labelsToRemove.length > 0) {
|
||||
@@ -199,46 +197,33 @@ module.exports = async ({ github, context, core }) => {
|
||||
name: label,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
core.warning(
|
||||
`Failed to remove label ${label} from #${issueNumber}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
if (e.status !== 404) core.warning(`Failed to remove label ${label} from #${issueNumber}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
core.info(
|
||||
`Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`,
|
||||
);
|
||||
core.info(`Successfully removed labels for #${issueNumber}: ${labelsToRemove.join(', ')}`);
|
||||
}
|
||||
|
||||
if (
|
||||
(entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') ||
|
||||
entry.effort_analysis
|
||||
) {
|
||||
// Post comment if needed
|
||||
const needsInfoAdded = labelsToAdd.includes('status/need-information') && !existingLabels.includes('status/need-information');
|
||||
const hasEffortAnalysis = !!entry.effort_analysis;
|
||||
|
||||
if (needsInfoAdded || hasEffortAnalysis) {
|
||||
let commentBody = '';
|
||||
if (entry.explanation && process.env.SUPPRESS_COMMENT !== 'true') {
|
||||
commentBody += entry.explanation;
|
||||
}
|
||||
if (entry.effort_analysis) {
|
||||
if (needsInfoAdded && entry.explanation) commentBody += entry.explanation;
|
||||
if (hasEffortAnalysis) {
|
||||
if (commentBody) commentBody += '\n\n';
|
||||
commentBody += `**Effort Analysis:**\n${entry.effort_analysis}`;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(!entry.labels_to_add || entry.labels_to_add.length === 0) &&
|
||||
(!entry.labels_to_remove || entry.labels_to_remove.length === 0)
|
||||
) {
|
||||
core.info(
|
||||
`No labels to add or remove for #${issueNumber}, leaving as is`,
|
||||
);
|
||||
if (commentBody) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
core.info(`Posted required comment for #${issueNumber}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,29 +22,53 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
for (const issue of issuesToCleanup) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
const { data: issueData } = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: 'status/need-triage',
|
||||
});
|
||||
core.info(
|
||||
`Successfully removed status/need-triage from #${issue.number}`,
|
||||
|
||||
const labels = issueData.labels.map((l) =>
|
||||
typeof l === 'string' ? l : l.name,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
|
||||
if (
|
||||
labels.includes('status/bot-triaged') &&
|
||||
labels.includes('status/need-triage')
|
||||
) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: 'status/need-triage',
|
||||
});
|
||||
core.info(
|
||||
`Label status/need-triage not found on #${issue.number}, skipping.`,
|
||||
);
|
||||
} else {
|
||||
core.warning(
|
||||
`Failed to remove label from #${issue.number}: ${error.message}`,
|
||||
`Successfully removed status/need-triage from #${issue.number}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
labels.includes('status/bot-triaged') &&
|
||||
labels.includes('status/manual-triage')
|
||||
) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: 'status/bot-triaged',
|
||||
});
|
||||
core.info(
|
||||
`Successfully removed status/bot-triaged from #${issue.number} because it requires manual triage`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Failed to clean up labels for #${issue.number}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Cleaned up status/need-triage from ${issuesToCleanup.length} issues.`,
|
||||
`Cleaned up conflicting labels from ${issuesToCleanup.length} issues.`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
core.info('Fetching open issues to check for conflicting labels...');
|
||||
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const conflictingLabelIssues = [];
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request) continue;
|
||||
|
||||
const areaLabels = issue.labels
|
||||
.filter((l) => l.name && l.name.startsWith('area/'))
|
||||
.map((l) => l.name);
|
||||
|
||||
const priorityLabels = issue.labels
|
||||
.filter((l) => l.name && l.name.startsWith('priority/'))
|
||||
.map((l) => l.name);
|
||||
|
||||
if (areaLabels.length > 1 || priorityLabels.length > 1) {
|
||||
let message = `Issue #${issue.number} has conflicting labels:`;
|
||||
if (areaLabels.length > 1)
|
||||
message += ` multiple areas (${areaLabels.join(', ')}).`;
|
||||
if (priorityLabels.length > 1)
|
||||
message += ` multiple priorities (${priorityLabels.join(', ')}).`;
|
||||
|
||||
core.info(message);
|
||||
|
||||
conflictingLabelIssues.push({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
body: issue.body || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to 50 to avoid overwhelming the AI in a single run
|
||||
const issuesToProcess = conflictingLabelIssues.slice(0, 50);
|
||||
|
||||
fs.writeFileSync(
|
||||
'conflicting_labels_issues.json',
|
||||
JSON.stringify(issuesToProcess, null, 2),
|
||||
);
|
||||
|
||||
core.info(
|
||||
`Found ${conflictingLabelIssues.length} issues with conflicting labels. Wrote ${issuesToProcess.length} to conflicting_labels_issues.json`,
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,8 @@ module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
core.info(`Running in ${dryRun ? 'DRY RUN' : 'PRODUCTION'} mode.`);
|
||||
|
||||
const STALE_LABEL = 'stale';
|
||||
const NEED_INFO_LABEL = 'status/need-information';
|
||||
const EXEMPT_LABELS = [
|
||||
@@ -79,14 +81,16 @@ module.exports = async ({ github, context, core }) => {
|
||||
async function processItems(query, callback) {
|
||||
core.info(`Searching: ${query}`);
|
||||
try {
|
||||
const response = await github.rest.search.issuesAndPullRequests({
|
||||
q: query,
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
order: 'asc',
|
||||
});
|
||||
const items = response.data.items;
|
||||
core.info(`Found ${items.length} items (batch limited).`);
|
||||
let items = await github.paginate(
|
||||
github.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: query,
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
order: 'asc',
|
||||
},
|
||||
);
|
||||
core.info(`Found ${items.length} items.`);
|
||||
for (const item of items) {
|
||||
try {
|
||||
await callback(item);
|
||||
@@ -114,16 +118,21 @@ module.exports = async ({ github, context, core }) => {
|
||||
per_page: 5,
|
||||
});
|
||||
|
||||
// Check if the last comment is from a non-maintainer
|
||||
// Check if the last comment is from a non-maintainer and not a bot
|
||||
const lastComment = comments[0];
|
||||
if (
|
||||
lastComment &&
|
||||
lastComment.user?.type !== 'Bot' &&
|
||||
!(await isMaintainer(lastComment.user, lastComment.author_association))
|
||||
) {
|
||||
core.info(
|
||||
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
if (dryRun) {
|
||||
core.info(
|
||||
`[DRY RUN] Would remove ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
);
|
||||
} else {
|
||||
core.info(
|
||||
`Removing ${NEED_INFO_LABEL} from #${item.number} due to contributor response.`,
|
||||
);
|
||||
await github.rest.issues
|
||||
.removeLabel({
|
||||
owner,
|
||||
@@ -141,10 +150,14 @@ module.exports = async ({ github, context, core }) => {
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${NEED_INFO_LABEL}" updated:<${noResponseThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(
|
||||
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
if (dryRun) {
|
||||
core.info(
|
||||
`[DRY RUN] Would close #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
|
||||
);
|
||||
} else {
|
||||
core.info(
|
||||
`Closing #${item.number} due to no response for ${NO_RESPONSE_DAYS} days.`,
|
||||
);
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
@@ -156,6 +169,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -163,11 +177,21 @@ module.exports = async ({ github, context, core }) => {
|
||||
|
||||
// 2. Handle Stale Mark (60 days inactivity, no stale label)
|
||||
const exemptQuery = EXEMPT_LABELS.map((l) => `-label:"${l}"`).join(' ');
|
||||
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open -label:"${STALE_LABEL}" ${exemptQuery} updated:<${staleThreshold.toISOString()}`,
|
||||
async (item) => {
|
||||
core.info(`Marking #${item.number} as stale.`);
|
||||
if (!dryRun) {
|
||||
const isBug = item.labels.some((l) =>
|
||||
(typeof l === 'string' ? l : l.name).toLowerCase().includes('bug'),
|
||||
);
|
||||
const bodyText = isBug
|
||||
? `This bug report has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. Many issues are resolved in newer releases. Please verify if the issue persists in the latest Gemini CLI version. If it does, please leave a comment to keep this open. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`
|
||||
: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`;
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[DRY RUN] Would mark #${item.number} as stale.`);
|
||||
} else {
|
||||
core.info(`Marking #${item.number} as stale.`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
@@ -178,18 +202,97 @@ module.exports = async ({ github, context, core }) => {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
body: `This item has been automatically marked as stale due to ${STALE_DAYS} days of inactivity. It will be closed in ${CLOSE_DAYS} days if no further activity occurs. Thank you!`,
|
||||
body: bodyText,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Handle Stale Close (14 days with stale label)
|
||||
// 3. Handle Stale Removal & Close
|
||||
await processItems(
|
||||
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery} updated:<${closeThreshold.toISOString()}`,
|
||||
`repo:${owner}/${repo} is:open label:"${STALE_LABEL}" ${exemptQuery}`,
|
||||
async (item) => {
|
||||
core.info(`Closing stale item #${item.number}.`);
|
||||
if (!dryRun) {
|
||||
// Fetch full timeline to see events and comments
|
||||
const timeline = await github.paginate(
|
||||
github.rest.issues.listEventsForTimeline,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
per_page: 100,
|
||||
},
|
||||
);
|
||||
|
||||
// Find exactly when the Stale label was added
|
||||
// We look for the last 'labeled' event for STALE_LABEL
|
||||
const staleEventIndex = timeline.findLastIndex(
|
||||
(e) =>
|
||||
e.event === 'labeled' &&
|
||||
e.label?.name?.toLowerCase() === STALE_LABEL.toLowerCase(),
|
||||
);
|
||||
|
||||
if (staleEventIndex === -1) return; // Fallback if no event found
|
||||
|
||||
const staleEvent = timeline[staleEventIndex];
|
||||
const eventsAfterStale = timeline.slice(staleEventIndex + 1);
|
||||
|
||||
// Check for meaningful activity after the Stale label was applied
|
||||
const meaningfulEvents = eventsAfterStale.filter((e) => {
|
||||
const actor = e.actor?.login || '';
|
||||
const isBot =
|
||||
actor.includes('[bot]') || actor.includes('github-actions');
|
||||
|
||||
if (isBot) return false;
|
||||
|
||||
// Explicit whitelist of meaningful events for humans
|
||||
if (
|
||||
[
|
||||
'commented',
|
||||
'cross-referenced',
|
||||
'connected',
|
||||
'reopened',
|
||||
'assigned',
|
||||
].includes(e.event)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (meaningfulEvents.length > 0) {
|
||||
// Activity detected, remove Stale label
|
||||
if (dryRun) {
|
||||
core.info(
|
||||
`[DRY RUN] Would remove ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
|
||||
);
|
||||
} else {
|
||||
core.info(
|
||||
`Removing ${STALE_LABEL} from #${item.number} due to meaningful activity (e.g., comment or PR).`,
|
||||
);
|
||||
await github.rest.issues
|
||||
.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
name: STALE_LABEL,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No meaningful activity. Check if 14 days have passed.
|
||||
const labeledDate = new Date(staleEvent.created_at);
|
||||
if (labeledDate > closeThreshold) {
|
||||
// Has not been 14 days since it was ACTUALLY marked stale
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`[DRY RUN] Would close stale item #${item.number}.`);
|
||||
} else {
|
||||
core.info(`Closing stale item #${item.number}.`);
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
@@ -201,6 +304,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -222,8 +326,12 @@ module.exports = async ({ github, context, core }) => {
|
||||
async (pr) => {
|
||||
if (await isMaintainer(pr.user, pr.author_association)) return;
|
||||
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
if (!dryRun) {
|
||||
if (dryRun) {
|
||||
core.info(
|
||||
`[DRY RUN] Would nudge PR #${pr.number} for contribution policy.`,
|
||||
);
|
||||
} else {
|
||||
core.info(`Nudging PR #${pr.number} for contribution policy.`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
@@ -246,10 +354,14 @@ module.exports = async ({ github, context, core }) => {
|
||||
async (pr) => {
|
||||
if (await isMaintainer(pr.user, pr.author_association)) return;
|
||||
|
||||
core.info(
|
||||
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
);
|
||||
if (!dryRun) {
|
||||
if (dryRun) {
|
||||
core.info(
|
||||
`[DRY RUN] Would close PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
);
|
||||
} else {
|
||||
core.info(
|
||||
`Closing PR #${pr.number} per contribution policy (no 'help wanted').`,
|
||||
);
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
|
||||
@@ -68,6 +68,7 @@ jobs:
|
||||
ISSUE_NUMBER: '${{ github.event.issue.number }}'
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
FIRESTORE_PROJECT: '${{ vars.FIRESTORE_PROJECT }}'
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
with:
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
|
||||
|
||||
@@ -335,14 +335,41 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
const newAreaLabel = labelsToAdd[0];
|
||||
|
||||
// Get current labels to resolve conflicts
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const currentLabelNames = currentLabels.map(l => l.name);
|
||||
const currentAreaLabels = currentLabelNames.filter(name => name.startsWith('area/'));
|
||||
|
||||
const labelsToRemove = currentAreaLabels.filter(name => name !== newAreaLabel);
|
||||
|
||||
for (const label of labelsToRemove) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
name: label
|
||||
});
|
||||
core.info(`Removed conflicting area label: ${label}`);
|
||||
} catch (e) {
|
||||
core.warning(`Failed to remove label ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set labels based on triage result
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToAdd
|
||||
labels: [newAreaLabel]
|
||||
});
|
||||
core.info(`Successfully added labels for #${issueNumber}: ${labelsToAdd.join(', ')}`);
|
||||
core.info(`Successfully added labels for #${issueNumber}: ${newAreaLabel}`);
|
||||
|
||||
- name: 'Post Issue Analysis Failure Comment'
|
||||
if: |-
|
||||
|
||||
@@ -62,12 +62,20 @@ jobs:
|
||||
- name: 'Find Issues with Conflicting Labels'
|
||||
if: |-
|
||||
${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |-
|
||||
const findConflictingLabels = require('./.github/scripts/find-conflicting-labels.cjs');
|
||||
await findConflictingLabels({ github, context, core });
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ steps.generate_token.outputs.token }}'
|
||||
GITHUB_REPOSITORY: '${{ github.repository }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo '🔍 Fetching open issues to find conflicts...'
|
||||
# Fetch up to 2000 open issues in one quick GraphQL-backed query
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" --search "is:issue is:open" --limit 2000 --json number,title,body,labels > all_open_issues.json
|
||||
|
||||
echo '🧹 Filtering issues with multiple area/ or priority/ labels...'
|
||||
jq -c '[ .[] | select( (.labels | map(select(.name | startswith("area/"))) | length) > 1 or (.labels | map(select(.name | startswith("priority/"))) | length) > 1 ) ] | .[0:50]' all_open_issues.json > conflicting_labels_issues.json
|
||||
|
||||
CONFLICT_COUNT=$(jq 'length' conflicting_labels_issues.json)
|
||||
echo "Found ${CONFLICT_COUNT} issues with conflicting labels (capped at 50 for processing)."
|
||||
|
||||
- name: 'Find untriaged issues'
|
||||
if: |-
|
||||
@@ -81,19 +89,19 @@ jobs:
|
||||
|
||||
echo '🔍 Finding issues missing area labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body > no_area_issues.json
|
||||
--search 'is:open is:issue -label:area/core -label:area/agent -label:area/enterprise -label:area/non-interactive -label:area/security -label:area/platform -label:area/extensions -label:area/documentation -label:area/unknown' --limit 50 --json number,title,body,labels > no_area_issues.json
|
||||
|
||||
echo '🔍 Finding issues missing kind labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:status/bot-triaged -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body > no_kind_issues.json
|
||||
--search 'is:open is:issue -label:kind/bug -label:kind/enhancement -label:kind/customer-issue -label:kind/question' --limit 50 --json number,title,body,labels > no_kind_issues.json
|
||||
|
||||
echo '🏷️ Finding issues missing priority labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body > no_priority_issues.json
|
||||
--search 'is:open is:issue -label:priority/p0 -label:priority/p1 -label:priority/p2 -label:priority/p3 -label:priority/unknown' --limit 50 --json number,title,body,labels > no_priority_issues.json
|
||||
|
||||
echo '📏 Finding issues missing effort labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 5 --json number,title,body > no_effort_issues.json
|
||||
--search 'is:open is:issue -label:effort/small -label:effort/medium -label:effort/large label:area/core,area/extensions,area/site,area/non-interactive' --limit 20 --json number,title,body,labels > no_effort_issues.json
|
||||
|
||||
echo '🔄 Merging and deduplicating standard triage issues...'
|
||||
if [ ! -f conflicting_labels_issues.json ]; then echo "[]" > conflicting_labels_issues.json; fi
|
||||
@@ -158,6 +166,7 @@ jobs:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
with:
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
|
||||
@@ -174,7 +183,7 @@ jobs:
|
||||
"read_file"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
@@ -188,12 +197,12 @@ jobs:
|
||||
## Steps
|
||||
|
||||
1. You are only able to use the echo and read_file commands. Review the available labels in the environment variable: "${AVAILABLE_LABELS}".
|
||||
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage.
|
||||
3. Review the issue title, body and any comments provided in the JSON file.
|
||||
2. Use the read_file tool to read the file "standard_issues_to_triage.json" which contains the JSON array of issues to triage (including their current labels).
|
||||
3. Review the issue title, body, current labels, and any comments provided in the JSON file.
|
||||
4. Identify the most relevant labels from the existing labels, specifically focusing on area/*, kind/*, and priority/*.
|
||||
5. Label Policy:
|
||||
- If the issue already has a kind/ label, do not change it.
|
||||
- If the issue has exactly ONE priority/ label, do not change it.
|
||||
- If the issue has exactly ONE priority/ label, do not change it (unless you are explicitly re-evaluating an ambiguous priority).
|
||||
- If the issue is missing a priority/ label, OR if the issue currently has MULTIPLE priority/ labels, you must evaluate the issue's impact to determine exactly ONE priority level (priority/p0, priority/p1, priority/p2, priority/p3, or priority/unknown) based the guidelines. If you are fixing an issue with multiple priority/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
|
||||
- If the issue has exactly ONE area/ label, do not change it.
|
||||
- If the issue is missing an area/ label, OR if the issue currently has MULTIPLE area/ labels, select exactly ONE area/ label that best fits the issue. Issues MUST NOT have multiple area/ labels. If you are fixing an issue with multiple area/ labels, put the correct one in `labels_to_add` and put all the incorrect ones in `labels_to_remove`.
|
||||
@@ -213,11 +222,12 @@ jobs:
|
||||
]
|
||||
```
|
||||
If an issue cannot be classified, do not include it in the output array.
|
||||
9. For each issue please check if CLI version is present, this is usually in the output of the /about command and will look like 0.1.5
|
||||
- Anything more than 6 versions older than the most recent should add the status/need-retesting label
|
||||
10. If you see that the issue doesn't look like it has sufficient information recommend the status/need-information label and leave a comment politely requesting the relevant information, eg.. if repro steps are missing request for repro steps. if version information is missing request for version information into the explanation section below.
|
||||
11. If you think an issue might be a Priority/P0 do not apply the priority/p0 label. Instead apply a status/manual-triage label and include a note in your explanation.
|
||||
12. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
|
||||
9. For each issue, carefully check if the CLI version is present. It is usually found under the "### Client information" header, as a bullet point (e.g., "• CLI Version: 0.33.1"), or in the output of the `/about` command.
|
||||
- If the version is provided but is more than 6 minor versions older than the most recent release, apply the status/need-information label and leave a comment politely asking the user to verify if the issue persists in the latest version.
|
||||
10. If the issue does not have sufficient information, recommend the status/need-information label and leave a comment politely requesting the missing details. For example, if repro steps are missing, ask for them; if the CLI version is completely missing, ask for the version information in the explanation section below. Do not ask for version info if it is already in the issue body.
|
||||
11. If you think an issue is a Priority/P0, you MUST apply the priority/p1 label AND the status/manual-triage label, and include a note in your explanation that it likely requires P0 escalation.
|
||||
12. If the issue is highly ambiguous, completely lacks a description, or you are torn between two lower priorities (like P2 vs P3), you MUST retain the existing priority label if one is already present. Do not toggle the priority if you do not have enough information to make a definitive change.
|
||||
13. If you are uncertain about a category, use the area/unknown, kind/question, or priority/unknown labels as appropriate. If you are extremely uncertain, apply the status/manual-triage label.
|
||||
|
||||
## Guidelines
|
||||
|
||||
@@ -230,12 +240,14 @@ jobs:
|
||||
- Identify exactly ONE area/ label. Do NOT assign multiple area/ labels to a single issue.
|
||||
- Identify only one kind/ label (Do not apply kind/duplicate or kind/parent-issue)
|
||||
- Identify exactly ONE priority/ label. Do NOT assign multiple priority/ labels to a single issue.
|
||||
- Once you categorize the issue if it needs information bump down the priority by 1 eg.. a p0 would become a p1 a p1 would become a p2. P2 and P3 can stay as is in this scenario.
|
||||
- **Do not manually downgrade the priority.** Always assign the true priority based on the guidelines. The system will handle downgrades programmatically if information is missing.
|
||||
- **NEVER mention label names, label removals, or label additions in your `explanation`.** The explanation must be purely written for the user (e.g., "Please provide your CLI version.") without exposing internal triage mechanics (e.g., do NOT say "Removing area/unknown to leave only area/core").
|
||||
|
||||
Categorization Guidelines (Priority):
|
||||
P0 - Urgent Blocking Issues:
|
||||
- DO NOT APPLY THE priority/p0 LABEL AUTOMATICALLY. Instead apply priority/p1 and status/manual-triage.
|
||||
- Definition: Critical failures breaking core functionality for a large portion of users. Examples: CLI fails to launch globally, core commands (gemini run) crash on valid input, unhandled promise rejections on boot, critical security vulnerability.
|
||||
- Note: You must apply status/manual-triage instead of priority/p0.
|
||||
- Note: You must apply priority/p1 and status/manual-triage instead of priority/p0.
|
||||
P1 - Critical but Workable:
|
||||
- Definition: Severe issues without a reasonable workaround, significantly degrading the developer experience but not globally blocking. Examples: Specific tools failing consistently (e.g., `web_search` returns 500s), persistent PTY streaming hangs, memory leaks leading to OOM after short use.
|
||||
P2 - Significant Issues:
|
||||
@@ -275,6 +287,7 @@ jobs:
|
||||
GEMINI_CLI_TRUST_WORKSPACE: 'true'
|
||||
GEMINI_EXP: 'gemini_exp.json'
|
||||
GEMINI_STRICT_TELEMETRY_LIMITS: 'true'
|
||||
GEMINI_MODEL: 'gemini-3-flash-preview'
|
||||
with:
|
||||
gcp_workload_identity_provider: '${{ vars.GCP_WIF_PROVIDER }}'
|
||||
gcp_project_id: '${{ vars.GOOGLE_CLOUD_PROJECT }}'
|
||||
@@ -293,7 +306,7 @@ jobs:
|
||||
"read_file"
|
||||
],
|
||||
"telemetry": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"target": "gcp"
|
||||
}
|
||||
}
|
||||
@@ -379,30 +392,14 @@ jobs:
|
||||
- This product is designed to use different models eg.. using pro, downgrading to flash etc.
|
||||
- When users report that they dont expect the model to change those would be categorized as feature requests.
|
||||
|
||||
- name: 'Apply Standard Labels to Issues'
|
||||
- name: 'Apply Triaged Labels'
|
||||
if: |-
|
||||
${{ steps.gemini_standard_issue_analysis.outcome == 'success' &&
|
||||
steps.gemini_standard_issue_analysis.outputs.summary != '[]' &&
|
||||
steps.gemini_standard_issue_analysis.outputs.summary != '' }}
|
||||
always() &&
|
||||
( (steps.gemini_standard_issue_analysis.outcome == 'success' && steps.gemini_standard_issue_analysis.outputs.summary != '[]' && steps.gemini_standard_issue_analysis.outputs.summary != '') ||
|
||||
(steps.gemini_effort_issue_analysis.outcome == 'success' && steps.gemini_effort_issue_analysis.outputs.summary != '[]' && steps.gemini_effort_issue_analysis.outputs.summary != '') )
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
|
||||
SUPPRESS_COMMENT: 'true'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
script: |-
|
||||
const applyLabels = require('./.github/scripts/apply-issue-labels.cjs');
|
||||
await applyLabels({ github, context, core });
|
||||
|
||||
- name: 'Apply Effort Labels to Issues'
|
||||
if: |-
|
||||
${{ steps.gemini_effort_issue_analysis.outcome == 'success' &&
|
||||
steps.gemini_effort_issue_analysis.outputs.summary != '[]' &&
|
||||
steps.gemini_effort_issue_analysis.outputs.summary != '' }}
|
||||
env:
|
||||
REPOSITORY: '${{ github.repository }}'
|
||||
LABELS_OUTPUT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
|
||||
LABELS_OUTPUT_STANDARD: '${{ steps.gemini_standard_issue_analysis.outputs.summary }}'
|
||||
LABELS_OUTPUT_EFFORT: '${{ steps.gemini_effort_issue_analysis.outputs.summary }}'
|
||||
uses: 'actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea'
|
||||
with:
|
||||
github-token: '${{ steps.generate_token.outputs.token }}'
|
||||
@@ -428,9 +425,12 @@ jobs:
|
||||
GITHUB_REPOSITORY: '${{ github.repository }}'
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
echo '🧹 Finding issues that have both bot-triaged and need-triage labels...'
|
||||
echo '🧹 Finding issues that have conflicting status labels...'
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > issues_to_cleanup.json
|
||||
--search 'is:open is:issue label:status/bot-triaged label:status/need-triage' --limit 50 --json number > cleanup_1.json
|
||||
gh issue list --repo "${GITHUB_REPOSITORY}" \
|
||||
--search 'is:open is:issue label:status/bot-triaged label:status/manual-triage' --limit 50 --json number > cleanup_2.json
|
||||
jq -c -s 'add | unique_by(.number)' cleanup_1.json cleanup_2.json > issues_to_cleanup.json
|
||||
|
||||
- name: 'Clean Up Triage Labels'
|
||||
if: |-
|
||||
|
||||
@@ -23,4 +23,3 @@ 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.1
|
||||
# Preview release: v0.43.0-preview.0
|
||||
|
||||
Released: May 19, 2026
|
||||
Released: May 12, 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,9 +26,6 @@ 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
|
||||
@@ -196,4 +193,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.1
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.42.0-preview.2...v0.43.0-preview.0
|
||||
|
||||
@@ -29,10 +29,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ 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,6 +138,7 @@ 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,7 +40,6 @@ 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
|
||||
|
||||
@@ -163,24 +162,25 @@ 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` |
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
### 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 edit the appropriate memory Markdown file, so the fact is loaded
|
||||
in future sessions.
|
||||
The agent will use the `save_memory` tool to store this fact in your global
|
||||
memory file.
|
||||
|
||||
**Prompt:** `Save the fact that the staging server IP is 10.0.0.5.`
|
||||
|
||||
|
||||
@@ -210,22 +210,6 @@ 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,13 +159,6 @@ 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 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
|
||||
# Run the published sandbox image
|
||||
docker run --rm -it us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.1
|
||||
```
|
||||
- **Using the `--sandbox` flag:** If you have Gemini CLI installed locally
|
||||
(using the standard installation described above), you can instruct it to run
|
||||
|
||||
@@ -265,6 +265,9 @@ 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,19 +105,9 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`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.
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **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
|
||||
@@ -213,11 +203,6 @@ 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):
|
||||
@@ -560,24 +545,6 @@ 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": {
|
||||
@@ -1047,6 +1014,12 @@ 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
|
||||
@@ -1190,6 +1163,13 @@ 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"]
|
||||
@@ -1851,12 +1831,6 @@ 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`
|
||||
@@ -1893,6 +1867,13 @@ 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
|
||||
@@ -1955,6 +1936,19 @@ 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,6 +120,7 @@ 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
|
||||
|
||||
@@ -172,6 +173,7 @@ 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,10 +221,8 @@ spawning MCP server processes.
|
||||
#### Automatic redaction
|
||||
|
||||
By default, the CLI redacts sensitive environment variables from the base
|
||||
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:
|
||||
environment (inherited from the host process) to prevent unintended exposure to
|
||||
third-party MCP servers. This includes:
|
||||
|
||||
- Core project keys: `GEMINI_API_KEY`, `GOOGLE_API_KEY`, etc.
|
||||
- Variables matching sensitive patterns: `*TOKEN*`, `*SECRET*`, `*PASSWORD*`,
|
||||
@@ -234,8 +232,7 @@ your environment. 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`
|
||||
(or `mcp_config.json` if configuring standard MCP clients or remote skills).
|
||||
state it in the `env` property of the server configuration in `settings.json`.
|
||||
Explicitly defined variables (including those from extensions) are trusted and
|
||||
are **not** subjected to the automatic redaction process.
|
||||
|
||||
@@ -250,24 +247,6 @@ 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
|
||||
|
||||
+13
-10
@@ -1,22 +1,25 @@
|
||||
# Memory files
|
||||
# Memory tool (`save_memory`)
|
||||
|
||||
Gemini CLI persists durable facts, user preferences, and project details by
|
||||
editing Markdown memory files directly.
|
||||
The `save_memory` tool allows the Gemini agent to persist specific facts, user
|
||||
preferences, and project details across sessions.
|
||||
|
||||
## Technical reference
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Technical behavior
|
||||
|
||||
- **Storage:** Edits Markdown files with `write_file` or `replace`.
|
||||
- **Storage:** Appends to the global context file in the user's home directory.
|
||||
- **Loading:** The stored facts are automatically included in the hierarchical
|
||||
context system for all future sessions.
|
||||
- **Format:** Keeps durable instructions concise and avoids duplicating the same
|
||||
fact across multiple memory tiers.
|
||||
- **Format:** Saves data as a bulleted list item within a dedicated Markdown
|
||||
section.
|
||||
|
||||
## Use cases
|
||||
|
||||
|
||||
+2
-41
@@ -63,6 +63,7 @@ const external = [
|
||||
'@lydell/node-pty-win32-arm64',
|
||||
'@lydell/node-pty-win32-x64',
|
||||
'@github/keytar',
|
||||
'@google/gemini-cli-devtools',
|
||||
];
|
||||
|
||||
const baseConfig = {
|
||||
@@ -101,46 +102,11 @@ 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: {
|
||||
@@ -167,18 +133,13 @@ Promise.allSettled([
|
||||
writeFileSync('./bundle/esbuild.json', JSON.stringify(metafile, null, 2));
|
||||
}
|
||||
}),
|
||||
esbuild.build(workerConfig),
|
||||
esbuild.build(a2aServerConfig),
|
||||
]).then((results) => {
|
||||
const [cliResult, workerResult, a2aResult] = results;
|
||||
const [cliResult, 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,7 +11,11 @@ import {
|
||||
loadConversationRecord,
|
||||
SESSION_FILE_PREFIX,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { evalTest, assertModelHasOutput } from './test-helper.js';
|
||||
import {
|
||||
evalTest,
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
function findDir(base: string, name: string): string | null {
|
||||
if (!fs.existsSync(base)) return null;
|
||||
@@ -73,13 +77,336 @@ async function waitForSessionScratchpad(
|
||||
return loadLatestSessionRecord(homeDir, sessionId);
|
||||
}
|
||||
|
||||
describe('memory persistence', () => {
|
||||
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}`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
@@ -135,9 +462,9 @@ describe('memory persistence', () => {
|
||||
prompt:
|
||||
'Please save any persistent preferences or facts about me from our conversation to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// The agent persists memories by editing markdown files directly with
|
||||
// write_file or replace. The user said
|
||||
// "I always prefer Vitest over
|
||||
// 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
|
||||
// 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
|
||||
@@ -195,12 +522,17 @@ describe('memory persistence', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryRoutesTeamConventionsToProjectGemini =
|
||||
const memoryV2RoutesTeamConventionsToProjectGemini =
|
||||
'Agent routes team-shared project conventions to ./GEMINI.md';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryRoutesTeamConventionsToProjectGemini,
|
||||
name: memoryV2RoutesTeamConventionsToProjectGemini,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
@@ -241,11 +573,11 @@ describe('memory persistence', () => {
|
||||
],
|
||||
prompt: 'Please save the preferences I mentioned earlier to memory.',
|
||||
assert: async (rig, result) => {
|
||||
// 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
|
||||
// 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
|
||||
// (~/.gemini/tmp/<hash>/memory/). The global ~/.gemini/GEMINI.md must
|
||||
// never be touched in this mode either.
|
||||
await rig.waitForToolCall('write_file').catch(() => {});
|
||||
@@ -303,13 +635,18 @@ describe('memory persistence', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memorySessionScratchpad =
|
||||
const memoryV2SessionScratchpad =
|
||||
'Session summary persists memory scratchpad for memory-saving sessions';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memorySessionScratchpad,
|
||||
name: memoryV2SessionScratchpad,
|
||||
sessionId: 'memory-scratchpad-eval',
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
@@ -358,7 +695,7 @@ describe('memory persistence', () => {
|
||||
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'Expected memory save flow to edit a markdown memory file',
|
||||
'Expected memoryV2 save flow to edit a markdown memory file',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
await rig.run({
|
||||
@@ -395,12 +732,17 @@ describe('memory persistence', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const memoryRoutesUserProject =
|
||||
const memoryV2RoutesUserProject =
|
||||
'Agent routes personal-to-user project notes to user-project memory';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryRoutesUserProject,
|
||||
name: memoryV2RoutesUserProject,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
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:
|
||||
@@ -419,11 +761,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) => {
|
||||
// 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
|
||||
// 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
|
||||
// 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(() => {});
|
||||
@@ -486,19 +828,24 @@ Quirks to remember:
|
||||
},
|
||||
});
|
||||
|
||||
const memoryRoutesCrossProjectToGlobal =
|
||||
const memoryV2RoutesCrossProjectToGlobal =
|
||||
'Agent routes cross-project personal preferences to ~/.gemini/GEMINI.md';
|
||||
evalTest('USUALLY_PASSES', {
|
||||
suiteName: 'default',
|
||||
suiteType: 'behavioral',
|
||||
name: memoryRoutesCrossProjectToGlobal,
|
||||
name: memoryV2RoutesCrossProjectToGlobal,
|
||||
params: {
|
||||
settings: {
|
||||
experimental: { memoryV2: true },
|
||||
},
|
||||
},
|
||||
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) => {
|
||||
// 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
|
||||
// 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
|
||||
// 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: "remember foo" should be fairly reliable.
|
||||
// prompts. For example: "call save_memory to 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'.
|
||||
//
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* @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);
|
||||
});
|
||||
});
|
||||
@@ -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 { resolveRipgrepPath } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { canUseRipgrep } 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 resolveRipgrepPath();
|
||||
const available = await canUseRipgrep();
|
||||
if (!available) {
|
||||
throw new Error('Failed to download ripgrep binary');
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* @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,10 +8,7 @@ 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,
|
||||
resolveRipgrepPath,
|
||||
} from '../packages/core/src/tools/ripGrep.js';
|
||||
import { RipGrepTool } 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';
|
||||
@@ -51,10 +48,6 @@ 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 { resolveRipgrepPath } from '../packages/core/src/tools/ripGrep.js';
|
||||
import { canUseRipgrep } 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 resolveRipgrepPath();
|
||||
const available = await canUseRipgrep();
|
||||
if (!available) {
|
||||
throw new Error('Failed to download ripgrep binary');
|
||||
}
|
||||
|
||||
Generated
+355
-364
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", "list"] for /memory list
|
||||
// The path to the command, e.g., ["memory", "add"] for /memory add
|
||||
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.19.0",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
|
||||
@@ -93,16 +93,10 @@ 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, isTrusted);
|
||||
const settings = loadSettings(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
return loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
taskId,
|
||||
isTrusted,
|
||||
);
|
||||
return loadConfig(settings, new SimpleExtensionLoader(extensions), taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,13 +5,17 @@
|
||||
*/
|
||||
|
||||
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,
|
||||
@@ -28,23 +32,44 @@ 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(() => {
|
||||
mockConfig = {} as unknown as Config;
|
||||
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;
|
||||
|
||||
mockContext = {
|
||||
config: mockConfig,
|
||||
};
|
||||
|
||||
vi.mocked(mockToolRegistry.getTool).mockReturnValue(mockSaveMemoryTool);
|
||||
});
|
||||
|
||||
describe('MemoryCommand', () => {
|
||||
@@ -111,4 +136,76 @@ 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,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
showMemory,
|
||||
@@ -14,6 +15,13 @@ 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';
|
||||
@@ -22,6 +30,7 @@ export class MemoryCommand implements Command {
|
||||
new ShowMemoryCommand(),
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new AddMemoryCommand(),
|
||||
];
|
||||
readonly topLevel = true;
|
||||
readonly requiresWorkspace = true;
|
||||
@@ -72,3 +81,43 @@ 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,6 +10,7 @@ import { loadConfig } from './config.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
type ExtensionLoader,
|
||||
FileDiscoveryService,
|
||||
getCodeAssistServer,
|
||||
Config,
|
||||
ExperimentFlags,
|
||||
@@ -19,9 +20,7 @@ import {
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
PolicyDecision,
|
||||
ApprovalMode,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
createPolicyEngineConfig,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -49,38 +48,18 @@ 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(),
|
||||
},
|
||||
@@ -289,82 +268,21 @@ describe('loadConfig', () => {
|
||||
expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]);
|
||||
});
|
||||
|
||||
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'],
|
||||
};
|
||||
it('should initialize FileDiscoveryService with correct options', async () => {
|
||||
const testPath = '/tmp/ignore';
|
||||
vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath);
|
||||
const settings: Settings = {
|
||||
fileFiltering: {
|
||||
respectGitIgnore: false,
|
||||
},
|
||||
};
|
||||
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
await loadConfig(settings, mockExtensionLoader, taskId);
|
||||
|
||||
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,
|
||||
);
|
||||
expect(FileDiscoveryService).toHaveBeenCalledWith(expect.any(String), {
|
||||
respectGitIgnore: false,
|
||||
respectGeminiIgnore: undefined,
|
||||
customIgnoreFilePaths: [testPath],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -492,19 +410,14 @@ describe('loadConfig', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => {
|
||||
it('should use default approval mode and empty 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: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
toolName: 'read_file',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
}),
|
||||
]),
|
||||
rules: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,9 @@ import * as dotenv from 'dotenv';
|
||||
import {
|
||||
AuthType,
|
||||
Config,
|
||||
FileDiscoveryService,
|
||||
ApprovalMode,
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
startupProfiler,
|
||||
@@ -23,8 +25,8 @@ import {
|
||||
ExperimentFlags,
|
||||
isHeadlessMode,
|
||||
FatalAuthenticationError,
|
||||
createPolicyEngineConfig,
|
||||
type PolicySettings,
|
||||
PolicyDecision,
|
||||
PRIORITY_YOLO_ALLOW_ALL,
|
||||
type TelemetryTarget,
|
||||
type ConfigParameters,
|
||||
type ExtensionLoader,
|
||||
@@ -38,7 +40,6 @@ export async function loadConfig(
|
||||
settings: Settings,
|
||||
extensionLoader: ExtensionLoader,
|
||||
taskId: string,
|
||||
trusted: boolean = false,
|
||||
): Promise<Config> {
|
||||
const workspaceDir = process.cwd();
|
||||
|
||||
@@ -64,24 +65,6 @@ 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',
|
||||
@@ -97,7 +80,20 @@ export async function loadConfig(
|
||||
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
|
||||
showMemoryUsage: settings.showMemoryUsage || false,
|
||||
approvalMode,
|
||||
policyEngineConfig,
|
||||
policyEngineConfig: {
|
||||
rules:
|
||||
approvalMode === ApprovalMode.YOLO
|
||||
? [
|
||||
{
|
||||
toolName: '*',
|
||||
decision: PolicyDecision.ALLOW,
|
||||
priority: PRIORITY_YOLO_ALLOW_ALL,
|
||||
modes: [ApprovalMode.YOLO],
|
||||
allowRedirection: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
mcpServers: settings.mcpServers,
|
||||
cwd: workspaceDir,
|
||||
telemetry: {
|
||||
@@ -124,7 +120,7 @@ export async function loadConfig(
|
||||
},
|
||||
ideMode: false,
|
||||
folderTrust,
|
||||
trustedFolder: trusted,
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
interactive: true,
|
||||
@@ -133,6 +129,23 @@ 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, checkPathTrust } from '@google/gemini-cli-core';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const suffix = Math.random().toString(36).slice(2);
|
||||
@@ -40,8 +40,6 @@ 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),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -148,7 +146,7 @@ describe('loadSettings', () => {
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir, true);
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
// Primitive value overwritten
|
||||
expect(result.showMemoryUsage).toBe(true);
|
||||
|
||||
@@ -156,78 +154,4 @@ 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,8 +14,6 @@ import {
|
||||
getErrorMessage,
|
||||
type TelemetrySettings,
|
||||
homedir,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import stripJsonComments from 'strip-json-comments';
|
||||
|
||||
@@ -53,8 +51,6 @@ export interface Settings {
|
||||
experimental?: {
|
||||
enableAgents?: boolean;
|
||||
};
|
||||
policyPaths?: string[];
|
||||
adminPolicyPaths?: string[];
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
@@ -68,16 +64,13 @@ export interface CheckpointingSettings {
|
||||
|
||||
/**
|
||||
* Loads settings from user and workspace directories.
|
||||
* Project settings override user settings if the workspace is trusted.
|
||||
* Project settings override user settings.
|
||||
*
|
||||
* 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,
|
||||
isTrustedOverride?: boolean,
|
||||
): Settings {
|
||||
export function loadSettings(workspaceDir: string): Settings {
|
||||
let userSettings: Settings = {};
|
||||
let workspaceSettings: Settings = {};
|
||||
const settingsErrors: SettingsError[] = [];
|
||||
@@ -99,39 +92,27 @@ export function loadSettings(
|
||||
});
|
||||
}
|
||||
|
||||
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 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,
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
settingsErrors.push({
|
||||
message: getErrorMessage(error),
|
||||
path: workspaceSettingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
if (settingsErrors.length > 0) {
|
||||
@@ -144,18 +125,10 @@ export function loadSettings(
|
||||
|
||||
// If there are overlapping keys, the values of workspaceSettings will
|
||||
// override values from userSettings
|
||||
const mergedSettings = {
|
||||
return {
|
||||
...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,8 +30,6 @@ import {
|
||||
debugLogger,
|
||||
SimpleExtensionLoader,
|
||||
GitService,
|
||||
checkPathTrust,
|
||||
isHeadlessMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Command, CommandArgument } from '../commands/types.js';
|
||||
|
||||
@@ -199,23 +197,12 @@ export async function createApp() {
|
||||
// Load the server configuration once on startup.
|
||||
const workspaceRoot = setTargetDir(undefined);
|
||||
loadEnvironment();
|
||||
|
||||
// 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 settings = loadSettings(workspaceRoot);
|
||||
const extensions = loadExtensions(workspaceRoot);
|
||||
const config = await loadConfig(
|
||||
settings,
|
||||
new SimpleExtensionLoader(extensions),
|
||||
'a2a-server',
|
||||
isTrusted ?? false,
|
||||
);
|
||||
|
||||
let git: GitService | undefined;
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface AgentSettings {
|
||||
kind: CoderAgentEvent.StateAgentSettingsEvent;
|
||||
workspacePath: string;
|
||||
autoExecute?: boolean;
|
||||
isTrusted?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolCallConfirmation {
|
||||
|
||||
@@ -17,8 +17,9 @@ describe('CommandHandler', () => {
|
||||
expect(memShow.commandToExecute?.name).toBe('memory show');
|
||||
expect(memShow.args).toBe('');
|
||||
|
||||
const memList = parse('/memory list');
|
||||
expect(memList.commandToExecute?.name).toBe('memory list');
|
||||
const memAdd = parse('/memory add hello world');
|
||||
expect(memAdd.commandToExecute?.name).toBe('memory add');
|
||||
expect(memAdd.args).toBe('hello world');
|
||||
|
||||
const extList = parse('/extensions list');
|
||||
expect(extList.commandToExecute?.name).toBe('extensions list');
|
||||
|
||||
@@ -100,11 +100,6 @@ 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,10 +26,6 @@ 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';
|
||||
@@ -143,9 +139,6 @@ 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(),
|
||||
@@ -714,322 +707,4 @@ 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,9 +34,6 @@ 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';
|
||||
@@ -64,7 +61,6 @@ 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}`;
|
||||
@@ -81,98 +77,8 @@ 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({
|
||||
@@ -190,7 +96,6 @@ export class Session {
|
||||
CoreEvent.ApprovalModeChanged,
|
||||
this.handleApprovalModeChanged,
|
||||
);
|
||||
this.disposeController.abort();
|
||||
}
|
||||
|
||||
async cancelPendingPrompt(): Promise<void> {
|
||||
|
||||
@@ -69,10 +69,7 @@ export class AcpSessionManager {
|
||||
);
|
||||
|
||||
const authType =
|
||||
loadedSettings.merged.security.auth.selectedType ||
|
||||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
|
||||
? AuthType.GATEWAY
|
||||
: AuthType.USE_GEMINI);
|
||||
loadedSettings.merged.security.auth.selectedType || AuthType.USE_GEMINI;
|
||||
|
||||
let isAuthenticated = false;
|
||||
let authErrorMessage = '';
|
||||
@@ -234,12 +231,7 @@ export class AcpSessionManager {
|
||||
mcpServers: acp.McpServer[],
|
||||
authDetails: AuthDetails,
|
||||
): Promise<Config> {
|
||||
const selectedAuthType =
|
||||
this.settings.merged.security.auth.selectedType ||
|
||||
(authDetails.baseUrl || process.env['GOOGLE_GEMINI_BASE_URL']
|
||||
? AuthType.GATEWAY
|
||||
: undefined);
|
||||
|
||||
const selectedAuthType = this.settings.merged.security.auth.selectedType;
|
||||
if (!selectedAuthType) {
|
||||
throw acp.RequestError.authRequired();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getDisplayString,
|
||||
AuthType,
|
||||
ToolConfirmationOutcome,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type * as acp from '@agentclientprotocol/sdk';
|
||||
@@ -271,6 +272,8 @@ export function buildAvailableModels(
|
||||
const useCustomToolModel =
|
||||
useGemini31 && selectedAuthType === AuthType.USE_GEMINI;
|
||||
|
||||
const releaseChannel = getChannelFromVersion(config.clientVersion);
|
||||
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
config.getExperimentalDynamicModelConfiguration?.() === true &&
|
||||
@@ -281,6 +284,7 @@ export function buildAvailableModels(
|
||||
useGemini3_1FlashLite: useGemini31FlashLite,
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -294,10 +298,7 @@ export function buildAvailableModels(
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
addMemory,
|
||||
listInboxMemoryPatches,
|
||||
listInboxSkills,
|
||||
listInboxPatches,
|
||||
@@ -18,6 +19,12 @@ 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.';
|
||||
@@ -25,6 +32,7 @@ export class MemoryCommand implements Command {
|
||||
new ShowMemoryCommand(),
|
||||
new RefreshMemoryCommand(),
|
||||
new ListMemoryCommand(),
|
||||
new AddMemoryCommand(),
|
||||
new InboxMemoryCommand(),
|
||||
];
|
||||
readonly requiresWorkspace = true;
|
||||
@@ -77,6 +85,48 @@ 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 =
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
EDIT_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
ASK_USER_TOOL_NAME,
|
||||
type ExtensionLoader,
|
||||
debugLogger,
|
||||
ApprovalMode,
|
||||
type MCPServerConfig,
|
||||
@@ -111,6 +112,27 @@ 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,
|
||||
@@ -1045,6 +1067,151 @@ 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,19 +16,21 @@ import { hooksCommand } from '../commands/hooks.js';
|
||||
import { gemmaCommand } from '../commands/gemma.js';
|
||||
import {
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
resetGeminiMdFilename,
|
||||
DEFAULT_CONTEXT_FILENAME,
|
||||
getCurrentGeminiMdFilename,
|
||||
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,
|
||||
@@ -103,7 +105,6 @@ export interface CliArgs {
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
fakeResponsesNonStrict?: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
@@ -475,12 +476,6 @@ 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.',
|
||||
@@ -576,6 +571,7 @@ export interface LoadCliConfigOptions {
|
||||
};
|
||||
worktreeSettings?: WorktreeSettings;
|
||||
skipExtensions?: boolean;
|
||||
skipMemoryLoad?: boolean;
|
||||
}
|
||||
|
||||
export async function loadCliConfig(
|
||||
@@ -584,7 +580,12 @@ export async function loadCliConfig(
|
||||
argv: CliArgs,
|
||||
options: LoadCliConfigOptions = {},
|
||||
): Promise<Config> {
|
||||
const { cwd = process.cwd(), projectHooks, skipExtensions = false } = options;
|
||||
const {
|
||||
cwd = process.cwd(),
|
||||
projectHooks,
|
||||
skipExtensions = false,
|
||||
skipMemoryLoad = false,
|
||||
} = options;
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
const worktreeSettings =
|
||||
@@ -594,6 +595,7 @@ 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;
|
||||
@@ -609,7 +611,7 @@ export async function loadCliConfig(
|
||||
query: argv.query,
|
||||
})?.isTrusted ?? false;
|
||||
|
||||
// Set the context filename in the server's memory file helpers before loading memory
|
||||
// Set the context filename in the server's memoryTool module 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.
|
||||
@@ -617,11 +619,16 @@ export async function loadCliConfig(
|
||||
setServerGeminiMdFilename(settings.context.fileName);
|
||||
} else {
|
||||
// Reset to default if not provided in settings.
|
||||
resetGeminiMdFilename(DEFAULT_CONTEXT_FILENAME);
|
||||
setServerGeminiMdFilename(getCurrentGeminiMdFilename());
|
||||
}
|
||||
|
||||
const fileService = new FileDiscoveryService(cwd);
|
||||
|
||||
const memoryFileFiltering = {
|
||||
...DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
};
|
||||
|
||||
const fileFiltering = {
|
||||
...DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
...settings.context?.fileFiltering,
|
||||
@@ -672,6 +679,8 @@ 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);
|
||||
@@ -682,9 +691,33 @@ 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
|
||||
@@ -996,6 +1029,9 @@ 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,
|
||||
@@ -1040,6 +1076,8 @@ 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,
|
||||
@@ -1081,7 +1119,6 @@ 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,7 +109,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
getFileExclusions: () => ({
|
||||
isIgnored: () => false,
|
||||
}),
|
||||
getMemoryContextManager: () => undefined,
|
||||
getGeminiMdFilePaths: () => [],
|
||||
getMcpServers: () => ({}),
|
||||
getAllowedMcpServers: () => [],
|
||||
@@ -186,7 +185,6 @@ describe('ExtensionManager theme loading', () => {
|
||||
getWorkspaceContext: () => ({
|
||||
getDirectories: () => [],
|
||||
}),
|
||||
getMemoryContextManager: () => undefined,
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({
|
||||
findFiles: async () => [],
|
||||
|
||||
@@ -571,18 +571,6 @@ 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,7 +12,6 @@
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
EDITOR_OPTIONS,
|
||||
AuthProviderType,
|
||||
type MCPServerConfig,
|
||||
type RequiredMcpServerConfig,
|
||||
@@ -193,27 +192,12 @@ const SETTINGS_SCHEMA = {
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
preferredEditor: {
|
||||
type: 'enum',
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: undefined as string | undefined,
|
||||
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.',
|
||||
description: 'The preferred editor to open files in.',
|
||||
showInDialog: false,
|
||||
},
|
||||
vimMode: {
|
||||
@@ -445,16 +429,6 @@ 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: {
|
||||
@@ -2210,16 +2184,6 @@ 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: {
|
||||
@@ -2288,6 +2252,16 @@ 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',
|
||||
@@ -2418,6 +2392,16 @@ 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,6 +26,11 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
|
||||
memoryContent: '',
|
||||
fileCount: 0,
|
||||
filePaths: [],
|
||||
}),
|
||||
createPolicyEngineConfig: vi.fn().mockResolvedValue({
|
||||
rules: [],
|
||||
checkers: [],
|
||||
|
||||
@@ -499,6 +499,7 @@ export async function main() {
|
||||
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
skipExtensions: true,
|
||||
skipMemoryLoad: true,
|
||||
});
|
||||
|
||||
adminControlsListner.setConfig(partialConfig);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @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';
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @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,6 +38,7 @@ 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(() => []),
|
||||
@@ -165,6 +166,7 @@ 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),
|
||||
|
||||
@@ -47,6 +47,7 @@ import { MouseProvider } from './contexts/MouseContext.js';
|
||||
import { ScrollProvider } from './contexts/ScrollProvider.js';
|
||||
import {
|
||||
type StartupWarning,
|
||||
type EditorType,
|
||||
type Config,
|
||||
type IdeInfo,
|
||||
type IdeContext,
|
||||
@@ -67,9 +68,9 @@ import {
|
||||
ShellExecutionService,
|
||||
saveApiKey,
|
||||
debugLogger,
|
||||
isValidEditorType,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
refreshServerHierarchicalMemory,
|
||||
flattenMemory,
|
||||
type MemoryChangedPayload,
|
||||
writeToStdout,
|
||||
@@ -609,10 +610,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
|
||||
|
||||
const getPreferredEditor = useCallback(() => {
|
||||
const val = settings.merged.general.preferredEditor;
|
||||
return isValidEditorType(val) ? val : undefined;
|
||||
}, [settings.merged.general.preferredEditor]);
|
||||
const getPreferredEditor = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
() => settings.merged.general.preferredEditor as EditorType,
|
||||
[settings.merged.general.preferredEditor],
|
||||
);
|
||||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: '',
|
||||
@@ -1073,10 +1075,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
Date.now(),
|
||||
);
|
||||
try {
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
config.updateSystemInstructionIfInitialized();
|
||||
const flattenedMemory = flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount();
|
||||
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;
|
||||
}
|
||||
|
||||
historyManager.addItem(
|
||||
{
|
||||
|
||||
@@ -80,7 +80,6 @@ describe('directoryCommand', () => {
|
||||
}),
|
||||
getWorkingDir: () => path.resolve('/test/dir'),
|
||||
shouldLoadMemoryFromIncludeDirectories: () => false,
|
||||
getMemoryContextManager: vi.fn(),
|
||||
getDebugMode: () => false,
|
||||
getFileService: () => ({}),
|
||||
getFileFilteringOptions: () => ({ ignore: [], include: [] }),
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
type CommandContext,
|
||||
} from './types.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
import { type Config } from '@google/gemini-cli-core';
|
||||
import {
|
||||
refreshServerHierarchicalMemory,
|
||||
type Config,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
expandHomeDir,
|
||||
getDirectorySuggestions,
|
||||
@@ -44,7 +47,7 @@ async function finishAddingDirectories(
|
||||
if (added.length > 0) {
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
|
||||
@@ -11,10 +11,13 @@ 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';
|
||||
@@ -29,28 +32,46 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return String(error);
|
||||
}),
|
||||
refreshMemory: vi.fn(async (config) => {
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
const memoryContent = original.flattenMemory(config.getUserMemory());
|
||||
const fileCount = config.getGeminiMdFileCount() || 0;
|
||||
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).`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Memory reloaded successfully. Loaded ${memoryContent.length} characters from ${fileCount} file(s).`,
|
||||
content: 'Memory reloaded successfully.',
|
||||
};
|
||||
}),
|
||||
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 = (): SlashCommand => memoryCommand(null);
|
||||
const buildMemoryCommand = (isMemoryV2 = false): SlashCommand => {
|
||||
const config: Pick<Config, 'isMemoryV2Enabled'> = {
|
||||
isMemoryV2Enabled: () => isMemoryV2,
|
||||
};
|
||||
return memoryCommand(config as Config);
|
||||
};
|
||||
|
||||
const getSubCommand = (name: 'show' | 'reload' | 'list'): SlashCommand => {
|
||||
const getSubCommand = (
|
||||
name: 'show' | 'add' | 'reload' | 'list',
|
||||
): SlashCommand => {
|
||||
const subCommand = buildMemoryCommand().subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
);
|
||||
@@ -60,11 +81,23 @@ describe('memoryCommand', () => {
|
||||
return subCommand;
|
||||
};
|
||||
|
||||
describe('subcommands', () => {
|
||||
it('does not include the legacy add subcommand', () => {
|
||||
const command = buildMemoryCommand();
|
||||
describe('Memory v2', () => {
|
||||
it('omits the /memory add subcommand when memoryV2 is enabled', () => {
|
||||
const command = buildMemoryCommand(true);
|
||||
const names = command.subCommands?.map((cmd) => cmd.name) ?? [];
|
||||
expect(names).toEqual(['show', 'reload', 'list', 'inbox']);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,6 +178,63 @@ 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;
|
||||
@@ -180,7 +270,8 @@ describe('memoryCommand', () => {
|
||||
updateSystemInstructionIfInitialized: vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
getMemoryContextManager: vi.fn().mockReturnValue({
|
||||
isJitContextEnabled: vi.fn().mockReturnValue(false),
|
||||
getContextManager: vi.fn().mockReturnValue({
|
||||
refresh: mockContextManagerRefresh,
|
||||
}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
@@ -203,18 +294,21 @@ describe('memoryCommand', () => {
|
||||
mockRefreshMemory.mockClear();
|
||||
});
|
||||
|
||||
it('should use MemoryContextManager.refresh', async () => {
|
||||
it('should use ContextManager.refresh when JIT is enabled', 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(
|
||||
{
|
||||
@@ -225,7 +319,7 @@ describe('memoryCommand', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should display success message when memory is reloaded with content', async () => {
|
||||
it('should display success message when memory is reloaded with content (Legacy)', async () => {
|
||||
if (!reloadCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const successMessage = {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
addMemory,
|
||||
type Config,
|
||||
listMemoryFiles,
|
||||
refreshMemory,
|
||||
@@ -40,6 +41,30 @@ 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'],
|
||||
@@ -145,9 +170,14 @@ const inboxSubCommand: SlashCommand = {
|
||||
},
|
||||
};
|
||||
|
||||
export const memoryCommand = (_config: Config | null): 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;
|
||||
|
||||
const subCommands: SlashCommand[] = [
|
||||
showSubCommand,
|
||||
...(isMemoryV2 ? [] : [addSubCommand]),
|
||||
reloadSubCommand,
|
||||
listSubCommand,
|
||||
inboxSubCommand,
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -57,7 +58,7 @@ async function rewindConversation(
|
||||
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
|
||||
const clientHistory = convertSessionToClientHistory(conversation.messages);
|
||||
|
||||
client.setHistory(clientHistory);
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
|
||||
// Reset context manager as we are rewinding history
|
||||
await context.services.agentContext?.config
|
||||
|
||||
@@ -154,8 +154,6 @@ 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,6 +22,7 @@ import {
|
||||
type EditorType,
|
||||
isEditorAvailable,
|
||||
EDITOR_DISPLAY_NAMES,
|
||||
coreEvents,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
|
||||
@@ -71,6 +72,10 @@ export function EditorSettingsDialog({
|
||||
)
|
||||
: 0;
|
||||
if (editorIndex === -1) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Editor is not supported: ${currentPreference}`,
|
||||
);
|
||||
editorIndex = 0;
|
||||
}
|
||||
|
||||
@@ -126,7 +131,10 @@ export function EditorSettingsDialog({
|
||||
isEditorAvailable(settings.merged.general.preferredEditor)
|
||||
) {
|
||||
mergedEditorName =
|
||||
EDITOR_DISPLAY_NAMES[settings.merged.general.preferredEditor];
|
||||
EDITOR_DISPLAY_NAMES[
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
settings.merged.general.preferredEditor as EditorType
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -153,7 +161,6 @@ 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 list',
|
||||
cursor: [0, 12],
|
||||
text: '/memory add',
|
||||
cursor: [0, 11],
|
||||
showSuggestions: false,
|
||||
},
|
||||
{
|
||||
@@ -4898,60 +4898,6 @@ 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,7 +92,6 @@ 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;
|
||||
|
||||
@@ -127,8 +126,6 @@ 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;
|
||||
@@ -217,8 +214,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
onEscapePromptChange,
|
||||
onSuggestionsVisibilityChange,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
isEmbeddedShellFocused,
|
||||
setQueueErrorMessage,
|
||||
streamingState,
|
||||
@@ -864,11 +859,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (shortcutsHelpVisible) {
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
@@ -888,8 +879,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0 &&
|
||||
(!vimEnabled || vimMode === 'INSERT')
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
@@ -1384,8 +1374,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
resetCompletionState,
|
||||
resetEscapeState,
|
||||
vimHandleInput,
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
cursorPosition,
|
||||
|
||||
@@ -34,11 +34,6 @@ 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,6 +26,7 @@ import {
|
||||
AuthType,
|
||||
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
|
||||
isProModel,
|
||||
getChannelFromVersion,
|
||||
getAutoModelDescription,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
@@ -65,7 +66,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() ?? false;
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false;
|
||||
const useGemini31FlashLite =
|
||||
config?.getGemini31FlashLiteLaunchedSync?.() ?? false;
|
||||
@@ -121,6 +122,12 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const releaseChannel = useMemo(
|
||||
() => getChannelFromVersion(config?.clientVersion ?? ''),
|
||||
[config?.clientVersion],
|
||||
);
|
||||
|
||||
const mainOptions = useMemo(() => {
|
||||
// --- DYNAMIC PATH ---
|
||||
if (
|
||||
@@ -135,6 +142,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
const list = allOptions
|
||||
@@ -162,10 +170,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
{
|
||||
value: GEMINI_MODEL_ALIAS_AUTO,
|
||||
title: getDisplayString(GEMINI_MODEL_ALIAS_AUTO),
|
||||
description: getAutoModelDescription(
|
||||
shouldShowPreviewModels,
|
||||
useGemini31,
|
||||
),
|
||||
description: getAutoModelDescription(releaseChannel, useGemini31),
|
||||
key: GEMINI_MODEL_ALIAS_AUTO,
|
||||
},
|
||||
{
|
||||
@@ -187,6 +192,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
]);
|
||||
|
||||
const manualOptions = useMemo(() => {
|
||||
@@ -203,6 +209,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useCustomTools: useCustomToolModel,
|
||||
hasAccessToPreview: shouldShowPreviewModels,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
});
|
||||
|
||||
return allOptions
|
||||
@@ -295,6 +302,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
useGemini31FlashLite,
|
||||
useCustomToolModel,
|
||||
hasAccessToProModel,
|
||||
releaseChannel,
|
||||
config,
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { type SessionMetrics } from '../contexts/SessionContext.js';
|
||||
import {
|
||||
ToolCallDecision,
|
||||
getShellConfiguration,
|
||||
isWindows,
|
||||
type WorktreeSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -21,6 +22,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
getShellConfiguration: vi.fn(),
|
||||
isWindows: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -43,6 +45,7 @@ vi.mock('../contexts/ConfigContext.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const getShellConfigurationMock = vi.mocked(getShellConfiguration);
|
||||
const isWindowsMock = vi.mocked(isWindows);
|
||||
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
|
||||
|
||||
@@ -101,6 +104,11 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'bash',
|
||||
argsPrefix: ['-c'],
|
||||
shell: 'bash',
|
||||
});
|
||||
isWindowsMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
@@ -165,6 +173,11 @@ 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(
|
||||
@@ -179,7 +192,11 @@ describe('<SessionSummaryDisplay />', () => {
|
||||
});
|
||||
|
||||
it('sanitizes a malicious session ID in the footer (powershell)', async () => {
|
||||
isWindowsMock.mockReturnValue(true);
|
||||
getShellConfigurationMock.mockReturnValue({
|
||||
executable: 'powershell.exe',
|
||||
argsPrefix: ['-NoProfile', '-Command'],
|
||||
shell: 'powershell',
|
||||
});
|
||||
|
||||
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: ShellType = isWindows() ? 'powershell' : 'bash';
|
||||
const { shell } = getShellConfiguration();
|
||||
|
||||
const worktreeSettings = config.getWorktreeSettings();
|
||||
|
||||
|
||||
@@ -174,6 +174,27 @@ 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,17 +9,6 @@ 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,17 +44,6 @@ 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,7 +13,6 @@ import { LRUCache } from 'mnemonist';
|
||||
import {
|
||||
coreEvents,
|
||||
debugLogger,
|
||||
getErrorMessage,
|
||||
unescapePath,
|
||||
type EditorType,
|
||||
} from '@google/gemini-cli-core';
|
||||
@@ -31,7 +30,6 @@ 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;
|
||||
@@ -2842,7 +2840,6 @@ export function useTextBuffer({
|
||||
singleLine = false,
|
||||
getPreferredEditor,
|
||||
}: UseTextBufferProps): TextBuffer {
|
||||
const settings = useSettings();
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const initialState = useMemo((): TextBufferState => {
|
||||
const lines = initialText.split('\n');
|
||||
@@ -3328,7 +3325,6 @@ export function useTextBuffer({
|
||||
stdin,
|
||||
setRawMode,
|
||||
getPreferredEditor?.(),
|
||||
settings.merged.general.openEditorInNewWindow,
|
||||
);
|
||||
|
||||
let newText = fs.readFileSync(filePath, 'utf8');
|
||||
@@ -3346,7 +3342,11 @@ export function useTextBuffer({
|
||||
|
||||
dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
|
||||
} catch (err) {
|
||||
coreEvents.emitFeedback('error', getErrorMessage(err), err);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'[useTextBuffer] external editor error',
|
||||
err,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
@@ -3359,14 +3359,7 @@ export function useTextBuffer({
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}, [
|
||||
text,
|
||||
pastedContent,
|
||||
stdin,
|
||||
setRawMode,
|
||||
getPreferredEditor,
|
||||
settings.merged.general.openEditorInNewWindow,
|
||||
]);
|
||||
}, [text, pastedContent, stdin, setRawMode, getPreferredEditor]);
|
||||
|
||||
const handleInput = useCallback(
|
||||
(key: Key): boolean => {
|
||||
|
||||
@@ -144,6 +144,7 @@ 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,7 +14,6 @@ import {
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import {
|
||||
checkPermissions,
|
||||
handleAtCommand,
|
||||
escapeAtSymbols,
|
||||
unescapeLiteralAt,
|
||||
@@ -36,7 +35,6 @@ 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';
|
||||
|
||||
@@ -96,7 +94,6 @@ describe('handleAtCommand', () => {
|
||||
p.startsWith(testRootDir) || p.startsWith('/private' + testRootDir),
|
||||
getDirectories: () => [testRootDir],
|
||||
}),
|
||||
getMemoryContextManager: () => undefined,
|
||||
storage: {
|
||||
getProjectTempDir: () => path.join(os.tmpdir(), 'gemini-cli-temp'),
|
||||
},
|
||||
@@ -1543,57 +1540,3 @@ 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,15 +188,9 @@ export async function checkPermissions(
|
||||
const pathName = part.content.substring(1);
|
||||
if (!pathName) continue;
|
||||
|
||||
let resolvedPathName: string;
|
||||
try {
|
||||
resolvedPathName = resolveToRealPath(
|
||||
path.resolve(config.getTargetDir(), pathName),
|
||||
);
|
||||
} catch {
|
||||
// skip if resolveToRealPath errors out
|
||||
continue;
|
||||
}
|
||||
const resolvedPathName = resolveToRealPath(
|
||||
path.resolve(config.getTargetDir(), pathName),
|
||||
);
|
||||
|
||||
if (config.validatePathAccess(resolvedPathName, 'read')) {
|
||||
if (await fileExists(resolvedPathName)) {
|
||||
|
||||
@@ -352,6 +352,7 @@ 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 +1951,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: 'activate_skill',
|
||||
toolArgs: { name: 'test-skill' },
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: 'test fact' },
|
||||
};
|
||||
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
|
||||
|
||||
const { result } = await renderTestHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/memory show');
|
||||
await result.current.submitQuery('/memory add "test fact"');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockScheduleToolCalls).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
name: 'activate_skill',
|
||||
args: { name: 'test-skill' },
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
}),
|
||||
],
|
||||
@@ -2194,25 +2195,25 @@ describe('useGeminiStream', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT record other client-initiated tool calls in history', async () => {
|
||||
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
|
||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
||||
|
||||
mockHandleSlashCommand.mockResolvedValue({
|
||||
type: 'schedule_tool',
|
||||
toolName: 'write_todos',
|
||||
toolArgs: { todos: [] },
|
||||
toolName: 'save_memory',
|
||||
toolArgs: { fact: 'test fact' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.submitQuery('/todos');
|
||||
await result.current.submitQuery('/memory add "test fact"');
|
||||
});
|
||||
|
||||
// Simulate tool completion
|
||||
const completedTool = {
|
||||
request: {
|
||||
callId: 'test-call-id',
|
||||
name: 'write_todos',
|
||||
args: { todos: [] },
|
||||
name: 'save_memory',
|
||||
args: { fact: 'test fact' },
|
||||
isClientInitiated: true,
|
||||
},
|
||||
status: CoreToolCallStatus.Success,
|
||||
@@ -2226,7 +2227,7 @@ describe('useGeminiStream', () => {
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'write_todos',
|
||||
name: 'save_memory',
|
||||
response: { success: true },
|
||||
},
|
||||
},
|
||||
@@ -2245,6 +2246,91 @@ 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
|
||||
|
||||
@@ -226,7 +226,7 @@ export const useGeminiStream = (
|
||||
shellModeActive: boolean,
|
||||
getPreferredEditor: () => EditorType | undefined,
|
||||
onAuthError: (error: string) => void,
|
||||
_performMemoryRefresh: () => Promise<void>,
|
||||
performMemoryRefresh: () => Promise<void>,
|
||||
modelSwitchedFromQuotaError: boolean,
|
||||
setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
onCancelSubmit: (
|
||||
@@ -266,6 +266,7 @@ 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(() => {
|
||||
@@ -1896,8 +1897,8 @@ export const useGeminiStream = (
|
||||
if (geminiClient) {
|
||||
for (const tool of clientTools) {
|
||||
// Only manually record skill activations in the chat history.
|
||||
// Other client-initiated tools update context and don't strictly
|
||||
// need to be in the history.
|
||||
// Other client-initiated tools (like save_memory) update the system
|
||||
// prompt/context and don't strictly need to be in the history.
|
||||
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
|
||||
continue;
|
||||
}
|
||||
@@ -1924,6 +1925,14 @@ 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) {
|
||||
@@ -1935,6 +1944,15 @@ 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,
|
||||
);
|
||||
@@ -2058,6 +2076,7 @@ export const useGeminiStream = (
|
||||
submitQuery,
|
||||
markToolsAsSubmitted,
|
||||
geminiClient,
|
||||
performMemoryRefresh,
|
||||
modelSwitchedFromQuotaError,
|
||||
addItem,
|
||||
registerBackgroundTask,
|
||||
|
||||
@@ -80,8 +80,6 @@ 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,7 +8,10 @@ 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 } from '@google/gemini-cli-core';
|
||||
import {
|
||||
debugLogger,
|
||||
refreshServerHierarchicalMemory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { MultiFolderTrustDialog } from '../components/MultiFolderTrustDialog.js';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import { MessageType, type HistoryItem } from '../types.js';
|
||||
@@ -32,7 +35,7 @@ async function finishAddingDirectories(
|
||||
|
||||
try {
|
||||
if (config.shouldLoadMemoryFromIncludeDirectories()) {
|
||||
await config.getMemoryContextManager()?.refresh();
|
||||
await refreshServerHierarchicalMemory(config);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
@@ -194,16 +194,14 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(2);
|
||||
expect(clientHistory.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there' }],
|
||||
},
|
||||
]);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
});
|
||||
expect(clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert thinking tokens (thoughts) to thinking history items', () => {
|
||||
@@ -256,12 +254,10 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Expanded content' }],
|
||||
},
|
||||
]);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Expanded content' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out slash commands from client history but keep in UI', () => {
|
||||
@@ -320,35 +316,33 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||
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[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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'get_time',
|
||||
response: { output: '12:00' },
|
||||
id: 'call_1',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(clientHistory[2]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'get_time',
|
||||
response: { output: '12:00' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,28 +12,22 @@ import {
|
||||
convertSessionToClientHistory,
|
||||
uiTelemetryService,
|
||||
loadConversationRecord,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
type Config,
|
||||
type 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[] } | HistoryTurn
|
||||
>,
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedSessionData: ResumedSessionData,
|
||||
) => Promise<void>,
|
||||
) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
ResumedSessionData,
|
||||
ConversationRecord,
|
||||
MessageRecord,
|
||||
HistoryTurn,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
@@ -528,12 +527,10 @@ describe('useSessionResume', () => {
|
||||
|
||||
// Should only have the non-slash-command message
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory.map((h: HistoryTurn) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
},
|
||||
]);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Regular message' }],
|
||||
});
|
||||
|
||||
// But UI history should have both
|
||||
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -7,17 +7,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
HistoryTurn,
|
||||
Config,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
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;
|
||||
@@ -57,9 +54,7 @@ export function useSessionResume({
|
||||
const loadHistoryForResume = useCallback(
|
||||
async (
|
||||
uiHistory: HistoryItemWithoutId[],
|
||||
clientHistory: Array<
|
||||
{ role: 'user' | 'model'; parts: Part[] } | HistoryTurn
|
||||
>,
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>,
|
||||
resumedData: ResumedSessionData,
|
||||
) => {
|
||||
// Wait for the client.
|
||||
|
||||
@@ -2258,80 +2258,6 @@ 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,11 +1486,6 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
// Unknown command, clear count and pending states
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
|
||||
// Ignore any Insertable key in Normal Mode
|
||||
if (normalizedKey.insertable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not handled by vim so allow other handlers to process it.
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ 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'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,33 +7,14 @@
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { ReadStream } from 'node:tty';
|
||||
import {
|
||||
ALL_EDITORS,
|
||||
CoreEvent,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
type EditorType,
|
||||
getEditorCommand,
|
||||
getEditorExtraArgs,
|
||||
getEditorWaitFlag,
|
||||
isGuiEditor,
|
||||
isTerminalEditor,
|
||||
isValidEditorType,
|
||||
resolveEditorTypeFromCommand,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
* Command name substrings used to guess whether an unknown $VISUAL/$EDITOR
|
||||
* value is a GUI editor. This is a fallback for editors not in the registry;
|
||||
* registered editors are detected via resolveEditorTypeFromCommand instead.
|
||||
*/
|
||||
const HEURISTIC_GUI_COMMANDS = [
|
||||
'code',
|
||||
'cursor',
|
||||
'subl',
|
||||
'zed',
|
||||
'atom',
|
||||
'agy',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Opens a file in an external editor and waits for it to close.
|
||||
* Handles raw mode switching to ensure the editor can interact with the terminal.
|
||||
@@ -42,65 +23,36 @@ const HEURISTIC_GUI_COMMANDS = [
|
||||
* @param stdin The stdin stream from Ink/Node
|
||||
* @param setRawMode Function to toggle raw mode
|
||||
* @param preferredEditorType The user's preferred editor from config
|
||||
* @param openInNewWindow Whether to open VS Code-family editors in a new window
|
||||
*/
|
||||
export async function openFileInEditor(
|
||||
filePath: string,
|
||||
stdin: ReadStream | null | undefined,
|
||||
setRawMode: ((mode: boolean) => void) | undefined,
|
||||
preferredEditorType?: EditorType,
|
||||
openInNewWindow?: boolean,
|
||||
): Promise<void> {
|
||||
let command: string | undefined = undefined;
|
||||
const args = [filePath];
|
||||
// Extra args that come before the file path (e.g. -nw for emacsclient)
|
||||
const extraArgs: string[] = [];
|
||||
|
||||
if (preferredEditorType) {
|
||||
if (!isValidEditorType(preferredEditorType)) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
`Editor '${preferredEditorType}' is not a recognized editor identifier. ` +
|
||||
`Supported editors: ${ALL_EDITORS.join(', ')}. ` +
|
||||
`Use /editor to select one, or set the $VISUAL or $EDITOR environment variable.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
command = getEditorCommand(preferredEditorType);
|
||||
if (isGuiEditor(preferredEditorType)) {
|
||||
args.unshift(getEditorWaitFlag(preferredEditorType));
|
||||
args.unshift('--wait');
|
||||
}
|
||||
extraArgs.push(
|
||||
...getEditorExtraArgs(preferredEditorType, {
|
||||
newWindow: openInNewWindow,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
const envCommand = process.env['VISUAL'] ?? process.env['EDITOR'];
|
||||
if (envCommand) {
|
||||
command = envCommand;
|
||||
const [envExecutable = ''] = envCommand.split(' ');
|
||||
const resolvedType = resolveEditorTypeFromCommand(envExecutable);
|
||||
if (resolvedType) {
|
||||
if (
|
||||
isGuiEditor(resolvedType) &&
|
||||
!envCommand.includes('--wait') &&
|
||||
!envCommand.includes('-w')
|
||||
) {
|
||||
args.unshift(getEditorWaitFlag(resolvedType));
|
||||
}
|
||||
extraArgs.push(
|
||||
...getEditorExtraArgs(resolvedType, { newWindow: openInNewWindow }),
|
||||
);
|
||||
} else {
|
||||
// Heuristic fallback for commands not in the registry
|
||||
const lower = envCommand.toLowerCase();
|
||||
const isGui = HEURISTIC_GUI_COMMANDS.some((g) => lower.includes(g));
|
||||
if (isGui && !lower.includes('--wait') && !lower.includes('-w')) {
|
||||
args.unshift(lower.includes('subl') ? '-w' : '--wait');
|
||||
}
|
||||
command = process.env['VISUAL'] ?? process.env['EDITOR'];
|
||||
if (command) {
|
||||
const lowerCommand = command.toLowerCase();
|
||||
const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) =>
|
||||
lowerCommand.includes(gui),
|
||||
);
|
||||
if (
|
||||
isGui &&
|
||||
!lowerCommand.includes('--wait') &&
|
||||
!lowerCommand.includes('-w')
|
||||
) {
|
||||
args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,16 +66,7 @@ export async function openFileInEditor(
|
||||
// Determine if we should use sync or async based on the command/editor type.
|
||||
// If we have a preferredEditorType, we can check if it's a terminal editor.
|
||||
// Otherwise, we guess based on the command name.
|
||||
const terminalEditors = [
|
||||
'vi',
|
||||
'vim',
|
||||
'nvim',
|
||||
'emacs',
|
||||
'emacsclient',
|
||||
'hx',
|
||||
'nano',
|
||||
'micro',
|
||||
];
|
||||
const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano'];
|
||||
const isTerminal = preferredEditorType
|
||||
? isTerminalEditor(preferredEditorType)
|
||||
: terminalEditors.some((te) => executable.toLowerCase().includes(te));
|
||||
@@ -143,60 +86,58 @@ export async function openFileInEditor(
|
||||
|
||||
try {
|
||||
if (isTerminal) {
|
||||
const result = spawnSync(
|
||||
executable,
|
||||
[...initialArgs, ...extraArgs, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
const result = spawnSync(executable, [...initialArgs, ...args], {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.error) {
|
||||
const spawnErr = result.error as NodeJS.ErrnoException;
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
spawnErr.code === 'ENOENT'
|
||||
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
|
||||
: (spawnErr.message ?? String(spawnErr)),
|
||||
'[editorUtils] external terminal editor error',
|
||||
result.error,
|
||||
);
|
||||
return;
|
||||
throw result.error;
|
||||
}
|
||||
if (typeof result.status === 'number' && result.status !== 0) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
const err = new Error(
|
||||
`External editor exited with status ${result.status}`,
|
||||
);
|
||||
return;
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'[editorUtils] external editor error',
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
const child = spawn(
|
||||
executable,
|
||||
[...initialArgs, ...extraArgs, ...args],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(executable, [...initialArgs, ...args], {
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const spawnErr = err as NodeJS.ErrnoException;
|
||||
resolve();
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
spawnErr.code === 'ENOENT'
|
||||
? `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`
|
||||
: (spawnErr.message ?? String(spawnErr)),
|
||||
'[editorUtils] external editor spawn error',
|
||||
err,
|
||||
);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on('close', (status) => {
|
||||
resolve();
|
||||
if (typeof status === 'number' && status !== 0) {
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
const err = new Error(
|
||||
`External editor exited with status ${status}`,
|
||||
);
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'[editorUtils] external editor error',
|
||||
err,
|
||||
);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,8 +28,8 @@ const mockCommands: readonly SlashCommand[] = [
|
||||
altNames: ['mem'],
|
||||
subCommands: [
|
||||
{
|
||||
name: 'list',
|
||||
description: 'List memory files',
|
||||
name: 'add',
|
||||
description: 'Add to memory',
|
||||
action: async () => {},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
@@ -64,27 +64,27 @@ describe('parseSlashCommand', () => {
|
||||
});
|
||||
|
||||
it('should parse a subcommand', () => {
|
||||
const result = parseSlashCommand('/memory list', mockCommands);
|
||||
expect(result.commandToExecute?.name).toBe('list');
|
||||
const result = parseSlashCommand('/memory add', mockCommands);
|
||||
expect(result.commandToExecute?.name).toBe('add');
|
||||
expect(result.args).toBe('');
|
||||
expect(result.canonicalPath).toEqual(['memory', 'list']);
|
||||
expect(result.canonicalPath).toEqual(['memory', 'add']);
|
||||
});
|
||||
|
||||
it('should parse a subcommand with arguments', () => {
|
||||
const result = parseSlashCommand(
|
||||
'/memory list some important data',
|
||||
'/memory add some important data',
|
||||
mockCommands,
|
||||
);
|
||||
expect(result.commandToExecute?.name).toBe('list');
|
||||
expect(result.commandToExecute?.name).toBe('add');
|
||||
expect(result.args).toBe('some important data');
|
||||
expect(result.canonicalPath).toEqual(['memory', 'list']);
|
||||
expect(result.canonicalPath).toEqual(['memory', 'add']);
|
||||
});
|
||||
|
||||
it('should handle a command alias', () => {
|
||||
const result = parseSlashCommand('/mem list some data', mockCommands);
|
||||
expect(result.commandToExecute?.name).toBe('list');
|
||||
const result = parseSlashCommand('/mem add some data', mockCommands);
|
||||
expect(result.commandToExecute?.name).toBe('add');
|
||||
expect(result.args).toBe('some data');
|
||||
expect(result.canonicalPath).toEqual(['memory', 'list']);
|
||||
expect(result.canonicalPath).toEqual(['memory', 'add']);
|
||||
});
|
||||
|
||||
it('should handle a subcommand alias', () => {
|
||||
@@ -113,12 +113,12 @@ describe('parseSlashCommand', () => {
|
||||
|
||||
it('should handle extra whitespace', () => {
|
||||
const result = parseSlashCommand(
|
||||
' /memory list some data ',
|
||||
' /memory add some data ',
|
||||
mockCommands,
|
||||
);
|
||||
expect(result.commandToExecute?.name).toBe('list');
|
||||
expect(result.commandToExecute?.name).toBe('add');
|
||||
expect(result.args).toBe('some data');
|
||||
expect(result.canonicalPath).toEqual(['memory', 'list']);
|
||||
expect(result.canonicalPath).toEqual(['memory', 'add']);
|
||||
});
|
||||
|
||||
it('should return undefined if query does not start with a slash', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ParsedSlashCommand = {
|
||||
* Parses a raw slash command string into its command, arguments, and canonical path.
|
||||
* If no valid command is found, the `commandToExecute` property will be `undefined`.
|
||||
*
|
||||
* @param query The raw input string, e.g., "/memory show" or "/help".
|
||||
* @param query The raw input string, e.g., "/memory add some data" or "/help".
|
||||
* @param commands The list of available top-level slash commands.
|
||||
* @returns An object containing the resolved command, its arguments, and its canonical path.
|
||||
*/
|
||||
|
||||
@@ -336,14 +336,7 @@ describe('sandbox', () => {
|
||||
await expect(promise).resolves.toBe(0);
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--entrypoint',
|
||||
'',
|
||||
]),
|
||||
expect.arrayContaining(['run', '-i', '--rm', '--init']),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
|
||||
@@ -794,67 +787,12 @@ describe('sandbox', () => {
|
||||
expect.arrayContaining(['--user', 'root', '--env', 'HOME=/home/user']),
|
||||
expect.any(Object),
|
||||
);
|
||||
// Check that the entrypoint command includes the defensive useradd check
|
||||
// Check that the entrypoint command includes useradd/groupadd
|
||||
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
||||
const entrypointCmd = args[args.length - 1];
|
||||
expect(entrypointCmd).toContain('if command -v useradd');
|
||||
expect(entrypointCmd).toContain('groupadd -g 1000 -o gemini');
|
||||
expect(entrypointCmd).toContain('id 1000');
|
||||
expect(entrypointCmd).toContain('useradd -o -u 1000');
|
||||
expect(entrypointCmd).toContain('USER_NAME=$(id -nu 1000 2>/dev/null);');
|
||||
expect(entrypointCmd).toContain('if [ -n "$USER_NAME" ]; then');
|
||||
expect(entrypointCmd).toContain('su -p "$USER_NAME"');
|
||||
expect(entrypointCmd).toContain('else');
|
||||
expect(entrypointCmd).toContain('Error: Failed to map host UID 1000');
|
||||
expect(entrypointCmd).toContain('exit 1');
|
||||
expect(entrypointCmd).toContain("Error: 'useradd' not found");
|
||||
});
|
||||
|
||||
it('should correctly escape home directory with spaces and special characters', async () => {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
image: 'gemini-cli-sandbox',
|
||||
});
|
||||
process.env['SANDBOX_SET_UID_GID'] = 'true';
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
|
||||
const specialHome = '/home/user name `$(id)`';
|
||||
mockedHomedir.mockReturnValue(specialHome);
|
||||
mockedGetContainerPath.mockImplementation((p: string) => p);
|
||||
|
||||
// Mock image check to return true
|
||||
interface MockProcessWithStdout extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
}
|
||||
const mockImageCheckProcess = new EventEmitter() as MockProcessWithStdout;
|
||||
mockImageCheckProcess.stdout = new EventEmitter();
|
||||
vi.mocked(spawn).mockImplementationOnce(() => {
|
||||
setTimeout(() => {
|
||||
mockImageCheckProcess.stdout.emit('data', Buffer.from('image-id'));
|
||||
mockImageCheckProcess.emit('close', 0);
|
||||
}, 1);
|
||||
return mockImageCheckProcess as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const mockSpawnProcess = new EventEmitter() as unknown as ReturnType<
|
||||
typeof spawn
|
||||
>;
|
||||
mockSpawnProcess.on = vi.fn().mockImplementation((event, cb) => {
|
||||
if (event === 'close') {
|
||||
setTimeout(() => cb(0), 10);
|
||||
}
|
||||
return mockSpawnProcess;
|
||||
});
|
||||
vi.mocked(spawn).mockImplementationOnce(() => mockSpawnProcess);
|
||||
|
||||
await start_sandbox(config);
|
||||
|
||||
const args = vi.mocked(spawn).mock.calls[1][1] as string[];
|
||||
const entrypointCmd = args[args.length - 1];
|
||||
|
||||
// Verify that the special home directory is properly quoted/escaped
|
||||
// The quote tool should handle spaces and backticks
|
||||
expect(entrypointCmd).toContain("'/home/user name `$(id)`'");
|
||||
expect(entrypointCmd).toContain('groupadd');
|
||||
expect(entrypointCmd).toContain('useradd');
|
||||
expect(entrypointCmd).toContain('su -p gemini');
|
||||
});
|
||||
|
||||
it('should register and unregister proxy exit handlers', async () => {
|
||||
|
||||
@@ -314,10 +314,6 @@ export async function start_sandbox(
|
||||
// run init binary inside container to forward signals & reap zombies
|
||||
const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir];
|
||||
|
||||
// explicitly clear the entrypoint to prevent the container's default
|
||||
// entrypoint from interfering with the CLI's spawn command.
|
||||
args.push('--entrypoint', '');
|
||||
|
||||
// add runsc runtime if using runsc
|
||||
if (config.command === 'runsc') {
|
||||
args.push('--runtime=runsc');
|
||||
@@ -680,34 +676,22 @@ export async function start_sandbox(
|
||||
// container's /etc/passwd file, which is required by os.userInfo().
|
||||
const username = 'gemini';
|
||||
const homeDir = getContainerPath(homedir());
|
||||
const quotedHomeDir = quote([homeDir]);
|
||||
|
||||
const setupUserCommands = [
|
||||
// Use -f with groupadd to avoid errors if the group already exists.
|
||||
`groupadd -f -g ${gid} ${username}`,
|
||||
// Create user only if it doesn't exist. Use -o for non-unique UID.
|
||||
`id -u ${username} &>/dev/null || useradd -o -u ${uid} -g ${gid} -d ${homeDir} -s /bin/bash ${username}`,
|
||||
].join(' && ');
|
||||
|
||||
const originalCommand = finalEntrypoint[2];
|
||||
const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''");
|
||||
|
||||
// Use defensive entrypoint logic that checks for useradd availability.
|
||||
// This ensures we can support UID/GID mapping on distros that have these
|
||||
// tools. If useradd is missing (e.g. on minimal images), we fail explicitly
|
||||
// to avoid insecurely falling back to root execution with host mounts.
|
||||
const defensiveEntrypoint = [
|
||||
`if command -v useradd >/dev/null 2>&1; then`,
|
||||
` (groupadd -g ${gid} -o ${username} 2>/dev/null || true) &&`,
|
||||
` (id ${uid} >/dev/null 2>&1 || useradd -o -u ${uid} -g ${gid} -d ${quotedHomeDir} -s /bin/bash ${username} 2>/dev/null || true) &&`,
|
||||
` USER_NAME=$(id -nu ${uid} 2>/dev/null);`,
|
||||
` if [ -n "$USER_NAME" ]; then`,
|
||||
` su -p "$USER_NAME" -c '${escapedOriginalCommand}';`,
|
||||
` else`,
|
||||
` echo "Error: Failed to map host UID ${uid} to a user in the container." >&2;`,
|
||||
` exit 1;`,
|
||||
` fi`,
|
||||
`else`,
|
||||
` echo "Error: 'useradd' not found in container. UID/GID mapping is required for Linux distros like NixOS/Arch to avoid permission issues. Please use a container image that includes standard user management tools (like 'ubuntu' or 'debian')." >&2;`,
|
||||
` exit 1;`,
|
||||
`fi`,
|
||||
].join('\n');
|
||||
// Use `su -p` to preserve the environment.
|
||||
const suCommand = `su -p ${username} -c '${escapedOriginalCommand}'`;
|
||||
|
||||
// The entrypoint is always `['bash', '-c', '<command>']`, so we modify the command part.
|
||||
finalEntrypoint[2] = defensiveEntrypoint;
|
||||
finalEntrypoint[2] = `${setupUserCommands} && ${suCommand}`;
|
||||
|
||||
// We still need userFlag for the simpler proxy container, which does not have this issue.
|
||||
userFlag = `--user ${uid}:${gid}`;
|
||||
@@ -732,8 +716,6 @@ export async function start_sandbox(
|
||||
'run',
|
||||
'--rm',
|
||||
'--init',
|
||||
'--entrypoint',
|
||||
'',
|
||||
...(userFlag ? userFlag.split(' ') : []),
|
||||
'--name',
|
||||
SANDBOX_PROXY_NAME,
|
||||
|
||||
@@ -143,95 +143,6 @@ describe('sandboxUtils', () => {
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true on NixOS', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID=nixos\n');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true on NixOS with quotes', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID="nixos"\n');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true on Ubuntu with single quotes', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue("ID='ubuntu'\n");
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true on Arch Linux', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID=arch\n');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false on unrecognized Linux and warn on UID mismatch', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
|
||||
vi.mocked(os.userInfo).mockReturnValue({
|
||||
uid: 1234,
|
||||
username: 'test',
|
||||
gid: 1234,
|
||||
shell: '/bin/bash',
|
||||
homedir: '/home/test',
|
||||
});
|
||||
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
|
||||
expect(debugLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Host UID mismatch detected (current UID: 1234)',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return true on Pop!_OS (via ID_LIKE)', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue(
|
||||
'ID=pop\nID_LIKE="ubuntu debian"\n',
|
||||
);
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false and NOT warn for host root user (UID 0)', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockResolvedValue('ID=unknown\n');
|
||||
vi.mocked(os.userInfo).mockReturnValue({
|
||||
uid: 0,
|
||||
username: 'root',
|
||||
gid: 0,
|
||||
shell: '/bin/bash',
|
||||
homedir: '/root',
|
||||
});
|
||||
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
|
||||
expect(debugLogger.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Host UID mismatch detected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn and return false if /etc/os-release is unreadable', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('linux');
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('EACCES'));
|
||||
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
expect(await shouldUseCurrentUserInSandbox()).toBe(false);
|
||||
expect(debugLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not read /etc/os-release'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false on non-Linux', async () => {
|
||||
delete process.env['SANDBOX_SET_UID_GID'];
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user