From b3ebab308ee2e42bdf6ee48f9f97d6c5f8d95d85 Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:33:58 -0700 Subject: [PATCH 001/177] Docs: Minor style updates from initial docs audit. (#22872) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Jenna Inouye --- docs/cli/tutorials/file-management.md | 10 +++++----- docs/cli/tutorials/memory-management.md | 12 ++++++------ docs/cli/tutorials/shell-commands.md | 6 +++--- docs/get-started/authentication.md | 16 ++++++++-------- docs/get-started/gemini-3.md | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/docs/cli/tutorials/file-management.md b/docs/cli/tutorials/file-management.md index 0f4fa09575..cf3fb3039c 100644 --- a/docs/cli/tutorials/file-management.md +++ b/docs/cli/tutorials/file-management.md @@ -7,9 +7,9 @@ create files, and control what Gemini CLI can see. ## Prerequisites - Gemini CLI installed and authenticated. -- A project directory to work with (e.g., a git repository). +- A project directory to work with (for example, a git repository). -## How to give the agent context (Reading files) +## Providing context by reading files Gemini CLI will generally try to read relevant files, sometimes prompting you for access (depending on your settings). To ensure that Gemini CLI uses a file, @@ -58,7 +58,7 @@ You know there's a `UserProfile` component, but you don't know where it lives. ``` Gemini uses the `glob` or `list_directory` tools to search your project -structure. It will return the specific path (e.g., +structure. It will return the specific path (for example, `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. @@ -111,8 +111,8 @@ or, better yet, run your project's tests. `Run the tests for the UserProfile component.` ``` -Gemini CLI uses the `run_shell_command` tool to execute your test runner (e.g., -`npm test` or `jest`). This ensures the changes didn't break existing +Gemini CLI uses the `run_shell_command` tool to execute your test runner (for +example, `npm test` or `jest`). This ensures the changes didn't break existing functionality. ## Advanced: Controlling what Gemini sees diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 4cbca4bda9..2268ebd923 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -11,8 +11,8 @@ persistent facts, and inspect the active context. ## Why manage context? -Out of the box, Gemini CLI is smart but generic. It doesn't know your preferred -testing framework, your indentation style, or that you hate using `any` in +Gemini CLI is powerful but general. It doesn't know your preferred testing +framework, your indentation style, or your preference against `any` in TypeScript. Context management solves this by giving the agent persistent memory. @@ -109,11 +109,11 @@ immediately. Force a reload with: ## Best practices -- **Keep it focused:** Don't dump your entire internal wiki into `GEMINI.md`. - Keep instructions actionable and relevant to code generation. +- **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep + instructions actionable and relevant to code generation. - **Use negative constraints:** Explicitly telling the agent what _not_ to do - (e.g., "Do not use class components") is often more effective than vague - positive instructions. + (for example, "Do not use class components") is often more effective than + vague positive instructions. - **Review often:** Periodically check your `GEMINI.md` files to remove outdated rules. diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md index 3eaaf2049e..390c8acab9 100644 --- a/docs/cli/tutorials/shell-commands.md +++ b/docs/cli/tutorials/shell-commands.md @@ -7,7 +7,7 @@ automate complex workflows, and manage background processes safely. ## Prerequisites - Gemini CLI installed and authenticated. -- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, etc.). +- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on). ## How to run commands directly (`!`) @@ -49,7 +49,7 @@ You want to run tests and fix any failures. 6. Gemini uses `replace` to fix the bug. 7. Gemini runs `npm test` again to verify the fix. -This loop turns Gemini into an autonomous engineer. +This loop lets Gemini work autonomously. ## How to manage background processes @@ -75,7 +75,7 @@ confirmation prompts) by streaming the output to you. However, for highly interactive tools (like `vim` or `top`), it's often better to run them yourself in a separate terminal window or use the `!` prefix. -## Safety first +## Safety features Giving an AI access to your shell is powerful but risky. Gemini CLI includes several safety layers. diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 964e776567..d08b05eb2b 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -40,8 +40,8 @@ Select the authentication method that matches your situation in the table below: If you run Gemini CLI on your local machine, the simplest authentication method is logging in with your Google account. This method requires a web browser on a -machine that can communicate with the terminal running Gemini CLI (e.g., your -local machine). +machine that can communicate with the terminal running Gemini CLI (for example, +your local machine). > **Important:** If you are a **Google AI Pro** or **Google AI Ultra** > subscriber, use the Google account associated with your subscription. @@ -130,7 +130,7 @@ For example: **macOS/Linux** ```bash -# Replace with your project ID and desired location (e.g., us-central1) +# Replace with your project ID and desired location (for example, us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -138,7 +138,7 @@ export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" **Windows (PowerShell)** ```powershell -# Replace with your project ID and desired location (e.g., us-central1) +# Replace with your project ID and desired location (for example, us-central1) $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" $env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -325,14 +325,14 @@ persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append the environment variable commands to your shell's startup file. - **macOS/Linux** (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`): + **macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` - **Windows (PowerShell)** (e.g., `$PROFILE`): + **Windows (PowerShell)** (for example, `$PROFILE`): ```powershell Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' @@ -346,8 +346,8 @@ persist them with the following methods: 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, - then in your home directory's `.gemini/.env` (e.g., `~/.gemini/.env` or - `%USERPROFILE%\.gemini\.env`). + then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` + or `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index d22baaa0c0..529dfd995a 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -25,7 +25,7 @@ Get started by upgrading Gemini CLI to the latest version: npm install -g @google/gemini-cli@latest ``` -After you’ve confirmed your version is 0.21.1 or later: +If your version is 0.21.1 or later: 1. Run `/model`. 2. Select **Auto (Gemini 3)**. @@ -109,7 +109,7 @@ then: Restart Gemini CLI and you should have access to Gemini 3. -## Need help? +## Next steps If you need help, we recommend searching for an existing [GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you From 33f630111f97e3d31ec09719739757d50cbbeb5c Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 19 Mar 2026 12:57:52 -0700 Subject: [PATCH 002/177] feat(core): add experimental memory manager agent to replace save_memory tool (#22726) Co-authored-by: Christian Gunderman --- .gemini/settings.json | 3 +- docs/cli/settings.md | 1 + docs/reference/configuration.md | 7 + packages/cli/src/config/config.ts | 1 + .../config/policy-engine.integration.test.ts | 6 +- packages/cli/src/config/settingsSchema.ts | 10 ++ packages/cli/src/ui/AppContainer.tsx | 14 +- .../src/agents/memory-manager-agent.test.ts | 153 +++++++++++++++++ .../core/src/agents/memory-manager-agent.ts | 156 ++++++++++++++++++ packages/core/src/agents/registry.ts | 19 +++ packages/core/src/config/config.test.ts | 29 ++++ packages/core/src/config/config.ts | 15 +- .../core/src/config/path-validation.test.ts | 68 ++++++++ .../core/__snapshots__/prompts.test.ts.snap | 20 +-- packages/core/src/core/client.test.ts | 19 ++- packages/core/src/core/client.ts | 6 + packages/core/src/core/prompts.test.ts | 2 + .../src/policy/memory-manager-policy.test.ts | 119 +++++++++++++ .../src/policy/policies/memory-manager.toml | 10 ++ .../core/src/prompts/promptProvider.test.ts | 1 + packages/core/src/prompts/promptProvider.ts | 1 + .../prompts/snippets-memory-manager.test.ts | 34 ++++ packages/core/src/prompts/snippets.legacy.ts | 7 +- packages/core/src/prompts/snippets.ts | 5 + packages/core/src/scheduler/scheduler.ts | 2 + packages/core/src/utils/toolCallContext.ts | 2 + schemas/settings.schema.json | 7 + 27 files changed, 696 insertions(+), 21 deletions(-) create mode 100644 packages/core/src/agents/memory-manager-agent.test.ts create mode 100644 packages/core/src/agents/memory-manager-agent.ts create mode 100644 packages/core/src/config/path-validation.test.ts create mode 100644 packages/core/src/policy/memory-manager-policy.test.ts create mode 100644 packages/core/src/policy/policies/memory-manager.toml create mode 100644 packages/core/src/prompts/snippets-memory-manager.test.ts diff --git a/.gemini/settings.json b/.gemini/settings.json index 1a4c889066..9051dc78de 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,7 +2,8 @@ "experimental": { "plan": true, "extensionReloading": true, - "modelSteering": true + "modelSteering": true, + "memoryManager": true }, "general": { "devtools": true diff --git a/docs/cli/settings.md b/docs/cli/settings.md index eb9ba4158e..9b08867cc4 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -152,6 +152,7 @@ they appear in the UI. | Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | | Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 7df1de61f1..f57fd40747 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1431,6 +1431,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes +- **`experimental.memoryManager`** (boolean): + - **Description:** Replace the built-in save_memory tool with a memory manager + subagent that supports adding, removing, de-duplicating, and organizing + memories. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.topicUpdateNarration`** (boolean): - **Description:** Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 80c1e19443..777950c0ca 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -840,6 +840,7 @@ export async function loadCliConfig( skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, + experimentalMemoryManager: settings.experimental?.memoryManager, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 847b47bbe3..2e74a28201 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -516,7 +516,9 @@ describe('Policy Engine Integration Tests', () => { ); expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server - const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); + const readOnlyToolRule = rules.find( + (r) => r.toolName === 'glob' && !r.subagent, + ); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); @@ -673,7 +675,7 @@ describe('Policy Engine Integration Tests', () => { const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) - const globRule = rules.find((r) => r.toolName === 'glob'); + const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); // Priority 70 in default tier → 1.07 expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 8a107c4d47..ea6b9f9239 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2045,6 +2045,16 @@ const SETTINGS_SCHEMA = { }, }, }, + memoryManager: { + type: 'boolean', + label: 'Memory Manager Agent', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', + showInDialog: true, + }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 07edb72642..9d05f54347 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1007,10 +1007,18 @@ Logging in with Google... Restarting Gemini CLI to continue. Date.now(), ); try { - const { memoryContent, fileCount } = - await refreshServerHierarchicalMemory(config); + let flattenedMemory: string; + let fileCount: number; - const flattenedMemory = flattenMemory(memoryContent); + if (config.isJitContextEnabled()) { + await config.getContextManager()?.refresh(); + flattenedMemory = flattenMemory(config.getUserMemory()); + fileCount = config.getGeminiMdFileCount(); + } else { + const result = await refreshServerHierarchicalMemory(config); + flattenedMemory = flattenMemory(result.memoryContent); + fileCount = result.fileCount; + } historyManager.addItem( { diff --git a/packages/core/src/agents/memory-manager-agent.test.ts b/packages/core/src/agents/memory-manager-agent.test.ts new file mode 100644 index 0000000000..c4f9879e8f --- /dev/null +++ b/packages/core/src/agents/memory-manager-agent.test.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { MemoryManagerAgent } from './memory-manager-agent.js'; +import { + ASK_USER_TOOL_NAME, + EDIT_TOOL_NAME, + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + LS_TOOL_NAME, + READ_FILE_TOOL_NAME, + WRITE_FILE_TOOL_NAME, +} from '../tools/tool-names.js'; +import { Storage } from '../config/storage.js'; +import type { Config } from '../config/config.js'; +import type { HierarchicalMemory } from '../config/memory.js'; + +function createMockConfig(memory: string | HierarchicalMemory = ''): Config { + return { + getUserMemory: vi.fn().mockReturnValue(memory), + } as unknown as Config; +} + +describe('MemoryManagerAgent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the correct name "save_memory"', () => { + const agent = MemoryManagerAgent(createMockConfig()); + expect(agent.name).toBe('save_memory'); + }); + + it('should be a local agent', () => { + const agent = MemoryManagerAgent(createMockConfig()); + expect(agent.kind).toBe('local'); + }); + + it('should have a description', () => { + const agent = MemoryManagerAgent(createMockConfig()); + expect(agent.description).toBeTruthy(); + expect(agent.description).toContain('memory'); + }); + + it('should have a system prompt with memory management instructions', () => { + const agent = MemoryManagerAgent(createMockConfig()); + const prompt = agent.promptConfig.systemPrompt; + const globalGeminiDir = Storage.getGlobalGeminiDir(); + expect(prompt).toContain(`Global (${globalGeminiDir}`); + expect(prompt).toContain('Project (./'); + expect(prompt).toContain('Memory Hierarchy'); + expect(prompt).toContain('De-duplicating'); + expect(prompt).toContain('Adding'); + expect(prompt).toContain('Removing stale entries'); + expect(prompt).toContain('Organizing'); + expect(prompt).toContain('Routing'); + }); + + it('should have efficiency guidelines in the system prompt', () => { + const agent = MemoryManagerAgent(createMockConfig()); + const prompt = agent.promptConfig.systemPrompt; + expect(prompt).toContain('Efficiency & Performance'); + expect(prompt).toContain('Use as few turns as possible'); + expect(prompt).toContain('Do not perform any exploration'); + expect(prompt).toContain('Be strategic with your thinking'); + expect(prompt).toContain('Context Awareness'); + }); + + it('should inject hierarchical memory into initial context', () => { + const config = createMockConfig({ + global: + '--- Context from: ../../.gemini/GEMINI.md ---\nglobal context\n--- End of Context from: ../../.gemini/GEMINI.md ---', + project: + '--- Context from: .gemini/GEMINI.md ---\nproject context\n--- End of Context from: .gemini/GEMINI.md ---', + }); + + const agent = MemoryManagerAgent(config); + const query = agent.promptConfig.query; + + expect(query).toContain('# Initial Context'); + expect(query).toContain('global context'); + expect(query).toContain('project context'); + }); + + it('should inject flat string memory into initial context', () => { + const config = createMockConfig('flat memory content'); + + const agent = MemoryManagerAgent(config); + const query = agent.promptConfig.query; + + expect(query).toContain('# Initial Context'); + expect(query).toContain('flat memory content'); + }); + + it('should exclude extension memory from initial context', () => { + const config = createMockConfig({ + global: 'global context', + extension: 'extension context that should be excluded', + project: 'project context', + }); + + const agent = MemoryManagerAgent(config); + const query = agent.promptConfig.query; + + expect(query).toContain('global context'); + expect(query).toContain('project context'); + expect(query).not.toContain('extension context'); + }); + + it('should not include initial context when memory is empty', () => { + const agent = MemoryManagerAgent(createMockConfig()); + const query = agent.promptConfig.query; + + expect(query).not.toContain('# Initial Context'); + }); + + it('should have file-management and search tools', () => { + const agent = MemoryManagerAgent(createMockConfig()); + expect(agent.toolConfig).toBeDefined(); + expect(agent.toolConfig!.tools).toEqual( + expect.arrayContaining([ + READ_FILE_TOOL_NAME, + EDIT_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + LS_TOOL_NAME, + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + ASK_USER_TOOL_NAME, + ]), + ); + }); + + it('should require a "request" input parameter', () => { + const agent = MemoryManagerAgent(createMockConfig()); + const schema = agent.inputConfig.inputSchema as Record; + expect(schema).toBeDefined(); + expect(schema['properties']).toHaveProperty('request'); + expect(schema['required']).toContain('request'); + }); + + it('should use a fast model', () => { + const agent = MemoryManagerAgent(createMockConfig()); + expect(agent.modelConfig.model).toBe('flash'); + }); +}); diff --git a/packages/core/src/agents/memory-manager-agent.ts b/packages/core/src/agents/memory-manager-agent.ts new file mode 100644 index 0000000000..1687da6d1f --- /dev/null +++ b/packages/core/src/agents/memory-manager-agent.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import type { LocalAgentDefinition } from './types.js'; +import { + ASK_USER_TOOL_NAME, + EDIT_TOOL_NAME, + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + LS_TOOL_NAME, + READ_FILE_TOOL_NAME, + WRITE_FILE_TOOL_NAME, +} from '../tools/tool-names.js'; +import { Storage } from '../config/storage.js'; +import { flattenMemory } from '../config/memory.js'; +import { GEMINI_MODEL_ALIAS_FLASH } from '../config/models.js'; +import type { Config } from '../config/config.js'; + +const MemoryManagerSchema = z.object({ + response: z + .string() + .describe('A summary of the memory operations performed.'), +}); + +/** + * A memory management agent that replaces the built-in save_memory tool. + * It provides richer memory operations: adding, removing, de-duplicating, + * and organizing memories in the global GEMINI.md file. + * + * Users can override this agent by placing a custom save_memory.md + * in ~/.gemini/agents/ or .gemini/agents/. + */ +export const MemoryManagerAgent = ( + config: Config, +): LocalAgentDefinition => { + const globalGeminiDir = Storage.getGlobalGeminiDir(); + + const getInitialContext = (): string => { + const memory = config.getUserMemory(); + // Only include global and project memory — extension memory is read-only + // and not relevant to the memory manager. + const content = + typeof memory === 'string' + ? memory + : flattenMemory({ global: memory.global, project: memory.project }); + if (!content.trim()) return ''; + return `\n# Initial Context\n\n${content}\n`; + }; + + const buildSystemPrompt = (): string => + ` +You are a memory management agent maintaining user memories in GEMINI.md files. + +# Memory Hierarchy + +## Global (${globalGeminiDir}) +- \`${globalGeminiDir}/GEMINI.md\` — Cross-project user preferences, key personal info, + and habits that apply everywhere. + +## Project (./) +- \`./GEMINI.md\` — **Table of Contents** for project-specific context: + architecture decisions, conventions, key contacts, and references to + subdirectory GEMINI.md files for detailed context. +- Subdirectory GEMINI.md files (e.g. \`src/GEMINI.md\`, \`docs/GEMINI.md\`) — + detailed, domain-specific context for that part of the project. Reference + these from the root \`./GEMINI.md\`. + +## Routing + +When adding a memory, route it to the right store: +- **Global**: User preferences, personal info, tool aliases, cross-project habits → **global** +- **Project Root**: Project architecture, conventions, workflows, team info → **project root** +- **Subdirectory**: Detailed context about a specific module or directory → **subdirectory + GEMINI.md**, with a reference added to the project root + +- **Ambiguity**: If a memory (like a coding preference or workflow) could be interpreted as either a global habit or a project-specific convention, you **MUST** use \`${ASK_USER_TOOL_NAME}\` to clarify the user's intent. Do NOT make a unilateral decision when ambiguity exists between Global and Project stores. + +# Operations + +1. **Adding** — Route to the correct store and file. Check for duplicates in your provided context first. +2. **Removing stale entries** — Delete outdated or unwanted entries. Clean up + dangling references. +3. **De-duplicating** — Semantically equivalent entries should be combined. Keep the most informative version. +4. **Organizing** — Restructure for clarity. Update references between files. + +# Restrictions +- Keep GEMINI.md files lean — they are loaded into context every session. +- Keep entries concise. +- Edit surgically — preserve existing structure and user-authored content. +- NEVER write or read any files other than GEMINI.md files. + +# Efficiency & Performance +- **Use as few turns as possible.** Execute independent reads and writes to different files in parallel by calling multiple tools in a single turn. +- **Do not perform any exploration of the codebase.** Try to use the provided file context and only search additional GEMINI.md files as needed to accomplish your task. +- **Be strategic with your thinking.** carefully decide where to route memories and how to de-duplicate memories, but be decisive with simple memory writes. +- **Minimize file system operations.** You should typically only modify the GEMINI.md files that are already provided in your context. Only read or write to other files if explicitly directed or if you are following a specific reference from an existing memory file. +- **Context Awareness.** If a file's content is already provided in the "Initial Context" section, you do not need to call \`read_file\` for it. + +# Insufficient context +If you find that you have insufficient context to read or modify the memories as described, +reply with what you need, and exit. Do not search the codebase for the missing context. +`.trim(); + + return { + kind: 'local', + name: 'save_memory', + displayName: 'Memory Manager', + description: `Writes and reads memory, preferences or facts across ALL future sessions. Use this for recurring instructions like coding styles or tool aliases.`, + inputConfig: { + inputSchema: { + type: 'object', + properties: { + request: { + type: 'string', + description: + 'The memory operation to perform. Examples: "Remember that I prefer tabs over spaces", "Clean up stale memories", "De-duplicate my memories", "Organize my memories".', + }, + }, + required: ['request'], + }, + }, + outputConfig: { + outputName: 'result', + description: 'A summary of the memory operations performed.', + schema: MemoryManagerSchema, + }, + modelConfig: { + model: GEMINI_MODEL_ALIAS_FLASH, + }, + toolConfig: { + tools: [ + READ_FILE_TOOL_NAME, + EDIT_TOOL_NAME, + WRITE_FILE_TOOL_NAME, + LS_TOOL_NAME, + GLOB_TOOL_NAME, + GREP_TOOL_NAME, + ASK_USER_TOOL_NAME, + ], + }, + get promptConfig() { + return { + systemPrompt: buildSystemPrompt(), + query: `${getInitialContext()}\${request}`, + }; + }, + runConfig: { + maxTimeMinutes: 5, + maxTurns: 10, + }, + }; +}; diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 3c681266fa..51d923001a 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -13,6 +13,7 @@ import { CodebaseInvestigatorAgent } from './codebase-investigator.js'; import { CliHelpAgent } from './cli-help-agent.js'; import { GeneralistAgent } from './generalist-agent.js'; import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js'; +import { MemoryManagerAgent } from './memory-manager-agent.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; import type { AuthenticationHandler } from '@a2a-js/sdk/client'; import { type z } from 'zod'; @@ -249,6 +250,24 @@ export class AgentRegistry { if (browserConfig.enabled) { this.registerLocalAgent(BrowserAgentDefinition(this.config)); } + + // Register the memory manager agent as a replacement for the save_memory tool. + if (this.config.isMemoryManagerEnabled()) { + this.registerLocalAgent(MemoryManagerAgent(this.config)); + + // Ensure the global .gemini directory is accessible to tools. + // This allows the save_memory agent to read and write to it. + // Access control is enforced by the Policy Engine (memory-manager.toml). + try { + const globalDir = Storage.getGlobalGeminiDir(); + this.config.getWorkspaceContext().addDirectory(globalDir); + } catch (e) { + debugLogger.warn( + `[AgentRegistry] Could not add global .gemini directory to workspace:`, + e, + ); + } + } } private async refreshAgents(): Promise { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index eff489dcd6..e1db5c6e8e 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3104,6 +3104,35 @@ describe('Config JIT Initialization', () => { expect(config.getUserMemory()).toBe('Initial Memory'); }); + describe('isMemoryManagerEnabled', () => { + it('should default to false', () => { + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: '/tmp/test', + debugMode: false, + model: 'test-model', + cwd: '/tmp/test', + }; + + config = new Config(params); + expect(config.isMemoryManagerEnabled()).toBe(false); + }); + + it('should return true when experimentalMemoryManager is true', () => { + const params: ConfigParameters = { + sessionId: 'test-session', + targetDir: '/tmp/test', + debugMode: false, + model: 'test-model', + cwd: '/tmp/test', + experimentalMemoryManager: true, + }; + + config = new Config(params); + expect(config.isMemoryManagerEnabled()).toBe(true); + }); + }); + describe('reloadSkills', () => { it('should refresh disabledSkills and re-register ActivateSkillTool when skills exist', async () => { const mockOnReload = vi.fn().mockResolvedValue({ diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index aa3e9aa5b6..81bfa82bd3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -629,6 +629,7 @@ export interface ConfigParameters { disabledSkills?: string[]; adminSkillsEnabled?: boolean; experimentalJitContext?: boolean; + experimentalMemoryManager?: boolean; topicUpdateNarration?: boolean; toolOutputMasking?: Partial; disableLLMCorrection?: boolean; @@ -853,6 +854,7 @@ export class Config implements McpContext, AgentLoopContext { private readonly adminSkillsEnabled: boolean; private readonly experimentalJitContext: boolean; + private readonly experimentalMemoryManager: boolean; private readonly topicUpdateNarration: boolean; private readonly disableLLMCorrection: boolean; private readonly planEnabled: boolean; @@ -1013,6 +1015,7 @@ export class Config implements McpContext, AgentLoopContext { ); this.experimentalJitContext = params.experimentalJitContext ?? true; + this.experimentalMemoryManager = params.experimentalMemoryManager ?? false; this.topicUpdateNarration = params.topicUpdateNarration ?? false; this.modelSteering = params.modelSteering ?? false; this.injectionService = new InjectionService(() => @@ -2157,6 +2160,10 @@ export class Config implements McpContext, AgentLoopContext { return this.experimentalJitContext; } + isMemoryManagerEnabled(): boolean { + return this.experimentalMemoryManager; + } + isTopicUpdateNarrationEnabled(): boolean { return this.topicUpdateNarration; } @@ -3184,9 +3191,11 @@ export class Config implements McpContext, AgentLoopContext { maybeRegister(ShellTool, () => registry.registerTool(new ShellTool(this, this.messageBus)), ); - maybeRegister(MemoryTool, () => - registry.registerTool(new MemoryTool(this.messageBus)), - ); + if (!this.isMemoryManagerEnabled()) { + maybeRegister(MemoryTool, () => + registry.registerTool(new MemoryTool(this.messageBus)), + ); + } maybeRegister(WebSearchTool, () => registry.registerTool(new WebSearchTool(this, this.messageBus)), ); diff --git a/packages/core/src/config/path-validation.test.ts b/packages/core/src/config/path-validation.test.ts new file mode 100644 index 0000000000..742704e394 --- /dev/null +++ b/packages/core/src/config/path-validation.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Config } from './config.js'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(true), + statSync: vi.fn().mockReturnValue({ + isDirectory: vi.fn().mockReturnValue(true), + }), + realpathSync: vi.fn((p) => p), + }; +}); + +vi.mock('../utils/paths.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveToRealPath: vi.fn((p) => p), + isSubpath: (parent: string, child: string) => child.startsWith(parent), + }; +}); + +describe('Config Path Validation', () => { + let config: Config; + const targetDir = '/mock/workspace'; + const globalGeminiDir = path.join(os.homedir(), '.gemini'); + + beforeEach(() => { + config = new Config({ + targetDir, + sessionId: 'test-session', + debugMode: false, + cwd: targetDir, + model: 'test-model', + }); + }); + + it('should allow access to ~/.gemini if it is added to the workspace', () => { + const geminiMdPath = path.join(globalGeminiDir, 'GEMINI.md'); + + // Before adding, it should be denied + expect(config.isPathAllowed(geminiMdPath)).toBe(false); + + // Add to workspace + config.getWorkspaceContext().addDirectory(globalGeminiDir); + + // Now it should be allowed + expect(config.isPathAllowed(geminiMdPath)).toBe(true); + expect(config.validatePathAccess(geminiMdPath, 'read')).toBeNull(); + expect(config.validatePathAccess(geminiMdPath, 'write')).toBeNull(); + }); + + it('should still allow project workspace paths', () => { + const workspacePath = path.join(targetDir, 'src/index.ts'); + expect(config.isPathAllowed(workspacePath)).toBe(true); + expect(config.validatePathAccess(workspacePath, 'read')).toBeNull(); + }); +}); diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 51468c9d8d..cf2635562d 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -447,7 +447,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1148,7 +1148,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1261,7 +1261,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1382,7 +1382,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -1508,7 +1508,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -2876,7 +2876,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3154,7 +3154,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3268,7 +3268,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -3702,7 +3702,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details @@ -4123,7 +4123,7 @@ IT IS CRITICAL TO FOLLOW THESE GUIDELINES TO AVOID EXCESSIVE TOKEN CONSUMPTION. - **Command Execution:** Use the 'run_shell_command' tool for running shell commands, remembering the safety rule to explain modifying commands first. - **Background Processes:** To run a command in the background, set the \`is_background\` parameter to true. - **Interactive Commands:** Always prefer non-interactive commands (e.g., using 'run once' or 'CI' flags for test runners to avoid persistent watch modes or 'git --no-pager') unless a persistent process is specifically required; however, some commands are only interactive and expect user input during their execution (e.g. ssh, vim). If you choose to execute an interactive command consider letting the user know they can press \`tab\` to focus into the shell to provide input. -- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" +- **Remembering Facts:** Use the 'save_memory' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information. If unsure whether to save something, you can ask the user, "Should I remember that for you?" - **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward. ## Interaction Details diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 77c4a5a498..e93eedf055 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -51,7 +51,7 @@ import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js' import * as policyCatalog from '../availability/policyCatalog.js'; import { LlmRole, LoopType } from '../telemetry/types.js'; import { partToString } from '../utils/partUtils.js'; -import { coreEvents } from '../utils/events.js'; +import { coreEvents, CoreEvent } from '../utils/events.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; // Mock fs module to prevent actual file system operations during tests @@ -1997,6 +1997,23 @@ ${JSON.stringify( ); }); + it('should update system instruction when MemoryChanged event is emitted', async () => { + vi.mocked(mockConfig.getSystemInstructionMemory).mockReturnValue( + 'Updated Memory', + ); + + const { getCoreSystemPrompt } = await import('./prompts.js'); + const mockGetCoreSystemPrompt = vi.mocked(getCoreSystemPrompt); + mockGetCoreSystemPrompt.mockClear(); + + coreEvents.emit(CoreEvent.MemoryChanged, { fileCount: 2 }); + + expect(mockGetCoreSystemPrompt).toHaveBeenCalledWith( + mockConfig, + 'Updated Memory', + ); + }); + it('should recursively call sendMessageStream with "Please continue." when InvalidStream event is received for Gemini 2 models', async () => { vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue( true, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 01577452f4..f357a0decb 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -117,6 +117,7 @@ export class GeminiClient { this.lastPromptId = this.config.getSessionId(); coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged); + coreEvents.on(CoreEvent.MemoryChanged, this.handleMemoryChanged); } private get config(): Config { @@ -127,6 +128,10 @@ export class GeminiClient { this.currentSequenceModel = null; }; + private handleMemoryChanged = () => { + this.updateSystemInstruction(); + }; + // Hook state to deduplicate BeforeAgent calls and track response for // AfterAgent private hookStateMap = new Map< @@ -306,6 +311,7 @@ export class GeminiClient { dispose() { coreEvents.off(CoreEvent.ModelChanged, this.handleModelChanged); + coreEvents.off(CoreEvent.MemoryChanged, this.handleMemoryChanged); } async resumeChat( diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 82a7943de4..d3f2087018 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -96,6 +96,7 @@ describe('Core System Prompt (prompts.ts)', () => { isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false), + isMemoryManagerEnabled: vi.fn().mockReturnValue(false), isAgentsEnabled: vi.fn().mockReturnValue(false), getPreviewFeatures: vi.fn().mockReturnValue(true), getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), @@ -423,6 +424,7 @@ describe('Core System Prompt (prompts.ts)', () => { isInteractive: vi.fn().mockReturnValue(false), isInteractiveShellEnabled: vi.fn().mockReturnValue(false), isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false), + isMemoryManagerEnabled: vi.fn().mockReturnValue(false), isAgentsEnabled: vi.fn().mockReturnValue(false), getModel: vi.fn().mockReturnValue('auto'), getActiveModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL), diff --git a/packages/core/src/policy/memory-manager-policy.test.ts b/packages/core/src/policy/memory-manager-policy.test.ts new file mode 100644 index 0000000000..5de6586166 --- /dev/null +++ b/packages/core/src/policy/memory-manager-policy.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { PolicyEngine } from './policy-engine.js'; +import { loadPoliciesFromToml } from './toml-loader.js'; +import { PolicyDecision, ApprovalMode } from './types.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +describe('Memory Manager Policy', () => { + let engine: PolicyEngine; + + beforeEach(async () => { + const policiesDir = path.join(__dirname, 'policies'); + const result = await loadPoliciesFromToml([policiesDir], () => 1); + engine = new PolicyEngine({ + rules: result.rules, + approvalMode: ApprovalMode.DEFAULT, + }); + }); + + it('should allow save_memory to read ~/.gemini/GEMINI.md', async () => { + const toolCall = { + name: 'read_file', + args: { file_path: '~/.gemini/GEMINI.md' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'save_memory', + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should allow save_memory to write ~/.gemini/GEMINI.md', async () => { + const toolCall = { + name: 'write_file', + args: { file_path: '~/.gemini/GEMINI.md', content: 'test' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'save_memory', + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should allow save_memory to list ~/.gemini/', async () => { + const toolCall = { + name: 'list_directory', + args: { dir_path: '~/.gemini/' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'save_memory', + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should fall through to global allow rule for save_memory reading non-.gemini files', async () => { + const toolCall = { + name: 'read_file', + args: { file_path: '/etc/passwd' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'save_memory', + ); + // The memory-manager policy only matches .gemini/ paths. + // Other paths fall through to the global read_file allow rule (priority 50). + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should not match paths where .gemini is a substring (e.g. not.gemini)', async () => { + const toolCall = { + name: 'read_file', + args: { file_path: '/tmp/not.gemini/evil' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'save_memory', + ); + // The tighter argsPattern requires .gemini/ to be preceded by start-of-string + // or a path separator, so "not.gemini/" should NOT match the memory-manager rule. + // It falls through to the global read_file allow rule instead. + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should fall through to global allow rule for other agents accessing ~/.gemini/', async () => { + const toolCall = { + name: 'read_file', + args: { file_path: '~/.gemini/GEMINI.md' }, + }; + const result = await engine.check( + toolCall, + undefined, + undefined, + 'other_agent', + ); + // The memory-manager policy rule (priority 100) only applies to 'save_memory'. + // Other agents fall through to the global read_file allow rule (priority 50). + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); +}); diff --git a/packages/core/src/policy/policies/memory-manager.toml b/packages/core/src/policy/policies/memory-manager.toml new file mode 100644 index 0000000000..2055fcdf3a --- /dev/null +++ b/packages/core/src/policy/policies/memory-manager.toml @@ -0,0 +1,10 @@ +# Policy for Memory Manager Agent +# Allows the save_memory agent to manage memories in the ~/.gemini/ folder. + +[[rule]] +subagent = "save_memory" +toolName = ["read_file", "write_file", "replace", "list_directory", "glob", "grep_search"] +decision = "allow" +priority = 100 +argsPattern = "(^|.*/)\\.gemini/.*" +deny_message = "Memory Manager is only allowed to access the .gemini folder." diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index c2253a9b57..700062de50 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -61,6 +61,7 @@ describe('PromptProvider', () => { isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false), + isMemoryManagerEnabled: vi.fn().mockReturnValue(false), getSkillManager: vi.fn().mockReturnValue({ getSkills: vi.fn().mockReturnValue([]), }), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index a2e1333895..bd884aeab5 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -192,6 +192,7 @@ export class PromptProvider { interactiveShellEnabled: context.config.isInteractiveShellEnabled(), topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(), + memoryManagerEnabled: context.config.isMemoryManagerEnabled(), }), ), sandbox: this.withSection('sandbox', () => getSandboxMode()), diff --git a/packages/core/src/prompts/snippets-memory-manager.test.ts b/packages/core/src/prompts/snippets-memory-manager.test.ts new file mode 100644 index 0000000000..070e49f8c0 --- /dev/null +++ b/packages/core/src/prompts/snippets-memory-manager.test.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { renderOperationalGuidelines } from './snippets.js'; + +describe('renderOperationalGuidelines - memoryManagerEnabled', () => { + const baseOptions = { + interactive: true, + interactiveShellEnabled: false, + topicUpdateNarration: false, + memoryManagerEnabled: false, + }; + + it('should include standard memory tool guidance when memoryManagerEnabled is false', () => { + const result = renderOperationalGuidelines(baseOptions); + expect(result).toContain('save_memory'); + expect(result).toContain('persistent user-related information'); + expect(result).not.toContain('subagent'); + }); + + it('should include subagent memory guidance when memoryManagerEnabled is true', () => { + const result = renderOperationalGuidelines({ + ...baseOptions, + memoryManagerEnabled: true, + }); + expect(result).toContain('save_memory'); + expect(result).toContain('subagent'); + expect(result).not.toContain('persistent user-related information'); + }); +}); diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 41e6edc183..19aaf56d78 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -67,6 +67,7 @@ export interface OperationalGuidelinesOptions { isGemini3: boolean; enableShellEfficiency: boolean; interactiveShellEnabled: boolean; + memoryManagerEnabled: boolean; } export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside'; @@ -647,8 +648,12 @@ function toolUsageInteractive( function toolUsageRememberingFacts( options: OperationalGuidelinesOptions, ): string { + if (options.memoryManagerEnabled) { + return ` +- **Memory Tool:** You MUST use the '${MEMORY_TOOL_NAME}' tool to proactively record facts, preferences, and workflows that apply across all sessions. Whenever the user explicitly tells you to "remember" something, or when they state a preference or workflow (like "always lint after editing"), you MUST immediately call the save_memory subagent. Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is strictly for persistent general knowledge.`; + } const base = ` -- **Remembering Facts:** Use the '${MEMORY_TOOL_NAME}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information.`; +- **Remembering Facts:** Use the '${MEMORY_TOOL_NAME}' tool to remember specific, *user-related* facts or preferences when the user explicitly asks, or when they state a clear, concise piece of information that would help personalize or streamline *your future interactions with them* (e.g., preferred coding style, common project paths they use, personal tool aliases, or a workflow like "always lint after editing"). This tool is for user-specific information that should persist across sessions. Do *not* use it for general project context or information.`; const suffix = options.interactive ? ' If unsure whether to save something, you can ask the user, "Should I remember that for you?"' : ''; diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 225fa21c4a..d5ff8714b0 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -79,6 +79,7 @@ export interface OperationalGuidelinesOptions { interactive: boolean; interactiveShellEnabled: boolean; topicUpdateNarration: boolean; + memoryManagerEnabled: boolean; } export type SandboxMode = 'macos-seatbelt' | 'generic' | 'outside'; @@ -777,6 +778,10 @@ function toolUsageInteractive( function toolUsageRememberingFacts( options: OperationalGuidelinesOptions, ): string { + if (options.memoryManagerEnabled) { + return ` +- **Memory Tool:** You MUST use ${formatToolName(MEMORY_TOOL_NAME)} to proactively record facts, preferences, and workflows that apply across all sessions. Whenever the user explicitly tells you to "remember" something, or when they state a preference or workflow (like "always lint after editing"), you MUST immediately call the save_memory subagent. Never save transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is strictly for persistent general knowledge.`; + } const base = ` - **Memory Tool:** Use ${formatToolName(MEMORY_TOOL_NAME)} only for global user preferences, personal facts, or high-level information that applies across all sessions. Never save workspace-specific context, local file paths, or transient session state. Do not use memory to store summaries of code changes, bug fixes, or findings discovered during a task; this tool is for persistent user-related information only.`; const suffix = options.interactive diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index 4a92617e6d..cc14e3d875 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -363,6 +363,7 @@ export class Scheduler { callId: request.callId, schedulerId: this.schedulerId, parentCallId: this.parentCallId, + subagent: this.subagent, }, () => { try { @@ -670,6 +671,7 @@ export class Scheduler { callId: activeCall.request.callId, schedulerId: this.schedulerId, parentCallId: this.parentCallId, + subagent: this.subagent, }, () => this.executor.execute({ diff --git a/packages/core/src/utils/toolCallContext.ts b/packages/core/src/utils/toolCallContext.ts index e89d20ddef..23c3bcaa46 100644 --- a/packages/core/src/utils/toolCallContext.ts +++ b/packages/core/src/utils/toolCallContext.ts @@ -16,6 +16,8 @@ export interface ToolCallContext { schedulerId: string; /** The ID of the parent tool call, if this is a nested execution (e.g., in a subagent). */ parentCallId?: string; + /** The name of the subagent executing the tool, if applicable. */ + subagent?: string; } /** diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f85a39bb35..2b528ad8dc 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2451,6 +2451,13 @@ }, "additionalProperties": false }, + "memoryManager": { + "title": "Memory Manager Agent", + "description": "Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.", + "markdownDescription": "Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "topicUpdateNarration": { "title": "Topic & Update Narration", "description": "Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting.", From 46ec71bf0e46ee62610147e01d670e47fadd6ca8 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 19 Mar 2026 12:43:48 -0700 Subject: [PATCH 003/177] Changelog for v0.35.0-preview.2 (#23142) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> --- docs/changelogs/preview.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 91d0c09a0b..39e1e0a2ed 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.35.0-preview.1 +# Preview release: v0.35.0-preview.2 -Released: March 17, 2026 +Released: March 19, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -33,6 +33,10 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch + version v0.35.0-preview.1 and create version 0.35.0-preview.2 by + @gemini-cli-robot in + [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) - feat(cli): customizable keyboard shortcuts by @scidomino in [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) - feat(core): Thread `AgentLoopContext` through core. by @joshualitt in @@ -373,4 +377,4 @@ npm install -g @google/gemini-cli@preview [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2 From 4fc059beb59552a08bae54bc8f802cf59f46897a Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:51:16 -0700 Subject: [PATCH 004/177] Update website issue template for label and title (#23036) --- .github/ISSUE_TEMPLATE/website_issue.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/website_issue.yml b/.github/ISSUE_TEMPLATE/website_issue.yml index 02146381ab..d9b30e1127 100644 --- a/.github/ISSUE_TEMPLATE/website_issue.yml +++ b/.github/ISSUE_TEMPLATE/website_issue.yml @@ -1,7 +1,9 @@ name: 'Website issue' description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' +title: 'GeminiCLI.com Feedback: [ISSUE]' labels: - 'area/extensions' + - 'area/documentation' body: - type: 'markdown' attributes: From 36dbaa8462c3db554bc251753a9f8b6edfc2b02e Mon Sep 17 00:00:00 2001 From: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:02:33 -0700 Subject: [PATCH 005/177] fix: upgrade ACP SDK from 0.12 to 0.16.1 (#23132) --- package-lock.json | 10 +++++----- package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/acp/fileSystemService.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 914d66d3ac..b70dc1413b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -84,9 +84,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", - "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz", + "integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -17531,7 +17531,7 @@ "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 531f9f75d9..72676cf90b 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "LICENSE" ], "devDependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 79cb21307a..40acd6cf88 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.12.0", + "@agentclientprotocol/sdk": "^0.16.1", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/packages/cli/src/acp/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts index 1d3c8ad0b8..02b9d68195 100644 --- a/packages/cli/src/acp/fileSystemService.ts +++ b/packages/cli/src/acp/fileSystemService.ts @@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService { constructor( private readonly connection: acp.AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapability, + private readonly capabilities: acp.FileSystemCapabilities, private readonly fallback: FileSystemService, ) {} From 2ebcd48a4e66383d4dd3665054e119f63bb7b223 Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:11:14 -0700 Subject: [PATCH 006/177] Update callouts to work on github. (#22245) --- .gemini/skills/docs-writer/SKILL.md | 39 ++++++++- docs/cli/checkpointing.md | 4 +- docs/cli/custom-commands.md | 12 +-- docs/cli/enterprise.md | 24 +++--- docs/cli/model-steering.md | 7 +- docs/cli/model.md | 4 +- docs/cli/notifications.md | 7 +- docs/cli/plan-mode.md | 16 ++-- docs/cli/sandbox.md | 8 +- docs/cli/settings.md | 4 +- docs/cli/skills.md | 6 +- docs/cli/system-prompt.md | 4 +- docs/cli/telemetry.md | 8 +- docs/cli/themes.md | 20 +++-- docs/cli/tutorials/file-management.md | 4 +- docs/cli/tutorials/mcp-setup.md | 6 +- docs/cli/tutorials/plan-mode-steering.md | 7 +- docs/core/remote-agents.md | 11 ++- docs/core/subagents.md | 34 +++++--- docs/extensions/reference.md | 8 +- docs/get-started/authentication.md | 94 ++++++++++++---------- docs/get-started/examples.md | 4 +- docs/get-started/gemini-3.md | 12 ++- docs/hooks/index.md | 4 +- docs/ide-integration/ide-companion-spec.md | 8 +- docs/ide-integration/index.md | 12 ++- docs/issue-and-pr-automation.md | 4 +- docs/local-development.md | 4 +- docs/reference/commands.md | 4 +- docs/reference/configuration.md | 14 ++-- docs/reference/policy-engine.md | 26 +++--- docs/reference/tools.md | 4 +- docs/release-confidence.md | 10 ++- docs/releases.md | 18 +++-- docs/resources/tos-privacy.md | 6 +- docs/resources/troubleshooting.md | 4 +- docs/tools/mcp-server.md | 26 +++--- docs/tools/planning.md | 4 +- docs/tools/shell.md | 8 +- 39 files changed, 322 insertions(+), 177 deletions(-) diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md index d7cf7b81be..6d9788a3b0 100644 --- a/.gemini/skills/docs-writer/SKILL.md +++ b/.gemini/skills/docs-writer/SKILL.md @@ -71,12 +71,44 @@ accessible. tables). - **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text for all images. +- **Details section:** Use the `
` tag to create a collapsible section. + This is useful for supplementary or data-heavy information that isn't critical + to the main flow. + + Example: + +
+ Title + + - First entry + - Second entry + +
+ +- **Callouts**: Use GitHub-flavored markdown alerts to highlight important + information. To ensure the formatting is preserved by `npm run format`, place + an empty line, then the `` comment directly before + the callout block. The callout type (`[!TYPE]`) should be on the first line, + followed by a newline, and then the content, with each subsequent line of + content starting with `>`. Available types are `NOTE`, `TIP`, `IMPORTANT`, + `WARNING`, and `CAUTION`. + + Example: + + +> [!NOTE] +> This is an example of a multi-line note that will be preserved +> by Prettier. ### Structure - **BLUF:** Start with an introduction explaining what to expect. - **Experimental features:** If a feature is clearly noted as experimental, -add the following note immediately after the introductory paragraph: - `> **Note:** This is a preview feature currently under active development.` + add the following note immediately after the introductory paragraph: + + +> [!NOTE] +> This is an experimental feature currently under active development. + - **Headings:** Use hierarchical headings to support the user journey. - **Procedures:** - Introduce lists of steps with a complete sentence. @@ -85,8 +117,7 @@ add the following note immediately after the introductory paragraph: - Put conditions before instructions (e.g., "On the Settings page, click..."). - Provide clear context for where the action takes place. - Indicate optional steps clearly (e.g., "Optional: ..."). -- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings - (`> **Warning:**`). +- **Elements:** Use bullet lists, tables, details, and callouts. - **Avoid using a table of contents:** If a table of contents is present, remove it. - **Next steps:** Conclude with a "Next steps" section if applicable. diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md index 0be8bd9508..3a4a690cea 100644 --- a/docs/cli/checkpointing.md +++ b/docs/cli/checkpointing.md @@ -39,7 +39,9 @@ file in your project's temporary directory, typically located at The Checkpointing feature is disabled by default. To enable it, you need to edit your `settings.json` file. -> **Note:** The `--checkpointing` command-line flag was removed in version + +> [!CAUTION] +> The `--checkpointing` command-line flag was removed in version > 0.11.0. Checkpointing can now only be enabled through the `settings.json` > configuration file. diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index dd2698290e..6fcce4e825 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -30,7 +30,9 @@ separator (`/` or `\`) being converted to a colon (`:`). - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. -> [!TIP] After creating or modifying `.toml` command files, run + +> [!TIP] +> After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. ## TOML file format (v1) @@ -177,10 +179,10 @@ ensure that only intended commands can be run. automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). 3. **Robust parsing:** The parser correctly handles complex shell commands that - include nested braces, such as JSON payloads. **Note:** The content inside - `!{...}` must have balanced braces (`{` and `}`). If you need to execute a - command containing unbalanced braces, consider wrapping it in an external - script file and calling the script within the `!{...}` block. + include nested braces, such as JSON payloads. The content inside `!{...}` + must have balanced braces (`{` and `}`). If you need to execute a command + containing unbalanced braces, consider wrapping it in an external script + file and calling the script within the `!{...}` block. 4. **Security check and confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 39c0f7c5c1..5e9cede33a 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -5,9 +5,11 @@ and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users. -> **A note on security:** The patterns described in this document are intended -> to help administrators create a more controlled and secure environment for -> using Gemini CLI. However, they should not be considered a foolproof security + +> [!WARNING] +> The patterns described in this document are intended to help +> administrators create a more controlled and secure environment for using +> Gemini CLI. However, they should not be considered a foolproof security > boundary. A determined user with sufficient privileges on their local machine > may still be able to circumvent these configurations. These measures are > designed to prevent accidental misuse and enforce corporate policy in a @@ -280,10 +282,12 @@ environment to a blocklist. } ``` -**Security note:** Blocklisting with `excludeTools` is less secure than -allowlisting with `coreTools`, as it relies on blocking known-bad commands, and -clever users may find ways to bypass simple string-based blocks. **Allowlisting -is the recommended approach.** + +> [!WARNING] +> Blocklisting with `excludeTools` is less secure than +> allowlisting with `coreTools`, as it relies on blocking known-bad commands, +> and clever users may find ways to bypass simple string-based blocks. +> **Allowlisting is the recommended approach.** ### Disabling YOLO mode @@ -494,8 +498,10 @@ other events. For more information, see the } ``` -**Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to -avoid collecting potentially sensitive information from user prompts. + +> [!NOTE] +> Ensure that `logPrompts` is set to `false` in an enterprise setting to +> avoid collecting potentially sensitive information from user prompts. ## Authentication diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md index 12b581c530..26ff4e1209 100644 --- a/docs/cli/model-steering.md +++ b/docs/cli/model-steering.md @@ -4,9 +4,10 @@ Model steering lets you provide real-time guidance and feedback to Gemini CLI while it is actively executing a task. This lets you correct course, add missing context, or skip unnecessary steps without having to stop and restart the agent. -> **Note:** This is a preview feature under active development. Preview features -> may only be available in the **Preview** channel or may need to be enabled -> under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) workflows or long-running subagent executions where you want to ensure the agent diff --git a/docs/cli/model.md b/docs/cli/model.md index 3da5ea4cbc..b85f597e08 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -5,7 +5,9 @@ used by Gemini CLI, giving you more control over your results. Use **Pro** models for complex tasks and reasoning, **Flash** models for high speed results, or the (recommended) **Auto** setting to choose the best model for your tasks. -> **Note:** The `/model` command (and the `--model` flag) does not override the + +> [!NOTE] +> The `/model` command (and the `--model` flag) does not override the > model used by sub-agents. Consequently, even when using the `/model` flag you > may see other models used in your model usage reports. diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md index 8326a1efb2..8cff6c54f3 100644 --- a/docs/cli/notifications.md +++ b/docs/cli/notifications.md @@ -4,9 +4,10 @@ Gemini CLI can send system notifications to alert you when a session completes or when it needs your attention, such as when it's waiting for you to approve a tool call. -> **Note:** This is a preview feature currently under active development. -> Preview features may be available on the **Preview** channel or may need to be -> enabled under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. Notifications are particularly useful when running long-running tasks or using [Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 9550e2a918..5299bb3463 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -35,19 +35,17 @@ To launch Gemini CLI in Plan Mode once: To start Plan Mode while using Gemini CLI: - **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). - - > **Note:** Plan Mode is automatically removed from the rotation when Gemini - > CLI is actively processing or showing confirmation dialogs. + (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from + the rotation when Gemini CLI is actively processing or showing confirmation + dialogs. - **Command:** Type `/plan` in the input box. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool - to switch modes. - > **Note:** This tool is not available when Gemini CLI is in - > [YOLO mode](../reference/configuration.md#command-line-arguments). + to switch modes. This tool is not available when Gemini CLI is in + [YOLO mode](../reference/configuration.md#command-line-arguments). ## How to use Plan Mode @@ -407,7 +405,9 @@ To build a custom planning workflow, you can use: [custom plan directories](#custom-plan-directory-and-policies) and [custom policies](#custom-policies). -> **Note:** Use [Conductor] as a reference when building your own custom + +> [!TIP] +> Use [Conductor] as a reference when building your own custom > planning workflow. By using Plan Mode as its execution environment, your custom methodology can diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index ec7e88f624..d05950419b 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -253,9 +253,11 @@ $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` -**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect -gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli -specific debug settings. + +> [!NOTE] +> If you have `DEBUG=true` in a project's `.env` file, it won't affect +> gemini-cli due to automatic exclusion. Use `.gemini/.env` files for +> gemini-cli specific debug settings. ### Inspect sandbox diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 9b08867cc4..853e46fc0a 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -11,7 +11,9 @@ locations: - **User settings**: `~/.gemini/settings.json` - **Workspace settings**: `your-project/.gemini/settings.json` -Note: Workspace settings override user settings. + +> [!IMPORTANT] +> Workspace settings override user settings. ## Settings reference diff --git a/docs/cli/skills.md b/docs/cli/skills.md index d3e8d4e84f..73e5eb66eb 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -63,8 +63,10 @@ Use the `/skills` slash command to view and manage available expertise: - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. -_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use -`--scope workspace` to manage workspace-specific settings._ + +> [!NOTE] +> `/skills disable` and `/skills enable` default to the `user` scope. Use +> `--scope workspace` to manage workspace-specific settings. ### From the Terminal diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index b1ff43e3fd..c249d55cec 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -14,7 +14,9 @@ core instructions will apply unless you include them yourself. This feature is intended for advanced users who need to enforce strict, project-specific behavior or create a customized persona. -> Tip: You can export the current default system prompt to a file first, review + +> [!TIP] +> You can export the current default system prompt to a file first, review > it, and then selectively modify or replace it (see > [“Export the default prompt”](#export-the-default-prompt-recommended)). diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 211d877071..2068759213 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -125,9 +125,11 @@ You must complete several setup steps before enabling Google Cloud telemetry. } ``` - > **Note:** This setting requires **Direct export** (in-process exporters) - > and cannot be used when `useCollector` is `true`. If both are enabled, - > telemetry will be disabled. + +> [!NOTE] +> This setting requires **Direct export** (in-process exporters) +> and cannot be used when `useCollector` is `true`. If both are enabled, +> telemetry will be disabled. 3. Ensure your account or service account has these IAM roles: - Cloud Trace Agent diff --git a/docs/cli/themes.md b/docs/cli/themes.md index adfe64d081..55acc75625 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -36,9 +36,11 @@ using the `/theme` command within Gemini CLI: preview or highlight as you select. 4. Confirm your selection to apply the theme. -**Note:** If a theme is defined in your `settings.json` file (either by name or -by a file path), you must remove the `"theme"` setting from the file before you -can change the theme using the `/theme` command. + +> [!NOTE] +> If a theme is defined in your `settings.json` file (either by name or +> by a file path), you must remove the `"theme"` setting from the file before +> you can change the theme using the `/theme` command. ### Theme persistence @@ -179,11 +181,13 @@ custom theme defined in `settings.json`. } ``` -**Security note:** For your safety, Gemini CLI will only load theme files that -are located within your home directory. If you attempt to load a theme from -outside your home directory, a warning will be displayed and the theme will not -be loaded. This is to prevent loading potentially malicious theme files from -untrusted sources. + +> [!WARNING] +> For your safety, Gemini CLI will only load theme files that +> are located within your home directory. If you attempt to load a theme from +> outside your home directory, a warning will be displayed and the theme will +> not be loaded. This is to prevent loading potentially malicious theme files +> from untrusted sources. ### Example custom theme diff --git a/docs/cli/tutorials/file-management.md b/docs/cli/tutorials/file-management.md index cf3fb3039c..37112d3bc7 100644 --- a/docs/cli/tutorials/file-management.md +++ b/docs/cli/tutorials/file-management.md @@ -62,7 +62,9 @@ structure. It will return the specific path (for example, `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. -> **Tip:** You can also ask for lists of files, like "Show me all the TypeScript + +> [!TIP] +> You can also ask for lists of files, like "Show me all the TypeScript > configuration files in the root directory." ## How to modify code diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 1f3edf716a..1eff7452ab 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -62,8 +62,10 @@ You tell Gemini about new servers by editing your `settings.json`. } ``` -> **Note:** The `command` is `docker`, and the rest are arguments passed to it. -> We map the local environment variable into the container so your secret isn't + +> [!NOTE] +> The `command` is `docker`, and the rest are arguments passed to it. We +> map the local environment variable into the container so your secret isn't > hardcoded in the config file. ## How to verify the connection diff --git a/docs/cli/tutorials/plan-mode-steering.md b/docs/cli/tutorials/plan-mode-steering.md index 86bc63edac..0384425848 100644 --- a/docs/cli/tutorials/plan-mode-steering.md +++ b/docs/cli/tutorials/plan-mode-steering.md @@ -5,9 +5,10 @@ structured environment with model steering's real-time feedback, you can guide Gemini CLI through the research and design phases to ensure the final implementation plan is exactly what you need. -> **Note:** This is a preview feature under active development. Preview features -> may only be available in the **Preview** channel or may need to be enabled -> under `/settings`. + +> [!NOTE] +> This is an experimental feature currently under active development and +> may need to be enabled under `/settings`. ## Prerequisites diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 1c48df00a3..2e34a9dbc4 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -10,7 +10,9 @@ agents in the following repositories: - [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) - [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. ## Configuration @@ -82,7 +84,8 @@ Markdown file. --- ``` -> **Note:** Mixed local and remote agents, or multiple local agents, are not + +> [!NOTE] Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. ## Authentication @@ -362,5 +365,7 @@ Users can manage subagents using the following commands within the Gemini CLI: - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. -> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance + +> [!TIP] +> You can use the `@cli_help` agent within Gemini CLI for assistance > with configuring subagents. diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 6d863f489e..b0cffca3b5 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -5,16 +5,18 @@ session. They are designed to handle specific, complex tasks—like deep codebas analysis, documentation lookup, or domain-specific reasoning—without cluttering the main agent's context or toolset. -> **Note: Subagents are currently an experimental feature.** -> -> To use custom subagents, you must ensure they are enabled in your -> `settings.json` (enabled by default): -> -> ```json -> { -> "experimental": { "enableAgents": true } -> } -> ``` + +> [!NOTE] +> Subagents are currently an experimental feature. +> +To use custom subagents, you must ensure they are enabled in your +`settings.json` (enabled by default): + +```json +{ + "experimental": { "enableAgents": true } +} +``` ## What are subagents? @@ -114,7 +116,9 @@ Gemini CLI comes with the following built-in subagents: the pricing table from this page," "Click the login button and enter my credentials." -> **Note:** This is a preview feature currently under active development. + +> [!NOTE] +> This is a preview feature currently under active development. #### Prerequisites @@ -217,7 +221,9 @@ captures a screenshot and sends it to the vision model for analysis. The model returns coordinates and element descriptions that the browser agent uses with the `click_at` tool for precise, coordinate-based interactions. -> **Note:** The visual agent requires API key or Vertex AI authentication. It is + +> [!NOTE] +> The visual agent requires API key or Vertex AI authentication. It is > not available when using "Sign in with Google". ## Creating custom subagents @@ -405,7 +411,9 @@ that your subagent was called with a specific prompt and the given description. Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent (A2A) protocol. -> **Note: Remote subagents are currently an experimental feature.** + +> [!NOTE] +> Remote subagents are currently an experimental feature. See the [Remote Subagents documentation](remote-agents) for detailed configuration, authentication, and usage instructions. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index e6012f4d33..708caeb08d 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -234,7 +234,9 @@ skill definitions in a `skills/` directory. For example, ### Sub-agents -> **Note:** Sub-agents are a preview feature currently under active development. + +> [!NOTE] +> Sub-agents are a preview feature currently under active development. Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. @@ -253,7 +255,9 @@ Rules contributed by extensions run in their own tier (tier 2), alongside workspace-defined policies. This tier has higher priority than the default rules but lower priority than user or admin policies. -> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo` + +> [!WARNING] +> For security, Gemini CLI ignores any `allow` decisions or `yolo` > mode configurations in extension policies. This ensures that an extension > cannot automatically approve tool calls or bypass security measures without > your confirmation. diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index d08b05eb2b..6d8758b958 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -4,7 +4,9 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. -> **Note:** Looking for a high-level comparison of all available subscriptions? + +> [!TIP] +> Looking for a high-level comparison of all available subscriptions? > To compare features and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -43,8 +45,8 @@ is logging in with your Google account. This method requires a web browser on a machine that can communicate with the terminal running Gemini CLI (for example, your local machine). -> **Important:** If you are a **Google AI Pro** or **Google AI Ultra** -> subscriber, use the Google account associated with your subscription. +If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google +account associated with your subscription. To authenticate and use Gemini CLI: @@ -107,7 +109,9 @@ To authenticate and use Gemini CLI with a Gemini API key: 4. Select **Use Gemini API key**. -> **Warning:** Treat API keys, especially for services like Gemini, as sensitive + +> [!WARNING] +> Treat API keys, especially for services like Gemini, as sensitive > credentials. Protect them to prevent unauthorized access and potential misuse > of the service under your account. @@ -150,20 +154,20 @@ To make any Vertex AI environment variable settings persistent, see Consider this authentication method if you have Google Cloud CLI installed. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them to use ADC: -> -> **macOS/Linux** -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` -> -> **Windows (PowerShell)** -> -> ```powershell -> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them to use ADC. + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. @@ -188,20 +192,20 @@ Consider this authentication method if you have Google Cloud CLI installed. Consider this method of authentication in non-interactive environments, CI/CD pipelines, or if your organization restricts user-based ADC or API key creation. -> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you -> must unset them: -> -> **macOS/Linux** -> -> ```bash -> unset GOOGLE_API_KEY GEMINI_API_KEY -> ``` -> -> **Windows (PowerShell)** -> -> ```powershell -> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -> ``` +If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset +them: + +**macOS/Linux** + +```bash +unset GOOGLE_API_KEY GEMINI_API_KEY +``` + +**Windows (PowerShell)** + +```powershell +Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the @@ -233,8 +237,11 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ``` 5. Select **Vertex AI**. - > **Warning:** Protect your service account key file as it gives access to - > your resources. + + +> [!WARNING] +> Protect your service account key file as it gives access to +> your resources. #### C. Vertex AI - Google Cloud API key @@ -257,10 +264,9 @@ pipelines, or if your organization restricts user-based ADC or API key creation. $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` - > **Note:** If you see errors like - > `"API keys are not supported by this API..."`, your organization might - > restrict API key usage for this service. Try the other Vertex AI - > authentication methods instead. + If you see errors like `"API keys are not supported by this API..."`, your + organization might restrict API key usage for this service. Try the other + Vertex AI authentication methods instead. 3. [Configure your Google Cloud Project](#set-gcp). @@ -274,7 +280,9 @@ pipelines, or if your organization restricts user-based ADC or API key creation. ## Set your Google Cloud project -> **Important:** Most individual Google accounts (free and paid) don't require a + +> [!IMPORTANT] +> Most individual Google accounts (free and paid) don't require a > Google Cloud project for authentication. When you sign in using your Google account, you may need to configure a Google @@ -339,9 +347,11 @@ persist them with the following methods: . $PROFILE ``` - > **Warning:** Be aware that when you export API keys or service account - > paths in your shell configuration file, any process launched from that - > shell can read them. + +> [!WARNING] +> Be aware that when you export API keys or service account +> paths in your shell configuration file, any process launched from that +> shell can read them. 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md index 5d31ddedb8..18ebf865b4 100644 --- a/docs/get-started/examples.md +++ b/docs/get-started/examples.md @@ -4,7 +4,9 @@ Gemini CLI helps you automate common engineering tasks by combining AI reasoning with local system tools. This document provides examples of how to use the CLI for file management, code analysis, and data transformation. -> **Note:** These examples demonstrate potential capabilities. Your actual + +> [!NOTE] +> These examples demonstrate potential capabilities. Your actual > results can vary based on the model used and your project environment. ## Rename your photographs based on content diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 529dfd995a..8e0af1a9ce 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,7 +2,9 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! -> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have + +> [!NOTE] +> Gemini 3.1 Pro Preview is rolling out. To determine whether you have > access to Gemini 3.1, use the `/model` command and select **Manual**. If you > have access, you will see `gemini-3.1-pro-preview`. > @@ -39,7 +41,9 @@ When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. -> **Note:** Looking to upgrade for higher limits? To compare subscription + +> [!TIP] +> Looking to upgrade for higher limits? To compare subscription > options and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -52,7 +56,9 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. -> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini + +> [!NOTE] +> The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry > doesn't happen immediately, please wait a few minutes for the request to > process. diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 7d526dd885..71fdec268f 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -143,7 +143,9 @@ Hooks are executed with a sanitized environment. ## Security and risks -> **Warning: Hooks execute arbitrary code with your user privileges.** By + +> [!WARNING] +> Hooks execute arbitrary code with your user privileges. By > configuring hooks, you are allowing scripts to run shell commands on your > machine. diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 8f17cd896e..7ae22b7eb5 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -132,9 +132,11 @@ to the CLI whenever the user's context changes. } ``` - **Note:** The `openFiles` list should only include files that exist on disk. - Virtual files (e.g., unsaved files without a path, editor settings pages) - **MUST** be excluded. + +> [!NOTE] +> The `openFiles` list should only include files that exist on disk. +> Virtual files (e.g., unsaved files without a path, editor settings pages) +> **MUST** be excluded. ### How the CLI uses this context diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 6686421ca4..6ff893a684 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -66,9 +66,11 @@ You can also install the extension directly from a marketplace. Follow your editor's instructions for installing extensions from this registry. -> NOTE: The "Gemini CLI Companion" extension may appear towards the bottom of -> search results. If you don't see it immediately, try scrolling down or sorting -> by "Newly Published". + +> [!NOTE] +> The "Gemini CLI Companion" extension may appear towards the bottom of +> search results. If you don't see it immediately, try scrolling down or +> sorting by "Newly Published". > > After manually installing the extension, you must run `/ide enable` in the CLI > to activate the integration. @@ -103,7 +105,9 @@ IDE, run: If connected, this command will show the IDE it's connected to and a list of recently opened files it is aware of. -> [!NOTE] The file list is limited to 10 recently accessed files within your + +> [!NOTE] +> The file list is limited to 10 recently accessed files within your > workspace and only includes local files on disk.) ### Working with diffs diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 6c023b651b..6f27592833 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -14,7 +14,9 @@ feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. --- diff --git a/docs/local-development.md b/docs/local-development.md index a31fa4aa11..83520c7506 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -79,7 +79,9 @@ You can view traces in the Jaeger UI for local development. You can use an OpenTelemetry collector to forward telemetry data to Google Cloud Trace for custom processing or routing. -> **Warning:** Ensure you complete the + +> [!WARNING] +> Ensure you complete the > [Google Cloud telemetry prerequisites](./cli/telemetry.md#prerequisites) > (Project ID, authentication, IAM roles, and APIs) before using this method. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e9383152d2..aa4a0d38db 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -60,8 +60,8 @@ Slash commands provide meta-level control over the CLI itself. - `list` (selecting this opens the auto-saved session browser) - `-- checkpoints --` - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) - - **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the - same grouped menu. + - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped + menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index f57fd40747..48601067f2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -25,7 +25,9 @@ overridden by higher numbers): Gemini CLI uses JSON settings files for persistent configuration. There are four locations for these files: -> **Tip:** JSON-aware editors can use autocomplete and validation by pointing to + +> [!TIP] +> JSON-aware editors can use autocomplete and validation by pointing to > the generated schema at `schemas/settings.schema.json` in this repository. > When working outside the repo, reference the hosted schema at > `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. @@ -66,9 +68,9 @@ an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. -> **Note for Enterprise Users:** For guidance on deploying and managing Gemini -> CLI in a corporate environment, please see the -> [Enterprise Configuration](../cli/enterprise.md) documentation. +**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI +in a corporate environment, please see the +[Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project @@ -1566,7 +1568,9 @@ for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. -> **Warning:** Avoid using underscores (`_`) in your server aliases (e.g., use + +> [!WARNING] +> Avoid using underscores (`_`) in your server aliases (e.g., use > `my-server` instead of `my_server`). The underlying policy engine parses Fully > Qualified Names (`mcp_server_tool`) using the first underscore after the > `mcp_` prefix. An underscore in your server alias will cause the parser to diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index fb97b5e071..c0ce814793 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -113,7 +113,9 @@ There are three possible decisions a rule can enforce: - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) -> **Note:** The `deny` decision is the recommended way to exclude tools. The + +> [!NOTE] +> The `deny` decision is the recommended way to exclude tools. The > legacy `tools.exclude` setting in `settings.json` is deprecated in favor of > policy rules with a `deny` decision. @@ -239,15 +241,17 @@ directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group or others (e.g., `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) - must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you - see a security warning, use the folder properties to remove write permissions - for non-admin groups. You may need to "Disable inheritance" in Advanced - Security Settings._ + must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a + security warning, use the folder properties to remove write permissions for + non-admin groups. You may need to "Disable inheritance" in Advanced Security + Settings. -**Note:** Supplemental admin policies (provided via `--admin-policy` or -`adminPolicyPaths` settings) are **NOT** subject to these strict ownership -checks, as they are explicitly provided by the user or administrator in their -current execution context. + +> [!NOTE] +> Supplemental admin policies (provided via `--admin-policy` or +> `adminPolicyPaths` settings) are **NOT** subject to these strict ownership +> checks, as they are explicitly provided by the user or administrator in their +> current execution context. ### TOML rule schema @@ -348,7 +352,9 @@ using the `mcpName` field. **This is the recommended approach** for defining MCP policies, as it is much more robust than manually writing Fully Qualified Names (FQNs) or string wildcards. -> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will diff --git a/docs/reference/tools.md b/docs/reference/tools.md index e1a0958866..c72888d072 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -95,7 +95,9 @@ For developers, the tool system is designed to be extensible and robust. The You can extend Gemini CLI with custom tools by configuring `tools.discoveryCommand` in your settings or by connecting to MCP servers. -> **Note:** For a deep dive into the internal Tool API and how to implement your + +> [!NOTE] +> For a deep dive into the internal Tool API and how to implement your > own tools in the codebase, see the `packages/core/src/tools/` directory in > GitHub. diff --git a/docs/release-confidence.md b/docs/release-confidence.md index 536e49772c..c46a702820 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -21,9 +21,13 @@ All workflows in `.github/workflows/ci.yml` must pass on the `main` branch (for nightly) or the release branch (for preview/stable). - **Platforms:** Tests must pass on **Linux and macOS**. - - _Note:_ Windows tests currently run with `continue-on-error: true`. While a - failure here doesn't block the release technically, it should be - investigated. + + +> [!NOTE] +> Windows tests currently run with `continue-on-error: true`. While a +> failure here doesn't block the release technically, it should be +> investigated. + - **Checks:** - **Linting:** No linting errors (ESLint, Prettier, etc.). - **Typechecking:** No TypeScript errors. diff --git a/docs/releases.md b/docs/releases.md index 8b506d45a8..23fb9fcf90 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -234,10 +234,12 @@ This workflow will automatically: Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. -**Security note:** The `release/*` branches are protected by branch protection -rules. A pull request to one of these branches requires at least one review from -a code owner before it can be merged. This ensures that no unauthorized code is -released. + +> [!WARNING] +> The `release/*` branches are protected by branch protection +> rules. A pull request to one of these branches requires at least one review from +> a code owner before it can be merged. This ensures that no unauthorized code is +> released. #### 2.5. Adding multiple commits to a hotfix (advanced) @@ -524,9 +526,11 @@ Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space. -> [!WARNING] The following instructions describe a fragile workaround that -> depends on the internal structure of the chat application's UI. It is likely -> to break with future updates. + +> [!WARNING] +> The following instructions describe a fragile workaround that depends on the +> internal structure of the chat application's UI. It is likely to break with +> future updates. The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 00de950e74..2aaa14cb90 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -16,8 +16,10 @@ account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and -pricing details that apply to your usage of the Gemini CLI. + +> [!NOTE] +> See [quotas and pricing](quota-and-pricing.md) for the quota and +> pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index 53b0262d36..f490d41ffe 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -187,5 +187,7 @@ guide_, consider searching the Gemini CLI If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! -> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project + +> [!NOTE] +> Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 5cdbbacf1c..9fc84d54c0 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -176,8 +176,8 @@ Each server configuration supports the following properties: enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are - exposed by the server. **Note:** `excludeTools` takes precedence over - `includeTools` - if a tool is in both lists, it will be excluded. + exposed by the server. `excludeTools` takes precedence over `includeTools`. If + a tool is in both lists, it will be excluded. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. @@ -238,7 +238,9 @@ This follows the security principle that if a variable is explicitly configured by the user for a specific server, it constitutes informed consent to share that specific data with that server. -> **Note:** Even when explicitly defined, you should avoid hardcoding secrets. + +> [!NOTE] +> Even when explicitly defined, you should avoid hardcoding secrets. > Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to > securely pull the value from your host environment at runtime. @@ -283,10 +285,12 @@ When connecting to an OAuth-enabled server: #### Browser redirect requirements -**Important:** OAuth authentication requires that your local machine can: - -- Open a web browser for authentication -- Receive redirects on `http://localhost:7777/oauth/callback` + +> [!IMPORTANT] +> OAuth authentication requires that your local machine can: +> +> - Open a web browser for authentication +> - Receive redirects on `http://localhost:7777/oauth/callback` This feature will not work in: @@ -577,7 +581,9 @@ every discovered MCP tool is assigned a strict namespace. [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) in the Policy Engine documentation. -> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use + +> [!WARNING] +> Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -1116,7 +1122,9 @@ command has no flags. gemini mcp list ``` -> **Note on Trust:** For security, `stdio` MCP servers (those using the + +> [!NOTE] +> For security, `stdio` MCP servers (those using the > `command` property) are only tested and displayed as "Connected" if the > current folder is trusted. If the folder is untrusted, they will show as > "Disconnected". Use `gemini trust` to trust the current folder. diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 9e9ab3d044..e554e47a34 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,7 +11,9 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. -> **Note:** This tool is not available when the CLI is in YOLO mode. + +> [!NOTE] +> This tool is not available when the CLI is in YOLO mode. - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode diff --git a/docs/tools/shell.md b/docs/tools/shell.md index f31f571eca..26f0769e98 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -57,8 +57,8 @@ implementation, which does not support interactive commands. ### Showing color in output To show color in the shell output, you need to set the `tools.shell.showColor` -setting to `true`. **Note: This setting only applies when -`tools.shell.enableInteractiveShell` is enabled.** +setting to `true`. This setting only applies when +`tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** @@ -75,8 +75,8 @@ setting to `true`. **Note: This setting only applies when ### Setting the pager You can set a custom pager for the shell output by setting the -`tools.shell.pager` setting. The default pager is `cat`. **Note: This setting -only applies when `tools.shell.enableInteractiveShell` is enabled.** +`tools.shell.pager` setting. The default pager is `cat`. This setting only +applies when `tools.shell.enableInteractiveShell` is enabled. **Example `settings.json`:** From 08063d7b0a75ab23716c7631b78fa0025501ac42 Mon Sep 17 00:00:00 2001 From: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:49:50 -0700 Subject: [PATCH 007/177] feat: ACP: Add token usage metadata to the `send` method's return value (#23148) --- packages/cli/src/acp/acpClient.test.ts | 16 +++---- packages/cli/src/acp/acpClient.ts | 64 +++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index ca525182b5..0f9c4a8e5b 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -551,7 +551,7 @@ describe('GeminiAgent', () => { }); expect(session.prompt).toHaveBeenCalled(); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should delegate setMode to session', async () => { @@ -750,7 +750,7 @@ describe('Session', () => { content: { type: 'text', text: 'Hello' }, }, }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle /memory command', async () => { @@ -767,7 +767,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/memory view' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/memory view', expect.any(Object), @@ -789,7 +789,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions list' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions list', expect.any(Object), @@ -811,7 +811,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions explore' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions explore', expect.any(Object), @@ -833,7 +833,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/restore' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/restore', expect.any(Object), @@ -855,7 +855,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/init' }], }); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object)); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); @@ -909,7 +909,7 @@ describe('Session', () => { }), }), ); - expect(result).toEqual({ stopReason: 'end_turn' }); + expect(result).toMatchObject({ stopReason: 'end_turn' }); }); it('should handle tool call permission request', async () => { diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index bd5a52f126..5e3f3666b1 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -699,10 +699,22 @@ export class Session { // It uses `parts` argument but effectively ignores it in current implementation const handled = await this.handleCommand(commandText, parts); if (handled) { - return { stopReason: 'end_turn' }; + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { input_tokens: 0, output_tokens: 0 }, + model_usage: [], + }, + }, + }; } } + let totalInputTokens = 0; + let totalOutputTokens = 0; + const modelUsageMap = new Map(); + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -727,11 +739,25 @@ export class Session { ); nextMessage = null; + let turnInputTokens = 0; + let turnOutputTokens = 0; + let turnModelId = model; + for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } + if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { + turnInputTokens = + resp.value.usageMetadata.promptTokenCount ?? turnInputTokens; + turnOutputTokens = + resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens; + if (resp.value.modelVersion) { + turnModelId = resp.value.modelVersion; + } + } + if ( resp.type === StreamEventType.CHUNK && resp.value.candidates && @@ -763,6 +789,19 @@ export class Session { } } + totalInputTokens += turnInputTokens; + totalOutputTokens += turnOutputTokens; + + if (turnInputTokens > 0 || turnOutputTokens > 0) { + const existing = modelUsageMap.get(turnModelId) ?? { + input: 0, + output: 0, + }; + existing.input += turnInputTokens; + existing.output += turnOutputTokens; + modelUsageMap.set(turnModelId, existing); + } + if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } @@ -799,7 +838,28 @@ export class Session { } } - return { stopReason: 'end_turn' }; + const modelUsageArray = Array.from(modelUsageMap.entries()).map( + ([modelName, counts]) => ({ + model: modelName, + token_count: { + input_tokens: counts.input, + output_tokens: counts.output, + }, + }), + ); + + return { + stopReason: 'end_turn', + _meta: { + quota: { + token_count: { + input_tokens: totalInputTokens, + output_tokens: totalOutputTokens, + }, + model_usage: modelUsageArray, + }, + }, + }; } private async handleCommand( From 98d1bec99f24602d3bb51cb21c4672f8d60cf2ee Mon Sep 17 00:00:00 2001 From: ruomeng Date: Thu, 19 Mar 2026 17:51:10 -0400 Subject: [PATCH 008/177] fix(plan): clarify that plan mode policies are combined with normal mode (#23158) --- packages/cli/src/ui/commands/policiesCommand.test.ts | 8 ++++++-- packages/cli/src/ui/commands/policiesCommand.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index c5baa89d5d..929b528290 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -116,7 +116,9 @@ describe('policiesCommand', () => { expect(content).toContain( '### Yolo Mode Policies (combined with normal mode policies)', ); - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); expect(content).toContain( '**DENY** tool: `dangerousTool` [Priority: 10]', ); @@ -162,7 +164,9 @@ describe('policiesCommand', () => { const content = (call[0] as { text: string }).text; // Plan-only rules appear under Plan Mode section - expect(content).toContain('### Plan Mode Policies'); + expect(content).toContain( + '### Plan Mode Policies (combined with normal mode policies)', + ); // glob ALLOW is plan-only, should appear in plan section expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); // shell ALLOW has no modes (applies to all), appears in normal section diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index 40ed56ae3b..c6f3b1e1e1 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -100,7 +100,10 @@ const listPoliciesCommand: SlashCommand = { 'Yolo Mode Policies (combined with normal mode policies)', uniqueYolo, ); - content += formatSection('Plan Mode Policies', uniquePlan); + content += formatSection( + 'Plan Mode Policies (combined with normal mode policies)', + uniquePlan, + ); context.ui.addItem( { From 0e66f545ca6bf7833f82f3f239dfd21ee131b37a Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Thu, 19 Mar 2026 15:22:08 -0700 Subject: [PATCH 009/177] Ensure that copied extensions are writable in the user's local directory (#23016) --- .../extension-manager-permissions.test.ts | 133 ++++++++++++++++++ .../config/extension-manager-skills.test.ts | 9 ++ packages/cli/src/config/extension-manager.ts | 21 +++ .../extensions/extensionUpdates.test.ts | 7 + 4 files changed, 170 insertions(+) create mode 100644 packages/cli/src/config/extension-manager-permissions.test.ts diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts new file mode 100644 index 0000000000..662f30d430 --- /dev/null +++ b/packages/cli/src/config/extension-manager-permissions.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { copyExtension } from './extension-manager.js'; + +describe('copyExtension permissions', () => { + let tempDir: string; + let sourceDir: string; + let destDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); + sourceDir = path.join(tempDir, 'source'); + destDir = path.join(tempDir, 'dest'); + fs.mkdirSync(sourceDir); + }); + + afterEach(() => { + // Ensure we can delete the temp directory by making everything writable again + const makeWritableSync = (p: string) => { + try { + const stats = fs.lstatSync(p); + fs.chmodSync(p, stats.mode | 0o700); + if (stats.isDirectory()) { + fs.readdirSync(p).forEach((child) => + makeWritableSync(path.join(p, child)), + ); + } + } catch (_e) { + // Ignore errors during cleanup + } + }; + + if (fs.existsSync(tempDir)) { + makeWritableSync(tempDir); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should make destination writable even if source is read-only', async () => { + const fileName = 'test.txt'; + const filePath = path.join(sourceDir, fileName); + fs.writeFileSync(filePath, 'hello'); + + // Make source read-only: 0o555 for directory, 0o444 for file + fs.chmodSync(filePath, 0o444); + fs.chmodSync(sourceDir, 0o555); + + // Verify source is read-only + expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify destination is writable + const destFilePath = path.join(destDir, fileName); + const destFileStats = fs.statSync(destFilePath); + const destDirStats = fs.statSync(destDir); + + // Check that owner write bits are set (0o200) + expect(destFileStats.mode & 0o200).toBe(0o200); + expect(destDirStats.mode & 0o200).toBe(0o200); + + // Verify we can actually write to the destination file + fs.writeFileSync(destFilePath, 'writable'); + expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); + + // Verify we can delete the destination (which requires write bit on destDir) + fs.rmSync(destFilePath); + expect(fs.existsSync(destFilePath)).toBe(false); + }); + + it('should handle nested directories with restrictive permissions', async () => { + const subDir = path.join(sourceDir, 'subdir'); + fs.mkdirSync(subDir); + const fileName = 'nested.txt'; + const filePath = path.join(subDir, fileName); + fs.writeFileSync(filePath, 'nested content'); + + // Make nested structure read-only + fs.chmodSync(filePath, 0o444); + fs.chmodSync(subDir, 0o555); + fs.chmodSync(sourceDir, 0o555); + + // Perform copy + await copyExtension(sourceDir, destDir); + + // Verify nested destination is writable + const destSubDir = path.join(destDir, 'subdir'); + const destFilePath = path.join(destSubDir, fileName); + + expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); + expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); + + // Verify we can delete the whole destination tree + await fs.promises.rm(destDir, { recursive: true, force: true }); + expect(fs.existsSync(destDir)).toBe(false); + }); + + it('should not follow symlinks or modify symlink targets', async () => { + const symlinkTarget = path.join(tempDir, 'external-target'); + fs.writeFileSync(symlinkTarget, 'external content'); + // Target is read-only + fs.chmodSync(symlinkTarget, 0o444); + + const symlinkPath = path.join(sourceDir, 'symlink-file'); + fs.symlinkSync(symlinkTarget, symlinkPath); + + // Perform copy + await copyExtension(sourceDir, destDir); + + const destSymlinkPath = path.join(destDir, 'symlink-file'); + const destSymlinkStats = fs.lstatSync(destSymlinkPath); + + // Verify it is still a symlink in the destination + expect(destSymlinkStats.isSymbolicLink()).toBe(true); + + // Verify the target (external to the extension) was NOT modified + const targetStats = fs.statSync(symlinkTarget); + // Owner write bit should still NOT be set (0o200) + expect(targetStats.mode & 0o200).toBe(0o000); + + // Clean up + fs.chmodSync(symlinkTarget, 0o644); + }); +}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index a76d88482d..800417de36 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -15,6 +15,10 @@ import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); +const mockIntegrityManager = vi.hoisted(() => ({ + verify: vi.fn().mockResolvedValue('verified'), + store: vi.fn().mockResolvedValue(undefined), +})); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -31,6 +35,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, + ExtensionIntegrityManager: vi + .fn() + .mockImplementation(() => mockIntegrityManager), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), @@ -64,6 +71,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); }); @@ -139,6 +147,7 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, + integrityManager: mockIntegrityManager, }); // 4. Load extensions diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 2c46a845e6..dd37d0ea1b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -1248,11 +1248,32 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { return Object.freeze(rest); } +/** + * Recursively ensures that the owner has write permissions for all files + * and directories within the target path. + */ +async function makeWritableRecursive(targetPath: string): Promise { + const stats = await fs.promises.lstat(targetPath); + + if (stats.isDirectory()) { + // Ensure directory is rwx for the owner (0o700) + await fs.promises.chmod(targetPath, stats.mode | 0o700); + const children = await fs.promises.readdir(targetPath); + for (const child of children) { + await makeWritableRecursive(path.join(targetPath, child)); + } + } else if (stats.isFile()) { + // Ensure file is rw for the owner (0o600) + await fs.promises.chmod(targetPath, stats.mode | 0o600); + } +} + export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); + await makeWritableRecursive(destination); } function getContextFileNames(config: ExtensionConfig): string[] { diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 69339b4eeb..89282fcd8a 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -36,6 +36,8 @@ vi.mock('node:fs', async (importOriginal) => { rm: vi.fn(), cp: vi.fn(), readFile: vi.fn(), + lstat: vi.fn(), + chmod: vi.fn(), }, }; }); @@ -143,6 +145,11 @@ describe('extensionUpdates', () => { vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); vi.mocked(fs.promises.readdir).mockResolvedValue([]); + vi.mocked(fs.promises.lstat).mockResolvedValue({ + isDirectory: () => true, + mode: 0o755, + } as unknown as fs.Stats); + vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', From 06a7873c5146a55f43dd86edb70cb8819fdb810d Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Thu, 19 Mar 2026 15:22:26 -0700 Subject: [PATCH 010/177] Add ModelChain support to ModelConfigService and make ModelDialog dynamic (#22914) --- docs/reference/configuration.md | 171 +++++++- packages/cli/src/config/settingsSchema.ts | 42 ++ .../cli/src/ui/components/ModelDialog.tsx | 111 ++++- .../src/availability/policyHelpers.test.ts | 62 +++ .../core/src/availability/policyHelpers.ts | 55 +++ packages/core/src/config/config.ts | 5 + .../core/src/config/defaultModelConfigs.ts | 146 ++++++- packages/core/src/config/models.test.ts | 8 - packages/core/src/config/models.ts | 15 +- .../core/src/services/modelConfigService.ts | 25 ++ schemas/settings.schema.json | 392 +++++++++++++++++- 11 files changed, 1014 insertions(+), 18 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 48601067f2..2606890b0a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -686,6 +686,16 @@ their corresponding top-level category object in your `settings.json` file. ```json { + "gemini-3.1-flash-lite-preview": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", @@ -797,7 +807,7 @@ their corresponding top-level category object in your `settings.json` file. "tier": "auto", "isPreview": true, "isVisible": true, - "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false @@ -826,6 +836,39 @@ their corresponding top-level category object in your `settings.json` file. ```json { + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ @@ -997,6 +1040,132 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes +- **`modelConfigs.modelChains`** (object): + - **Description:** Availability policy chains defining fallback behavior for + models. + - **Default:** + + ```json + { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "gemini-2.5-flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] + } + ``` + + - **Requires restart:** Yes + #### `agents` - **`agents.overrides`** (object): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ea6b9f9239..77e1bb0c09 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1081,6 +1081,20 @@ const SETTINGS_SCHEMA = { ref: 'ModelResolution', }, }, + modelChains: { + type: 'object', + label: 'Model Chains', + category: 'Model', + requiresRestart: true, + default: DEFAULT_MODEL_CONFIGS.modelChains, + description: + 'Availability policy chains defining fallback behavior for models.', + showInDialog: false, + additionalProperties: { + type: 'array', + ref: 'ModelPolicy', + }, + }, }, }, @@ -2877,6 +2891,34 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelPolicy: { + type: 'object', + description: + 'Defines the policy for a single model in the availability chain.', + properties: { + model: { type: 'string' }, + isLastResort: { type: 'boolean' }, + actions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['silent', 'prompt'] }, + transient: { type: 'string', enum: ['silent', 'prompt'] }, + not_found: { type: 'string', enum: ['silent', 'prompt'] }, + unknown: { type: 'string', enum: ['silent', 'prompt'] }, + }, + }, + stateTransitions: { + type: 'object', + properties: { + terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, + }, + }, + }, + required: ['model'], + }, }; export function getSettingsSchema(): SettingsSchemaType { diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index b8ff3f251a..85cf16de3b 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -68,6 +68,17 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { useGemini31 && selectedAuthType === AuthType.USE_GEMINI; const manualModelSelected = useMemo(() => { + if ( + config?.getExperimentalDynamicModelConfiguration?.() === true && + config.modelConfigService + ) { + const def = config.modelConfigService.getModelDefinition(preferredModel); + // Only treat as manual selection if it's a visible, non-auto model. + return def && def.tier !== 'auto' && def.isVisible === true + ? preferredModel + : ''; + } + const manualModels = [ DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, @@ -81,7 +92,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { return preferredModel; } return ''; - }, [preferredModel]); + }, [preferredModel, config]); useKeypress( (key) => { @@ -103,6 +114,47 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ); const mainOptions = useMemo(() => { + // --- DYNAMIC PATH --- + if ( + config?.getExperimentalDynamicModelConfiguration?.() === true && + config.modelConfigService + ) { + const list = Object.entries( + config.modelConfigService.getModelDefinitions?.() ?? {}, + ) + .filter(([_, m]) => { + // Basic visibility and Preview access + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + // Only auto models are shown on the main menu + if (m.tier !== 'auto') return false; + return true; + }) + .map(([id, m]) => ({ + value: id, + title: m.displayName ?? getDisplayString(id, config ?? undefined), + description: + id === 'auto-gemini-3' && useGemini31 + ? (m.dialogDescription ?? '').replace( + 'gemini-3-pro', + 'gemini-3.1-pro', + ) + : (m.dialogDescription ?? ''), + key: id, + })); + + list.push({ + value: 'Manual', + title: manualModelSelected + ? `Manual (${getDisplayString(manualModelSelected, config ?? undefined)})` + : 'Manual', + description: 'Manually select a model', + key: 'Manual', + }); + return list; + } + + // --- LEGACY PATH --- const list = [ { value: DEFAULT_GEMINI_MODEL_AUTO, @@ -132,10 +184,65 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }); } return list; - }, [shouldShowPreviewModels, manualModelSelected, useGemini31]); + }, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]); const manualOptions = useMemo(() => { const isFreeTier = config?.getUserTier() === UserTierId.FREE; + // --- DYNAMIC PATH --- + if ( + config?.getExperimentalDynamicModelConfiguration?.() === true && + config.modelConfigService + ) { + const list = Object.entries( + config.modelConfigService.getModelDefinitions?.() ?? {}, + ) + .filter(([id, m]) => { + // Basic visibility and Preview access + if (m.isVisible !== true) return false; + if (m.isPreview && !shouldShowPreviewModels) return false; + // Auto models are for main menu only + if (m.tier === 'auto') return false; + // Pro models are shown for users with pro access + if (!hasAccessToProModel && m.tier === 'pro') return false; + // 3.1 Preview Flash-lite is only available on free tier + if (m.tier === 'flash-lite' && m.isPreview && !isFreeTier) + return false; + + // Flag Guard: Versioned models only show if their flag is active. + if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; + if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31) + return false; + + return true; + }) + .map(([id, m]) => { + const resolvedId = config.modelConfigService.resolveModelId(id, { + useGemini3_1: useGemini31, + useCustomTools: useCustomToolModel, + }); + // Title ID is the resolved ID without custom tools flag + const titleId = config.modelConfigService.resolveModelId(id, { + useGemini3_1: useGemini31, + }); + return { + value: resolvedId, + title: + m.displayName ?? getDisplayString(titleId, config ?? undefined), + key: id, + }; + }); + + // Deduplicate: only show one entry per unique resolved model value. + // This is needed because 3 pro and 3.1 pro models can resolve to the same value. + const seen = new Set(); + return list.filter((option) => { + if (seen.has(option.value)) return false; + seen.add(option.value); + return true; + }); + } + + // --- LEGACY PATH --- const list = [ { value: DEFAULT_GEMINI_MODEL, diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index 23c6ef4fd4..8ec32e8292 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -19,6 +19,8 @@ import { PREVIEW_GEMINI_3_1_MODEL, } from '../config/models.js'; import { AuthType } from '../core/contentGenerator.js'; +import { ModelConfigService } from '../services/modelConfigService.js'; +import { DEFAULT_MODEL_CONFIGS } from '../config/defaultModelConfigs.js'; const createMockConfig = (overrides: Partial = {}): Config => { const config = { @@ -163,6 +165,66 @@ describe('policyHelpers', () => { }); }); + describe('resolvePolicyChain behavior is identical between dynamic and legacy implementations', () => { + const testCases = [ + { name: 'Default Auto', model: DEFAULT_GEMINI_MODEL_AUTO }, + { name: 'Gemini 3 Auto', model: 'auto-gemini-3' }, + { name: 'Flash Lite', model: DEFAULT_GEMINI_FLASH_LITE_MODEL }, + { + name: 'Gemini 3 Auto (3.1 Enabled)', + model: 'auto-gemini-3', + useGemini31: true, + }, + { + name: 'Gemini 3 Auto (3.1 + Custom Tools)', + model: 'auto-gemini-3', + useGemini31: true, + authType: AuthType.USE_GEMINI, + }, + { + name: 'Gemini 3 Auto (No Access)', + model: 'auto-gemini-3', + hasAccess: false, + }, + { name: 'Concrete Model (2.5 Pro)', model: 'gemini-2.5-pro' }, + { name: 'Custom Model', model: 'my-custom-model' }, + { + name: 'Wrap Around', + model: DEFAULT_GEMINI_MODEL_AUTO, + wrapsAround: true, + }, + ]; + + testCases.forEach( + ({ name, model, useGemini31, hasAccess, authType, wrapsAround }) => { + it(`achieves parity for: ${name}`, () => { + const createBaseConfig = (dynamic: boolean) => + createMockConfig({ + getExperimentalDynamicModelConfiguration: () => dynamic, + getModel: () => model, + getGemini31LaunchedSync: () => useGemini31 ?? false, + getHasAccessToPreviewModel: () => hasAccess ?? true, + getContentGeneratorConfig: () => ({ authType }), + modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS), + }); + + const legacyChain = resolvePolicyChain( + createBaseConfig(false), + model, + wrapsAround, + ); + const dynamicChain = resolvePolicyChain( + createBaseConfig(true), + model, + wrapsAround, + ); + + expect(dynamicChain).toEqual(legacyChain); + }); + }, + ); + }); + describe('buildFallbackPolicyContext', () => { it('returns remaining candidates after the failed model', () => { const chain = [ diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 290c47d896..bd8cede300 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -53,12 +53,57 @@ export function resolvePolicyChain( useGemini31, useCustomToolModel, hasAccessToPreview, + config, ); const isAutoPreferred = preferredModel ? isAutoModel(preferredModel, config) : false; const isAutoConfigured = isAutoModel(configuredModel, config); + // --- DYNAMIC PATH --- + if (config.getExperimentalDynamicModelConfiguration?.() === true) { + const context = { + useGemini3_1: useGemini31, + useCustomTools: useCustomToolModel, + }; + + if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { + chain = config.modelConfigService.resolveChain('lite', context); + } else if ( + isGemini3Model(resolvedModel, config) || + isAutoModel(preferredModel ?? '', config) || + isAutoModel(configuredModel, config) + ) { + // 1. Try to find a chain specifically for the current configured alias + if ( + isAutoModel(configuredModel, config) && + config.modelConfigService.getModelChain(configuredModel) + ) { + chain = config.modelConfigService.resolveChain( + configuredModel, + context, + ); + } + // 2. Fallback to family-based auto-routing + if (!chain) { + const previewEnabled = + hasAccessToPreview && + (isGemini3Model(resolvedModel, config) || + preferredModel === PREVIEW_GEMINI_MODEL_AUTO || + configuredModel === PREVIEW_GEMINI_MODEL_AUTO); + const chainKey = previewEnabled ? 'preview' : 'default'; + chain = config.modelConfigService.resolveChain(chainKey, context); + } + } + if (!chain) { + // No matching modelChains found, default to single model chain + chain = createSingleModelChain(modelFromConfig); + } + return applyDynamicSlicing(chain, resolvedModel, wrapsAround); + } + + // --- LEGACY PATH --- + if (resolvedModel === DEFAULT_GEMINI_FLASH_LITE_MODEL) { chain = getFlashLitePolicyChain(); } else if ( @@ -90,7 +135,17 @@ export function resolvePolicyChain( } else { chain = createSingleModelChain(modelFromConfig); } + return applyDynamicSlicing(chain, resolvedModel, wrapsAround); +} +/** + * Applies active-index slicing and wrap-around logic to a chain template. + */ +function applyDynamicSlicing( + chain: ModelPolicy[], + resolvedModel: string, + wrapsAround: boolean, +): ModelPolicyChain { const activeIndex = chain.findIndex( (policy) => policy.model === resolvedModel, ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 81bfa82bd3..f9db411c9d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -994,6 +994,10 @@ export class Config implements McpContext, AgentLoopContext { ...DEFAULT_MODEL_CONFIGS.classifierIdResolutions, ...modelConfigServiceConfig.classifierIdResolutions, }; + const mergedModelChains = { + ...DEFAULT_MODEL_CONFIGS.modelChains, + ...modelConfigServiceConfig.modelChains, + }; modelConfigServiceConfig = { // Preserve other user settings like customAliases @@ -1007,6 +1011,7 @@ export class Config implements McpContext, AgentLoopContext { modelDefinitions: mergedModelDefinitions, modelIdResolutions: mergedModelIdResolutions, classifierIdResolutions: mergedClassifierIdResolutions, + modelChains: mergedModelChains, }; } diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts index 4a9315359b..3e18ee187d 100644 --- a/packages/core/src/config/defaultModelConfigs.ts +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -251,6 +251,13 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { ], modelDefinitions: { // Concrete Models + 'gemini-3.1-flash-lite-preview': { + tier: 'flash-lite', + family: 'gemini-3', + isPreview: true, + isVisible: true, + features: { thinking: false, multimodalToolUse: true }, + }, 'gemini-3.1-pro-preview': { tier: 'pro', family: 'gemini-3', @@ -331,7 +338,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { isPreview: true, isVisible: true, dialogDescription: - 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash', + 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', features: { thinking: true, multimodalToolUse: false }, }, 'auto-gemini-2.5': { @@ -345,6 +352,27 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, }, modelIdResolutions: { + 'gemini-3.1-pro-preview': { + default: 'gemini-3.1-pro-preview', + contexts: [ + { condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' }, + ], + }, + 'gemini-3.1-pro-preview-customtools': { + default: 'gemini-3.1-pro-preview-customtools', + contexts: [ + { condition: { hasAccessToPreview: false }, target: 'gemini-2.5-pro' }, + ], + }, + 'gemini-3-flash-preview': { + default: 'gemini-3-flash-preview', + contexts: [ + { + condition: { hasAccessToPreview: false }, + target: 'gemini-2.5-flash', + }, + ], + }, 'gemini-3-pro-preview': { default: 'gemini-3-pro-preview', contexts: [ @@ -451,4 +479,120 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { ], }, }, + modelChains: { + preview: [ + { + model: 'gemini-3-pro-preview', + actions: { + terminal: 'prompt', + transient: 'prompt', + not_found: 'prompt', + unknown: 'prompt', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + { + model: 'gemini-3-flash-preview', + isLastResort: true, + actions: { + terminal: 'prompt', + transient: 'prompt', + not_found: 'prompt', + unknown: 'prompt', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + ], + default: [ + { + model: 'gemini-2.5-pro', + actions: { + terminal: 'prompt', + transient: 'prompt', + not_found: 'prompt', + unknown: 'prompt', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + { + model: 'gemini-2.5-flash', + isLastResort: true, + actions: { + terminal: 'prompt', + transient: 'prompt', + not_found: 'prompt', + unknown: 'prompt', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + ], + lite: [ + { + model: 'gemini-2.5-flash-lite', + actions: { + terminal: 'silent', + transient: 'silent', + not_found: 'silent', + unknown: 'silent', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + { + model: 'gemini-2.5-flash', + actions: { + terminal: 'silent', + transient: 'silent', + not_found: 'silent', + unknown: 'silent', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + { + model: 'gemini-2.5-pro', + isLastResort: true, + actions: { + terminal: 'silent', + transient: 'silent', + not_found: 'silent', + unknown: 'silent', + }, + stateTransitions: { + terminal: 'terminal', + transient: 'terminal', + not_found: 'terminal', + unknown: 'terminal', + }, + }, + ], + }, }; diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 9aa1e00058..dbe558fc85 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -190,14 +190,6 @@ describe('Dynamic Configuration Parity', () => { } }); - it('supportsModernFeatures should match legacy behavior', () => { - for (const model of modelsToTest) { - const legacy = supportsModernFeatures(model); - const dynamic = supportsModernFeatures(model); - expect(dynamic).toBe(legacy); - } - }); - it('supportsMultimodalFunctionResponse should match legacy behavior', () => { for (const model of modelsToTest) { const legacy = supportsMultimodalFunctionResponse(model, legacyConfig); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 7e1a57c5c3..f356bebbaa 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -102,11 +102,24 @@ export function resolveModel( config?: ModelCapabilityContext, ): string { if (config?.getExperimentalDynamicModelConfiguration?.() === true) { - return config.modelConfigService.resolveModelId(requestedModel, { + const resolved = config.modelConfigService.resolveModelId(requestedModel, { useGemini3_1, useCustomTools: useCustomToolModel, hasAccessToPreview, }); + + if (!hasAccessToPreview && isPreviewModel(resolved, config)) { + // Fallback for unknown preview models. + if (resolved.includes('flash-lite')) { + return DEFAULT_GEMINI_FLASH_LITE_MODEL; + } + if (resolved.includes('flash')) { + return DEFAULT_GEMINI_FLASH_MODEL; + } + return DEFAULT_GEMINI_MODEL; + } + + return resolved; } let resolved: string; diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index 581dbfecb9..e88f1287d5 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -5,6 +5,7 @@ */ import type { GenerateContentConfig } from '@google/genai'; +import type { ModelPolicy } from '../availability/modelPolicy.js'; // The primary key for the ModelConfig is the model string. However, we also // support a secondary key to limit the override scope, typically an agent name. @@ -111,6 +112,7 @@ export interface ModelConfigServiceConfig { modelDefinitions?: Record; modelIdResolutions?: Record; classifierIdResolutions?: Record; + modelChains?: Record; } const MAX_ALIAS_CHAIN_DEPTH = 100; @@ -221,6 +223,29 @@ export class ModelConfigService { return resolution.default; } + getModelChain(chainName: string): ModelPolicy[] | undefined { + return this.config.modelChains?.[chainName]; + } + + /** + * Fetches a chain template and resolves all model IDs within it + * based on the provided context. + */ + resolveChain( + chainName: string, + context: ResolutionContext = {}, + ): ModelPolicy[] | undefined { + const template = this.config.modelChains?.[chainName]; + if (!template) { + return undefined; + } + // Map through the template and resolve each model ID + return template.map((policy) => ({ + ...policy, + model: this.resolveModelId(policy.model, context), + })); + } + registerRuntimeModelConfig(aliasName: string, alias: ModelConfigAlias): void { this.runtimeAliases[aliasName] = alias; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 2b528ad8dc..a6f507ae63 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -629,7 +629,7 @@ "modelConfigs": { "title": "Model Configs", "description": "Model configurations.", - "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ],\n \"modelDefinitions\": {\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n },\n \"modelIdResolutions\": {\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n },\n \"classifierIdResolutions\": {\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-flash\"\n },\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-3\",\n \"gemini-3-pro-preview\"\n ]\n },\n \"target\": \"gemini-3-flash-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n }\n }\n}`", + "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ],\n \"modelDefinitions\": {\n \"gemini-3.1-flash-lite-preview\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n },\n \"modelIdResolutions\": {\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n },\n \"classifierIdResolutions\": {\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-flash\"\n },\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-3\",\n \"gemini-3-pro-preview\"\n ]\n },\n \"target\": \"gemini-3-flash-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n }\n },\n \"modelChains\": {\n \"preview\": [\n {\n \"model\": \"gemini-3-pro-preview\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-3-flash-preview\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"default\": [\n {\n \"model\": \"gemini-2.5-pro\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"lite\": [\n {\n \"model\": \"gemini-2.5-flash-lite\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-pro\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ]\n }\n}`", "default": { "aliases": { "base": { @@ -873,6 +873,16 @@ } ], "modelDefinitions": { + "gemini-3.1-flash-lite-preview": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", @@ -984,7 +994,7 @@ "tier": "auto", "isPreview": true, "isVisible": true, - "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false @@ -1003,6 +1013,39 @@ } }, "modelIdResolutions": { + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ @@ -1159,6 +1202,122 @@ } ] } + }, + "modelChains": { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "gemini-2.5-flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] } }, "type": "object", @@ -1425,8 +1584,18 @@ "modelDefinitions": { "title": "Model Definitions", "description": "Registry of model metadata, including tier, family, and features.", - "markdownDescription": "Registry of model metadata, including tier, family, and features.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n}`", + "markdownDescription": "Registry of model metadata, including tier, family, and features.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3.1-flash-lite-preview\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n}`", "default": { + "gemini-3.1-flash-lite-preview": { + "tier": "flash-lite", + "family": "gemini-3", + "isPreview": true, + "isVisible": true, + "features": { + "thinking": false, + "multimodalToolUse": true + } + }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", @@ -1538,7 +1707,7 @@ "tier": "auto", "isPreview": true, "isVisible": true, - "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false @@ -1564,8 +1733,41 @@ "modelIdResolutions": { "title": "Model ID Resolutions", "description": "Rules for resolving requested model names to concrete model IDs based on context.", - "markdownDescription": "Rules for resolving requested model names to concrete model IDs based on context.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n}`", + "markdownDescription": "Rules for resolving requested model names to concrete model IDs based on context.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n}`", "default": { + "gemini-3.1-pro-preview": { + "default": "gemini-3.1-pro-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3.1-pro-preview-customtools": { + "default": "gemini-3.1-pro-preview-customtools", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-pro" + } + ] + }, + "gemini-3-flash-preview": { + "default": "gemini-3-flash-preview", + "contexts": [ + { + "condition": { + "hasAccessToPreview": false + }, + "target": "gemini-2.5-flash" + } + ] + }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ @@ -1736,6 +1938,131 @@ "additionalProperties": { "$ref": "#/$defs/ModelResolution" } + }, + "modelChains": { + "title": "Model Chains", + "description": "Availability policy chains defining fallback behavior for models.", + "markdownDescription": "Availability policy chains defining fallback behavior for models.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"preview\": [\n {\n \"model\": \"gemini-3-pro-preview\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-3-flash-preview\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"default\": [\n {\n \"model\": \"gemini-2.5-pro\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"lite\": [\n {\n \"model\": \"gemini-2.5-flash-lite\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-pro\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ]\n}`", + "default": { + "preview": [ + { + "model": "gemini-3-pro-preview", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-3-flash-preview", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "default": [ + { + "model": "gemini-2.5-pro", + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "isLastResort": true, + "actions": { + "terminal": "prompt", + "transient": "prompt", + "not_found": "prompt", + "unknown": "prompt" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ], + "lite": [ + { + "model": "gemini-2.5-flash-lite", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-flash", + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + }, + { + "model": "gemini-2.5-pro", + "isLastResort": true, + "actions": { + "terminal": "silent", + "transient": "silent", + "not_found": "silent", + "unknown": "silent" + }, + "stateTransitions": { + "terminal": "terminal", + "transient": "terminal", + "not_found": "terminal", + "unknown": "terminal" + } + } + ] + }, + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ModelPolicy" + } } }, "additionalProperties": false @@ -3253,6 +3580,61 @@ } } } + }, + "ModelPolicy": { + "type": "object", + "description": "Defines the policy for a single model in the availability chain.", + "properties": { + "model": { + "type": "string" + }, + "isLastResort": { + "type": "boolean" + }, + "actions": { + "type": "object", + "properties": { + "terminal": { + "type": "string", + "enum": ["silent", "prompt"] + }, + "transient": { + "type": "string", + "enum": ["silent", "prompt"] + }, + "not_found": { + "type": "string", + "enum": ["silent", "prompt"] + }, + "unknown": { + "type": "string", + "enum": ["silent", "prompt"] + } + } + }, + "stateTransitions": { + "type": "object", + "properties": { + "terminal": { + "type": "string", + "enum": ["terminal", "sticky_retry"] + }, + "transient": { + "type": "string", + "enum": ["terminal", "sticky_retry"] + }, + "not_found": { + "type": "string", + "enum": ["terminal", "sticky_retry"] + }, + "unknown": { + "type": "string", + "enum": ["terminal", "sticky_retry"] + } + } + } + }, + "required": ["model"] } } } From c9a336976b060ca16bf19723fa5f4d8f964334d8 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Thu, 19 Mar 2026 15:25:22 -0700 Subject: [PATCH 011/177] feat(core): implement native Windows sandboxing (#21807) --- .geminiignore | 1 + docs/cli/sandbox.md | 20 +- docs/cli/settings.md | 2 + docs/reference/configuration.md | 13 +- eslint.config.js | 7 +- packages/cli/src/config/config.ts | 13 + packages/cli/src/config/sandboxConfig.test.ts | 7 + packages/cli/src/config/sandboxConfig.ts | 19 +- packages/cli/src/config/settingsSchema.ts | 22 +- .../core/scripts/compile-windows-sandbox.js | 121 ++++++ packages/core/src/config/config.ts | 45 ++- packages/core/src/index.ts | 2 + .../core/src/services/sandboxManager.test.ts | 14 +- packages/core/src/services/sandboxManager.ts | 24 +- .../src/services/sandboxManagerFactory.ts | 45 +++ .../sandboxedFileSystemService.test.ts | 133 +++++++ .../services/sandboxedFileSystemService.ts | 128 ++++++ .../src/services/scripts/GeminiSandbox.cs | 370 ++++++++++++++++++ .../src/services/shellExecutionService.ts | 209 +++++----- .../services/windowsSandboxManager.test.ts | 68 ++++ .../src/services/windowsSandboxManager.ts | 228 +++++++++++ schemas/settings.schema.json | 21 +- scripts/copy_files.js | 2 +- 23 files changed, 1365 insertions(+), 149 deletions(-) create mode 100644 .geminiignore create mode 100644 packages/core/scripts/compile-windows-sandbox.js create mode 100644 packages/core/src/services/sandboxManagerFactory.ts create mode 100644 packages/core/src/services/sandboxedFileSystemService.test.ts create mode 100644 packages/core/src/services/sandboxedFileSystemService.ts create mode 100644 packages/core/src/services/scripts/GeminiSandbox.cs create mode 100644 packages/core/src/services/windowsSandboxManager.test.ts create mode 100644 packages/core/src/services/windowsSandboxManager.ts diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 0000000000..e40b6ba36e --- /dev/null +++ b/.geminiignore @@ -0,0 +1 @@ +packages/core/src/services/scripts/*.exe diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index d05950419b..b34433a878 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,7 +50,25 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. gVisor / runsc (Linux only) +### 3. Windows Native Sandbox (Windows only) + +... **Troubleshooting and Side Effects:** + +The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory +Level" on files and directories it needs to write to. + +- **Persistence**: These integrity level changes are persistent on the + filesystem. Even after the sandbox session ends, files created or modified by + the sandbox will retain their "Low" integrity level. +- **Manual Reset**: If you need to reset the integrity level of a file or + directory, you can use: + ```powershell + icacls "C:\path\to\dir" /setintegritylevel Medium + ``` +- **System Folders**: The sandbox manager automatically skips setting integrity + levels on system folders (like `C:\Windows`) for safety. + +### 4. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 853e46fc0a..85373f1034 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -117,6 +117,8 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | +| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2606890b0a..81a05bf51c 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1276,10 +1276,21 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", - "lxc"). + "lxc", "windows-native"). - **Default:** `undefined` - **Requires restart:** Yes +- **`tools.sandboxAllowedPaths`** (array): + - **Description:** List of additional paths that the sandbox is allowed to + access. + - **Default:** `[]` + - **Requires restart:** Yes + +- **`tools.sandboxNetworkAccess`** (boolean): + - **Description:** Whether the sandbox is allowed to access the network. + - **Default:** `false` + - **Requires restart:** Yes + - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. diff --git a/eslint.config.js b/eslint.config.js index 99b1b28f4b..76230fdfe5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -319,7 +319,12 @@ export default tseslint.config( }, }, { - files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'], + files: [ + './scripts/**/*.js', + 'packages/*/scripts/**/*.js', + 'esbuild.config.js', + 'packages/core/scripts/**/*.{js,mjs}', + ], languageOptions: { globals: { ...globals.node, diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 777950c0ca..3c74fd05bd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -702,6 +702,19 @@ export async function loadCliConfig( ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); + if (sandboxConfig) { + const existingPaths = sandboxConfig.allowedPaths || []; + if (settings.tools.sandboxAllowedPaths?.length) { + sandboxConfig.allowedPaths = [ + ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), + ]; + } + if (settings.tools.sandboxNetworkAccess !== undefined) { + sandboxConfig.networkAccess = + sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; + } + } + const screenReader = argv.screenReader !== undefined ? argv.screenReader diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index cfe1fed660..3ec0e6a5bb 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -338,6 +338,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, command: 'podman', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -353,6 +355,8 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, image: 'custom/image', + allowedPaths: [], + networkAccess: false, }, }, }, @@ -367,6 +371,8 @@ describe('loadSandboxConfig', () => { tools: { sandbox: { enabled: false, + allowedPaths: [], + networkAccess: false, }, }, }, @@ -382,6 +388,7 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], + networkAccess: false, }, }, }, diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 59a9685f70..1a047760d3 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,6 +29,7 @@ const VALID_SANDBOX_COMMANDS = [ 'sandbox-exec', 'runsc', 'lxc', + 'windows-native', ]; function isSandboxCommand( @@ -75,8 +76,15 @@ function getSandboxCommand( 'gVisor (runsc) sandboxing is only supported on Linux', ); } - // confirm that specified command exists - if (!commandExists.sync(sandbox)) { + // windows-native is only supported on Windows + if (sandbox === 'windows-native' && os.platform() !== 'win32') { + throw new FatalSandboxError( + 'Windows native sandboxing is only supported on Windows', + ); + } + + // confirm that specified command exists (unless it's built-in) + if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); @@ -149,7 +157,12 @@ export async function loadSandboxConfig( customImage ?? packageJson?.config?.sandboxImageUri; - return command && image + const isNative = + command === 'windows-native' || + command === 'sandbox-exec' || + command === 'lxc'; + + return command && (image || isNative) ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 77e1bb0c09..de8fe65c46 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1358,10 +1358,30 @@ const SETTINGS_SCHEMA = { description: oneLine` Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, - or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). `, showInDialog: false, }, + sandboxAllowedPaths: { + type: 'array', + label: 'Sandbox Allowed Paths', + category: 'Tools', + requiresRestart: true, + default: [] as string[], + description: + 'List of additional paths that the sandbox is allowed to access.', + showInDialog: true, + items: { type: 'string' }, + }, + sandboxNetworkAccess: { + type: 'boolean', + label: 'Sandbox Network Access', + category: 'Tools', + requiresRestart: true, + default: false, + description: 'Whether the sandbox is allowed to access the network.', + showInDialog: true, + }, shell: { type: 'object', label: 'Shell', diff --git a/packages/core/scripts/compile-windows-sandbox.js b/packages/core/scripts/compile-windows-sandbox.js new file mode 100644 index 0000000000..a52987c24e --- /dev/null +++ b/packages/core/scripts/compile-windows-sandbox.js @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * Compiles the GeminiSandbox C# helper on Windows. + * This is used to provide native restricted token sandboxing. + */ +function compileWindowsSandbox() { + if (os.platform() !== 'win32') { + return; + } + + const srcHelperPath = path.resolve( + __dirname, + '../src/services/scripts/GeminiSandbox.exe', + ); + const distHelperPath = path.resolve( + __dirname, + '../dist/src/services/scripts/GeminiSandbox.exe', + ); + const sourcePath = path.resolve( + __dirname, + '../src/services/scripts/GeminiSandbox.cs', + ); + + if (!fs.existsSync(sourcePath)) { + console.error(`Sandbox source not found at ${sourcePath}`); + return; + } + + // Ensure directories exist + [srcHelperPath, distHelperPath].forEach((p) => { + const dir = path.dirname(p); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + // Find csc.exe (C# Compiler) which is built into Windows .NET Framework + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const cscPaths = [ + 'csc.exe', // Try in PATH first + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.0.30319', + 'csc.exe', + ), + ]; + + let csc = undefined; + for (const p of cscPaths) { + if (p === 'csc.exe') { + const result = spawnSync('where', ['csc.exe'], { stdio: 'ignore' }); + if (result.status === 0) { + csc = 'csc.exe'; + break; + } + } else if (fs.existsSync(p)) { + csc = p; + break; + } + } + + if (!csc) { + console.warn( + 'Windows C# compiler (csc.exe) not found. Native sandboxing will attempt to compile on first run.', + ); + return; + } + + console.log(`Compiling native Windows sandbox helper...`); + // Compile to src + let result = spawnSync( + csc, + [`/out:${srcHelperPath}`, '/optimize', sourcePath], + { + stdio: 'inherit', + }, + ); + + if (result.status === 0) { + console.log('Successfully compiled GeminiSandbox.exe to src'); + // Copy to dist if dist exists + const distDir = path.resolve(__dirname, '../dist'); + if (fs.existsSync(distDir)) { + const distScriptsDir = path.dirname(distHelperPath); + if (!fs.existsSync(distScriptsDir)) { + fs.mkdirSync(distScriptsDir, { recursive: true }); + } + fs.copyFileSync(srcHelperPath, distHelperPath); + console.log('Successfully copied GeminiSandbox.exe to dist'); + } + } else { + console.error('Failed to compile Windows sandbox helper.'); + } +} + +compileWindowsSandbox(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f9db411c9d..5bac6d086c 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,9 +42,11 @@ import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; import { - createSandboxManager, type SandboxManager, + NoopSandboxManager, } from '../services/sandboxManager.js'; +import { createSandboxManager } from '../services/sandboxManagerFactory.js'; +import { SandboxedFileSystemService } from '../services/sandboxedFileSystemService.js'; import { initializeTelemetry, DEFAULT_TELEMETRY_TARGET, @@ -467,7 +469,13 @@ export interface SandboxConfig { enabled: boolean; allowedPaths?: string[]; networkAccess?: boolean; - command?: 'docker' | 'podman' | 'sandbox-exec' | 'runsc' | 'lxc'; + command?: + | 'docker' + | 'podman' + | 'sandbox-exec' + | 'runsc' + | 'lxc' + | 'windows-native'; image?: string; } @@ -478,7 +486,14 @@ export const ConfigSchema = z.object({ allowedPaths: z.array(z.string()).default([]), networkAccess: z.boolean().default(false), command: z - .enum(['docker', 'podman', 'sandbox-exec', 'runsc', 'lxc']) + .enum([ + 'docker', + 'podman', + 'sandbox-exec', + 'runsc', + 'lxc', + 'windows-native', + ]) .optional(), image: z.string().optional(), }) @@ -876,7 +891,6 @@ export class Config implements McpContext, AgentLoopContext { this.approvedPlanPath = undefined; this.embeddingModel = params.embeddingModel ?? DEFAULT_GEMINI_EMBEDDING_MODEL; - this.fileSystemService = new StandardFileSystemService(); this.sandbox = params.sandbox ? { enabled: params.sandbox.enabled ?? false, @@ -890,6 +904,21 @@ export class Config implements McpContext, AgentLoopContext { allowedPaths: [], networkAccess: false, }; + + this._sandboxManager = createSandboxManager(this.sandbox, params.targetDir); + + if ( + !(this._sandboxManager instanceof NoopSandboxManager) && + this.sandbox.enabled + ) { + this.fileSystemService = new SandboxedFileSystemService( + this._sandboxManager, + params.targetDir, + ); + } else { + this.fileSystemService = new StandardFileSystemService(); + } + this.targetDir = path.resolve(params.targetDir); this.folderTrust = params.folderTrust ?? false; this.workspaceContext = new WorkspaceContext(this.targetDir, []); @@ -1072,7 +1101,8 @@ export class Config implements McpContext, AgentLoopContext { showColor: params.shellExecutionConfig?.showColor ?? false, pager: params.shellExecutionConfig?.pager ?? 'cat', sanitizationConfig: this.sanitizationConfig, - sandboxManager: this.sandboxManager, + sandboxManager: this._sandboxManager, + sandboxConfig: this.sandbox, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -1194,12 +1224,7 @@ export class Config implements McpContext, AgentLoopContext { } } this._geminiClient = new GeminiClient(this); - this._sandboxManager = createSandboxManager( - params.toolSandboxing ?? false, - this.targetDir, - ); this.a2aClientManager = new A2AClientManager(this); - this.shellExecutionConfig.sandboxManager = this._sandboxManager; this.modelRouterService = new ModelRouterService(this); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 47412dd73c..32572c86a0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,8 @@ export * from './services/gitService.js'; export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; +export * from './services/sandboxedFileSystemService.js'; +export * from './services/windowsSandboxManager.js'; export * from './services/sessionSummaryUtils.js'; export * from './services/contextManager.js'; export * from './services/trackerService.js'; diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 1c351ce483..d201314d9f 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -6,13 +6,11 @@ import os from 'node:os'; import { describe, expect, it, vi } from 'vitest'; -import { - NoopSandboxManager, - LocalSandboxManager, - createSandboxManager, -} from './sandboxManager.js'; +import { NoopSandboxManager } from './sandboxManager.js'; +import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); @@ -121,20 +119,20 @@ describe('NoopSandboxManager', () => { describe('createSandboxManager', () => { it('should return NoopSandboxManager if sandboxing is disabled', () => { - const manager = createSandboxManager(false, '/workspace'); + const manager = createSandboxManager({ enabled: false }, '/workspace'); expect(manager).toBeInstanceOf(NoopSandboxManager); }); it.each([ { platform: 'linux', expected: LinuxSandboxManager }, { platform: 'darwin', expected: MacOsSandboxManager }, - { platform: 'win32', expected: LocalSandboxManager }, + { platform: 'win32', expected: WindowsSandboxManager }, ] as const)( 'should return $expected.name if sandboxing is enabled and platform is $platform', ({ platform, expected }) => { const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform); try { - const manager = createSandboxManager(true, '/workspace'); + const manager = createSandboxManager({ enabled: true }, '/workspace'); expect(manager).toBeInstanceOf(expected); } finally { osSpy.mockRestore(); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index b48f010cea..8642edff11 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -4,14 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import os from 'node:os'; import { sanitizeEnvironment, getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; -import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; -import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; /** * Request for preparing a command to run in a sandbox. @@ -28,6 +25,8 @@ export interface SandboxRequest { /** Optional sandbox-specific configuration. */ config?: { sanitizationConfig?: Partial; + allowedPaths?: string[]; + networkAccess?: boolean; }; } @@ -88,21 +87,4 @@ export class LocalSandboxManager implements SandboxManager { } } -/** - * Creates a sandbox manager based on the provided settings. - */ -export function createSandboxManager( - sandboxingEnabled: boolean, - workspace: string, -): SandboxManager { - if (sandboxingEnabled) { - if (os.platform() === 'linux') { - return new LinuxSandboxManager({ workspace }); - } - if (os.platform() === 'darwin') { - return new MacOsSandboxManager({ workspace }); - } - return new LocalSandboxManager(); - } - return new NoopSandboxManager(); -} +export { createSandboxManager } from './sandboxManagerFactory.js'; diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts new file mode 100644 index 0000000000..fffc366da9 --- /dev/null +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import { + type SandboxManager, + NoopSandboxManager, + LocalSandboxManager, +} from './sandboxManager.js'; +import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; +import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; + +/** + * Creates a sandbox manager based on the provided settings. + */ +export function createSandboxManager( + sandbox: SandboxConfig | undefined, + workspace: string, +): SandboxManager { + const isWindows = os.platform() === 'win32'; + + if ( + isWindows && + (sandbox?.enabled || sandbox?.command === 'windows-native') + ) { + return new WindowsSandboxManager(); + } + + if (sandbox?.enabled) { + if (os.platform() === 'linux') { + return new LinuxSandboxManager({ workspace }); + } + if (os.platform() === 'darwin') { + return new MacOsSandboxManager({ workspace }); + } + return new LocalSandboxManager(); + } + + return new NoopSandboxManager(); +} diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts new file mode 100644 index 0000000000..9983bcfca7 --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; +import { SandboxedFileSystemService } from './sandboxedFileSystemService.js'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import type { Writable } from 'node:stream'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +class MockSandboxManager implements SandboxManager { + async prepareCommand(req: SandboxRequest): Promise { + return { + program: 'sandbox.exe', + args: ['0', req.cwd, req.command, ...req.args], + env: req.env || {}, + }; + } +} + +describe('SandboxedFileSystemService', () => { + let sandboxManager: MockSandboxManager; + let service: SandboxedFileSystemService; + const cwd = '/test/cwd'; + + beforeEach(() => { + sandboxManager = new MockSandboxManager(); + service = new SandboxedFileSystemService(sandboxManager, cwd); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should read a file through the sandbox', async () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + // Use setImmediate to ensure events are emitted after the promise starts executing + setImmediate(() => { + mockChild.stdout!.emit('data', Buffer.from('file content')); + mockChild.emit('close', 0); + }); + + const content = await readPromise; + expect(content).toBe('file content'); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__read', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should write a file through the sandbox', async () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + const mockStdin = new EventEmitter(); + Object.assign(mockStdin, { + write: vi.fn(), + end: vi.fn(), + }); + Object.assign(mockChild, { + stdin: mockStdin as unknown as Writable, + stderr: new EventEmitter(), + }); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const writePromise = service.writeTextFile('/test/file.txt', 'new content'); + + setImmediate(() => { + mockChild.emit('close', 0); + }); + + await writePromise; + expect( + (mockStdin as unknown as { write: Mock }).write, + ).toHaveBeenCalledWith('new content'); + expect((mockStdin as unknown as { end: Mock }).end).toHaveBeenCalled(); + expect(spawn).toHaveBeenCalledWith( + 'sandbox.exe', + ['0', cwd, '__write', '/test/file.txt'], + expect.any(Object), + ); + }); + + it('should reject if sandbox command fails', async () => { + const mockChild = new EventEmitter() as unknown as ChildProcess; + Object.assign(mockChild, { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + }); + + vi.mocked(spawn).mockReturnValue(mockChild); + + const readPromise = service.readTextFile('/test/file.txt'); + + setImmediate(() => { + mockChild.stderr!.emit('data', Buffer.from('access denied')); + mockChild.emit('close', 1); + }); + + await expect(readPromise).rejects.toThrow( + "Sandbox Error: read_file failed for '/test/file.txt'. Exit code 1. Details: access denied", + ); + }); +}); diff --git a/packages/core/src/services/sandboxedFileSystemService.ts b/packages/core/src/services/sandboxedFileSystemService.ts new file mode 100644 index 0000000000..575fed49dd --- /dev/null +++ b/packages/core/src/services/sandboxedFileSystemService.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { type FileSystemService } from './fileSystemService.js'; +import { type SandboxManager } from './sandboxManager.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +/** + * A FileSystemService implementation that performs operations through a sandbox. + */ +export class SandboxedFileSystemService implements FileSystemService { + constructor( + private sandboxManager: SandboxManager, + private cwd: string, + ) {} + + async readTextFile(filePath: string): Promise { + const prepared = await this.sandboxManager.prepareCommand({ + command: '__read', + args: [filePath], + cwd: this.cwd, + env: process.env, + }); + + return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + + const child = spawn(prepared.program, prepared.args, { + cwd: this.cwd, + env: prepared.env, + }); + + let output = ''; + let error = ''; + + child.stdout?.on('data', (data) => { + output += data.toString(); + }); + + child.stderr?.on('data', (data) => { + error += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(output); + } else { + reject( + new Error( + `Sandbox Error: read_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, + ), + ); + } + }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn read_file for '${filePath}': ${err.message}`, + ), + ); + }); + }); + } + + async writeTextFile(filePath: string, content: string): Promise { + const prepared = await this.sandboxManager.prepareCommand({ + command: '__write', + args: [filePath], + cwd: this.cwd, + env: process.env, + }); + + return new Promise((resolve, reject) => { + // Direct spawn is necessary here for streaming large file contents. + + const child = spawn(prepared.program, prepared.args, { + cwd: this.cwd, + env: prepared.env, + }); + + child.stdin?.on('error', (err) => { + // Silently ignore EPIPE errors on stdin, they will be caught by the process error/close listeners + if (isNodeError(err) && err.code === 'EPIPE') { + return; + } + debugLogger.error( + `Sandbox Error: stdin error for '${filePath}': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + + child.stdin?.write(content); + child.stdin?.end(); + + let error = ''; + child.stderr?.on('data', (data) => { + error += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject( + new Error( + `Sandbox Error: write_file failed for '${filePath}'. Exit code ${code}. ${error ? 'Details: ' + error : ''}`, + ), + ); + } + }); + + child.on('error', (err) => { + reject( + new Error( + `Sandbox Error: Failed to spawn write_file for '${filePath}': ${err.message}`, + ), + ); + }); + }); + } +} diff --git a/packages/core/src/services/scripts/GeminiSandbox.cs b/packages/core/src/services/scripts/GeminiSandbox.cs new file mode 100644 index 0000000000..8c3fc9de06 --- /dev/null +++ b/packages/core/src/services/scripts/GeminiSandbox.cs @@ -0,0 +1,370 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +using System; +using System.Runtime.InteropServices; +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Principal; +using System.IO; + +public class GeminiSandbox { + [StructLayout(LayoutKind.Sequential)] + public struct STARTUPINFO { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public ushort wShowWindow; + public ushort cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + public struct PROCESS_INFORMATION { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + public Int64 PerProcessUserTimeLimit; + public Int64 PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + public struct IO_COUNTERS { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [StructLayout(LayoutKind.Sequential)] + public struct SID_AND_ATTRIBUTES { + public IntPtr Sid; + public uint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + public struct TOKEN_MANDATORY_LABEL { + public SID_AND_ATTRIBUTES Label; + } + + public enum JobObjectInfoClass { + ExtendedLimitInformation = 9 + } + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetCurrentProcess(); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint ResumeThread(IntPtr hThread); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid); + + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); + + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr LocalFree(IntPtr hMem); + + public const uint TOKEN_DUPLICATE = 0x0002; + public const uint TOKEN_QUERY = 0x0008; + public const uint TOKEN_ASSIGN_PRIMARY = 0x0001; + public const uint TOKEN_ADJUST_DEFAULT = 0x0080; + public const uint DISABLE_MAX_PRIVILEGE = 0x1; + public const uint CREATE_SUSPENDED = 0x00000004; + public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; + public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + public const uint STARTF_USESTDHANDLES = 0x00000100; + public const int TokenIntegrityLevel = 25; + public const uint SE_GROUP_INTEGRITY = 0x00000020; + public const uint INFINITE = 0xFFFFFFFF; + + static int Main(string[] args) { + if (args.Length < 3) { + Console.WriteLine("Usage: GeminiSandbox.exe [args...]"); + Console.WriteLine("Internal commands: __read , __write "); + return 1; + } + + bool networkAccess = args[0] == "1"; + string cwd = args[1]; + string command = args[2]; + + IntPtr hToken = IntPtr.Zero; + IntPtr hRestrictedToken = IntPtr.Zero; + IntPtr hJob = IntPtr.Zero; + IntPtr pSidsToDisable = IntPtr.Zero; + IntPtr pSidsToRestrict = IntPtr.Zero; + IntPtr networkSid = IntPtr.Zero; + IntPtr restrictedSid = IntPtr.Zero; + IntPtr lowIntegritySid = IntPtr.Zero; + + try { + // 1. Setup Token + IntPtr hCurrentProcess = GetCurrentProcess(); + if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) { + Console.Error.WriteLine("Failed to open process token"); + return 1; + } + + uint sidCount = 0; + uint restrictCount = 0; + + // "networkAccess == false" implies Strict Sandbox Level 1. + if (!networkAccess) { + if (ConvertStringSidToSid("S-1-5-2", out networkSid)) { + sidCount = 1; + int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); + pSidsToDisable = Marshal.AllocHGlobal(saaSize); + SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); + saa.Sid = networkSid; + saa.Attributes = 0; + Marshal.StructureToPtr(saa, pSidsToDisable, false); + } + + // S-1-5-12 is Restricted Code SID + if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) { + restrictCount = 1; + int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); + pSidsToRestrict = Marshal.AllocHGlobal(saaSize); + SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); + saa.Sid = restrictedSid; + saa.Attributes = 0; + Marshal.StructureToPtr(saa, pSidsToRestrict, false); + } + } + + if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) { + Console.Error.WriteLine("Failed to create restricted token"); + return 1; + } + + // 2. Set Integrity Level to Low + if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) { + TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL(); + tml.Label.Sid = lowIntegritySid; + tml.Label.Attributes = SE_GROUP_INTEGRITY; + int tmlSize = Marshal.SizeOf(tml); + IntPtr pTml = Marshal.AllocHGlobal(tmlSize); + try { + Marshal.StructureToPtr(tml, pTml, false); + SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + } finally { + Marshal.FreeHGlobal(pTml); + } + } + + // 3. Handle Internal Commands or External Process + if (command == "__read") { + string path = args[3]; + return RunInImpersonation(hRestrictedToken, () => { + try { + using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (StreamReader sr = new StreamReader(fs, System.Text.Encoding.UTF8)) { + char[] buffer = new char[4096]; + int bytesRead; + while ((bytesRead = sr.Read(buffer, 0, buffer.Length)) > 0) { + Console.Write(buffer, 0, bytesRead); + } + } + return 0; + } catch (Exception e) { + Console.Error.WriteLine(e.Message); + return 1; + } + }); + } else if (command == "__write") { + string path = args[3]; + return RunInImpersonation(hRestrictedToken, () => { + try { + using (StreamReader reader = new StreamReader(Console.OpenStandardInput(), System.Text.Encoding.UTF8)) + using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) + using (StreamWriter writer = new StreamWriter(fs, System.Text.Encoding.UTF8)) { + char[] buffer = new char[4096]; + int bytesRead; + while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0) { + writer.Write(buffer, 0, bytesRead); + } + } + return 0; + } catch (Exception e) { + Console.Error.WriteLine(e.Message); + return 1; + } + }); + } + + // 4. Setup Job Object for external process + hJob = CreateJobObject(IntPtr.Zero, null); + if (hJob != IntPtr.Zero) { + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + int limitSize = Marshal.SizeOf(limitInfo); + IntPtr pLimit = Marshal.AllocHGlobal(limitSize); + try { + Marshal.StructureToPtr(limitInfo, pLimit, false); + SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); + } finally { + Marshal.FreeHGlobal(pLimit); + } + } + + // 5. Launch Process + STARTUPINFO si = new STARTUPINFO(); + si.cb = (uint)Marshal.SizeOf(si); + si.dwFlags = STARTF_USESTDHANDLES; + si.hStdInput = GetStdHandle(-10); + si.hStdOutput = GetStdHandle(-11); + si.hStdError = GetStdHandle(-12); + + string commandLine = ""; + for (int i = 2; i < args.Length; i++) { + if (i > 2) commandLine += " "; + commandLine += QuoteArgument(args[i]); + } + + PROCESS_INFORMATION pi; + if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) { + Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error()); + return 1; + } + + try { + if (hJob != IntPtr.Zero) { + AssignProcessToJobObject(hJob, pi.hProcess); + } + + ResumeThread(pi.hThread); + WaitForSingleObject(pi.hProcess, INFINITE); + + uint exitCode = 0; + GetExitCodeProcess(pi.hProcess, out exitCode); + return (int)exitCode; + } finally { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + } catch (Exception e) { + Console.Error.WriteLine("Unexpected error: " + e.Message); + return 1; + } finally { + if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); + if (hToken != IntPtr.Zero) CloseHandle(hToken); + if (hJob != IntPtr.Zero) CloseHandle(hJob); + if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable); + if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict); + if (networkSid != IntPtr.Zero) LocalFree(networkSid); + if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid); + if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid); + } + } + + private static string QuoteArgument(string arg) { + if (string.IsNullOrEmpty(arg)) return "\"\""; + + bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1; + if (!hasSpace && arg.IndexOf('\"') == -1) return arg; + + // Windows command line escaping for arguments is complex. + // Rule: Backslashes only need escaping if they precede a double quote or the end of the string. + System.Text.StringBuilder sb = new System.Text.StringBuilder(); + sb.Append('\"'); + for (int i = 0; i < arg.Length; i++) { + int backslashCount = 0; + while (i < arg.Length && arg[i] == '\\') { + backslashCount++; + i++; + } + + if (i == arg.Length) { + // Escape backslashes before the closing double quote + sb.Append('\\', backslashCount * 2); + } else if (arg[i] == '\"') { + // Escape backslashes before a literal double quote + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('\"'); + } else { + // Backslashes don't need escaping here + sb.Append('\\', backslashCount); + sb.Append(arg[i]); + } + } + sb.Append('\"'); + return sb.ToString(); + } + + private static int RunInImpersonation(IntPtr hToken, Func action) { + using (WindowsIdentity.Impersonate(hToken)) { + return action(); + } + } +} diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 47601172ac..e96cf7e037 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -27,8 +27,12 @@ import { serializeTerminalToObject, type AnsiOutput, } from '../utils/terminalSerializer.js'; -import { type EnvironmentSanitizationConfig } from './environmentSanitization.js'; -import { type SandboxManager } from './sandboxManager.js'; +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; +import { NoopSandboxManager, type SandboxManager } from './sandboxManager.js'; +import type { SandboxConfig } from '../config/config.js'; import { killProcessGroup } from '../utils/process-utils.js'; import { ExecutionLifecycleService, @@ -92,6 +96,7 @@ export interface ShellExecutionConfig { disableDynamicLineTrimming?: boolean; scrollback?: number; maxSerializedLines?: number; + sandboxConfig?: SandboxConfig; } /** @@ -331,37 +336,119 @@ export class ShellExecutionService { } private static async prepareExecution( - executable: string, - args: string[], + commandToExecute: string, cwd: string, - env: NodeJS.ProcessEnv, shellExecutionConfig: ShellExecutionConfig, - sanitizationConfigOverride?: EnvironmentSanitizationConfig, + isInteractive: boolean, ): Promise<{ program: string; args: string[]; - env: NodeJS.ProcessEnv; + env: Record; cwd: string; }> { + const sandboxManager = + shellExecutionConfig.sandboxManager ?? new NoopSandboxManager(); + + // 1. Determine Shell Configuration + const isWindows = os.platform() === 'win32'; + const isStrictSandbox = + isWindows && + shellExecutionConfig.sandboxConfig?.enabled && + shellExecutionConfig.sandboxConfig?.command === 'windows-native' && + !shellExecutionConfig.sandboxConfig?.networkAccess; + + let { executable, argsPrefix, shell } = getShellConfiguration(); + if (isStrictSandbox) { + shell = 'cmd'; + argsPrefix = ['/c']; + executable = 'cmd.exe'; + } + const resolvedExecutable = (await resolveExecutable(executable)) ?? executable; - const prepared = await shellExecutionConfig.sandboxManager.prepareCommand({ + const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); + const spawnArgs = [...argsPrefix, guardedCommand]; + + // 2. Prepare Environment + const gitConfigKeys: string[] = []; + if (!isInteractive) { + for (const key in process.env) { + if (key.startsWith('GIT_CONFIG_')) { + gitConfigKeys.push(key); + } + } + } + + const sanitizationConfig = { + ...shellExecutionConfig.sanitizationConfig, + allowedEnvironmentVariables: [ + ...(shellExecutionConfig.sanitizationConfig + .allowedEnvironmentVariables || []), + ...gitConfigKeys, + ], + }; + + const sanitizedEnv = sanitizeEnvironment(process.env, sanitizationConfig); + + const baseEnv: Record = { + ...sanitizedEnv, + [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: + GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, + TERM: 'xterm-256color', + PAGER: shellExecutionConfig.pager ?? 'cat', + GIT_PAGER: shellExecutionConfig.pager ?? 'cat', + }; + + if (!isInteractive) { + // Ensure all GIT_CONFIG_* variables are preserved even if they were redacted + for (const key of gitConfigKeys) { + baseEnv[key] = process.env[key]; + } + + const gitConfigCount = parseInt(baseEnv['GIT_CONFIG_COUNT'] || '0', 10); + const newKey = `GIT_CONFIG_KEY_${gitConfigCount}`; + const newValue = `GIT_CONFIG_VALUE_${gitConfigCount}`; + + // Ensure these new keys are allowed through sanitization + sanitizationConfig.allowedEnvironmentVariables.push( + 'GIT_CONFIG_COUNT', + newKey, + newValue, + ); + + Object.assign(baseEnv, { + GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', + SSH_ASKPASS: '', + GH_PROMPT_DISABLED: '1', + GCM_INTERACTIVE: 'never', + DISPLAY: '', + DBUS_SESSION_BUS_ADDRESS: '', + GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), + [newKey]: 'credential.helper', + [newValue]: '', + }); + } + + // 3. Prepare Sandboxed Command + const sandboxedCommand = await sandboxManager.prepareCommand({ command: resolvedExecutable, - args, + args: spawnArgs, + env: baseEnv, cwd, - env, config: { - sanitizationConfig: - sanitizationConfigOverride ?? shellExecutionConfig.sanitizationConfig, + ...shellExecutionConfig, + ...(shellExecutionConfig.sandboxConfig || {}), + sanitizationConfig, }, }); return { - program: prepared.program, - args: prepared.args, - env: prepared.env, - cwd: prepared.cwd ?? cwd, + program: sandboxedCommand.program, + args: sandboxedCommand.args, + env: sandboxedCommand.env, + cwd: sandboxedCommand.cwd ?? cwd, }; } @@ -375,70 +462,19 @@ export class ShellExecutionService { ): Promise { try { const isWindows = os.platform() === 'win32'; - const { executable, argsPrefix, shell } = getShellConfiguration(); - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const spawnArgs = [...argsPrefix, guardedCommand]; - - // Specifically allow GIT_CONFIG_* variables to pass through sanitization - // in non-interactive mode so we can safely append our overrides. - const gitConfigKeys = !isInteractive - ? Object.keys(process.env).filter((k) => k.startsWith('GIT_CONFIG_')) - : []; - const localSanitizationConfig = { - ...shellExecutionConfig.sanitizationConfig, - allowedEnvironmentVariables: [ - ...(shellExecutionConfig.sanitizationConfig - .allowedEnvironmentVariables || []), - ...gitConfigKeys, - ], - }; - - const env = { - ...process.env, - [GEMINI_CLI_IDENTIFICATION_ENV_VAR]: - GEMINI_CLI_IDENTIFICATION_ENV_VAR_VALUE, - TERM: 'xterm-256color', - PAGER: 'cat', - GIT_PAGER: 'cat', - }; const { program: finalExecutable, args: finalArgs, - env: sanitizedEnv, + env: finalEnv, cwd: finalCwd, } = await this.prepareExecution( - executable, - spawnArgs, + commandToExecute, cwd, - env, shellExecutionConfig, - localSanitizationConfig, + isInteractive, ); - const finalEnv = { ...sanitizedEnv }; - - if (!isInteractive) { - const gitConfigCount = parseInt( - finalEnv['GIT_CONFIG_COUNT'] || '0', - 10, - ); - Object.assign(finalEnv, { - // Disable interactive prompts and session-linked credential helpers - // in non-interactive mode to prevent hangs in detached process groups. - GIT_TERMINAL_PROMPT: '0', - GIT_ASKPASS: '', - SSH_ASKPASS: '', - GH_PROMPT_DISABLED: '1', - GCM_INTERACTIVE: 'never', - DISPLAY: '', - DBUS_SESSION_BUS_ADDRESS: '', - GIT_CONFIG_COUNT: (gitConfigCount + 1).toString(), - [`GIT_CONFIG_KEY_${gitConfigCount}`]: 'credential.helper', - [`GIT_CONFIG_VALUE_${gitConfigCount}`]: '', - }); - } - const child = cpSpawn(finalExecutable, finalArgs, { cwd: finalCwd, stdio: ['ignore', 'pipe', 'pipe'], @@ -732,32 +768,6 @@ export class ShellExecutionService { try { const cols = shellExecutionConfig.terminalWidth ?? 80; const rows = shellExecutionConfig.terminalHeight ?? 30; - const { executable, argsPrefix, shell } = getShellConfiguration(); - - const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); - const args = [...argsPrefix, guardedCommand]; - - const env = { - ...process.env, - GEMINI_CLI: '1', - TERM: 'xterm-256color', - PAGER: shellExecutionConfig.pager ?? 'cat', - GIT_PAGER: shellExecutionConfig.pager ?? 'cat', - }; - - // Specifically allow GIT_CONFIG_* variables to pass through sanitization - // so we can safely append our overrides if needed. - const gitConfigKeys = Object.keys(process.env).filter((k) => - k.startsWith('GIT_CONFIG_'), - ); - const localSanitizationConfig = { - ...shellExecutionConfig.sanitizationConfig, - allowedEnvironmentVariables: [ - ...(shellExecutionConfig.sanitizationConfig - ?.allowedEnvironmentVariables ?? []), - ...gitConfigKeys, - ], - }; const { program: finalExecutable, @@ -765,12 +775,10 @@ export class ShellExecutionService { env: finalEnv, cwd: finalCwd, } = await this.prepareExecution( - executable, - args, + commandToExecute, cwd, - env, shellExecutionConfig, - localSanitizationConfig, + true, ); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -782,6 +790,7 @@ export class ShellExecutionService { env: finalEnv, handleFlowControl: true, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion spawnedPty = ptyProcess as IPty; const ptyPid = Number(ptyProcess.pid); diff --git a/packages/core/src/services/windowsSandboxManager.test.ts b/packages/core/src/services/windowsSandboxManager.test.ts new file mode 100644 index 0000000000..6bec183410 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.test.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { WindowsSandboxManager } from './windowsSandboxManager.js'; +import type { SandboxRequest } from './sandboxManager.js'; + +describe('WindowsSandboxManager', () => { + const manager = new WindowsSandboxManager('win32'); + + it('should prepare a GeminiSandbox.exe command', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: ['/groups'], + cwd: '/test/cwd', + env: { TEST_VAR: 'test_value' }, + config: { + networkAccess: false, + }, + }; + + const result = await manager.prepareCommand(req); + + expect(result.program).toContain('GeminiSandbox.exe'); + expect(result.args).toEqual(['0', '/test/cwd', 'whoami', '/groups']); + }); + + it('should handle networkAccess from config', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: '/test/cwd', + env: {}, + config: { + networkAccess: true, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should sanitize environment variables', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: '/test/cwd', + env: { + API_KEY: 'secret', + PATH: '/usr/bin', + }, + config: { + sanitizationConfig: { + allowedEnvironmentVariables: ['PATH'], + blockedEnvironmentVariables: ['API_KEY'], + enableEnvironmentVariableRedaction: true, + }, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['API_KEY']).toBeUndefined(); + }); +}); diff --git a/packages/core/src/services/windowsSandboxManager.ts b/packages/core/src/services/windowsSandboxManager.ts new file mode 100644 index 0000000000..dc39b9ee67 --- /dev/null +++ b/packages/core/src/services/windowsSandboxManager.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { + SandboxManager, + SandboxRequest, + SandboxedCommand, +} from './sandboxManager.js'; +import { + sanitizeEnvironment, + type EnvironmentSanitizationConfig, +} from './environmentSanitization.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { spawnAsync } from '../utils/shell-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * A SandboxManager implementation for Windows that uses Restricted Tokens, + * Job Objects, and Low Integrity levels for process isolation. + * Uses a native C# helper to bypass PowerShell restrictions. + */ +export class WindowsSandboxManager implements SandboxManager { + private readonly helperPath: string; + private readonly platform: string; + private initialized = false; + private readonly lowIntegrityCache = new Set(); + + constructor(platform: string = process.platform) { + this.platform = platform; + this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe'); + } + + private async ensureInitialized(): Promise { + if (this.initialized) return; + if (this.platform !== 'win32') { + this.initialized = true; + return; + } + + try { + if (!fs.existsSync(this.helperPath)) { + debugLogger.log( + `WindowsSandboxManager: Helper not found at ${this.helperPath}. Attempting to compile...`, + ); + // If the exe doesn't exist, we try to compile it from the .cs file + const sourcePath = this.helperPath.replace(/\.exe$/, '.cs'); + if (fs.existsSync(sourcePath)) { + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const cscPaths = [ + 'csc.exe', // Try in PATH first + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.0.30319', + 'csc.exe', + ), + // Added newer framework paths + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework', + 'v4.8', + 'csc.exe', + ), + path.join( + systemRoot, + 'Microsoft.NET', + 'Framework64', + 'v3.5', + 'csc.exe', + ), + ]; + + let compiled = false; + for (const csc of cscPaths) { + try { + debugLogger.log( + `WindowsSandboxManager: Trying to compile using ${csc}...`, + ); + // We use spawnAsync but we don't need to capture output + await spawnAsync(csc, ['/out:' + this.helperPath, sourcePath]); + debugLogger.log( + `WindowsSandboxManager: Successfully compiled sandbox helper at ${this.helperPath}`, + ); + compiled = true; + break; + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to compile using ${csc}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + if (!compiled) { + debugLogger.log( + 'WindowsSandboxManager: Failed to compile sandbox helper from any known CSC path.', + ); + } + } else { + debugLogger.log( + `WindowsSandboxManager: Source file not found at ${sourcePath}. Cannot compile helper.`, + ); + } + } else { + debugLogger.log( + `WindowsSandboxManager: Found helper at ${this.helperPath}`, + ); + } + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: Failed to initialize sandbox helper:', + e, + ); + } + + this.initialized = true; + } + + /** + * Prepares a command for sandboxed execution on Windows. + */ + async prepareCommand(req: SandboxRequest): Promise { + await this.ensureInitialized(); + + const sanitizationConfig: EnvironmentSanitizationConfig = { + allowedEnvironmentVariables: + req.config?.sanitizationConfig?.allowedEnvironmentVariables ?? [], + blockedEnvironmentVariables: + req.config?.sanitizationConfig?.blockedEnvironmentVariables ?? [], + enableEnvironmentVariableRedaction: + req.config?.sanitizationConfig?.enableEnvironmentVariableRedaction ?? + true, + }; + + const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + + // 1. Handle filesystem permissions for Low Integrity + // Grant "Low Mandatory Level" write access to the CWD. + await this.grantLowIntegrityAccess(req.cwd); + + // Grant "Low Mandatory Level" read access to allowedPaths. + if (req.config?.allowedPaths) { + for (const allowedPath of req.config.allowedPaths) { + await this.grantLowIntegrityAccess(allowedPath); + } + } + + // 2. Construct the helper command + // GeminiSandbox.exe [args...] + const program = this.helperPath; + + // If the command starts with __, it's an internal command for the sandbox helper itself. + const args = [ + req.config?.networkAccess ? '1' : '0', + req.cwd, + req.command, + ...req.args, + ]; + + return { + program, + args, + env: sanitizedEnv, + }; + } + + /** + * Grants "Low Mandatory Level" access to a path using icacls. + */ + private async grantLowIntegrityAccess(targetPath: string): Promise { + if (this.platform !== 'win32') { + return; + } + + const resolvedPath = path.resolve(targetPath); + if (this.lowIntegrityCache.has(resolvedPath)) { + return; + } + + // Never modify integrity levels for system directories + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + if ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ) { + return; + } + + try { + await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']); + this.lowIntegrityCache.add(resolvedPath); + } catch (e) { + debugLogger.log( + 'WindowsSandboxManager: icacls failed for', + resolvedPath, + e, + ); + } + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index a6f507ae63..17409313ce 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2251,10 +2251,27 @@ "properties": { "sandbox": { "title": "Sandbox", - "description": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").", - "markdownDescription": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\").\n\n- Category: `Tools`\n- Requires restart: `yes`", + "description": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\", \"windows-native\").", + "markdownDescription": "Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., \"docker\", \"podman\", \"lxc\", \"windows-native\").\n\n- Category: `Tools`\n- Requires restart: `yes`", "$ref": "#/$defs/BooleanOrStringOrObject" }, + "sandboxAllowedPaths": { + "title": "Sandbox Allowed Paths", + "description": "List of additional paths that the sandbox is allowed to access.", + "markdownDescription": "List of additional paths that the sandbox is allowed to access.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "sandboxNetworkAccess": { + "title": "Sandbox Network Access", + "description": "Whether the sandbox is allowed to access the network.", + "markdownDescription": "Whether the sandbox is allowed to access the network.\n\n- Category: `Tools`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "shell": { "title": "Shell", "description": "Settings for shell execution.", diff --git a/scripts/copy_files.js b/scripts/copy_files.js index fc612fd144..d02070362f 100644 --- a/scripts/copy_files.js +++ b/scripts/copy_files.js @@ -26,7 +26,7 @@ import path from 'node:path'; const sourceDir = path.join('src'); const targetDir = path.join('dist', 'src'); -const extensionsToCopy = ['.md', '.json', '.sb', '.toml']; +const extensionsToCopy = ['.md', '.json', '.sb', '.toml', '.cs', '.exe']; function copyFilesRecursive(source, target) { if (!fs.existsSync(target)) { From 8615315711a8edfe06dee2aafdac3d3f1c6c3558 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:32:43 -0700 Subject: [PATCH 012/177] feat(core): add support for admin-forced MCP server installations (#23163) --- docs/admin/enterprise-controls.md | 61 ++++++++ docs/reference/configuration.md | 6 +- packages/cli/src/commands/mcp/list.test.ts | 1 + packages/cli/src/config/config.ts | 20 +++ packages/cli/src/config/settings.test.ts | 22 +++ packages/cli/src/config/settings.ts | 1 + packages/cli/src/config/settingsSchema.ts | 89 ++++++++++- .../code_assist/admin/admin_controls.test.ts | 83 ++++++++++ .../src/code_assist/admin/admin_controls.ts | 11 ++ .../src/code_assist/admin/mcpUtils.test.ts | 148 +++++++++++++++++- .../core/src/code_assist/admin/mcpUtils.ts | 58 ++++++- packages/core/src/code_assist/types.ts | 35 +++++ schemas/settings.schema.json | 85 +++++++++- 13 files changed, 609 insertions(+), 11 deletions(-) diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md index 8c9ba60a13..5792a6c5bc 100644 --- a/docs/admin/enterprise-controls.md +++ b/docs/admin/enterprise-controls.md @@ -106,6 +106,67 @@ organization. ensures users maintain final control over which permitted servers are actually active in their environment. +#### Required MCP Servers (preview) + +**Default**: empty + +Allows administrators to define MCP servers that are **always injected** into +the user's environment. Unlike the allowlist (which filters user-configured +servers), required servers are automatically added regardless of the user's +local configuration. + +**Required Servers Format:** + +```json +{ + "requiredMcpServers": { + "corp-compliance-tool": { + "url": "https://mcp.corp/compliance", + "type": "http", + "trust": true, + "description": "Corporate compliance tool" + }, + "internal-registry": { + "url": "https://registry.corp/mcp", + "type": "sse", + "authProviderType": "google_credentials", + "oauth": { + "scopes": ["https://www.googleapis.com/auth/scope"] + } + } + } +} +``` + +**Supported Fields:** + +- `url`: (Required) The full URL of the MCP server endpoint. +- `type`: (Required) The connection type (`sse` or `http`). +- `trust`: (Optional) If set to `true`, tool execution will not require user + approval. Defaults to `true` for required servers. +- `description`: (Optional) Human-readable description of the server. +- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, + `google_credentials`, or `service_account_impersonation`). +- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and + `clientSecret`. +- `targetAudience`: (Optional) OAuth target audience for service-to-service + auth. +- `targetServiceAccount`: (Optional) Service account email to impersonate. +- `headers`: (Optional) Additional HTTP headers to send with requests. +- `includeTools` / `excludeTools`: (Optional) Tool filtering lists. +- `timeout`: (Optional) Timeout in milliseconds for MCP requests. + +**Client Enforcement Logic:** + +- Required servers are injected **after** allowlist filtering, so they are + always available even if the allowlist is active. +- If a required server has the **same name** as a locally configured server, the + admin configuration **completely overrides** the local one. +- Required servers only support remote transports (`sse`, `http`). Local + execution fields (`command`, `args`, `env`, `cwd`) are not supported. +- Required servers can coexist with allowlisted servers — both features work + independently. + ### Unmanaged Capabilities **Enabled/Disabled** | Default: disabled diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 81a05bf51c..d3b08d565a 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1728,7 +1728,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **`admin.mcp.config`** (object): - - **Description:** Admin-configured MCP servers. + - **Description:** Admin-configured MCP servers (allowlist). + - **Default:** `{}` + +- **`admin.mcp.requiredConfig`** (object): + - **Description:** Admin-required MCP servers that are always injected. - **Default:** `{}` - **`admin.skills.enabled`** (boolean): diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 54534961dd..578894845e 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -264,6 +264,7 @@ describe('mcp list command', () => { config: { 'allowed-server': { url: 'http://allowed' }, }, + requiredConfig: {}, }, }; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 3c74fd05bd..d5e4851e97 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -36,6 +36,7 @@ import { Config, resolveToRealPath, applyAdminAllowlist, + applyRequiredServers, getAdminBlockedMcpServersMessage, type HookDefinition, type HookEventName, @@ -750,6 +751,25 @@ export async function loadCliConfig( } } + // Apply admin-required MCP servers (injected regardless of allowlist) + if (mcpEnabled) { + const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; + if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { + const requiredResult = applyRequiredServers( + mcpServers ?? {}, + requiredMcpConfig, + ); + mcpServers = requiredResult.mcpServers; + + if (requiredResult.requiredServerNames.length > 0) { + coreEvents.emitConsoleLog( + 'info', + `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, + ); + } + } + } + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; let clientName: string | undefined = undefined; if (isAcpMode) { diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 06129a4760..a58b9889a2 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2751,6 +2751,28 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); }); + it('should map requiredMcpConfig from remote settings', () => { + const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); + const requiredMcpConfig = { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + trust: true, + }, + }; + + loadedSettings.setRemoteAdminSettings({ + mcpSetting: { + mcpEnabled: true, + requiredMcpConfig, + }, + }); + + expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( + requiredMcpConfig, + ); + }); + it('should set skills based on unmanagedCapabilitiesEnabled', () => { const loadedSettings = loadSettings(); loadedSettings.setRemoteAdminSettings({ diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 711ff93271..beecd6a017 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -480,6 +480,7 @@ export class LoadedSettings { admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, + requiredConfig: mcpSetting?.requiredMcpConfig, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index de8fe65c46..f1711f3b92 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,7 +12,9 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, + AuthProviderType, type MCPServerConfig, + type RequiredMcpServerConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, @@ -2435,7 +2437,7 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: {} as Record, - description: 'Admin-configured MCP servers.', + description: 'Admin-configured MCP servers (allowlist).', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { @@ -2443,6 +2445,20 @@ const SETTINGS_SCHEMA = { ref: 'MCPServerConfig', }, }, + requiredConfig: { + type: 'object', + label: 'Required MCP Config', + category: 'Admin', + requiresRestart: false, + default: {} as Record, + description: 'Admin-required MCP servers that are always injected.', + showInDialog: false, + mergeStrategy: MergeStrategy.REPLACE, + additionalProperties: { + type: 'object', + ref: 'RequiredMcpServerConfig', + }, + }, }, }, skills: { @@ -2567,11 +2583,72 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< type: 'string', description: 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', - enum: [ - 'dynamic_discovery', - 'google_credentials', - 'service_account_impersonation', - ], + enum: Object.values(AuthProviderType), + }, + targetAudience: { + type: 'string', + description: + 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', + }, + targetServiceAccount: { + type: 'string', + description: + 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', + }, + }, + }, + RequiredMcpServerConfig: { + type: 'object', + description: + 'Admin-required MCP server configuration (remote transports only).', + additionalProperties: false, + properties: { + url: { + type: 'string', + description: 'URL for the required MCP server.', + }, + type: { + type: 'string', + description: 'Transport type for the required server.', + enum: ['sse', 'http'], + }, + headers: { + type: 'object', + description: 'Additional HTTP headers sent to the server.', + additionalProperties: { type: 'string' }, + }, + timeout: { + type: 'number', + description: 'Timeout in milliseconds for MCP requests.', + }, + trust: { + type: 'boolean', + description: + 'Marks the server as trusted. Defaults to true for admin-required servers.', + }, + description: { + type: 'string', + description: 'Human-readable description of the server.', + }, + includeTools: { + type: 'array', + description: 'Subset of tools enabled for this server.', + items: { type: 'string' }, + }, + excludeTools: { + type: 'array', + description: 'Tools disabled for this server.', + items: { type: 'string' }, + }, + oauth: { + type: 'object', + description: 'OAuth configuration for authenticating with the server.', + additionalProperties: true, + }, + authProviderType: { + type: 'string', + description: 'Authentication provider used for acquiring credentials.', + enum: Object.values(AuthProviderType), }, targetAudience: { type: 'string', diff --git a/packages/core/src/code_assist/admin/admin_controls.test.ts b/packages/core/src/code_assist/admin/admin_controls.test.ts index d676a59a92..afd80ad758 100644 --- a/packages/core/src/code_assist/admin/admin_controls.test.ts +++ b/packages/core/src/code_assist/admin/admin_controls.test.ts @@ -224,6 +224,89 @@ describe('Admin Controls', () => { const result = sanitizeAdminSettings(input); expect(result.strictModeDisabled).toBe(true); }); + + it('should parse requiredMcpServers from mcpConfigJson', () => { + const mcpConfig = { + mcpServers: { + 'allowed-server': { + url: 'http://allowed.com', + type: 'sse' as const, + }, + }, + requiredMcpServers: { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + trust: true, + description: 'Corp compliance tool', + }, + }, + }; + + const input: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: true, + mcpConfigJson: JSON.stringify(mcpConfig), + }, + }; + + const result = sanitizeAdminSettings(input); + expect(result.mcpSetting?.mcpConfig?.mcpServers).toEqual( + mcpConfig.mcpServers, + ); + expect(result.mcpSetting?.requiredMcpConfig).toEqual( + mcpConfig.requiredMcpServers, + ); + }); + + it('should sort requiredMcpServers tool lists for stable comparison', () => { + const mcpConfig = { + requiredMcpServers: { + 'corp-tool': { + url: 'https://mcp.corp/tool', + type: 'http' as const, + includeTools: ['toolC', 'toolA', 'toolB'], + excludeTools: ['toolZ', 'toolX'], + }, + }, + }; + + const input: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: true, + mcpConfigJson: JSON.stringify(mcpConfig), + }, + }; + + const result = sanitizeAdminSettings(input); + const corpTool = result.mcpSetting?.requiredMcpConfig?.['corp-tool']; + expect(corpTool?.includeTools).toEqual(['toolA', 'toolB', 'toolC']); + expect(corpTool?.excludeTools).toEqual(['toolX', 'toolZ']); + }); + + it('should handle mcpConfigJson with only requiredMcpServers and no mcpServers', () => { + const mcpConfig = { + requiredMcpServers: { + 'required-only': { + url: 'https://required.corp/tool', + type: 'http' as const, + }, + }, + }; + + const input: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: true, + mcpConfigJson: JSON.stringify(mcpConfig), + }, + }; + + const result = sanitizeAdminSettings(input); + expect(result.mcpSetting?.mcpConfig?.mcpServers).toBeUndefined(); + expect(result.mcpSetting?.requiredMcpConfig).toEqual( + mcpConfig.requiredMcpServers, + ); + }); }); describe('isDeepStrictEqual verification', () => { diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index d18fcf3d66..4812ce013e 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -48,6 +48,16 @@ export function sanitizeAdminSettings( } } } + if (mcpConfig.requiredMcpServers) { + for (const server of Object.values(mcpConfig.requiredMcpServers)) { + if (server.includeTools) { + server.includeTools.sort(); + } + if (server.excludeTools) { + server.excludeTools.sort(); + } + } + } } } catch (_e) { // Ignore parsing errors @@ -77,6 +87,7 @@ export function sanitizeAdminSettings( mcpSetting: { mcpEnabled: sanitized.mcpSetting?.mcpEnabled ?? false, mcpConfig: mcpConfig ?? {}, + requiredMcpConfig: mcpConfig?.requiredMcpServers, }, }; } diff --git a/packages/core/src/code_assist/admin/mcpUtils.test.ts b/packages/core/src/code_assist/admin/mcpUtils.test.ts index 313e654d7d..fadfa59331 100644 --- a/packages/core/src/code_assist/admin/mcpUtils.test.ts +++ b/packages/core/src/code_assist/admin/mcpUtils.test.ts @@ -5,8 +5,10 @@ */ import { describe, it, expect } from 'vitest'; -import { applyAdminAllowlist } from './mcpUtils.js'; +import { applyAdminAllowlist, applyRequiredServers } from './mcpUtils.js'; import type { MCPServerConfig } from '../../config/config.js'; +import { AuthProviderType } from '../../config/config.js'; +import type { RequiredMcpServerConfig } from '../types.js'; describe('applyAdminAllowlist', () => { it('should return original servers if no allowlist provided', () => { @@ -111,3 +113,147 @@ describe('applyAdminAllowlist', () => { expect(result.mcpServers['server1']?.includeTools).toEqual(['local-tool']); }); }); + +describe('applyRequiredServers', () => { + it('should return original servers if no required servers provided', () => { + const mcpServers: Record = { + server1: { command: 'cmd1' }, + }; + const result = applyRequiredServers(mcpServers, undefined); + expect(result.mcpServers).toEqual(mcpServers); + expect(result.requiredServerNames).toEqual([]); + }); + + it('should return original servers if required servers is empty', () => { + const mcpServers: Record = { + server1: { command: 'cmd1' }, + }; + const result = applyRequiredServers(mcpServers, {}); + expect(result.mcpServers).toEqual(mcpServers); + expect(result.requiredServerNames).toEqual([]); + }); + + it('should inject required servers when no local config exists', () => { + const mcpServers: Record = { + 'local-server': { command: 'cmd1' }, + }; + const required: Record = { + 'corp-tool': { + url: 'https://mcp.corp.internal/tool', + type: 'http', + description: 'Corp compliance tool', + }, + }; + + const result = applyRequiredServers(mcpServers, required); + expect(Object.keys(result.mcpServers)).toContain('local-server'); + expect(Object.keys(result.mcpServers)).toContain('corp-tool'); + expect(result.requiredServerNames).toEqual(['corp-tool']); + + const corpTool = result.mcpServers['corp-tool']; + expect(corpTool).toBeDefined(); + expect(corpTool?.url).toBe('https://mcp.corp.internal/tool'); + expect(corpTool?.type).toBe('http'); + expect(corpTool?.description).toBe('Corp compliance tool'); + // trust defaults to true for admin-forced servers + expect(corpTool?.trust).toBe(true); + // stdio fields should not be set + expect(corpTool?.command).toBeUndefined(); + expect(corpTool?.args).toBeUndefined(); + }); + + it('should override local server with same name', () => { + const mcpServers: Record = { + 'shared-server': { + command: 'local-cmd', + args: ['local-arg'], + description: 'Local version', + }, + }; + const required: Record = { + 'shared-server': { + url: 'https://admin.corp/shared', + type: 'sse', + trust: false, + description: 'Admin-mandated version', + }, + }; + + const result = applyRequiredServers(mcpServers, required); + const server = result.mcpServers['shared-server']; + + // Admin config should completely override local + expect(server?.url).toBe('https://admin.corp/shared'); + expect(server?.type).toBe('sse'); + expect(server?.trust).toBe(false); + expect(server?.description).toBe('Admin-mandated version'); + // Local fields should NOT be preserved + expect(server?.command).toBeUndefined(); + expect(server?.args).toBeUndefined(); + }); + + it('should preserve auth configuration', () => { + const required: Record = { + 'auth-server': { + url: 'https://auth.corp/tool', + type: 'http', + authProviderType: AuthProviderType.GOOGLE_CREDENTIALS, + oauth: { + scopes: ['https://www.googleapis.com/auth/scope1'], + }, + targetAudience: 'client-id.apps.googleusercontent.com', + headers: { 'X-Custom': 'value' }, + }, + }; + + const result = applyRequiredServers({}, required); + const server = result.mcpServers['auth-server']; + + expect(server?.authProviderType).toBe(AuthProviderType.GOOGLE_CREDENTIALS); + expect(server?.oauth).toEqual({ + scopes: ['https://www.googleapis.com/auth/scope1'], + }); + expect(server?.targetAudience).toBe('client-id.apps.googleusercontent.com'); + expect(server?.headers).toEqual({ 'X-Custom': 'value' }); + }); + + it('should preserve tool filtering', () => { + const required: Record = { + 'filtered-server': { + url: 'https://corp/tool', + type: 'http', + includeTools: ['toolA', 'toolB'], + excludeTools: ['toolC'], + }, + }; + + const result = applyRequiredServers({}, required); + const server = result.mcpServers['filtered-server']; + + expect(server?.includeTools).toEqual(['toolA', 'toolB']); + expect(server?.excludeTools).toEqual(['toolC']); + }); + + it('should coexist with allowlisted servers', () => { + // Simulate post-allowlist filtering + const afterAllowlist: Record = { + 'allowed-server': { + url: 'http://allowed', + type: 'sse', + trust: true, + }, + }; + const required: Record = { + 'required-server': { + url: 'https://required.corp/tool', + type: 'http', + }, + }; + + const result = applyRequiredServers(afterAllowlist, required); + expect(Object.keys(result.mcpServers)).toHaveLength(2); + expect(result.mcpServers['allowed-server']).toBeDefined(); + expect(result.mcpServers['required-server']).toBeDefined(); + expect(result.requiredServerNames).toEqual(['required-server']); + }); +}); diff --git a/packages/core/src/code_assist/admin/mcpUtils.ts b/packages/core/src/code_assist/admin/mcpUtils.ts index 12c5845d5b..768a40847e 100644 --- a/packages/core/src/code_assist/admin/mcpUtils.ts +++ b/packages/core/src/code_assist/admin/mcpUtils.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { MCPServerConfig } from '../../config/config.js'; +import { MCPServerConfig } from '../../config/config.js'; +import type { RequiredMcpServerConfig } from '../types.js'; /** * Applies the admin allowlist to the local MCP servers. @@ -65,3 +66,58 @@ export function applyAdminAllowlist( } return { mcpServers: filteredMcpServers, blockedServerNames }; } + +/** + * Applies admin-required MCP servers by injecting them into the MCP server + * list. Required servers always take precedence over locally configured servers + * with the same name and cannot be disabled by the user. + * + * @param mcpServers The current MCP servers (after allowlist filtering). + * @param requiredServers The admin-required MCP server configurations. + * @returns The MCP servers with required servers injected, and the list of + * required server names for informational purposes. + */ +export function applyRequiredServers( + mcpServers: Record, + requiredServers: Record | undefined, +): { + mcpServers: Record; + requiredServerNames: string[]; +} { + if (!requiredServers || Object.keys(requiredServers).length === 0) { + return { mcpServers, requiredServerNames: [] }; + } + + const result: Record = { ...mcpServers }; + const requiredServerNames: string[] = []; + + for (const [serverId, requiredConfig] of Object.entries(requiredServers)) { + requiredServerNames.push(serverId); + + // Convert RequiredMcpServerConfig to MCPServerConfig. + // Required servers completely override any local config with the same name. + result[serverId] = new MCPServerConfig( + undefined, // command (stdio not supported for required servers) + undefined, // args + undefined, // env + undefined, // cwd + requiredConfig.url, // url + undefined, // httpUrl (use url + type instead) + requiredConfig.headers, // headers + undefined, // tcp + requiredConfig.type, // type + requiredConfig.timeout, // timeout + requiredConfig.trust ?? true, // trust defaults to true for admin-forced + requiredConfig.description, // description + requiredConfig.includeTools, // includeTools + requiredConfig.excludeTools, // excludeTools + undefined, // extension + requiredConfig.oauth, // oauth + requiredConfig.authProviderType, // authProviderType + requiredConfig.targetAudience, // targetAudience + requiredConfig.targetServiceAccount, // targetServiceAccount + ); + } + + return { mcpServers: result, requiredServerNames }; +} diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index d238d1a75e..d2aa4c3c1d 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -5,6 +5,7 @@ */ import { z } from 'zod'; +import { AuthProviderType } from '../config/config.js'; export interface ClientMetadata { ideType?: ClientMetadataIdeType; @@ -359,8 +360,41 @@ const McpServerConfigSchema = z.object({ excludeTools: z.array(z.string()).optional(), }); +const RequiredMcpServerOAuthSchema = z.object({ + scopes: z.array(z.string()).optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), +}); + +export const RequiredMcpServerConfigSchema = z.object({ + // Connection (required for forced servers) + url: z.string(), + type: z.enum(['sse', 'http']), + + // Auth + authProviderType: z.nativeEnum(AuthProviderType).optional(), + oauth: RequiredMcpServerOAuthSchema.optional(), + targetAudience: z.string().optional(), + targetServiceAccount: z.string().optional(), + headers: z.record(z.string()).optional(), + + // Common + trust: z.boolean().optional(), + timeout: z.number().optional(), + description: z.string().optional(), + + // Tool filtering + includeTools: z.array(z.string()).optional(), + excludeTools: z.array(z.string()).optional(), +}); + +export type RequiredMcpServerConfig = z.infer< + typeof RequiredMcpServerConfigSchema +>; + export const McpConfigDefinitionSchema = z.object({ mcpServers: z.record(McpServerConfigSchema).optional(), + requiredMcpServers: z.record(RequiredMcpServerConfigSchema).optional(), }); export type McpConfigDefinition = z.infer; @@ -377,6 +411,7 @@ export const AdminControlsSettingsSchema = z.object({ .object({ mcpEnabled: z.boolean().optional(), mcpConfig: McpConfigDefinitionSchema.optional(), + requiredMcpConfig: z.record(RequiredMcpServerConfigSchema).optional(), }) .optional(), cliFeatureSetting: CliFeatureSettingSchema.optional(), diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 17409313ce..9c790c6268 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -3040,13 +3040,23 @@ }, "config": { "title": "MCP Config", - "description": "Admin-configured MCP servers.", - "markdownDescription": "Admin-configured MCP servers.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`", + "description": "Admin-configured MCP servers (allowlist).", + "markdownDescription": "Admin-configured MCP servers (allowlist).\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`", "default": {}, "type": "object", "additionalProperties": { "$ref": "#/$defs/MCPServerConfig" } + }, + "requiredConfig": { + "title": "Required MCP Config", + "description": "Admin-required MCP servers that are always injected.", + "markdownDescription": "Admin-required MCP servers that are always injected.\n\n- Category: `Admin`\n- Requires restart: `no`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/RequiredMcpServerConfig" + } } }, "additionalProperties": false @@ -3181,6 +3191,77 @@ } } }, + "RequiredMcpServerConfig": { + "type": "object", + "description": "Admin-required MCP server configuration (remote transports only).", + "additionalProperties": false, + "properties": { + "url": { + "type": "string", + "description": "URL for the required MCP server." + }, + "type": { + "type": "string", + "description": "Transport type for the required server.", + "enum": ["sse", "http"] + }, + "headers": { + "type": "object", + "description": "Additional HTTP headers sent to the server.", + "additionalProperties": { + "type": "string" + } + }, + "timeout": { + "type": "number", + "description": "Timeout in milliseconds for MCP requests." + }, + "trust": { + "type": "boolean", + "description": "Marks the server as trusted. Defaults to true for admin-required servers." + }, + "description": { + "type": "string", + "description": "Human-readable description of the server." + }, + "includeTools": { + "type": "array", + "description": "Subset of tools enabled for this server.", + "items": { + "type": "string" + } + }, + "excludeTools": { + "type": "array", + "description": "Tools disabled for this server.", + "items": { + "type": "string" + } + }, + "oauth": { + "type": "object", + "description": "OAuth configuration for authenticating with the server.", + "additionalProperties": true + }, + "authProviderType": { + "type": "string", + "description": "Authentication provider used for acquiring credentials.", + "enum": [ + "dynamic_discovery", + "google_credentials", + "service_account_impersonation" + ] + }, + "targetAudience": { + "type": "string", + "description": "OAuth target audience (CLIENT_ID.apps.googleusercontent.com)." + }, + "targetServiceAccount": { + "type": "string", + "description": "Service account email to impersonate (name@project.iam.gserviceaccount.com)." + } + } + }, "TelemetrySettings": { "type": "object", "description": "Telemetry configuration for Gemini CLI.", From b52641de0d170dcadb25c5ab25fdea838e865d25 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Thu, 19 Mar 2026 22:57:59 -0700 Subject: [PATCH 013/177] chore(lint): ignore .gemini directory and recursive node_modules (#23211) --- eslint.config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 76230fdfe5..38dec43857 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ export default tseslint.config( { // Global ignores ignores: [ - 'node_modules/*', + '**/node_modules/**', 'eslint.config.js', 'packages/**/dist/**', 'bundle/**', @@ -50,7 +50,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', - '.gemini/skills/**', + '.gemini/**', '**/*.d.ts', ], }, From 52250c162d10f97cafc12f0fdd57cea88997b36d Mon Sep 17 00:00:00 2001 From: nmcnamara-eng <118702206+nmcnamara-eng@users.noreply.github.com> Date: Thu, 19 Mar 2026 23:18:55 -0700 Subject: [PATCH 014/177] feat(cli): conditionally exclude ask_user tool in ACP mode (#23045) Co-authored-by: Sri Pasumarthi Co-authored-by: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> --- packages/cli/src/config/config.test.ts | 24 ++++++++++++++++++++++++ packages/cli/src/config/config.ts | 7 +++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index a94d1f0a28..c046f0c0e7 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2225,6 +2225,30 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).toContain('ask_user'); }); + it('should exclude ask_user in interactive mode when --acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + + it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => { + process.stdin.isTTY = true; + process.argv = ['node', 'script.js', '--experimental-acp']; + const argv = await parseArguments(createTestMergedSettings()); + const config = await loadCliConfig( + createTestMergedSettings(), + 'test-session', + argv, + ); + expect(config.getExcludeTools()).toContain('ask_user'); + }); + it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d5e4851e97..fdcd18c086 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -649,12 +649,16 @@ export async function loadCliConfig( const allowedTools = argv.allowedTools || settings.tools?.allowed || []; + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; + // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; - if (!interactive) { + if (!interactive || isAcpMode) { // The Policy Engine natively handles headless safety by translating ASK_USER // decisions to DENY. However, we explicitly block ask_user here to guarantee // it can never be allowed via a high-priority policy rule when no human is present. + // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, + // breaking conversational flows. extraExcludes.push(ASK_USER_TOOL_NAME); } @@ -770,7 +774,6 @@ export async function loadCliConfig( } } - const isAcpMode = !!argv.acp || !!argv.experimentalAcp; let clientName: string | undefined = undefined; if (isAcpMode) { const ide = detectIdeFromEnv(); From b9c87c14a23bf1599b393494c7700c95f895728a Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Fri, 20 Mar 2026 06:40:10 -0700 Subject: [PATCH 015/177] feat(core): introduce AgentSession and rename stream events to agent events (#23159) --- packages/core/src/agent/agent-session.test.ts | 279 +++++++++++++++++ packages/core/src/agent/agent-session.ts | 212 +++++++++++++ packages/core/src/agent/mock.test.ts | 278 ++++++++--------- packages/core/src/agent/mock.ts | 290 ++++++++---------- packages/core/src/agent/types.ts | 48 +-- 5 files changed, 762 insertions(+), 345 deletions(-) create mode 100644 packages/core/src/agent/agent-session.test.ts create mode 100644 packages/core/src/agent/agent-session.ts diff --git a/packages/core/src/agent/agent-session.test.ts b/packages/core/src/agent/agent-session.test.ts new file mode 100644 index 0000000000..c390d719d4 --- /dev/null +++ b/packages/core/src/agent/agent-session.test.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AgentSession } from './agent-session.js'; +import { MockAgentProtocol } from './mock.js'; +import type { AgentEvent } from './types.js'; + +describe('AgentSession', () => { + it('should passthrough simple methods', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + await session.send({ update: { title: 't' } }); + // update, agent_start, message, agent_end = 4 events + expect(session.events).toHaveLength(4); + + let emitted = false; + session.subscribe(() => { + emitted = true; + }); + protocol.pushResponse([]); + await session.send({ update: { title: 't' } }); + expect(emitted).toBe(true); + + protocol.pushResponse([], { keepOpen: true }); + await session.send({ update: { title: 't' } }); + await session.abort(); + expect( + session.events.some( + (e) => + e.type === 'agent_end' && + (e as AgentEvent<'agent_end'>).reason === 'aborted', + ), + ).toBe(true); + }); + + it('should yield events via sendStream', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + message: [{ type: 'text', text: 'hi' }], + })) { + events.push(event); + } + + // agent_start, agent message, agent_end = 3 events (user message skipped) + expect(events).toHaveLength(3); + expect(events[0].type).toBe('agent_start'); + expect(events[1].type).toBe('message'); + expect((events[1] as AgentEvent<'message'>).role).toBe('agent'); + expect(events[2].type).toBe('agent_end'); + }); + + it('should filter events by streamId in sendStream', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + + const events: AgentEvent[] = []; + const stream = session.sendStream({ update: { title: 'foo' } }); + + for await (const event of stream) { + events.push(event); + } + + expect(events).toHaveLength(3); // agent_start, message, agent_end (update skipped) + const streamId = events[0].streamId; + expect(streamId).not.toBeNull(); + expect(events.every((e) => e.streamId === streamId)).toBe(true); + }); + + it('should handle events arriving before send() resolves', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + update: { title: 'foo' }, + })) { + events.push(event); + } + + expect(events).toHaveLength(3); // agent_start, message, agent_end (update skipped) + expect(events[0].type).toBe('agent_start'); + expect(events[1].type).toBe('message'); + expect(events[2].type).toBe('agent_end'); + }); + + it('should return immediately from sendStream if streamId is null', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // No response queued, so send() returns streamId: null + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + update: { title: 'foo' }, + })) { + events.push(event); + } + + expect(events).toHaveLength(0); + expect(protocol.events).toHaveLength(1); + expect(protocol.events[0].type).toBe('session_update'); + }); + + it('should skip events that occur before agent_start', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Custom emission to ensure events happen before agent_start + protocol.pushResponse([ + { + type: 'message', + role: 'agent', + content: [{ type: 'text', text: 'hello' }], + }, + ]); + + // We can't easily inject events before agent_start with MockAgentProtocol.pushResponse + // because it emits them all together. + // But we know session_update is emitted first. + + const events: AgentEvent[] = []; + for await (const event of session.sendStream({ + message: [{ type: 'text', text: 'hi' }], + })) { + events.push(event); + } + + // The session_update (from the 'hi' message) should be skipped. + expect(events.some((e) => e.type === 'session_update')).toBe(false); + expect(events[0].type).toBe('agent_start'); + }); + + describe('stream()', () => { + it('should replay events after eventId', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Create some events + protocol.pushResponse([{ type: 'message' }]); + await session.send({ update: { title: 't1' } }); + // Wait for events to be emitted + await new Promise((resolve) => setTimeout(resolve, 10)); + + const allEvents = session.events; + expect(allEvents.length).toBeGreaterThan(2); + const eventId = allEvents[1].id; + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ eventId })) { + streamedEvents.push(event); + } + + expect(streamedEvents).toEqual(allEvents.slice(2)); + }); + + it('should replay events for streamId starting with agent_start', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }]); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const allEvents = session.events; + const startEventIndex = allEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === streamId, + ); + expect(startEventIndex).toBeGreaterThan(-1); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ streamId: streamId! })) { + streamedEvents.push(event); + } + + expect(streamedEvents).toEqual(allEvents.slice(startEventIndex)); + }); + + it('should continue listening for active stream after replay', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Start a stream but keep it open + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const streamedEvents: AgentEvent[] = []; + const streamPromise = (async () => { + for await (const event of session.stream({ streamId: streamId! })) { + streamedEvents.push(event); + } + })(); + + // Push more to the stream + await new Promise((resolve) => setTimeout(resolve, 20)); + protocol.pushToStream(streamId!, [{ type: 'message' }], { close: true }); + + await streamPromise; + + const allEvents = session.events; + const startEventIndex = allEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === streamId, + ); + expect(streamedEvents).toEqual(allEvents.slice(startEventIndex)); + expect(streamedEvents.at(-1)?.type).toBe('agent_end'); + }); + + it('should follow an active stream if no options provided', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + protocol.pushResponse([{ type: 'message' }], { keepOpen: true }); + const { streamId } = await session.send({ update: { title: 't1' } }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const streamedEvents: AgentEvent[] = []; + const streamPromise = (async () => { + for await (const event of session.stream()) { + streamedEvents.push(event); + } + })(); + + await new Promise((resolve) => setTimeout(resolve, 20)); + protocol.pushToStream(streamId!, [{ type: 'message' }], { close: true }); + await streamPromise; + + expect(streamedEvents.length).toBeGreaterThan(0); + expect(streamedEvents.at(-1)?.type).toBe('agent_end'); + }); + + it('should ONLY yield events for specific streamId even if newer streams exist', async () => { + const protocol = new MockAgentProtocol(); + const session = new AgentSession(protocol); + + // Stream 1 + protocol.pushResponse([{ type: 'message' }]); + const { streamId: streamId1 } = await session.send({ + update: { title: 's1' }, + }); + + // Stream 2 + protocol.pushResponse([{ type: 'message' }]); + const { streamId: streamId2 } = await session.send({ + update: { title: 's2' }, + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + const streamedEvents: AgentEvent[] = []; + for await (const event of session.stream({ streamId: streamId1! })) { + streamedEvents.push(event); + } + + expect(streamedEvents.every((e) => e.streamId === streamId1)).toBe(true); + expect(streamedEvents.some((e) => e.type === 'agent_end')).toBe(true); + expect(streamedEvents.some((e) => e.streamId === streamId2)).toBe(false); + }); + }); +}); diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts new file mode 100644 index 0000000000..0d9fc86bb0 --- /dev/null +++ b/packages/core/src/agent/agent-session.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentProtocol, + AgentSend, + AgentEvent, + Unsubscribe, +} from './types.js'; + +/** + * AgentSession is a wrapper around AgentProtocol that provides a more + * convenient API for consuming agent activity as an AsyncIterable. + */ +export class AgentSession implements AgentProtocol { + private _protocol: AgentProtocol; + + constructor(protocol: AgentProtocol) { + this._protocol = protocol; + } + + async send(payload: AgentSend): Promise<{ streamId: string | null }> { + return this._protocol.send(payload); + } + + subscribe(callback: (event: AgentEvent) => void): Unsubscribe { + return this._protocol.subscribe(callback); + } + + async abort(): Promise { + return this._protocol.abort(); + } + + get events(): AgentEvent[] { + return this._protocol.events; + } + + /** + * Sends a payload to the agent and returns an AsyncIterable that yields + * events for the resulting stream. + * + * @param payload The payload to send to the agent. + */ + async *sendStream(payload: AgentSend): AsyncIterable { + const result = await this._protocol.send(payload); + const streamId = result.streamId; + + if (streamId === null) { + return; + } + + yield* this.stream({ streamId }); + } + + /** + * Returns an AsyncIterable that yields events from the agent session, + * optionally replaying events from history or reattaching to an existing stream. + * + * @param options Options for replaying or reattaching to the event stream. + */ + async *stream( + options: { + eventId?: string; + streamId?: string; + } = {}, + ): AsyncIterable { + let resolve: (() => void) | undefined; + let next = new Promise((res) => { + resolve = res; + }); + + let eventQueue: AgentEvent[] = []; + const earlyEvents: AgentEvent[] = []; + let done = false; + let trackedStreamId = options.streamId; + let started = false; + + // 1. Subscribe early to avoid missing any events that occur during replay setup + const unsubscribe = this._protocol.subscribe((event) => { + if (done) return; + + if (!started) { + earlyEvents.push(event); + return; + } + + if (trackedStreamId && event.streamId !== trackedStreamId) return; + + // If we don't have a tracked stream yet, the first agent_start we see becomes it. + if (!trackedStreamId && event.type === 'agent_start') { + trackedStreamId = event.streamId ?? undefined; + } + + // If we still don't have a tracked stream and we aren't replaying everything (eventId), ignore. + if (!trackedStreamId && !options.eventId) return; + + eventQueue.push(event); + if ( + event.type === 'agent_end' && + event.streamId === (trackedStreamId ?? null) + ) { + done = true; + } + + const currentResolve = resolve; + next = new Promise((r) => { + resolve = r; + }); + currentResolve?.(); + }); + + try { + const currentEvents = this._protocol.events; + let replayStartIndex = -1; + + if (options.eventId) { + const index = currentEvents.findIndex((e) => e.id === options.eventId); + if (index !== -1) { + replayStartIndex = index + 1; + } + } else if (options.streamId) { + const index = currentEvents.findIndex( + (e) => e.type === 'agent_start' && e.streamId === options.streamId, + ); + if (index !== -1) { + replayStartIndex = index; + } + } + + if (replayStartIndex !== -1) { + for (let i = replayStartIndex; i < currentEvents.length; i++) { + const event = currentEvents[i]; + if (options.streamId && event.streamId !== options.streamId) continue; + + eventQueue.push(event); + if (event.type === 'agent_start' && !trackedStreamId) { + trackedStreamId = event.streamId ?? undefined; + } + if ( + event.type === 'agent_end' && + event.streamId === (trackedStreamId ?? null) + ) { + done = true; + break; + } + } + } + + if (!done && !trackedStreamId) { + // Find active stream in history + const activeStarts = currentEvents.filter( + (e) => e.type === 'agent_start', + ); + for (let i = activeStarts.length - 1; i >= 0; i--) { + const start = activeStarts[i]; + if ( + !currentEvents.some( + (e) => e.type === 'agent_end' && e.streamId === start.streamId, + ) + ) { + trackedStreamId = start.streamId ?? undefined; + break; + } + } + } + + // If we replayed to the end and no stream is active, and we were specifically + // replaying from an eventId (or we've already finished the stream we were looking for), we are done. + if (!done && !trackedStreamId && options.eventId) { + done = true; + } + + started = true; + + // Process events that arrived while we were replaying + for (const event of earlyEvents) { + if (done) break; + if (trackedStreamId && event.streamId !== trackedStreamId) continue; + if (!trackedStreamId && event.type === 'agent_start') { + trackedStreamId = event.streamId ?? undefined; + } + if (!trackedStreamId && !options.eventId) continue; + + eventQueue.push(event); + if ( + event.type === 'agent_end' && + event.streamId === (trackedStreamId ?? null) + ) { + done = true; + } + } + + while (true) { + if (eventQueue.length > 0) { + const eventsToYield = eventQueue; + eventQueue = []; + for (const event of eventsToYield) { + yield event; + } + } + + if (done) break; + await next; + } + } finally { + unsubscribe(); + } + } +} diff --git a/packages/core/src/agent/mock.test.ts b/packages/core/src/agent/mock.test.ts index 41672223a9..4f102d5dbd 100644 --- a/packages/core/src/agent/mock.test.ts +++ b/packages/core/src/agent/mock.test.ts @@ -5,12 +5,24 @@ */ import { describe, expect, it } from 'vitest'; -import { MockAgentSession } from './mock.js'; -import type { AgentEvent } from './types.js'; +import { MockAgentProtocol } from './mock.js'; +import type { AgentEvent, AgentProtocol } from './types.js'; -describe('MockAgentSession', () => { - it('should yield queued events on send and stream', async () => { - const session = new MockAgentSession(); +const waitForStreamEnd = (session: AgentProtocol): Promise => + new Promise((resolve) => { + const events: AgentEvent[] = []; + const unsubscribe = session.subscribe((e) => { + events.push(e); + if (e.type === 'agent_end') { + unsubscribe(); + resolve(events); + } + }); + }); + +describe('MockAgentProtocol', () => { + it('should emit queued events on send and subscribe', async () => { + const session = new MockAgentProtocol(); const event1 = { type: 'message', role: 'agent', @@ -19,31 +31,30 @@ describe('MockAgentSession', () => { session.pushResponse([event1]); + const streamPromise = waitForStreamEnd(session); + const { streamId } = await session.send({ message: [{ type: 'text', text: 'hi' }], }); expect(streamId).toBeDefined(); - const streamedEvents: AgentEvent[] = []; - for await (const event of session.stream()) { - streamedEvents.push(event); - } + const streamedEvents = await streamPromise; - // Auto stream_start, auto user message, agent message, auto stream_end = 4 events + // Ordered: user message, agent_start, agent message, agent_end = 4 events expect(streamedEvents).toHaveLength(4); - expect(streamedEvents[0].type).toBe('stream_start'); - expect(streamedEvents[1].type).toBe('message'); - expect((streamedEvents[1] as AgentEvent<'message'>).role).toBe('user'); + expect(streamedEvents[0].type).toBe('message'); + expect((streamedEvents[0] as AgentEvent<'message'>).role).toBe('user'); + expect(streamedEvents[1].type).toBe('agent_start'); expect(streamedEvents[2].type).toBe('message'); expect((streamedEvents[2] as AgentEvent<'message'>).role).toBe('agent'); - expect(streamedEvents[3].type).toBe('stream_end'); + expect(streamedEvents[3].type).toBe('agent_end'); expect(session.events).toHaveLength(4); expect(session.events).toEqual(streamedEvents); }); it('should handle multiple responses', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); // Test with empty payload (no message injected) session.pushResponse([]); @@ -57,204 +68,154 @@ describe('MockAgentSession', () => { ]); // First send + const stream1Promise = waitForStreamEnd(session); const { streamId: s1 } = await session.send({ - update: {}, + update: { title: 't1' }, }); - const events1: AgentEvent[] = []; - for await (const e of session.stream()) events1.push(e); - expect(events1).toHaveLength(3); // stream_start, session_update, stream_end - expect(events1[0].type).toBe('stream_start'); - expect(events1[1].type).toBe('session_update'); - expect(events1[2].type).toBe('stream_end'); + const events1 = await stream1Promise; + expect(events1).toHaveLength(3); // session_update, agent_start, agent_end + expect(events1[0].type).toBe('session_update'); + expect(events1[1].type).toBe('agent_start'); + expect(events1[2].type).toBe('agent_end'); // Second send + const stream2Promise = waitForStreamEnd(session); const { streamId: s2 } = await session.send({ - update: {}, + update: { title: 't2' }, }); expect(s1).not.toBe(s2); - const events2: AgentEvent[] = []; - for await (const e of session.stream()) events2.push(e); - expect(events2).toHaveLength(4); // stream_start, session_update, error, stream_end - expect(events2[1].type).toBe('session_update'); + const events2 = await stream2Promise; + expect(events2).toHaveLength(4); // session_update, agent_start, error, agent_end + expect(events2[0].type).toBe('session_update'); + expect(events2[1].type).toBe('agent_start'); expect(events2[2].type).toBe('error'); + expect(events2[3].type).toBe('agent_end'); expect(session.events).toHaveLength(7); }); - it('should allow streaming by streamId', async () => { - const session = new MockAgentSession(); - session.pushResponse([{ type: 'message' }]); - - const { streamId } = await session.send({ - update: {}, - }); + it('should handle abort on a waiting stream', async () => { + const session = new MockAgentProtocol(); + // Use keepOpen to prevent auto agent_end + session.pushResponse([{ type: 'message' }], { keepOpen: true }); const events: AgentEvent[] = []; - for await (const e of session.stream({ streamId })) { + let resolveStream: (evs: AgentEvent[]) => void; + const streamPromise = new Promise((res) => { + resolveStream = res; + }); + + session.subscribe((e) => { events.push(e); - } - expect(events).toHaveLength(4); // start, update, message, end - }); + if (e.type === 'agent_end') { + resolveStream(events); + } + }); - it('should throw when streaming non-existent streamId', async () => { - const session = new MockAgentSession(); - await expect(async () => { - const stream = session.stream({ streamId: 'invalid' }); - await stream.next(); - }).rejects.toThrow('Stream not found: invalid'); - }); + const { streamId: _streamId } = await session.send({ + update: { title: 't' }, + }); - it('should throw when streaming non-existent eventId', async () => { - const session = new MockAgentSession(); - session.pushResponse([{ type: 'message' }]); - await session.send({ update: {} }); - - await expect(async () => { - const stream = session.stream({ eventId: 'invalid' }); - await stream.next(); - }).rejects.toThrow('Event not found: invalid'); - }); - - it('should handle abort on a waiting stream', async () => { - const session = new MockAgentSession(); - // Use keepOpen to prevent auto stream_end - session.pushResponse([{ type: 'message' }], { keepOpen: true }); - const { streamId } = await session.send({ update: {} }); - - const stream = session.stream({ streamId }); - - // Read initial events - const e1 = await stream.next(); - expect(e1.value.type).toBe('stream_start'); - const e2 = await stream.next(); - expect(e2.value.type).toBe('session_update'); - const e3 = await stream.next(); - expect(e3.value.type).toBe('message'); + // Initial events should have been emitted + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + 'message', + ]); // At this point, the stream should be "waiting" for more events because it's still active - // and hasn't seen a stream_end. - const abortPromise = session.abort(); - const e4 = await stream.next(); - expect(e4.value.type).toBe('stream_end'); - expect((e4.value as AgentEvent<'stream_end'>).reason).toBe('aborted'); + // and hasn't seen an agent_end. + await session.abort(); - await abortPromise; - expect(await stream.next()).toEqual({ done: true, value: undefined }); + const finalEvents = await streamPromise; + expect(finalEvents[3].type).toBe('agent_end'); + expect((finalEvents[3] as AgentEvent<'agent_end'>).reason).toBe('aborted'); }); it('should handle pushToStream on a waiting stream', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); session.pushResponse([], { keepOpen: true }); - const { streamId } = await session.send({ update: {} }); - const stream = session.stream({ streamId }); - await stream.next(); // start - await stream.next(); // update + const events: AgentEvent[] = []; + session.subscribe((e) => events.push(e)); + + const { streamId } = await session.send({ update: { title: 't' } }); + + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + ]); // Push new event to active stream - session.pushToStream(streamId, [{ type: 'message' }]); + session.pushToStream(streamId!, [{ type: 'message' }]); - const e3 = await stream.next(); - expect(e3.value.type).toBe('message'); + expect(events).toHaveLength(3); + expect(events[2].type).toBe('message'); await session.abort(); - const e4 = await stream.next(); - expect(e4.value.type).toBe('stream_end'); + expect(events).toHaveLength(4); + expect(events[3].type).toBe('agent_end'); }); it('should handle pushToStream with close option', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); session.pushResponse([], { keepOpen: true }); - const { streamId } = await session.send({ update: {} }); - const stream = session.stream({ streamId }); - await stream.next(); // start - await stream.next(); // update + const streamPromise = waitForStreamEnd(session); + const { streamId } = await session.send({ update: { title: 't' } }); // Push new event and close - session.pushToStream(streamId, [{ type: 'message' }], { close: true }); + session.pushToStream(streamId!, [{ type: 'message' }], { close: true }); - const e3 = await stream.next(); - expect(e3.value.type).toBe('message'); - - const e4 = await stream.next(); - expect(e4.value.type).toBe('stream_end'); - expect((e4.value as AgentEvent<'stream_end'>).reason).toBe('completed'); - - expect(await stream.next()).toEqual({ done: true, value: undefined }); + const events = await streamPromise; + expect(events.map((e) => e.type)).toEqual([ + 'session_update', + 'agent_start', + 'message', + 'agent_end', + ]); + expect((events[3] as AgentEvent<'agent_end'>).reason).toBe('completed'); }); - it('should not double up on stream_end if provided manually', async () => { - const session = new MockAgentSession(); + it('should not double up on agent_end if provided manually', async () => { + const session = new MockAgentProtocol(); session.pushResponse([ { type: 'message' }, - { type: 'stream_end', reason: 'completed' }, + { type: 'agent_end', reason: 'completed' }, ]); - const { streamId } = await session.send({ update: {} }); - const events: AgentEvent[] = []; - for await (const e of session.stream({ streamId })) { - events.push(e); - } + const streamPromise = waitForStreamEnd(session); + await session.send({ update: { title: 't' } }); - const endEvents = events.filter((e) => e.type === 'stream_end'); + const events = await streamPromise; + const endEvents = events.filter((e) => e.type === 'agent_end'); expect(endEvents).toHaveLength(1); }); - it('should stream after eventId', async () => { - const session = new MockAgentSession(); - // Use manual IDs to test resumption - session.pushResponse([ - { type: 'stream_start', id: 'e1' }, - { type: 'message', id: 'e2' }, - { type: 'stream_end', id: 'e3' }, - ]); - - await session.send({ update: {} }); - - // Stream first event only - const first: AgentEvent[] = []; - for await (const e of session.stream()) { - first.push(e); - if (e.id === 'e1') break; - } - expect(first).toHaveLength(1); - expect(first[0].id).toBe('e1'); - - // Resume from e1 - const second: AgentEvent[] = []; - for await (const e of session.stream({ eventId: 'e1' })) { - second.push(e); - } - expect(second).toHaveLength(3); // update, message, end - expect(second[0].type).toBe('session_update'); - expect(second[1].id).toBe('e2'); - expect(second[2].id).toBe('e3'); - }); - it('should handle elicitations', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); session.pushResponse([]); + const streamPromise = waitForStreamEnd(session); await session.send({ elicitations: [ { requestId: 'r1', action: 'accept', content: { foo: 'bar' } }, ], }); - const events: AgentEvent[] = []; - for await (const e of session.stream()) events.push(e); - - expect(events[1].type).toBe('elicitation_response'); - expect((events[1] as AgentEvent<'elicitation_response'>).requestId).toBe( + const events = await streamPromise; + expect(events[0].type).toBe('elicitation_response'); + expect((events[0] as AgentEvent<'elicitation_response'>).requestId).toBe( 'r1', ); + expect(events[1].type).toBe('agent_start'); }); it('should handle updates and track state', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); session.pushResponse([]); + const streamPromise = waitForStreamEnd(session); await session.send({ update: { title: 'New Title', model: 'gpt-4', config: { x: 1 } }, }); @@ -263,15 +224,24 @@ describe('MockAgentSession', () => { expect(session.model).toBe('gpt-4'); expect(session.config).toEqual({ x: 1 }); - const events: AgentEvent[] = []; - for await (const e of session.stream()) events.push(e); - expect(events[1].type).toBe('session_update'); + const events = await streamPromise; + expect(events[0].type).toBe('session_update'); + expect(events[1].type).toBe('agent_start'); + }); + + it('should return streamId: null if no response queued', async () => { + const session = new MockAgentProtocol(); + const { streamId } = await session.send({ update: { title: 'foo' } }); + expect(streamId).toBeNull(); + expect(session.events).toHaveLength(1); + expect(session.events[0].type).toBe('session_update'); + expect(session.events[0].streamId).toBeNull(); }); it('should throw on action', async () => { - const session = new MockAgentSession(); + const session = new MockAgentProtocol(); await expect( session.send({ action: { type: 'foo', data: {} } }), - ).rejects.toThrow('Actions not supported in MockAgentSession: foo'); + ).rejects.toThrow('Actions not supported in MockAgentProtocol: foo'); }); }); diff --git a/packages/core/src/agent/mock.ts b/packages/core/src/agent/mock.ts index 7baeb61a83..f29e87f878 100644 --- a/packages/core/src/agent/mock.ts +++ b/packages/core/src/agent/mock.ts @@ -9,31 +9,32 @@ import type { AgentEventCommon, AgentEventData, AgentSend, - AgentSession, + AgentProtocol, + Unsubscribe, } from './types.js'; export type MockAgentEvent = Partial & AgentEventData; export interface PushResponseOptions { - /** If true, does not automatically add a stream_end event. */ + /** If true, does not automatically add an agent_end event. */ keepOpen?: boolean; } /** - * A mock implementation of AgentSession for testing. + * A mock implementation of AgentProtocol for testing. * Allows queuing responses that will be yielded when send() is called. */ -export class MockAgentSession implements AgentSession { +export class MockAgentProtocol implements AgentProtocol { private _events: AgentEvent[] = []; private _responses: Array<{ events: MockAgentEvent[]; options?: PushResponseOptions; }> = []; - private _streams = new Map(); + private _subscribers = new Set<(event: AgentEvent) => void>(); private _activeStreamIds = new Set(); - private _lastStreamId?: string; + private _lastStreamId?: string | null; private _nextEventId = 1; - private _streamResolvers = new Map void>>(); + private _nextStreamId = 1; title?: string; model?: string; @@ -50,12 +51,28 @@ export class MockAgentSession implements AgentSession { return this._events; } + subscribe(callback: (event: AgentEvent) => void): Unsubscribe { + this._subscribers.add(callback); + return () => this._subscribers.delete(callback); + } + + private _emit(event: AgentEvent) { + if (!this._events.some((e) => e.id === event.id)) { + this._events.push(event); + } + for (const callback of this._subscribers) { + callback(event); + } + if (event.type === 'agent_end' && event.streamId) { + this._activeStreamIds.delete(event.streamId); + } + } + /** * Queues a sequence of events to be "emitted" by the agent in response to the * next send() call. */ pushResponse(events: MockAgentEvent[], options?: PushResponseOptions) { - // We store them as data and normalize them when send() is called this._responses.push({ events, options }); } @@ -67,11 +84,6 @@ export class MockAgentSession implements AgentSession { events: MockAgentEvent[], options?: { close?: boolean }, ) { - const stream = this._streams.get(streamId); - if (!stream) { - throw new Error(`Stream not found: ${streamId}`); - } - const now = new Date().toISOString(); for (const eventData of events) { const event: AgentEvent = { @@ -80,205 +92,147 @@ export class MockAgentSession implements AgentSession { timestamp: eventData.timestamp ?? now, streamId: eventData.streamId ?? streamId, } as AgentEvent; - stream.push(event); + this._emit(event); } if ( options?.close && - !events.some((eventData) => eventData.type === 'stream_end') + !events.some((eventData) => eventData.type === 'agent_end') ) { - stream.push({ + this._emit({ id: `e-${this._nextEventId++}`, timestamp: now, streamId, - type: 'stream_end', + type: 'agent_end', reason: 'completed', } as AgentEvent); } - - this._notify(streamId); } - private _notify(streamId: string) { - const resolvers = this._streamResolvers.get(streamId); - if (resolvers) { - this._streamResolvers.delete(streamId); - for (const resolve of resolvers) resolve(); - } - } - - async send(payload: AgentSend): Promise<{ streamId: string }> { - const { events: response, options } = this._responses.shift() ?? { + async send(payload: AgentSend): Promise<{ streamId: string | null }> { + const responseData = this._responses.shift(); + const { events: response, options } = responseData ?? { events: [], }; - const streamId = - response[0]?.streamId ?? `mock-stream-${this._streams.size + 1}`; + + // If there were queued responses (even if empty array), we trigger a stream. + const hasResponseEvents = responseData !== undefined; + const streamId = hasResponseEvents + ? (response[0]?.streamId ?? `mock-stream-${this._nextStreamId++}`) + : null; const now = new Date().toISOString(); + const eventsToEmit: AgentEvent[] = []; - if (!response.some((eventData) => eventData.type === 'stream_start')) { - response.unshift({ - type: 'stream_start', - streamId, - }); - } - - const startIndex = response.findIndex( - (eventData) => eventData.type === 'stream_start', - ); + // Helper to normalize and prepare for emission + const normalize = (eventData: MockAgentEvent): AgentEvent => + ({ + ...eventData, + id: eventData.id ?? `e-${this._nextEventId++}`, + timestamp: eventData.timestamp ?? now, + streamId: eventData.streamId ?? streamId, + }) as AgentEvent; + // 1. User/Update event (BEFORE agent_start) if ('message' in payload && payload.message) { - response.splice(startIndex + 1, 0, { - type: 'message', - role: 'user', - content: payload.message, - _meta: payload._meta, - }); - } else if ('elicitations' in payload && payload.elicitations) { - payload.elicitations.forEach((elicitation, i) => { - response.splice(startIndex + 1 + i, 0, { - type: 'elicitation_response', - ...elicitation, + eventsToEmit.push( + normalize({ + type: 'message', + role: 'user', + content: payload.message, _meta: payload._meta, - }); + }), + ); + } else if ('elicitations' in payload && payload.elicitations) { + payload.elicitations.forEach((elicitation) => { + eventsToEmit.push( + normalize({ + type: 'elicitation_response', + ...elicitation, + _meta: payload._meta, + }), + ); }); - } else if ('update' in payload && payload.update) { + } else if ( + 'update' in payload && + payload.update && + Object.keys(payload.update).length > 0 + ) { if (payload.update.title) this.title = payload.update.title; if (payload.update.model) this.model = payload.update.model; if (payload.update.config) { this.config = payload.update.config; } - response.splice(startIndex + 1, 0, { - type: 'session_update', - ...payload.update, - _meta: payload._meta, - }); + eventsToEmit.push( + normalize({ + type: 'session_update', + ...payload.update, + _meta: payload._meta, + }), + ); } else if ('action' in payload && payload.action) { throw new Error( - `Actions not supported in MockAgentSession: ${payload.action.type}`, + `Actions not supported in MockAgentProtocol: ${payload.action.type}`, ); } - if ( - !options?.keepOpen && - !response.some((eventData) => eventData.type === 'stream_end') - ) { - response.push({ - type: 'stream_end', - reason: 'completed', - streamId, - }); - } - - const normalizedResponse: AgentEvent[] = []; - for (const eventData of response) { - const event: AgentEvent = { - ...eventData, - id: eventData.id ?? `e-${this._nextEventId++}`, - timestamp: eventData.timestamp ?? now, - streamId: eventData.streamId ?? streamId, - } as AgentEvent; - normalizedResponse.push(event); - } - - this._streams.set(streamId, normalizedResponse); - this._activeStreamIds.add(streamId); - this._lastStreamId = streamId; - - return { streamId }; - } - - async *stream(options?: { - streamId?: string; - eventId?: string; - }): AsyncIterableIterator { - let streamId = options?.streamId; - - if (options?.eventId) { - const event = this._events.find( - (eventData) => eventData.id === options.eventId, - ); - if (!event) { - throw new Error(`Event not found: ${options.eventId}`); - } - streamId = streamId ?? event.streamId; - } - - streamId = streamId ?? this._lastStreamId; - - if (!streamId) { - return; - } - - const events = this._streams.get(streamId); - if (!events) { - throw new Error(`Stream not found: ${streamId}`); - } - - let i = 0; - if (options?.eventId) { - const idx = events.findIndex( - (eventData) => eventData.id === options.eventId, - ); - if (idx !== -1) { - i = idx + 1; - } else { - // This should theoretically not happen if the event was found in this._events - // but the trajectories match. - throw new Error( - `Event ${options.eventId} not found in stream ${streamId}`, + // 2. agent_start (if stream) + if (streamId) { + if (!response.some((eventData) => eventData.type === 'agent_start')) { + eventsToEmit.push( + normalize({ + type: 'agent_start', + streamId, + }), ); } } - while (true) { - if (i < events.length) { - const event = events[i++]; - // Add to session trajectory if not already present - if (!this._events.some((eventData) => eventData.id === event.id)) { - this._events.push(event); - } - yield event; + // 3. Response events + for (const eventData of response) { + eventsToEmit.push(normalize(eventData)); + } - // If it's a stream_end, we're done with this stream - if (event.type === 'stream_end') { - this._activeStreamIds.delete(streamId); - return; - } - } else { - // No more events in the array currently. Check if we're still active. - if (!this._activeStreamIds.has(streamId)) { - // If we weren't terminated by a stream_end but we're no longer active, - // it was an abort. - const abortEvent: AgentEvent = { - id: `e-${this._nextEventId++}`, - timestamp: new Date().toISOString(), + // 4. agent_end (if stream and not manual) + if (streamId && !options?.keepOpen) { + if (!eventsToEmit.some((e) => e.type === 'agent_end')) { + eventsToEmit.push( + normalize({ + type: 'agent_end', + reason: 'completed', streamId, - type: 'stream_end', - reason: 'aborted', - } as AgentEvent; - if (!this._events.some((e) => e.id === abortEvent.id)) { - this._events.push(abortEvent); - } - yield abortEvent; - return; - } - - // Wait for notification (new event or abort) - await new Promise((resolve) => { - const resolvers = this._streamResolvers.get(streamId) ?? []; - resolvers.push(resolve); - this._streamResolvers.set(streamId, resolvers); - }); + }), + ); } } + + if (streamId) { + this._activeStreamIds.add(streamId); + } + this._lastStreamId = streamId; + + // Emit events asynchronously so the caller receives the streamId first. + if (eventsToEmit.length > 0) { + void Promise.resolve().then(() => { + for (const event of eventsToEmit) { + this._emit(event); + } + }); + } + + return { streamId }; } async abort(): Promise { - if (this._lastStreamId) { + if (this._lastStreamId && this._activeStreamIds.has(this._lastStreamId)) { const streamId = this._lastStreamId; - this._activeStreamIds.delete(streamId); - this._notify(streamId); + this._emit({ + id: `e-${this._nextEventId++}`, + timestamp: new Date().toISOString(), + streamId, + type: 'agent_end', + reason: 'aborted', + } as AgentEvent); } } } diff --git a/packages/core/src/agent/types.ts b/packages/core/src/agent/types.ts index 8b698a8e48..3b1c740ad4 100644 --- a/packages/core/src/agent/types.ts +++ b/packages/core/src/agent/types.ts @@ -6,25 +6,27 @@ export type WithMeta = { _meta?: Record }; -export interface AgentSession extends Trajectory { +export type Unsubscribe = () => void; + +export interface AgentProtocol extends Trajectory { /** * Send data to the agent. Promise resolves when action is acknowledged. - * Returns the `streamId` of the stream the message was correlated to -- this may - * be a new stream if idle or an existing stream. - */ - send(payload: AgentSend): Promise<{ streamId: string }>; - /** - * Begin listening to actively streaming data. Stream must have the following - * properties: + * Returns the `streamId` of the stream the message was correlated to -- + * this may be a new stream if idle, an existing stream, or null if no + * stream was triggered. * - * - If no arguments are provided, streams events from an active stream. - * - If a {streamId} is provided, streams ALL events from that stream. - * - If an {eventId} is provided, streams all events AFTER that event. + * When a new stream is created by a send, the streamId MUST be returned + * before the `agent_start` event is emitted for the stream. */ - stream(options?: { - streamId?: string; - eventId?: string; - }): AsyncIterableIterator; + send(payload: AgentSend): Promise<{ streamId: string | null }>; + + /** + * Subscribes the provided callback to all future events emitted by this + * session. Returns an unsubscribe function. + * + * @param callback The callback function to listen to events. + */ + subscribe(callback: (event: AgentEvent) => void): Unsubscribe; /** * Aborts an active stream of agent activity. @@ -32,7 +34,7 @@ export interface AgentSession extends Trajectory { abort(): Promise; /** - * AgentSession implements the Trajectory interface and can retrieve existing events. + * AgentProtocol implements the Trajectory interface and can retrieve existing events. */ readonly events: AgentEvent[]; } @@ -61,7 +63,7 @@ export interface AgentEventCommon { /** Identifies the subagent thread, omitted for "main thread" events. */ threadId?: string; /** Identifies a particular stream of a particular thread. */ - streamId?: string; + streamId?: string | null; /** ISO Timestamp for the time at which the event occurred. */ timestamp: string; /** The concrete type of the event. */ @@ -90,10 +92,10 @@ export interface AgentEvents { session_update: SessionUpdate; /** Message content provided by user, agent, or developer. */ message: Message; - /** Event indicating the start of a new stream. */ - stream_start: StreamStart; - /** Event indicating the end of a running stream. */ - stream_end: StreamEnd; + /** Event indicating the start of agent activity on a stream. */ + agent_start: AgentStart; + /** Event indicating the end of agent activity on a stream. */ + agent_end: AgentEnd; /** Tool request issued by the agent. */ tool_request: ToolRequest; /** Tool update issued by the agent. */ @@ -257,7 +259,7 @@ export interface Usage { cost?: { amount: number; currency?: string }; } -export interface StreamStart { +export interface AgentStart { streamId: string; } @@ -272,7 +274,7 @@ type StreamEndReason = | 'elicitation' | (string & {}); -export interface StreamEnd { +export interface AgentEnd { streamId: string; reason: StreamEndReason; elicitationIds?: string[]; From 5a3c7154df30546dabf96330946e9139c885d13a Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 20 Mar 2026 10:10:51 -0400 Subject: [PATCH 016/177] feat(worktree): add Git worktree support for isolated parallel sessions (#22973) --- docs/cli/cli-reference.md | 1 + docs/cli/git-worktrees.md | 107 ++++++ docs/cli/session-management.md | 6 + docs/cli/settings.md | 1 + docs/reference/configuration.md | 5 + docs/sidebar.json | 5 + packages/cli/src/config/config.test.ts | 45 +++ packages/cli/src/config/config.ts | 106 +++++- packages/cli/src/config/settings.ts | 4 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/gemini.test.tsx | 2 + packages/cli/src/gemini.tsx | 10 + packages/cli/src/gemini_cleanup.test.tsx | 2 + .../components/SessionSummaryDisplay.test.tsx | 47 ++- .../ui/components/SessionSummaryDisplay.tsx | 14 +- packages/cli/src/utils/worktreeSetup.test.ts | 124 +++++++ packages/cli/src/utils/worktreeSetup.ts | 43 +++ packages/core/src/config/config.ts | 13 + packages/core/src/index.ts | 1 + .../core/src/services/worktreeService.test.ts | 311 ++++++++++++++++++ packages/core/src/services/worktreeService.ts | 225 +++++++++++++ .../core/src/utils/memoryImportProcessor.ts | 10 +- schemas/settings.schema.json | 7 + 23 files changed, 1090 insertions(+), 9 deletions(-) create mode 100644 docs/cli/git-worktrees.md create mode 100644 packages/cli/src/utils/worktreeSetup.test.ts create mode 100644 packages/cli/src/utils/worktreeSetup.ts create mode 100644 packages/core/src/services/worktreeService.test.ts create mode 100644 packages/core/src/services/worktreeService.ts diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 167801ca05..bc8f8b44ce 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -50,6 +50,7 @@ These commands are available within the interactive REPL. | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md new file mode 100644 index 0000000000..5020b3fa9a --- /dev/null +++ b/docs/cli/git-worktrees.md @@ -0,0 +1,107 @@ +# Git Worktrees (experimental) + +When working on multiple tasks at once, you can use Git worktrees to give each +Gemini session its own copy of the codebase. Git worktrees create separate +working directories that each have their own files and branch while sharing the +same repository history. This prevents changes in one session from colliding +with another. + +Learn more about [session management](./session-management.md). + + +> [!NOTE] +> This is an experimental feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, +> suggestions, or encounter issues: +> +> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. + +Learn more in the official Git worktree +[documentation](https://git-scm.com/docs/git-worktree). + +## How to enable Git worktrees + +Git worktrees are an experimental feature. You must enable them in your settings +using the `/settings` command or by manually editing your `settings.json` file. + +1. Use the `/settings` command. +2. Search for and set **Enable Git Worktrees** to `true`. + +Alternatively, add the following to your `settings.json`: + +```json +{ + "experimental": { + "worktrees": true + } +} +``` + +## How to use Git worktrees + +Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini +CLI in it. + +- **Start with a specific name:** The value you pass becomes both the directory + name (within `.gemini/worktrees/`) and the branch name. + + ```bash + gemini --worktree feature-search + ``` + +- **Start with a random name:** If you omit the name, Gemini generates a random + one automatically (for example, `worktree-a1b2c3d4`). + + ```bash + gemini --worktree + ``` + + +> [!NOTE] +> Remember to initialize your development environment in each new +> worktree according to your project's setup. Depending on your stack, this +> might include running dependency installation (`npm install`, `yarn`), setting +> up virtual environments, or following your project's standard build process. + +## How to exit a Git worktree session + +When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the +worktree intact so your work is not lost. This includes your uncommitted changes +(modified files, staged changes, or untracked files) and any new commits you +have made. + +Gemini prioritizes a fast and safe exit: it **does not automatically delete** +your worktree or branch. You are responsible for cleaning up your worktrees +manually once you are finished with them. + +When you exit, Gemini displays instructions on how to resume your work or how to +manually remove the worktree if you no longer need it. + +## Resuming work in a Git worktree + +To resume a session in a worktree, navigate to the worktree directory and start +Gemini CLI with the `--resume` flag and the session ID: + +```bash +cd .gemini/worktrees/feature-search +gemini --resume +``` + +## Managing Git worktrees manually + +For more control over worktree location and branch configuration, or to clean up +a preserved worktree, you can use Git directly: + +- **Clean up a preserved Git worktree:** + ```bash + git worktree remove .gemini/worktrees/feature-search --force + git branch -D worktree-feature-search + ``` +- **Create a Git worktree manually:** + ```bash + git worktree add ../project-feature-search -b feature-search + cd ../project-feature-search && gemini + ``` + +[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 8e60f61630..74bc4a4337 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -96,6 +96,12 @@ Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. +## Parallel sessions with Git worktrees + +When working on multiple tasks at once, you can use +[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of +the codebase. This prevents changes in one session from colliding with another. + ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 85373f1034..ead0050fbd 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -151,6 +151,7 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | +| 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` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index d3b08d565a..5791bbf457 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1527,6 +1527,11 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`experimental.worktrees`** (boolean): + - **Description:** Enable automated Git worktree management for parallel work. + - **Default:** `false` + - **Requires restart:** Yes + - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` diff --git a/docs/sidebar.json b/docs/sidebar.json index 6cac5ec9fd..7198a0336b 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -99,6 +99,11 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, + { + "label": "Git worktrees", + "badge": "🔬", + "slug": "docs/cli/git-worktrees" + }, { "label": "Hooks", "collapsed": true, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c046f0c0e7..746fc14475 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -226,6 +226,51 @@ afterEach(() => { }); describe('parseArguments', () => { + describe('worktree', () => { + it('should parse --worktree flag when provided with a name', async () => { + process.argv = ['node', 'script.js', '--worktree', 'my-feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBe('my-feature'); + }); + + it('should generate a random name when --worktree is provided without a name', async () => { + process.argv = ['node', 'script.js', '--worktree']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = true; + const argv = await parseArguments(settings); + expect(argv.worktree).toBeDefined(); + expect(argv.worktree).not.toBe(''); + expect(typeof argv.worktree).toBe('string'); + }); + + it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { + process.argv = ['node', 'script.js', '--worktree', 'feature']; + const settings = createTestMergedSettings(); + settings.experimental.worktrees = false; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments(settings)).rejects.toThrow( + 'process.exit called', + ); + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + }); + it.each([ { description: 'long flags', diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fdcd18c086..227ad4e8ed 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yargs from 'yargs/yargs'; +import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; +import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -38,6 +39,9 @@ import { applyAdminAllowlist, applyRequiredServers, getAdminBlockedMcpServersMessage, + getProjectRootForWorktree, + isGeminiWorktree, + type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, @@ -48,6 +52,8 @@ import { type MergedSettings, saveModelChange, loadSettings, + isWorktreeEnabled, + type LoadedSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -74,6 +80,7 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; + worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; @@ -115,6 +122,36 @@ const coerceCommaSeparated = (values: string[]): string[] => { ); }; +/** + * Pre-parses the command line arguments to find the worktree flag. + * Used for early setup before full argument parsing with settings. + */ +export function getWorktreeArg(argv: string[]): string | undefined { + const result = yargs(hideBin(argv)) + .help(false) + .version(false) + .option('worktree', { alias: 'w', type: 'string' }) + .strict(false) + .exitProcess(false) + .parseSync(); + + if (result.worktree === undefined) return undefined; + return typeof result.worktree === 'string' ? result.worktree.trim() : ''; +} + +/** + * Checks if a worktree is requested via CLI and enabled in settings. + * Returns the requested name (can be empty string for auto-generated) or undefined. + */ +export function getRequestedWorktreeName( + settings: LoadedSettings, +): string | undefined { + if (!isWorktreeEnabled(settings)) { + return undefined; + } + return getWorktreeArg(process.argv); +} + export async function parseArguments( settings: MergedSettings, ): Promise { @@ -158,6 +195,20 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) + .option('worktree', { + alias: 'w', + type: 'string', + skipValidation: true, + description: + 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', + coerce: (value: unknown): string => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (trimmed === '') { + return Math.random().toString(36).substring(2, 10); + } + return trimmed; + }, + }) .option('sandbox', { alias: 's', type: 'boolean', @@ -335,6 +386,9 @@ export async function parseArguments( ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } + if (argv['worktree'] && !settings.experimental?.worktrees) { + return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; + } return true; }); @@ -420,6 +474,7 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; + worktreeSettings?: WorktreeSettings; } export async function loadCliConfig( @@ -431,6 +486,9 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); + const worktreeSettings = + options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); + if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; } @@ -802,6 +860,7 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat, debugMode, question, + worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -943,3 +1002,48 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } + +async function resolveWorktreeSettings( + cwd: string, +): Promise { + let worktreePath: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { + cwd, + }); + const toplevel = stdout.trim(); + const projectRoot = await getProjectRootForWorktree(toplevel); + + if (isGeminiWorktree(toplevel, projectRoot)) { + worktreePath = toplevel; + } + } catch (_e) { + return undefined; + } + + if (!worktreePath) { + return undefined; + } + + let worktreeBaseSha: string | undefined; + try { + const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: worktreePath, + }); + worktreeBaseSha = stdout.trim(); + } catch (e: unknown) { + debugLogger.debug( + `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!worktreeBaseSha) { + return undefined; + } + + return { + name: path.basename(worktreePath), + path: worktreePath, + baseSha: worktreeBaseSha, + }; +} diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index beecd6a017..984bdb8d60 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -632,6 +632,10 @@ export function resetSettingsCacheForTesting() { settingsCache.clear(); } +export function isWorktreeEnabled(settings: LoadedSettings): boolean { + return settings.merged.experimental.worktrees; +} + /** * Loads settings from user and workspace directories. * Project settings override user settings. diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index f1711f3b92..3724253e97 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1906,6 +1906,16 @@ const SETTINGS_SCHEMA = { description: 'Enable local and remote subagents.', showInDialog: false, }, + worktrees: { + type: 'boolean', + label: 'Enable Git Worktrees', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable automated Git worktree management for parallel work.', + showInDialog: true, + }, extensionManagement: { type: 'boolean', label: 'Extension Management', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 31fec36db0..08c2cbabe8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -199,6 +199,8 @@ vi.mock('./config/config.js', () => ({ networkAccess: false, }), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 4722bb73f3..c8cd2b3cd8 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -9,6 +9,7 @@ import { WarningPriority, type Config, type ResumedSessionData, + type WorktreeInfo, type OutputPayload, type ConsoleLogPayload, type UserFeedbackPayload, @@ -63,6 +64,7 @@ import { registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, @@ -210,6 +212,13 @@ export async function main() { const settings = loadSettings(); loadSettingsHandle?.end(); + // If a worktree is requested and enabled, set it up early. + const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); + let worktreeInfo: WorktreeInfo | undefined; + if (requestedWorktree !== undefined) { + worktreeInfo = await setupWorktree(requestedWorktree || undefined); + } + // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -426,6 +435,7 @@ export async function main() { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, + worktreeSettings: worktreeInfo, }); loadConfigHandle?.end(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 9be9fc6194..382ad3f81f 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -72,6 +72,8 @@ vi.mock('./config/config.js', () => ({ } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 9c811fc741..f5d1ebbd5e 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -8,10 +8,12 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { type SessionMetrics } from '../contexts/SessionContext.js'; import { ToolCallDecision, getShellConfiguration, + type WorktreeSettings, } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -24,19 +26,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); vi.mock('../contexts/SessionContext.js', async (importOriginal) => { - const actual = await importOriginal(); + const actual = + await importOriginal(); return { ...actual, useSessionStats: vi.fn(), }; }); +vi.mock('../contexts/ConfigContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useConfig: vi.fn(), + }; +}); + const getShellConfigurationMock = vi.mocked(getShellConfiguration); const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats); const renderWithMockedStats = async ( metrics: SessionMetrics, sessionId = 'test-session', + worktreeSettings?: WorktreeSettings, ) => { useSessionStatsMock.mockReturnValue({ stats: { @@ -49,7 +62,11 @@ const renderWithMockedStats = async ( getPromptCount: () => 5, startNewPrompt: vi.fn(), - }); + } as unknown as ReturnType); + + vi.mocked(useConfig).mockReturnValue({ + getWorktreeSettings: () => worktreeSettings, + } as never); const result = await renderWithProviders( , @@ -188,4 +205,30 @@ describe('', () => { unmount(); }); }); + + describe('Worktree status', () => { + it('renders worktree instructions when worktreeSettings are present', async () => { + const worktreeSettings: WorktreeSettings = { + name: 'foo-bar', + path: '/path/to/foo-bar', + baseSha: 'base-sha', + }; + + const { lastFrame, unmount } = await renderWithMockedStats( + emptyMetrics, + 'test-session', + worktreeSettings, + ); + const output = lastFrame(); + + expect(output).toContain('To resume work in this worktree:'); + expect(output).toContain( + 'cd /path/to/foo-bar && gemini --resume test-session', + ); + expect(output).toContain( + 'To remove manually: git worktree remove /path/to/foo-bar', + ); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index 5b0a461682..7313949a9c 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { StatsDisplay } from './StatsDisplay.js'; import { useSessionStats } from '../contexts/SessionContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import { escapeShellArg, getShellConfiguration } from '@google/gemini-cli-core'; interface SessionSummaryDisplayProps { @@ -17,8 +18,19 @@ export const SessionSummaryDisplay: React.FC = ({ duration, }) => { const { stats } = useSessionStats(); + const config = useConfig(); const { shell } = getShellConfiguration(); - const footer = `To resume this session: gemini --resume ${escapeShellArg(stats.sessionId, shell)}`; + + const worktreeSettings = config.getWorktreeSettings(); + + const escapedSessionId = escapeShellArg(stats.sessionId, shell); + let footer = `To resume this session: gemini --resume ${escapedSessionId}`; + + if (worktreeSettings) { + footer = + `To resume work in this worktree: cd ${escapeShellArg(worktreeSettings.path, shell)} && gemini --resume ${escapedSessionId}\n` + + `To remove manually: git worktree remove ${escapeShellArg(worktreeSettings.path, shell)}`; + } return ( { + const actual = + await importOriginal(); + return { + ...actual, + getProjectRootForWorktree: vi.fn(), + createWorktreeService: vi.fn(), + debugLogger: { + log: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + }; +}); + +describe('setupWorktree', () => { + const originalEnv = { ...process.env }; + const originalCwd = process.cwd; + + const mockService = { + setup: vi.fn(), + maybeCleanup: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + + // Mock process.cwd and process.chdir + let currentPath = '/mock/project'; + process.cwd = vi.fn().mockImplementation(() => currentPath); + process.chdir = vi.fn().mockImplementation((newPath) => { + currentPath = newPath; + }); + + // Mock successful execution of core utilities + vi.mocked(coreFunctions.getProjectRootForWorktree).mockResolvedValue( + '/mock/project', + ); + vi.mocked(coreFunctions.createWorktreeService).mockResolvedValue( + mockService as never, + ); + mockService.setup.mockResolvedValue({ + name: 'my-feature', + path: '/mock/project/.gemini/worktrees/my-feature', + baseSha: 'base-sha', + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + process.cwd = originalCwd; + delete (process as { chdir?: typeof process.chdir }).chdir; + }); + + it('should create and switch to a new worktree', async () => { + await setupWorktree('my-feature'); + + expect(coreFunctions.getProjectRootForWorktree).toHaveBeenCalledWith( + '/mock/project', + ); + expect(coreFunctions.createWorktreeService).toHaveBeenCalledWith( + '/mock/project', + ); + expect(mockService.setup).toHaveBeenCalledWith('my-feature'); + expect(process.chdir).toHaveBeenCalledWith( + '/mock/project/.gemini/worktrees/my-feature', + ); + expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1'); + }); + + it('should generate a name if worktreeName is undefined', async () => { + mockService.setup.mockResolvedValue({ + name: 'generated-name', + path: '/mock/project/.gemini/worktrees/generated-name', + baseSha: 'base-sha', + }); + + await setupWorktree(undefined); + + expect(mockService.setup).toHaveBeenCalledWith(undefined); + }); + + it('should skip worktree creation if GEMINI_CLI_WORKTREE_HANDLED is set', async () => { + process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1'; + + await setupWorktree('my-feature'); + + expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled(); + expect(process.chdir).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully and exit', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT'); + }); + + mockService.setup.mockRejectedValue(new Error('Git failure')); + + await expect(setupWorktree('my-feature')).rejects.toThrow('PROCESS_EXIT'); + + expect(coreFunctions.writeToStderr).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to create or switch to worktree: Git failure', + ), + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); +}); diff --git a/packages/cli/src/utils/worktreeSetup.ts b/packages/cli/src/utils/worktreeSetup.ts new file mode 100644 index 0000000000..596c367d3e --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getProjectRootForWorktree, + createWorktreeService, + writeToStderr, + type WorktreeInfo, +} from '@google/gemini-cli-core'; + +/** + * Sets up a git worktree for parallel sessions. + * + * This function uses a guard (GEMINI_CLI_WORKTREE_HANDLED) to ensure that + * when the CLI relaunches itself (e.g. for memory allocation), it doesn't + * attempt to create a nested worktree. + */ +export async function setupWorktree( + worktreeName: string | undefined, +): Promise { + if (process.env['GEMINI_CLI_WORKTREE_HANDLED'] === '1') { + return undefined; + } + + try { + const projectRoot = await getProjectRootForWorktree(process.cwd()); + const service = await createWorktreeService(projectRoot); + + const worktreeInfo = await service.setup(worktreeName || undefined); + + process.chdir(worktreeInfo.path); + process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1'; + + return worktreeInfo; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + writeToStderr(`Failed to create or switch to worktree: ${errorMessage}\n`); + process.exit(1); + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5bac6d086c..eb2c3f90f1 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -528,6 +528,12 @@ export interface PolicyUpdateConfirmationRequest { newHash: string; } +export interface WorktreeSettings { + name: string; + path: string; + baseSha: string; +} + export interface ConfigParameters { sessionId: string; clientName?: string; @@ -651,6 +657,7 @@ export interface ConfigParameters { plan?: boolean; tracker?: boolean; planSettings?: PlanSettings; + worktreeSettings?: WorktreeSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -695,6 +702,7 @@ export class Config implements McpContext, AgentLoopContext { private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; private readonly question: string | undefined; + private readonly worktreeSettings: WorktreeSettings | undefined; readonly enableConseca: boolean; private readonly coreTools: string[] | undefined; @@ -925,6 +933,7 @@ export class Config implements McpContext, AgentLoopContext { this.pendingIncludeDirectories = params.includeDirectories ?? []; this.debugMode = params.debugMode; this.question = params.question; + this.worktreeSettings = params.worktreeSettings; this.coreTools = params.coreTools; this.mainAgentTools = params.mainAgentTools; @@ -1555,6 +1564,10 @@ export class Config implements McpContext, AgentLoopContext { return this.promptId; } + getWorktreeSettings(): WorktreeSettings | undefined { + return this.worktreeSettings; + } + getClientName(): string | undefined { return this.clientName; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 32572c86a0..5729730365 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -237,6 +237,7 @@ export * from './agents/types.js'; // Export stdio utils export * from './utils/stdio.js'; export * from './utils/terminal.js'; +export * from './services/worktreeService.js'; // Export voice utilities export * from './voice/responseFormatter.js'; diff --git a/packages/core/src/services/worktreeService.test.ts b/packages/core/src/services/worktreeService.test.ts new file mode 100644 index 0000000000..b3d831e6b4 --- /dev/null +++ b/packages/core/src/services/worktreeService.test.ts @@ -0,0 +1,311 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { + getProjectRootForWorktree, + createWorktree, + isGeminiWorktree, + hasWorktreeChanges, + cleanupWorktree, + getWorktreePath, + WorktreeService, +} from './worktreeService.js'; +import { execa } from 'execa'; + +vi.mock('execa'); +vi.mock('node:fs/promises'); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + realpathSync: vi.fn((p: string) => p), + }; +}); + +describe('worktree utilities', () => { + const projectRoot = '/mock/project'; + const worktreeName = 'test-feature'; + const expectedPath = path.join( + projectRoot, + '.gemini', + 'worktrees', + worktreeName, + ); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getProjectRootForWorktree', () => { + it('should return the project root from git common dir', async () => { + // In main repo, git-common-dir is often just ".git" + vi.mocked(execa).mockResolvedValue({ + stdout: '.git\n', + } as never); + + const result = await getProjectRootForWorktree('/mock/project'); + expect(result).toBe('/mock/project'); + expect(execa).toHaveBeenCalledWith( + 'git', + ['rev-parse', '--git-common-dir'], + { cwd: '/mock/project' }, + ); + }); + + it('should resolve absolute git common dir paths (as seen in worktrees)', async () => { + // Inside a worktree, git-common-dir is usually an absolute path to the main .git folder + vi.mocked(execa).mockResolvedValue({ + stdout: '/mock/project/.git\n', + } as never); + + const result = await getProjectRootForWorktree( + '/mock/project/.gemini/worktrees/my-feature', + ); + expect(result).toBe('/mock/project'); + }); + + it('should fallback to cwd if git command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('not a git repo')); + + const result = await getProjectRootForWorktree('/mock/non-git/src'); + expect(result).toBe('/mock/non-git/src'); + }); + }); + + describe('getWorktreePath', () => { + it('should return the correct path for a given name', () => { + expect(getWorktreePath(projectRoot, worktreeName)).toBe(expectedPath); + }); + }); + + describe('createWorktree', () => { + it('should execute git worktree add with correct branch and path', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); + + const resultPath = await createWorktree(projectRoot, worktreeName); + + expect(resultPath).toBe(expectedPath); + expect(execa).toHaveBeenCalledWith( + 'git', + ['worktree', 'add', expectedPath, '-b', `worktree-${worktreeName}`], + { cwd: projectRoot }, + ); + }); + + it('should throw an error if git worktree add fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('git failed')); + + await expect(createWorktree(projectRoot, worktreeName)).rejects.toThrow( + 'git failed', + ); + }); + }); + + describe('isGeminiWorktree', () => { + it('should return true for a valid gemini worktree path', () => { + expect(isGeminiWorktree(expectedPath, projectRoot)).toBe(true); + expect( + isGeminiWorktree(path.join(expectedPath, 'src'), projectRoot), + ).toBe(true); + }); + + it('should return false for a path outside gemini worktrees', () => { + expect(isGeminiWorktree(path.join(projectRoot, 'src'), projectRoot)).toBe( + false, + ); + expect(isGeminiWorktree('/some/other/path', projectRoot)).toBe(false); + }); + }); + + describe('hasWorktreeChanges', () => { + it('should return true if git status --porcelain has output', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: ' M somefile.txt\n?? newfile.txt', + } as never); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + expect(execa).toHaveBeenCalledWith('git', ['status', '--porcelain'], { + cwd: expectedPath, + }); + }); + + it('should return true if there are untracked files', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: '?? untracked-file.txt\n', + } as never); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + }); + + it('should return true if HEAD differs from baseSha', async () => { + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status clean + .mockResolvedValueOnce({ stdout: 'different-sha' } as never); // HEAD moved + + const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha'); + + expect(hasChanges).toBe(true); + }); + + it('should return false if status is clean and HEAD matches baseSha', async () => { + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status clean + .mockResolvedValueOnce({ stdout: 'base-sha' } as never); // HEAD same + + const hasChanges = await hasWorktreeChanges(expectedPath, 'base-sha'); + + expect(hasChanges).toBe(false); + }); + + it('should return true if any git command fails', async () => { + vi.mocked(execa).mockRejectedValue(new Error('git error')); + + const hasChanges = await hasWorktreeChanges(expectedPath); + + expect(hasChanges).toBe(true); + }); + }); + + describe('cleanupWorktree', () => { + it('should remove the worktree and delete the branch', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa) + .mockResolvedValueOnce({ + stdout: `worktree-${worktreeName}\n`, + } as never) // branch --show-current + .mockResolvedValueOnce({ stdout: '' } as never) // remove + .mockResolvedValueOnce({ stdout: '' } as never); // branch -D + + await cleanupWorktree(expectedPath, projectRoot); + + expect(execa).toHaveBeenCalledTimes(3); + expect(execa).toHaveBeenNthCalledWith( + 1, + 'git', + ['-C', expectedPath, 'branch', '--show-current'], + { cwd: projectRoot }, + ); + expect(execa).toHaveBeenNthCalledWith( + 2, + 'git', + ['worktree', 'remove', expectedPath, '--force'], + { cwd: projectRoot }, + ); + expect(execa).toHaveBeenNthCalledWith( + 3, + 'git', + ['branch', '-D', `worktree-${worktreeName}`], + { cwd: projectRoot }, + ); + }); + + it('should handle branch discovery failure gracefully', async () => { + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // no branch found + .mockResolvedValueOnce({ stdout: '' } as never); // remove + + await cleanupWorktree(expectedPath, projectRoot); + + expect(execa).toHaveBeenCalledTimes(2); + expect(execa).toHaveBeenNthCalledWith( + 2, + 'git', + ['worktree', 'remove', expectedPath, '--force'], + { cwd: projectRoot }, + ); + }); + }); +}); + +describe('WorktreeService', () => { + const projectRoot = '/mock/project'; + const service = new WorktreeService(projectRoot); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('setup', () => { + it('should capture baseSha and create a worktree', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'current-sha\n', + } as never); + + const info = await service.setup('feature-x'); + + expect(execa).toHaveBeenCalledWith('git', ['rev-parse', 'HEAD'], { + cwd: projectRoot, + }); + expect(info.name).toBe('feature-x'); + expect(info.baseSha).toBe('current-sha'); + expect(info.path).toContain('feature-x'); + }); + + it('should generate a timestamped name if none provided', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'current-sha\n', + } as never); + + const info = await service.setup(); + + expect(info.name).toMatch(/^\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}-\w+/); + expect(info.path).toContain(info.name); + }); + }); + + describe('maybeCleanup', () => { + const info = { + name: 'feature-x', + path: '/mock/project/.gemini/worktrees/feature-x', + baseSha: 'base-sha', + }; + + it('should cleanup unmodified worktrees', async () => { + // Mock hasWorktreeChanges -> false (no changes) + vi.mocked(execa) + .mockResolvedValueOnce({ stdout: '' } as never) // status check + .mockResolvedValueOnce({ stdout: 'base-sha' } as never); // SHA check + + vi.mocked(fs.access).mockResolvedValue(undefined); + vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); // cleanup calls + + const cleanedUp = await service.maybeCleanup(info); + + expect(cleanedUp).toBe(true); + // Verify cleanupWorktree utilities were called (execa calls inside cleanupWorktree) + expect(execa).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['worktree', 'remove', info.path, '--force']), + expect.anything(), + ); + }); + + it('should preserve modified worktrees', async () => { + // Mock hasWorktreeChanges -> true (changes detected) + vi.mocked(execa).mockResolvedValue({ + stdout: ' M modified-file.ts', + } as never); + + const cleanedUp = await service.maybeCleanup(info); + + expect(cleanedUp).toBe(false); + // Ensure cleanupWorktree was NOT called + expect(execa).not.toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining(['worktree', 'remove']), + expect.anything(), + ); + }); + }); +}); diff --git a/packages/core/src/services/worktreeService.ts b/packages/core/src/services/worktreeService.ts new file mode 100644 index 0000000000..0b6bd20648 --- /dev/null +++ b/packages/core/src/services/worktreeService.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { realpathSync } from 'node:fs'; +import { execa } from 'execa'; +import { debugLogger } from '../utils/debugLogger.js'; + +export interface WorktreeInfo { + name: string; + path: string; + baseSha: string; +} + +/** + * Service for managing Git worktrees within Gemini CLI. + * Handles creation, cleanup, and environment setup for isolated sessions. + */ +export class WorktreeService { + constructor(private readonly projectRoot: string) {} + + /** + * Creates a new worktree and prepares the environment. + */ + async setup(name?: string): Promise { + let worktreeName = name?.trim(); + + if (!worktreeName) { + const now = new Date(); + const timestamp = now + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '-') + .replace('Z', ''); + const randomSuffix = Math.random().toString(36).substring(2, 6); + worktreeName = `${timestamp}-${randomSuffix}`; + } + + // Capture the base commit before creating the worktree + const { stdout: baseSha } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: this.projectRoot, + }); + + const worktreePath = await createWorktree(this.projectRoot, worktreeName); + + return { + name: worktreeName, + path: worktreePath, + baseSha: baseSha.trim(), + }; + } + + /** + * Checks if a worktree has changes and cleans it up if it's unmodified. + */ + async maybeCleanup(info: WorktreeInfo): Promise { + const hasChanges = await hasWorktreeChanges(info.path, info.baseSha); + + if (!hasChanges) { + try { + await cleanupWorktree(info.path, this.projectRoot); + debugLogger.log( + `Automatically cleaned up unmodified worktree: ${info.path}`, + ); + return true; + } catch (error) { + debugLogger.error( + `Failed to clean up worktree ${info.path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { + debugLogger.debug( + `Preserving worktree ${info.path} because it has changes.`, + ); + } + + return false; + } +} + +export async function createWorktreeService( + cwd: string, +): Promise { + const projectRoot = await getProjectRootForWorktree(cwd); + return new WorktreeService(projectRoot); +} + +// Low-level worktree utilities + +export async function getProjectRootForWorktree(cwd: string): Promise { + try { + const { stdout } = await execa('git', ['rev-parse', '--git-common-dir'], { + cwd, + }); + const gitCommonDir = stdout.trim(); + const absoluteGitDir = path.isAbsolute(gitCommonDir) + ? gitCommonDir + : path.resolve(cwd, gitCommonDir); + + // The project root is the parent of the .git directory/file + return path.dirname(absoluteGitDir); + } catch (e: unknown) { + debugLogger.debug( + `Failed to get project root for worktree at ${cwd}: ${e instanceof Error ? e.message : String(e)}`, + ); + return cwd; + } +} + +export function getWorktreePath(projectRoot: string, name: string): string { + return path.join(projectRoot, '.gemini', 'worktrees', name); +} + +export async function createWorktree( + projectRoot: string, + name: string, +): Promise { + const worktreePath = getWorktreePath(projectRoot, name); + const branchName = `worktree-${name}`; + + await execa('git', ['worktree', 'add', worktreePath, '-b', branchName], { + cwd: projectRoot, + }); + + return worktreePath; +} + +export function isGeminiWorktree( + dirPath: string, + projectRoot: string, +): boolean { + try { + const realDirPath = realpathSync(dirPath); + const realProjectRoot = realpathSync(projectRoot); + const worktreesBaseDir = path.join(realProjectRoot, '.gemini', 'worktrees'); + const relative = path.relative(worktreesBaseDir, realDirPath); + return !relative.startsWith('..') && !path.isAbsolute(relative); + } catch { + return false; + } +} + +export async function hasWorktreeChanges( + dirPath: string, + baseSha?: string, +): Promise { + try { + // 1. Check for uncommitted changes (index or working tree) + const { stdout: status } = await execa('git', ['status', '--porcelain'], { + cwd: dirPath, + }); + if (status.trim() !== '') { + return true; + } + + // 2. Check if the current commit has moved from the base + if (baseSha) { + const { stdout: currentSha } = await execa('git', ['rev-parse', 'HEAD'], { + cwd: dirPath, + }); + if (currentSha.trim() !== baseSha) { + return true; + } + } + + return false; + } catch (e: unknown) { + debugLogger.debug( + `Failed to check worktree changes at ${dirPath}: ${e instanceof Error ? e.message : String(e)}`, + ); + // If any git command fails, assume the worktree is dirty to be safe. + return true; + } +} + +export async function cleanupWorktree( + dirPath: string, + projectRoot: string, +): Promise { + try { + await fs.access(dirPath); + } catch { + return; // Worktree already gone + } + + let branchName: string | undefined; + + try { + // 1. Discover the branch name associated with this worktree path + const { stdout } = await execa( + 'git', + ['-C', dirPath, 'branch', '--show-current'], + { + cwd: projectRoot, + }, + ); + branchName = stdout.trim() || undefined; + + // 2. Remove the worktree + await execa('git', ['worktree', 'remove', dirPath, '--force'], { + cwd: projectRoot, + }); + } catch (e: unknown) { + debugLogger.debug( + `Failed to remove worktree ${dirPath}: ${e instanceof Error ? e.message : String(e)}`, + ); + } finally { + // 3. Delete the branch if we found it + if (branchName) { + try { + await execa('git', ['branch', '-D', branchName], { + cwd: projectRoot, + }); + } catch (e: unknown) { + debugLogger.debug( + `Failed to delete branch ${branchName}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } +} diff --git a/packages/core/src/utils/memoryImportProcessor.ts b/packages/core/src/utils/memoryImportProcessor.ts index bf20bd6c13..10bf1ad592 100644 --- a/packages/core/src/utils/memoryImportProcessor.ts +++ b/packages/core/src/utils/memoryImportProcessor.ts @@ -48,16 +48,16 @@ export interface ProcessImportsResult { importTree: MemoryFile; } -// Helper to find the project root (looks for .git directory) +// Helper to find the project root (looks for .git directory or file for worktrees) async function findProjectRoot(startDir: string): Promise { let currentDir = path.resolve(startDir); while (true) { const gitPath = path.join(currentDir, '.git'); try { - const stats = await fs.lstat(gitPath); - if (stats.isDirectory()) { - return currentDir; - } + // Check for existence only — .git can be a directory (normal repos) + // or a file (submodules / worktrees). + await fs.access(gitPath); + return currentDir; } catch { // .git not found, continue to parent } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 9c790c6268..85a907e57e 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2663,6 +2663,13 @@ "default": true, "type": "boolean" }, + "worktrees": { + "title": "Enable Git Worktrees", + "description": "Enable automated Git worktree management for parallel work.", + "markdownDescription": "Enable automated Git worktree management for parallel work.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "extensionManagement": { "title": "Extension Management", "description": "Enable extension management features.", From 7a65c1e91dd483e6c0b4e6cb60cc19cdbc440840 Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Fri, 20 Mar 2026 08:08:34 -0700 Subject: [PATCH 017/177] Add support for linking in the extension registry (#23153) --- .../src/ui/commands/extensionsCommand.test.ts | 24 ++++++--- .../cli/src/ui/commands/extensionsCommand.ts | 18 +++++-- .../views/ExtensionDetails.test.tsx | 51 +++++++++++++++++++ .../ui/components/views/ExtensionDetails.tsx | 27 +++++++++- .../views/ExtensionRegistryView.tsx | 24 +++++++++ 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index dc49390c7e..8f065438e2 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -710,10 +710,14 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -733,10 +737,14 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith({ - source: packageName, - type: 'link', - }); + expect(mockInstallExtension).toHaveBeenCalledWith( + { + source: packageName, + type: 'link', + }, + undefined, + undefined, + ); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8e988917e5..aed7595389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -286,6 +286,11 @@ async function exploreAction( await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, + onLink: async (extension, requestConsentOverride) => { + debugLogger.log(`Linking extension: ${extension.extensionName}`); + await linkAction(context, extension.url, requestConsentOverride); + context.ui.removeComponent(); + }, onClose: () => context.ui.removeComponent(), extensionManager, }), @@ -533,7 +538,11 @@ async function installAction( } } -async function linkAction(context: CommandContext, args: string) { +async function linkAction( + context: CommandContext, + args: string, + requestConsentOverride?: (consent: string) => Promise, +) { const extensionLoader = context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { @@ -582,8 +591,11 @@ async function linkAction(context: CommandContext, args: string) { source: sourceFilepath, type: 'link', }; - const extension = - await extensionLoader.installOrUpdateExtension(installMetadata); + const extension = await extensionLoader.installOrUpdateExtension( + installMetadata, + undefined, + requestConsentOverride, + ); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx index 2da019d485..239f728472 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.test.tsx @@ -32,13 +32,20 @@ const mockExtension: RegistryExtension = { licenseKey: 'Apache-2.0', }; +const linkableExtension: RegistryExtension = { + ...mockExtension, + url: '/local/path/to/extension', +}; + describe('ExtensionDetails', () => { let mockOnBack: ReturnType; let mockOnInstall: ReturnType; + let mockOnLink: ReturnType; beforeEach(() => { mockOnBack = vi.fn(); mockOnInstall = vi.fn(); + mockOnLink = vi.fn(); }); const renderDetails = async (isInstalled = false) => @@ -47,6 +54,7 @@ describe('ExtensionDetails', () => { extension={mockExtension} onBack={mockOnBack} onInstall={mockOnInstall} + onLink={mockOnLink} isInstalled={isInstalled} />, ); @@ -117,4 +125,47 @@ describe('ExtensionDetails', () => { expect(mockOnInstall).not.toHaveBeenCalled(); vi.useRealTimers(); }); + + it('should call onLink when "l" is pressed and is linkable', async () => { + const { stdin, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + await React.act(async () => { + stdin.write('l'); + }); + await waitFor(() => { + expect(mockOnLink).toHaveBeenCalled(); + }); + }); + + it('should NOT show "Link" button for GitHub extensions', async () => { + const { lastFrame, waitUntilReady } = await renderDetails(false); + await waitUntilReady(); + await waitFor(() => { + expect(lastFrame()).not.toContain('[L] Link'); + }); + }); + + it('should show "Link" button for local extensions', async () => { + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + ); + await waitUntilReady(); + await waitFor(() => { + expect(lastFrame()).toContain('[L] Link'); + }); + }); }); diff --git a/packages/cli/src/ui/components/views/ExtensionDetails.tsx b/packages/cli/src/ui/components/views/ExtensionDetails.tsx index 7ee38c0e54..82a6c42b78 100644 --- a/packages/cli/src/ui/components/views/ExtensionDetails.tsx +++ b/packages/cli/src/ui/components/views/ExtensionDetails.tsx @@ -19,6 +19,9 @@ export interface ExtensionDetailsProps { onInstall: ( requestConsentOverride: (consent: string) => Promise, ) => void | Promise; + onLink: ( + requestConsentOverride: (consent: string) => Promise, + ) => void | Promise; isInstalled: boolean; } @@ -26,6 +29,7 @@ export function ExtensionDetails({ extension, onBack, onInstall, + onLink, isInstalled, }: ExtensionDetailsProps): React.JSX.Element { const keyMatchers = useKeyMatchers(); @@ -35,6 +39,11 @@ export function ExtensionDetails({ } | null>(null); const [isInstalling, setIsInstalling] = useState(false); + const isLinkable = + !extension.url.startsWith('http') && + !extension.url.startsWith('git@') && + !extension.url.startsWith('sso://'); + useKeypress( (key) => { if (consentRequest) { @@ -56,6 +65,7 @@ export function ExtensionDetails({ onBack(); return true; } + if (keyMatchers[Command.RETURN](key) && !isInstalled && !isInstalling) { setIsInstalling(true); void onInstall( @@ -66,6 +76,16 @@ export function ExtensionDetails({ ); return true; } + if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) { + setIsInstalling(true); + void onLink( + (prompt: string) => + new Promise((resolve) => { + setConsentRequest({ prompt, resolve }); + }), + ); + return true; + } return false; }, { isActive: true, priority: true }, @@ -230,8 +250,11 @@ export function ExtensionDetails({ understand the permissions it requires and the actions it may perform. - - [{'Enter'}] Install + + + [{'Enter'}] Install + + {isLinkable && [L] Link} )} diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 0539437fc3..60b0deec4a 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -29,6 +29,10 @@ export interface ExtensionRegistryViewProps { extension: RegistryExtension, requestConsentOverride?: (consent: string) => Promise, ) => void | Promise; + onLink?: ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => void | Promise; onClose?: () => void; extensionManager: ExtensionManager; } @@ -39,6 +43,7 @@ interface ExtensionItem extends GenericListItem { export function ExtensionRegistryView({ onSelect, + onLink, onClose, extensionManager, }: ExtensionRegistryViewProps): React.JSX.Element { @@ -96,6 +101,22 @@ export function ExtensionRegistryView({ [onSelect, extensionManager], ); + const handleLink = useCallback( + async ( + extension: RegistryExtension, + requestConsentOverride?: (consent: string) => Promise, + ) => { + await onLink?.(extension, requestConsentOverride); + + // Refresh installed extensions list + setInstalledExtensions(extensionManager.getExtensions()); + + // Go back to the search page (list view) + setSelectedExtension(null); + }, + [onLink, extensionManager], + ); + const renderItem = useCallback( (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { const isInstalled = installedExtensions.some( @@ -260,6 +281,9 @@ export function ExtensionRegistryView({ onInstall={async (requestConsentOverride) => { await handleInstall(selectedExtension, requestConsentOverride); }} + onLink={async (requestConsentOverride) => { + await handleLink(selectedExtension, requestConsentOverride); + }} isInstalled={installedExtensions.some( (e) => e.name === selectedExtension.extensionName, )} From 62cb14fa520f6f2caa29bd5bd7cf2b01afd15ab2 Mon Sep 17 00:00:00 2001 From: Ratish P <114130421+Ratish1@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:40:59 +0530 Subject: [PATCH 018/177] feat(extensions): add --skip-settings flag to install command (#17212) --- docs/extensions/reference.md | 3 +- .../src/commands/extensions/install.test.ts | 124 +++++++++++------- .../cli/src/commands/extensions/install.ts | 10 +- 3 files changed, 86 insertions(+), 51 deletions(-) diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 708caeb08d..56c51d30df 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run GitHub, you must have `git` installed on your machine. ```bash -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] ``` - ``: The GitHub URL or local path of the extension. @@ -31,6 +31,7 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. +- `--skip-settings`: Skip the configuration on install process. ### Uninstall an extension diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 417e750651..8b3f8c5807 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -12,48 +12,46 @@ import { beforeEach, afterEach, type MockInstance, - type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; -import { - ExtensionManager, - type inferInstallMetadata, -} from '../../config/extension-manager.js'; -import type { - promptForConsentNonInteractive, - requestConsentNonInteractive, -} from '../../config/extensions/consent.js'; -import type { - isWorkspaceTrusted, - loadTrustedFolders, -} from '../../config/trustedFolders.js'; -import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; +import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -const mockInstallOrUpdateExtension: Mock< - typeof ExtensionManager.prototype.installOrUpdateExtension -> = vi.hoisted(() => vi.fn()); -const mockRequestConsentNonInteractive: Mock< - typeof requestConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockPromptForConsentNonInteractive: Mock< - typeof promptForConsentNonInteractive -> = vi.hoisted(() => vi.fn()); -const mockStat: Mock = vi.hoisted(() => vi.fn()); -const mockInferInstallMetadata: Mock = vi.hoisted( - () => vi.fn(), -); -const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => - vi.fn(), -); -const mockLoadTrustedFolders: Mock = vi.hoisted(() => - vi.fn(), -); -const mockDiscover: Mock = - vi.hoisted(() => vi.fn()); +const { + mockInstallOrUpdateExtension, + mockLoadExtensions, + mockExtensionManager, + mockRequestConsentNonInteractive, + mockPromptForConsentNonInteractive, + mockStat, + mockInferInstallMetadata, + mockIsWorkspaceTrusted, + mockLoadTrustedFolders, + mockDiscover, +} = vi.hoisted(() => { + const mockLoadExtensions = vi.fn(); + const mockInstallOrUpdateExtension = vi.fn(); + const mockExtensionManager = vi.fn().mockImplementation(() => ({ + loadExtensions: mockLoadExtensions, + installOrUpdateExtension: mockInstallOrUpdateExtension, + })); + + return { + mockLoadExtensions, + mockInstallOrUpdateExtension, + mockExtensionManager, + mockRequestConsentNonInteractive: vi.fn(), + mockPromptForConsentNonInteractive: vi.fn(), + mockStat: vi.fn(), + mockInferInstallMetadata: vi.fn(), + mockIsWorkspaceTrusted: vi.fn(), + mockLoadTrustedFolders: vi.fn(), + mockDiscover: vi.fn(), + }; +}); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, @@ -84,6 +82,7 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), + ExtensionManager: mockExtensionManager, inferInstallMetadata: mockInferInstallMetadata, })); @@ -117,19 +116,18 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi.spyOn(core.debugLogger, 'log'); - debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); + debugLogSpy = vi + .spyOn(core.debugLogger, 'log') + .mockImplementation(() => {}); + debugErrorSpy = vi + .spyOn(core.debugLogger, 'error') + .mockImplementation(() => {}); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); - vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( - [], - ); - vi.spyOn( - ExtensionManager.prototype, - 'installOrUpdateExtension', - ).mockImplementation(mockInstallOrUpdateExtension); + mockLoadExtensions.mockResolvedValue([]); + mockInstallOrUpdateExtension.mockReset(); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ @@ -163,12 +161,7 @@ describe('handleInstall', () => { }); afterEach(() => { - mockInstallOrUpdateExtension.mockClear(); - mockRequestConsentNonInteractive.mockClear(); - mockStat.mockClear(); - mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); - vi.restoreAllMocks(); }); function createMockExtension( @@ -288,6 +281,39 @@ describe('handleInstall', () => { expect(processSpy).toHaveBeenCalledWith(1); }); + it('should pass promptForSetting when skipSettings is not provided', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: promptForSetting, + }), + ); + }); + + it('should pass null for requestSetting when skipSettings is true', async () => { + mockInstallOrUpdateExtension.mockResolvedValue({ + name: 'test-extension', + } as unknown as core.GeminiCLIExtension); + + await handleInstall({ + source: 'http://google.com', + skipSettings: true, + }); + + expect(mockExtensionManager).toHaveBeenCalledWith( + expect.objectContaining({ + requestSetting: null, + }), + ); + }); + it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 542d1240be..cf135a9366 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -37,6 +37,7 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; + skipSettings?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -153,7 +154,7 @@ export async function handleInstall(args: InstallArgs) { const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, - requestSetting: promptForSetting, + requestSetting: args.skipSettings ? null : promptForSetting, settings, }); await extensionManager.loadExtensions(); @@ -196,6 +197,11 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) + .option('skip-settings', { + describe: 'Skip the configuration on install process.', + type: 'boolean', + default: false, + }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -214,6 +220,8 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + skipSettings: argv['skip-settings'] as boolean | undefined, }); await exitCli(); }, From b459e1a1082abf7779d5b236e10bb0c436b302b0 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 20 Mar 2026 15:01:12 -0400 Subject: [PATCH 019/177] feat(telemetry): track if session is running in a Git worktree (#23265) --- docs/cli/telemetry.md | 1 + packages/cli/src/test-utils/mockConfig.ts | 1 + .../clearcut-logger/clearcut-logger.ts | 5 + .../clearcut-logger/event-metadata-key.ts | 3 + packages/core/src/telemetry/loggers.test.ts | 108 +++++++++++------- packages/core/src/telemetry/types.ts | 3 + 6 files changed, 80 insertions(+), 41 deletions(-) diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index 2068759213..fec0fb41c3 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -306,6 +306,7 @@ Emitted at startup with the CLI configuration. - `extension_ids` (string) - `extensions_count` (int) - `auth_type` (string) +- `worktree_active` (boolean) - `github_workflow_name` (string, optional) - `github_repository_hash` (string, optional) - `github_event_name` (string, optional) diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index d4f11212e3..e1505df970 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -44,6 +44,7 @@ export const createMockConfig = (overrides: Partial = {}): Config => getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), + getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), getAcpMode: vi.fn(() => false), isBrowserLaunchSuppressed: vi.fn(() => false), diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 2f059030ca..11433db3e8 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -687,6 +687,11 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_EXTENSION_IDS, value: event.extension_ids.toString(), }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_START_SESSION_WORKTREE_ACTIVE, + value: event.worktree_active.toString(), + }, ]; // Add hardware information only to the start session event diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 632730aeeb..b7b9c0fd3a 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -452,6 +452,9 @@ export enum EventMetadataKey { // Logs the name of extensions as a comma-separated string GEMINI_CLI_START_SESSION_EXTENSION_IDS = 120, + // Logs whether the session is running in a Git worktree. + GEMINI_CLI_START_SESSION_WORKTREE_ACTIVE = 191, + // Logs the setting scope for an extension enablement. GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 4373a6b96c..27c23e7baa 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -195,48 +195,51 @@ describe('loggers', () => { }); describe('logCliConfiguration', () => { + const baseMockConfig = { + getSessionId: () => 'test-session-id', + getModel: () => 'test-model', + getEmbeddingModel: () => 'test-embedding-model', + getSandbox: () => true, + getCoreTools: () => ['ls', 'read-file'], + getApprovalMode: () => 'default', + getContentGeneratorConfig: () => ({ + model: 'test-model', + apiKey: 'test-api-key', + authType: AuthType.USE_VERTEX_AI, + }), + getTelemetryEnabled: () => true, + getUsageStatisticsEnabled: () => true, + getTelemetryLogPromptsEnabled: () => true, + getFileFilteringRespectGitIgnore: () => true, + getFileFilteringAllowBuildArtifacts: () => false, + getDebugMode: () => true, + getMcpServers: () => { + throw new Error('Should not call'); + }, + getQuestion: () => 'test-question', + getTargetDir: () => 'target-dir', + getProxy: () => 'http://test.proxy.com:8080', + getOutputFormat: () => OutputFormat.JSON, + getExtensions: () => + [ + { name: 'ext-one', id: 'id-one' }, + { name: 'ext-two', id: 'id-two' }, + ] as GeminiCLIExtension[], + getMcpClientManager: () => ({ + getMcpServers: () => ({ + 'test-server': { + command: 'test-command', + }, + }), + }), + isInteractive: () => false, + getExperiments: () => undefined, + getExperimentsAsync: async () => undefined, + getWorktreeSettings: () => undefined, + } as unknown as Config; + it('should log the cli configuration', async () => { - const mockConfig = { - getSessionId: () => 'test-session-id', - getModel: () => 'test-model', - getEmbeddingModel: () => 'test-embedding-model', - getSandbox: () => true, - getCoreTools: () => ['ls', 'read-file'], - getApprovalMode: () => 'default', - getContentGeneratorConfig: () => ({ - model: 'test-model', - apiKey: 'test-api-key', - authType: AuthType.USE_VERTEX_AI, - }), - getTelemetryEnabled: () => true, - getUsageStatisticsEnabled: () => true, - getTelemetryLogPromptsEnabled: () => true, - getFileFilteringRespectGitIgnore: () => true, - getFileFilteringAllowBuildArtifacts: () => false, - getDebugMode: () => true, - getMcpServers: () => { - throw new Error('Should not call'); - }, - getQuestion: () => 'test-question', - getTargetDir: () => 'target-dir', - getProxy: () => 'http://test.proxy.com:8080', - getOutputFormat: () => OutputFormat.JSON, - getExtensions: () => - [ - { name: 'ext-one', id: 'id-one' }, - { name: 'ext-two', id: 'id-two' }, - ] as GeminiCLIExtension[], - getMcpClientManager: () => ({ - getMcpServers: () => ({ - 'test-server': { - command: 'test-command', - }, - }), - }), - isInteractive: () => false, - getExperiments: () => undefined, - getExperimentsAsync: async () => undefined, - } as unknown as Config; + const mockConfig = baseMockConfig; const startSessionEvent = new StartSessionEvent(mockConfig); logCliConfiguration(mockConfig, startSessionEvent); @@ -270,9 +273,32 @@ describe('loggers', () => { extensions_count: 2, extensions: 'ext-one,ext-two', auth_type: 'vertex-ai', + worktree_active: false, }, }); }); + + it('should set worktree_active to true when worktree settings are present', async () => { + const mockConfig = { + ...baseMockConfig, + getWorktreeSettings: () => ({ + name: 'test-worktree', + path: '/path/to/worktree', + baseSha: 'test-sha', + }), + } as unknown as Config; + + const startSessionEvent = new StartSessionEvent(mockConfig); + logCliConfiguration(mockConfig, startSessionEvent); + + await new Promise(process.nextTick); + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'CLI configuration loaded.', + attributes: expect.objectContaining({ + worktree_active: true, + }), + }); + }); }); describe('logUserPrompt', () => { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 0ee6e63503..1e0e3abc6e 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -77,6 +77,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { extensions: string; extension_ids: string; auth_type?: string; + worktree_active: boolean; constructor(config: Config, toolRegistry?: ToolRegistry) { const generatorConfig = config.getContentGeneratorConfig(); @@ -114,6 +115,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { this.extensions = extensions.map((e) => e.name).join(','); this.extension_ids = extensions.map((e) => e.id).join(','); this.auth_type = generatorConfig?.authType; + this.worktree_active = !!config.getWorktreeSettings(); if (toolRegistry) { const mcpTools = toolRegistry .getAllTools() @@ -147,6 +149,7 @@ export class StartSessionEvent implements BaseTelemetryEvent { extensions_count: this.extensions_count, extension_ids: this.extension_ids, auth_type: this.auth_type, + worktree_active: this.worktree_active, }; } From 26b9af1cdc433aa674e1f6cf6461939a02263c6e Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Fri, 20 Mar 2026 12:10:01 -0700 Subject: [PATCH 020/177] refactor(core): use absolute paths in GEMINI.md context markers (#23135) --- .../core/src/services/contextManager.test.ts | 2 +- packages/core/src/services/contextManager.ts | 8 +- .../core/src/utils/memoryDiscovery.test.ts | 74 +++++++++---------- packages/core/src/utils/memoryDiscovery.ts | 10 +-- 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/packages/core/src/services/contextManager.test.ts b/packages/core/src/services/contextManager.test.ts index 945c9263f6..1d078fd8fb 100644 --- a/packages/core/src/services/contextManager.test.ts +++ b/packages/core/src/services/contextManager.test.ts @@ -198,7 +198,7 @@ describe('ContextManager', () => { expect.any(Set), expect.any(Set), ); - expect(result).toMatch(/--- Context from: src[\\/]GEMINI\.md ---/); + expect(result).toMatch(/--- Context from: \/app\/src\/GEMINI\.md ---/); expect(result).toContain('Src Content'); expect(contextManager.getLoadedPaths()).toContain('/app/src/GEMINI.md'); }); diff --git a/packages/core/src/services/contextManager.ts b/packages/core/src/services/contextManager.ts index cec7c89ef9..b9da286e9c 100644 --- a/packages/core/src/services/contextManager.ts +++ b/packages/core/src/services/contextManager.ts @@ -98,12 +98,7 @@ export class ContextManager { paths: { global: string[]; extension: string[]; project: string[] }, contentsMap: Map, ) { - const workingDir = this.config.getWorkingDir(); - const hierarchicalMemory = categorizeAndConcatenate( - paths, - contentsMap, - workingDir, - ); + const hierarchicalMemory = categorizeAndConcatenate(paths, contentsMap); this.globalMemory = hierarchicalMemory.global || ''; this.extensionMemory = hierarchicalMemory.extension || ''; @@ -155,7 +150,6 @@ export class ContextManager { } return concatenateInstructions( result.files.map((f) => ({ filePath: f.path, content: f.content })), - this.config.getWorkingDir(), ); } diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index f9c1671283..8ec6909b41 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -171,7 +171,7 @@ describe('memoryDiscovery', () => { ); expect(fileCount).toEqual(1); - expect(memoryContent).toContain(path.relative(cwd, filepath).toString()); + expect(memoryContent).toContain(filepath); expect(filePaths).toEqual([filepath]); }); }); @@ -215,9 +215,9 @@ describe('memoryDiscovery', () => { memoryContent: flattenMemory(result.memoryContent), }).toEqual({ memoryContent: `--- Global --- ---- Context from: ${path.relative(cwd, defaultContextFile)} --- +--- Context from: ${defaultContextFile} --- default context content ---- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, +--- End of Context from: ${defaultContextFile} ---`, fileCount: 1, filePaths: [defaultContextFile], }); @@ -244,9 +244,9 @@ default context content expect(result).toEqual({ memoryContent: `--- Global --- ---- Context from: ${normMarker(path.relative(cwd, customContextFile))} --- +--- Context from: ${customContextFile} --- custom context content ---- End of Context from: ${normMarker(path.relative(cwd, customContextFile))} ---`, +--- End of Context from: ${customContextFile} ---`, fileCount: 1, filePaths: [customContextFile], }); @@ -277,13 +277,13 @@ custom context content expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(path.relative(cwd, projectContextFile))} --- +--- Context from: ${projectContextFile} --- project context content ---- End of Context from: ${normMarker(path.relative(cwd, projectContextFile))} --- +--- End of Context from: ${projectContextFile} --- ---- Context from: ${normMarker(path.relative(cwd, cwdContextFile))} --- +--- Context from: ${cwdContextFile} --- cwd context content ---- End of Context from: ${normMarker(path.relative(cwd, cwdContextFile))} ---`, +--- End of Context from: ${cwdContextFile} ---`, fileCount: 2, filePaths: [projectContextFile, cwdContextFile], }); @@ -314,13 +314,13 @@ cwd context content expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(customFilename)} --- +--- Context from: ${cwdCustomFile} --- CWD custom memory ---- End of Context from: ${normMarker(customFilename)} --- +--- End of Context from: ${cwdCustomFile} --- ---- Context from: ${normMarker(path.join('subdir', customFilename))} --- +--- Context from: ${subdirCustomFile} --- Subdir custom memory ---- End of Context from: ${normMarker(path.join('subdir', customFilename))} ---`, +--- End of Context from: ${subdirCustomFile} ---`, fileCount: 2, filePaths: [cwdCustomFile, subdirCustomFile], }); @@ -348,13 +348,13 @@ Subdir custom memory expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} --- +--- Context from: ${projectRootGeminiFile} --- Project root memory ---- End of Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} --- +--- End of Context from: ${projectRootGeminiFile} --- ---- Context from: ${normMarker(path.relative(cwd, srcGeminiFile))} --- +--- Context from: ${srcGeminiFile} --- Src directory memory ---- End of Context from: ${normMarker(path.relative(cwd, srcGeminiFile))} ---`, +--- End of Context from: ${srcGeminiFile} ---`, fileCount: 2, filePaths: [projectRootGeminiFile, srcGeminiFile], }); @@ -382,13 +382,13 @@ Src directory memory expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(DEFAULT_CONTEXT_FILENAME)} --- +--- Context from: ${cwdGeminiFile} --- CWD memory ---- End of Context from: ${normMarker(DEFAULT_CONTEXT_FILENAME)} --- +--- End of Context from: ${cwdGeminiFile} --- ---- Context from: ${normMarker(path.join('subdir', DEFAULT_CONTEXT_FILENAME))} --- +--- Context from: ${subDirGeminiFile} --- Subdir memory ---- End of Context from: ${normMarker(path.join('subdir', DEFAULT_CONTEXT_FILENAME))} ---`, +--- End of Context from: ${subDirGeminiFile} ---`, fileCount: 2, filePaths: [cwdGeminiFile, subDirGeminiFile], }); @@ -428,26 +428,26 @@ Subdir memory expect(result).toEqual({ memoryContent: `--- Global --- ---- Context from: ${normMarker(path.relative(cwd, defaultContextFile))} --- +--- Context from: ${defaultContextFile} --- default context content ---- End of Context from: ${normMarker(path.relative(cwd, defaultContextFile))} --- +--- End of Context from: ${defaultContextFile} --- --- Project --- ---- Context from: ${normMarker(path.relative(cwd, rootGeminiFile))} --- +--- Context from: ${rootGeminiFile} --- Project parent memory ---- End of Context from: ${normMarker(path.relative(cwd, rootGeminiFile))} --- +--- End of Context from: ${rootGeminiFile} --- ---- Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} --- +--- Context from: ${projectRootGeminiFile} --- Project root memory ---- End of Context from: ${normMarker(path.relative(cwd, projectRootGeminiFile))} --- +--- End of Context from: ${projectRootGeminiFile} --- ---- Context from: ${normMarker(path.relative(cwd, cwdGeminiFile))} --- +--- Context from: ${cwdGeminiFile} --- CWD memory ---- End of Context from: ${normMarker(path.relative(cwd, cwdGeminiFile))} --- +--- End of Context from: ${cwdGeminiFile} --- ---- Context from: ${normMarker(path.relative(cwd, subDirGeminiFile))} --- +--- Context from: ${subDirGeminiFile} --- Subdir memory ---- End of Context from: ${normMarker(path.relative(cwd, subDirGeminiFile))} ---`, +--- End of Context from: ${subDirGeminiFile} ---`, fileCount: 5, filePaths: [ defaultContextFile, @@ -491,9 +491,9 @@ Subdir memory expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(path.relative(cwd, regularSubDirGeminiFile))} --- +--- Context from: ${regularSubDirGeminiFile} --- My code memory ---- End of Context from: ${normMarker(path.relative(cwd, regularSubDirGeminiFile))} ---`, +--- End of Context from: ${regularSubDirGeminiFile} ---`, fileCount: 1, filePaths: [regularSubDirGeminiFile], }); @@ -565,9 +565,9 @@ My code memory expect(result).toEqual({ memoryContent: `--- Extension --- ---- Context from: ${normMarker(path.relative(cwd, extensionFilePath))} --- +--- Context from: ${extensionFilePath} --- Extension memory content ---- End of Context from: ${normMarker(path.relative(cwd, extensionFilePath))} ---`, +--- End of Context from: ${extensionFilePath} ---`, fileCount: 1, filePaths: [extensionFilePath], }); @@ -594,9 +594,9 @@ Extension memory content expect(result).toEqual({ memoryContent: `--- Project --- ---- Context from: ${normMarker(path.relative(cwd, includedFile))} --- +--- Context from: ${includedFile} --- included directory memory ---- End of Context from: ${normMarker(path.relative(cwd, includedFile))} ---`, +--- End of Context from: ${includedFile} ---`, fileCount: 1, filePaths: [includedFile], }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 15b4b2c701..21b87330a1 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -424,8 +424,6 @@ export async function readGeminiMdFiles( export function concatenateInstructions( instructionContents: GeminiFileContent[], - // CWD is needed to resolve relative paths for display markers - currentWorkingDirectoryForDisplay: string, ): string { return instructionContents .filter((item) => typeof item.content === 'string') @@ -435,10 +433,7 @@ export function concatenateInstructions( if (trimmedContent.length === 0) { return null; } - const displayPath = path.isAbsolute(item.filePath) - ? path.relative(currentWorkingDirectoryForDisplay, item.filePath) - : item.filePath; - return `--- Context from: ${displayPath} ---\n${trimmedContent}\n--- End of Context from: ${displayPath} ---`; + return `--- Context from: ${item.filePath} ---\n${trimmedContent}\n--- End of Context from: ${item.filePath} ---`; }) .filter((block): block is string => block !== null) .join('\n\n'); @@ -514,14 +509,12 @@ export async function getEnvironmentMemoryPaths( export function categorizeAndConcatenate( paths: { global: string[]; extension: string[]; project: string[] }, contentsMap: Map, - workingDir: string, ): HierarchicalMemory { const getConcatenated = (pList: string[]) => concatenateInstructions( pList .map((p) => contentsMap.get(p)) .filter((c): c is GeminiFileContent => !!c), - workingDir, ); return { @@ -687,7 +680,6 @@ export async function loadServerHierarchicalMemory( project: discoveryResult.project, }, contentsMap, - currentWorkingDirectory, ); return { From cca595971d51e38e58739674206621d9d4f22ddb Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Fri, 20 Mar 2026 15:19:18 -0400 Subject: [PATCH 021/177] fix(core): add sanitization to sub agent thoughts and centralize utilities (#22828) --- .../browser/browserAgentInvocation.test.ts | 48 ++++++ .../agents/browser/browserAgentInvocation.ts | 137 +--------------- .../core/src/agents/local-invocation.test.ts | 33 ++++ packages/core/src/agents/local-invocation.ts | 23 ++- .../utils/agent-sanitization-utils.test.ts | 103 ++++++++++++ .../src/utils/agent-sanitization-utils.ts | 154 ++++++++++++++++++ 6 files changed, 362 insertions(+), 136 deletions(-) create mode 100644 packages/core/src/utils/agent-sanitization-utils.test.ts create mode 100644 packages/core/src/utils/agent-sanitization-utils.ts diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index 6cf47ae9d9..e41377bdd4 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -343,9 +343,57 @@ describe('BrowserAgentInvocation', () => { a.content.includes('Navigating to the page...'), ), ); + expect(thoughtProgress).toBeDefined(); }); + it('should overwrite the thought content with new THOUGHT_CHUNK activity', async () => { + const { fireActivity } = setupActivityCapture(); + const updateOutput = vi.fn(); + + const invocation = new BrowserAgentInvocation( + mockConfig, + mockParams, + mockMessageBus, + ); + + const executePromise = invocation.execute( + new AbortController().signal, + updateOutput, + ); + + // Allow createBrowserAgentDefinition to resolve and onActivity to be registered + await Promise.resolve(); + await Promise.resolve(); + + fireActivity({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'THOUGHT_CHUNK', + data: { text: 'I am thinking.' }, + }); + fireActivity({ + isSubagentActivityEvent: true, + agentName: 'browser_agent', + type: 'THOUGHT_CHUNK', + data: { text: 'Now I will act.' }, + }); + + await executePromise; + + const progressCalls = updateOutput.mock.calls + .map((c) => c[0] as SubagentProgress) + .filter((p) => p.isSubagentProgress); + + const lastCall = progressCalls[progressCalls.length - 1]; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Now I will act.', + }), + ); + }); + it('should handle TOOL_CALL_START and TOOL_CALL_END with callId tracking', async () => { const { fireActivity } = setupActivityCapture(); const updateOutput = vi.fn(); diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 5776aa85cd..60bd5201f0 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -37,138 +37,16 @@ import { cleanupBrowserAgent, } from './browserAgentFactory.js'; import { removeInputBlocker } from './inputBlocker.js'; +import { + sanitizeThoughtContent, + sanitizeToolArgs, + sanitizeErrorMessage, +} from '../../utils/agent-sanitization-utils.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; const MAX_RECENT_ACTIVITY = 20; -/** - * Sensitive key patterns used for redaction. - */ -const SENSITIVE_KEY_PATTERNS = [ - 'password', - 'pwd', - 'apikey', - 'api_key', - 'api-key', - 'token', - 'secret', - 'credential', - 'auth', - 'authorization', - 'access_token', - 'access_key', - 'refresh_token', - 'session_id', - 'cookie', - 'passphrase', - 'privatekey', - 'private_key', - 'private-key', - 'secret_key', - 'client_secret', - 'client_id', -]; - -/** - * Sanitizes tool arguments by recursively redacting sensitive fields. - * Supports nested objects and arrays. - */ -function sanitizeToolArgs(args: unknown): unknown { - if (typeof args === 'string') { - return sanitizeErrorMessage(args); - } - if (typeof args !== 'object' || args === null) { - return args; - } - - if (Array.isArray(args)) { - return args.map(sanitizeToolArgs); - } - - const sanitized: Record = {}; - - for (const [key, value] of Object.entries(args)) { - // Decode key to handle URL-encoded sensitive keys (e.g., api%5fkey) - let decodedKey = key; - try { - decodedKey = decodeURIComponent(key); - } catch { - // Ignore decoding errors - } - const keyNormalized = decodedKey.toLowerCase().replace(/[-_]/g, ''); - const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => - keyNormalized.includes(pattern.replace(/[-_]/g, '')), - ); - if (isSensitive) { - sanitized[key] = '[REDACTED]'; - } else { - sanitized[key] = sanitizeToolArgs(value); - } - } - - return sanitized; -} - -/** - * Sanitizes error messages by redacting potential sensitive data patterns. - * Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values. - */ -function sanitizeErrorMessage(message: string): string { - if (!message) return message; - - let sanitized = message; - - // 1. Redact inline PEM content - sanitized = sanitized.replace( - /-----BEGIN\s+[\w\s]+-----[\s\S]*?-----END\s+[\w\s]+-----/g, - '[REDACTED_PEM]', - ); - - const unquotedValue = `[^\\s]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>]+)*`; - const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; - - // 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag) - const urlSafeKeyPatternStr = SENSITIVE_KEY_PATTERNS.map((p) => - p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), - ).join('|'); - - const keyWithDelimiter = new RegExp( - `((?:--)?("|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`, - 'gi', - ); - sanitized = sanitized.replace(keyWithDelimiter, '$1[REDACTED]'); - - // 3. Handle space-separated sensitive keywords (e.g. "password mypass", "--api-key secret") - const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`; - const spaceKeywords = [ - ...SENSITIVE_KEY_PATTERNS.map((p) => - p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), - ), - 'bearer', - ]; - const spaceSeparated = new RegExp( - `\\b((?:--)?(?:${spaceKeywords.join('|')})(?:\\s*:\\s*bearer)?\\s+)(${tokenValuePattern})`, - 'gi', - ); - sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]'); - - // 4. Handle file path redaction - sanitized = sanitized.replace( - /((?:[/\\][a-zA-Z0-9_-]+)*[/\\][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, - '/path/to/[REDACTED].key', - ); - - return sanitized; -} - -/** - * Sanitizes LLM thought content by redacting sensitive data patterns. - */ -function sanitizeThoughtContent(text: string): string { - return sanitizeErrorMessage(text); -} - /** * Browser agent invocation with async tool setup. * @@ -284,14 +162,13 @@ export class BrowserAgentInvocation extends BaseToolInvocation< case 'THOUGHT_CHUNK': { const text = String(activity.data['text']); const lastItem = recentActivity[recentActivity.length - 1]; + if ( lastItem && lastItem.type === 'thought' && lastItem.status === 'running' ) { - lastItem.content = sanitizeThoughtContent( - lastItem.content + text, - ); + lastItem.content = sanitizeThoughtContent(text); } else { recentActivity.push({ id: randomUUID(), diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 34df9844c9..2153f538c9 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -271,6 +271,39 @@ describe('LocalSubagentInvocation', () => { ); }); + it('should overwrite the thought content with new THOUGHT_CHUNK activity', async () => { + mockExecutorInstance.run.mockImplementation(async () => { + const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; + + if (onActivity) { + onActivity({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'THOUGHT_CHUNK', + data: { text: 'I am thinking.' }, + } as SubagentActivityEvent); + onActivity({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'THOUGHT_CHUNK', + data: { text: 'Now I will act.' }, + } as SubagentActivityEvent); + } + return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL }; + }); + + await invocation.execute(signal, updateOutput); + + const calls = updateOutput.mock.calls; + const lastCall = calls[calls.length - 1][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Now I will act.', + }), + ); + }); + it('should stream other activities (e.g., TOOL_CALL_START, ERROR)', async () => { mockExecutorInstance.run.mockImplementation(async () => { const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index e8b98d4744..08a4aa8264 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -24,6 +24,11 @@ import { } from './types.js'; import { randomUUID } from 'node:crypto'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { + sanitizeThoughtContent, + sanitizeToolArgs, + sanitizeErrorMessage, +} from '../utils/agent-sanitization-utils.js'; const INPUT_PREVIEW_MAX_LENGTH = 50; const DESCRIPTION_MAX_LENGTH = 200; @@ -118,17 +123,18 @@ export class LocalSubagentInvocation extends BaseToolInvocation< case 'THOUGHT_CHUNK': { const text = String(activity.data['text']); const lastItem = recentActivity[recentActivity.length - 1]; + if ( lastItem && lastItem.type === 'thought' && lastItem.status === 'running' ) { - lastItem.content = text; + lastItem.content = sanitizeThoughtContent(text); } else { recentActivity.push({ id: randomUUID(), type: 'thought', - content: text, + content: sanitizeThoughtContent(text), status: 'running', }); } @@ -138,12 +144,14 @@ export class LocalSubagentInvocation extends BaseToolInvocation< case 'TOOL_CALL_START': { const name = String(activity.data['name']); const displayName = activity.data['displayName'] - ? String(activity.data['displayName']) + ? sanitizeErrorMessage(String(activity.data['displayName'])) : undefined; const description = activity.data['description'] - ? String(activity.data['description']) + ? sanitizeErrorMessage(String(activity.data['description'])) : undefined; - const args = JSON.stringify(activity.data['args']); + const args = JSON.stringify( + sanitizeToolArgs(activity.data['args']), + ); recentActivity.push({ id: randomUUID(), type: 'tool_call', @@ -175,6 +183,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< case 'ERROR': { const error = String(activity.data['error']); const errorType = activity.data['errorType']; + const sanitizedError = sanitizeErrorMessage(error); const isCancellation = errorType === SubagentActivityErrorType.CANCELLED || error === SUBAGENT_CANCELLED_ERROR_MESSAGE; @@ -217,7 +226,9 @@ export class LocalSubagentInvocation extends BaseToolInvocation< id: randomUUID(), type: 'thought', content: - isCancellation || isRejection ? error : `Error: ${error}`, + isCancellation || isRejection + ? sanitizedError + : `Error: ${sanitizedError}`, status: isCancellation || isRejection ? 'cancelled' : 'error', }); updated = true; diff --git a/packages/core/src/utils/agent-sanitization-utils.test.ts b/packages/core/src/utils/agent-sanitization-utils.test.ts new file mode 100644 index 0000000000..fa030024a6 --- /dev/null +++ b/packages/core/src/utils/agent-sanitization-utils.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + sanitizeErrorMessage, + sanitizeToolArgs, + sanitizeThoughtContent, +} from './agent-sanitization-utils.js'; + +describe('agent-sanitization-utils', () => { + describe('sanitizeErrorMessage', () => { + it('should redact standard inline PEM content', () => { + const input = + 'Here is my key: -----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA12345\n-----END RSA PRIVATE KEY----- do not share.'; + const expected = 'Here is my key: [REDACTED_PEM] do not share.'; + expect(sanitizeErrorMessage(input)).toBe(expected); + }); + + it('should redact non-standard inline PEM content (with punctuation)', () => { + const input = + '-----BEGIN X.509 CERTIFICATE-----\nMIIEowIBAAKCAQEA12345\n-----END X.509 CERTIFICATE-----'; + const expected = '[REDACTED_PEM]'; + expect(sanitizeErrorMessage(input)).toBe(expected); + }); + + it('should not hang on ReDoS attack string for PEM redaction', () => { + const start = Date.now(); + // A string that starts with -----BEGIN but has no ending, with many spaces + // In the vulnerable regex, this would cause catastrophic backtracking. + const maliciousInput = '-----BEGIN ' + ' '.repeat(50000) + 'A'; + const result = sanitizeErrorMessage(maliciousInput); + const duration = Date.now() - start; + + // Should process very quickly (e.g. < 50ms) + expect(duration).toBeLessThan(50); + + // Since it doesn't match the full PEM block pattern, it should return the input unaltered + expect(result).toBe(maliciousInput); + }); + + it('should redact key-value pairs with sensitive keys', () => { + const input = 'Error: connection failed. --api-key="secret123"'; + const result = sanitizeErrorMessage(input); + expect(result).toContain('[REDACTED]'); + expect(result).not.toContain('secret123'); + }); + + it('should redact space-separated sensitive keywords', () => { + // The keyword regex requires tokens to be 8+ chars + const input = 'Using password mySuperSecretPassword123'; + const result = sanitizeErrorMessage(input); + expect(result).toContain('[REDACTED]'); + expect(result).not.toContain('mySuperSecretPassword123'); + }); + }); + + describe('sanitizeToolArgs', () => { + it('should redact sensitive fields in an object', () => { + const input = { + username: 'admin', + password: 'superSecretPassword', + nested: { + api_key: 'abc123xyz', + normal_field: 'hello', + }, + }; + + const result = sanitizeToolArgs(input); + + expect(result).toEqual({ + username: 'admin', + password: '[REDACTED]', + nested: { + api_key: '[REDACTED]', + normal_field: 'hello', + }, + }); + }); + + it('should handle arrays and strings correctly', () => { + const input = ['normal string', '--api-key="secret123"']; + const result = sanitizeToolArgs(input) as string[]; + + expect(result[0]).toBe('normal string'); + expect(result[1]).toContain('[REDACTED]'); + expect(result[1]).not.toContain('secret123'); + }); + }); + + describe('sanitizeThoughtContent', () => { + it('should redact sensitive patterns from thought content', () => { + const input = 'I will now authenticate using token 1234567890abcdef.'; + const result = sanitizeThoughtContent(input); + + expect(result).toContain('[REDACTED]'); + expect(result).not.toContain('1234567890abcdef'); + }); + }); +}); diff --git a/packages/core/src/utils/agent-sanitization-utils.ts b/packages/core/src/utils/agent-sanitization-utils.ts new file mode 100644 index 0000000000..e83c879fae --- /dev/null +++ b/packages/core/src/utils/agent-sanitization-utils.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Sensitive key patterns used for redaction. + */ +export const SENSITIVE_KEY_PATTERNS = [ + 'password', + 'pwd', + 'apikey', + 'api_key', + 'api-key', + 'token', + 'secret', + 'credential', + 'auth', + 'authorization', + 'access_token', + 'access_key', + 'refresh_token', + 'session_id', + 'cookie', + 'passphrase', + 'privatekey', + 'private_key', + 'private-key', + 'secret_key', + 'client_secret', + 'client_id', +]; + +/** + * Sanitizes tool arguments by recursively redacting sensitive fields. + * Supports nested objects and arrays. + */ +export function sanitizeToolArgs(args: unknown): unknown { + if (typeof args === 'string') { + return sanitizeErrorMessage(args); + } + if (typeof args !== 'object' || args === null) { + return args; + } + + if (Array.isArray(args)) { + return args.map(sanitizeToolArgs); + } + + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(args)) { + // Decode key to handle URL-encoded sensitive keys (e.g., api%5fkey) + let decodedKey = key; + try { + decodedKey = decodeURIComponent(key); + } catch { + // Ignore decoding errors + } + const keyNormalized = decodedKey.toLowerCase().replace(/[-_]/g, ''); + const isSensitive = SENSITIVE_KEY_PATTERNS.some((pattern) => + keyNormalized.includes(pattern.replace(/[-_]/g, '')), + ); + if (isSensitive) { + sanitized[key] = '[REDACTED]'; + } else { + sanitized[key] = sanitizeToolArgs(value); + } + } + + return sanitized; +} + +/** + * Sanitizes error messages by redacting potential sensitive data patterns. + * Uses [^\s'"]+ to catch JWTs, tokens with dots/slashes, and other complex values. + */ +export function sanitizeErrorMessage(message: string): string { + if (!message) return message; + + let sanitized = message; + + // 1. Redact inline PEM content (Safe iterative approach to avoid ReDoS) + let startIndex = 0; + while ((startIndex = sanitized.indexOf('-----BEGIN', startIndex)) !== -1) { + const endOfBegin = sanitized.indexOf('-----', startIndex + 10); + if (endOfBegin === -1) { + break; // No closing dashes for the BEGIN header + } + + // Find the END header + const endHeaderStart = sanitized.indexOf('-----END', endOfBegin + 5); + if (endHeaderStart === -1) { + break; // No END header found + } + + const endHeaderEnd = sanitized.indexOf('-----', endHeaderStart + 8); + if (endHeaderEnd === -1) { + break; // No closing dashes for the END header + } + + // We found a complete block. Replace it. + const before = sanitized.substring(0, startIndex); + const after = sanitized.substring(endHeaderEnd + 5); + sanitized = before + '[REDACTED_PEM]' + after; + + // Resume searching after the redacted block + startIndex = before.length + 14; // length of '[REDACTED_PEM]' + } + + const unquotedValue = `[^\\s]+(?:\\s+(?![a-zA-Z0-9_.-]+(?:=|:))[^\\s=:<>]+)*`; + const valuePattern = `(?:"[^"]*"|'[^']*'|${unquotedValue})`; + + // 2. Handle key-value pairs with delimiters (=, :, space, CLI-style --flag) + const urlSafeKeyPatternStr = SENSITIVE_KEY_PATTERNS.map((p) => + p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), + ).join('|'); + + const keyWithDelimiter = new RegExp( + `((?:--)?("|')?(${urlSafeKeyPatternStr})\\2\\s*(?:[:=]|%3A|%3D)\\s*)${valuePattern}`, + 'gi', + ); + sanitized = sanitized.replace(keyWithDelimiter, '$1[REDACTED]'); + + // 3. Handle space-separated sensitive keywords (e.g. "password mypass", "--api-key secret") + const tokenValuePattern = `[A-Za-z0-9._\\-/+=]{8,}`; + const spaceKeywords = [ + ...SENSITIVE_KEY_PATTERNS.map((p) => + p.replace(/[-_]/g, '(?:[-_]|%2D|%5F|%2d|%5f)?'), + ), + 'bearer', + ]; + const spaceSeparated = new RegExp( + `\\b((?:--)?(?:${spaceKeywords.join('|')})(?:\\s*:\\s*bearer)?\\s+)(${tokenValuePattern})`, + 'gi', + ); + sanitized = sanitized.replace(spaceSeparated, '$1[REDACTED]'); + + // 4. Handle file path redaction + sanitized = sanitized.replace( + /((?:[/\\][a-zA-Z0-9_-]+)*[/\\][a-zA-Z0-9_-]*\.(?:key|pem|p12|pfx))/gi, + '/path/to/[REDACTED].key', + ); + + return sanitized; +} + +/** + * Sanitizes LLM thought content by redacting sensitive data patterns. + */ +export function sanitizeThoughtContent(text: string): string { + return sanitizeErrorMessage(text); +} From 05e4ea80eed76be095af0bbdbdd63c16fc738f6b Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Mar 2026 15:31:01 -0400 Subject: [PATCH 022/177] feat(core): refine User-Agent for VS Code traffic (unified format) (#23256) --- .../core/src/core/contentGenerator.test.ts | 121 +++++++++++++++++- packages/core/src/core/contentGenerator.ts | 39 +++++- packages/core/src/utils/surface.ts | 7 +- 3 files changed, 156 insertions(+), 11 deletions(-) diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 4bacd1b488..a264b2fb6c 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -131,6 +131,10 @@ describe('createContentGenerator', () => { // Set a fixed version for testing vi.stubEnv('CLI_VERSION', '1.2.3'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + vi.stubEnv('VSCODE_PID', ''); + vi.stubEnv('GITHUB_SHA', ''); + vi.stubEnv('GEMINI_CLI_SURFACE', ''); const mockGenerator = { models: {}, @@ -149,7 +153,7 @@ describe('createContentGenerator', () => { httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.stringMatching( - /GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; .*\)/, + /GeminiCLI\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/, ), }), }), @@ -159,7 +163,7 @@ describe('createContentGenerator', () => { ); }); - it('should include clientName prefix in User-Agent when specified', async () => { + it('should use standard User-Agent for a2a-server running outside VS Code', async () => { const mockConfig = { getModel: vi.fn().mockReturnValue('gemini-pro'), getProxy: vi.fn().mockReturnValue(undefined), @@ -169,6 +173,10 @@ describe('createContentGenerator', () => { // Set a fixed version for testing vi.stubEnv('CLI_VERSION', '1.2.3'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + vi.stubEnv('VSCODE_PID', ''); + vi.stubEnv('GITHUB_SHA', ''); + vi.stubEnv('GEMINI_CLI_SURFACE', ''); const mockGenerator = { models: {}, @@ -185,7 +193,7 @@ describe('createContentGenerator', () => { httpOptions: expect.objectContaining({ headers: expect.objectContaining({ 'User-Agent': expect.stringMatching( - /GeminiCLI-a2a-server\/.*\/gemini-pro \(.*; .*; .*\)/, + /GeminiCLI-a2a-server\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/, ), }), }), @@ -193,6 +201,113 @@ describe('createContentGenerator', () => { ); }); + it('should include unified User-Agent for a2a-server (VS Code Agent Mode)', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => true, + getClientName: vi.fn().mockReturnValue('a2a-server'), + } as unknown as Config; + + // Set a fixed version for testing + vi.stubEnv('CLI_VERSION', '1.2.3'); + // Mock the environment variable that the VS Code extension host would provide to the a2a-server process + vi.stubEnv('VSCODE_PID', '12345'); + vi.stubEnv('TERM_PROGRAM', 'vscode'); + vi.stubEnv('TERM_PROGRAM_VERSION', '1.85.0'); + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + await createContentGenerator( + { apiKey: 'test-api-key', authType: AuthType.USE_GEMINI }, + mockConfig, + undefined, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringMatching( + /CloudCodeVSCode\/1\.2\.3 \(aidev_client; os_type=.*; os_version=.*; arch=.*; host_path=VSCode\/1\.85\.0; proxy_client=geminicli\)/, + ), + }), + }), + }), + ); + }); + + it('should include clientName prefix in User-Agent when specified (non-VSCode)', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => true, + getClientName: vi.fn().mockReturnValue('my-client'), + } as unknown as Config; + + // Set a fixed version for testing + vi.stubEnv('CLI_VERSION', '1.2.3'); + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + vi.stubEnv('VSCODE_PID', ''); + vi.stubEnv('GITHUB_SHA', ''); + vi.stubEnv('GEMINI_CLI_SURFACE', ''); + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + await createContentGenerator( + { apiKey: 'test-api-key', authType: AuthType.USE_GEMINI }, + mockConfig, + undefined, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringMatching( + /GeminiCLI-my-client\/1\.2\.3\/gemini-pro \(.*; .*; terminal\)/, + ), + }), + }), + }), + ); + }); + + it('should allow custom headers to override User-Agent', async () => { + const mockConfig = { + getModel: vi.fn().mockReturnValue('gemini-pro'), + getProxy: vi.fn().mockReturnValue(undefined), + getUsageStatisticsEnabled: () => true, + getClientName: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + vi.stubEnv('GEMINI_CLI_CUSTOM_HEADERS', 'User-Agent:MyCustomUA'); + + const mockGenerator = { + models: {}, + } as unknown as GoogleGenAI; + vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); + await createContentGenerator( + { apiKey: 'test-api-key', authType: AuthType.USE_GEMINI }, + mockConfig, + undefined, + ); + + expect(GoogleGenAI).toHaveBeenCalledWith( + expect.objectContaining({ + httpOptions: expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': 'MyCustomUA', + }), + }), + }), + ); + }); + it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => { const mockGenerator = {} as unknown as ContentGenerator; vi.mocked(createCodeAssistContentGenerator).mockResolvedValue( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index ff1739c04b..c901562eb7 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -13,7 +13,9 @@ import { type EmbedContentResponse, type EmbedContentParameters, } from '@google/genai'; +import * as os from 'node:os'; import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js'; +import { isCloudShell } from '../ide/detect-ide.js'; import type { Config } from '../config/config.js'; import { loadApiKey } from './apiKeyCredentialStorage.js'; @@ -185,19 +187,46 @@ export async function createContentGenerator( const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; const clientName = gcConfig.getClientName(); - const userAgentPrefix = clientName - ? `GeminiCLI-${clientName}` - : 'GeminiCLI'; const surface = determineSurface(); - const userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`; + + let userAgent: string; + // Use unified format for VS Code traffic. + // Note: We don't automatically assume a2a-server is VS Code, + // as it could be used by other clients unless the surface explicitly says 'vscode'. + if (clientName === 'acp-vscode' || surface === 'vscode') { + const osTypeMap: Record = { + darwin: 'macOS', + win32: 'Windows', + linux: 'Linux', + }; + const osType = osTypeMap[process.platform] || process.platform; + const osVersion = os.release(); + const arch = process.arch; + + const vscodeVersion = process.env['TERM_PROGRAM_VERSION'] || 'unknown'; + let hostPath = `VSCode/${vscodeVersion}`; + if (isCloudShell()) { + const cloudShellVersion = + process.env['CLOUD_SHELL_VERSION'] || 'unknown'; + hostPath += ` > CloudShell/${cloudShellVersion}`; + } + + userAgent = `CloudCodeVSCode/${version} (aidev_client; os_type=${osType}; os_version=${osVersion}; arch=${arch}; host_path=${hostPath}; proxy_client=geminicli)`; + } else { + const userAgentPrefix = clientName + ? `GeminiCLI-${clientName}` + : 'GeminiCLI'; + userAgent = `${userAgentPrefix}/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`; + } + const customHeadersMap = parseCustomHeaders(customHeadersEnv); const apiKeyAuthMechanism = process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key'; const apiVersionEnv = process.env['GOOGLE_GENAI_API_VERSION']; const baseHeaders: Record = { - ...customHeadersMap, 'User-Agent': userAgent, + ...customHeadersMap, }; if ( diff --git a/packages/core/src/utils/surface.ts b/packages/core/src/utils/surface.ts index e4b1241d84..7c6bd4da6b 100644 --- a/packages/core/src/utils/surface.ts +++ b/packages/core/src/utils/surface.ts @@ -37,9 +37,10 @@ export function determineSurface(): string { return ide.name; } - // If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM confirms it. - // This prevents generic terminals from being misidentified as VSCode. - if (process.env['TERM_PROGRAM'] === 'vscode') { + // If the detected IDE is 'vscode', we only accept it if TERM_PROGRAM or VSCODE_PID confirms it. + // This prevents generic terminals from being misidentified as VSCode, while still detecting + // background processes spawned by the VS Code extension host (like a2a-server). + if (process.env['TERM_PROGRAM'] === 'vscode' || process.env['VSCODE_PID']) { return ide.name; } From 86a3a913b5840dde19b5079e9a00dd4aa4142c0c Mon Sep 17 00:00:00 2001 From: kevinjwang1 Date: Fri, 20 Mar 2026 12:50:15 -0700 Subject: [PATCH 023/177] Fix schema for ModelChains (#23284) --- .../cli/src/config/settingsSchema.test.ts | 24 +++++++++++++++++++ packages/cli/src/config/settingsSchema.ts | 10 +++++++- .../cli/src/ui/components/ModelDialog.tsx | 3 ++- schemas/settings.schema.json | 10 +++++++- 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 37ddf87642..c358cd65aa 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -538,8 +538,32 @@ describe('SettingsSchema', () => { } }; + const visitJsonSchema = (jsonSchema: Record) => { + const ref = jsonSchema['ref']; + if (typeof ref === 'string') { + referenced.add(ref); + } + const properties = jsonSchema['properties']; + if ( + properties && + typeof properties === 'object' && + !Array.isArray(properties) + ) { + Object.values(properties as Record).forEach((prop) => + visitJsonSchema(prop as Record), + ); + } + const items = jsonSchema['items']; + if (items && typeof items === 'object' && !Array.isArray(items)) { + visitJsonSchema(items as Record); + } + }; + Object.values(schema).forEach(visitDefinition); + // Also visit all definitions to find nested references + Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema); + // Ensure definitions map doesn't accumulate stale entries. Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { if (!referenced.has(key)) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3724253e97..3a622460aa 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1094,7 +1094,7 @@ const SETTINGS_SCHEMA = { showInDialog: false, additionalProperties: { type: 'array', - ref: 'ModelPolicy', + ref: 'ModelPolicyChain', }, }, }, @@ -2998,6 +2998,14 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + ModelPolicyChain: { + type: 'array', + description: 'A chain of model policies for fallback behavior.', + items: { + type: 'object', + ref: 'ModelPolicy', + }, + }, ModelPolicy: { type: 'object', description: diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 85cf16de3b..c42838c070 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -233,7 +233,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }); // Deduplicate: only show one entry per unique resolved model value. - // This is needed because 3 pro and 3.1 pro models can resolve to the same value. + // This is needed because 3 pro and 3.1 pro models can resolve to the same + // value, depending on the useGemini31 flag. const seen = new Set(); return list.filter((option) => { if (seen.has(option.value)) return false; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 85a907e57e..a231558bf7 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2061,7 +2061,7 @@ }, "type": "object", "additionalProperties": { - "$ref": "#/$defs/ModelPolicy" + "$ref": "#/$defs/ModelPolicyChain" } } }, @@ -3686,6 +3686,14 @@ } } }, + "ModelPolicyChain": { + "type": "array", + "description": "A chain of model policies for fallback behavior.", + "items": { + "type": "object", + "ref": "ModelPolicy" + } + }, "ModelPolicy": { "type": "object", "description": "Defines the policy for a single model in the availability chain.", From 6c78eb7a39cc2fee281d50e245ebac8f259ed0a7 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 20 Mar 2026 20:08:29 +0000 Subject: [PATCH 024/177] test(cli): refactor tests for async render utilities (#23252) --- .../cli/src/config/extensions/consent.test.ts | 5 +- packages/cli/src/test-utils/render.test.tsx | 53 +- packages/cli/src/test-utils/render.tsx | 63 +- packages/cli/src/ui/App.test.tsx | 134 +-- packages/cli/src/ui/AppContainer.test.tsx | 942 +++++++----------- .../cli/src/ui/IdeIntegrationNudge.test.tsx | 13 +- .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 15 +- packages/cli/src/ui/auth/AuthDialog.test.tsx | 75 +- .../cli/src/ui/auth/AuthInProgress.test.tsx | 17 +- .../src/ui/auth/BannedAccountDialog.test.tsx | 30 +- .../LoginWithGoogleRestartDialog.test.tsx | 9 +- packages/cli/src/ui/auth/useAuth.test.tsx | 203 ++-- .../cli/src/ui/components/AboutBox.test.tsx | 12 +- .../AdminSettingsChangedDialog.test.tsx | 9 +- .../ui/components/AgentConfigDialog.test.tsx | 1 - .../AlternateBufferQuittingDisplay.test.tsx | 18 +- .../cli/src/ui/components/AnsiOutput.test.tsx | 24 +- .../cli/src/ui/components/AppHeader.test.tsx | 26 +- .../components/ApprovalModeIndicator.test.tsx | 18 +- .../src/ui/components/AskUserDialog.test.tsx | 25 +- .../BackgroundShellDisplay.test.tsx | 36 +- .../cli/src/ui/components/Checklist.test.tsx | 15 +- .../src/ui/components/ChecklistItem.test.tsx | 9 +- .../cli/src/ui/components/CliSpinner.test.tsx | 13 +- .../src/ui/components/ColorsDisplay.test.tsx | 3 +- .../cli/src/ui/components/Composer.test.tsx | 3 +- .../ui/components/ConfigInitDisplay.test.tsx | 5 +- .../src/ui/components/ConsentPrompt.test.tsx | 15 +- .../components/ConsoleSummaryDisplay.test.tsx | 6 +- .../components/ContextSummaryDisplay.test.tsx | 3 +- .../components/ContextUsageDisplay.test.tsx | 15 +- .../ui/components/CopyModeWarning.test.tsx | 6 +- .../src/ui/components/DebugProfiler.test.tsx | 12 +- .../DetailedMessagesDisplay.test.tsx | 15 +- .../src/ui/components/DialogManager.test.tsx | 6 +- .../components/EditorSettingsDialog.test.tsx | 11 +- .../ui/components/EmptyWalletDialog.test.tsx | 33 +- .../ui/components/ExitPlanModeDialog.test.tsx | 60 +- .../src/ui/components/ExitWarning.test.tsx | 12 +- .../ui/components/FolderTrustDialog.test.tsx | 51 +- .../cli/src/ui/components/Footer.test.tsx | 728 ++++++-------- .../ui/components/FooterConfigDialog.test.tsx | 23 +- .../GeminiRespondingSpinner.test.tsx | 21 +- .../ui/components/GradientRegression.test.tsx | 28 +- .../cli/src/ui/components/Header.test.tsx | 22 +- packages/cli/src/ui/components/Help.test.tsx | 9 +- .../ui/components/HistoryItemDisplay.test.tsx | 157 ++- .../ui/components/HookStatusDisplay.test.tsx | 12 +- .../src/ui/components/HooksDialog.test.tsx | 28 +- .../components/IdeTrustChangeDialog.test.tsx | 12 +- .../ui/components/LoadingIndicator.test.tsx | 52 +- .../LogoutConfirmationDialog.test.tsx | 9 +- .../LoopDetectionConfirmation.test.tsx | 6 +- .../src/ui/components/MainContent.test.tsx | 73 +- .../ui/components/MemoryUsageDisplay.test.tsx | 8 +- .../src/ui/components/ModelDialog.test.tsx | 1 - .../ui/components/ModelStatsDisplay.test.tsx | 6 +- .../MultiFolderTrustDialog.test.tsx | 24 +- .../components/NewAgentsNotification.test.tsx | 6 +- .../src/ui/components/Notifications.test.tsx | 39 +- .../ui/components/OverageMenuDialog.test.tsx | 44 +- .../PermissionsModifyTrustDialog.test.tsx | 15 +- .../ui/components/PolicyUpdateDialog.test.tsx | 3 +- .../src/ui/components/ProQuotaDialog.test.tsx | 40 +- .../components/QueuedMessageDisplay.test.tsx | 15 +- .../ui/components/QuittingDisplay.test.tsx | 6 +- .../src/ui/components/QuotaDisplay.test.tsx | 27 +- .../components/RawMarkdownIndicator.test.tsx | 10 +- .../ui/components/RewindConfirmation.test.tsx | 12 +- .../src/ui/components/RewindViewer.test.tsx | 38 +- .../src/ui/components/SessionBrowser.test.tsx | 18 +- .../SessionBrowserSearchNav.test.tsx | 23 +- .../SessionBrowserStates.test.tsx | 9 +- .../src/ui/components/SettingsDialog.test.tsx | 146 +-- .../ui/components/ShellInputPrompt.test.tsx | 27 +- .../ui/components/ShellModeIndicator.test.tsx | 5 +- .../src/ui/components/ShortcutsHelp.test.tsx | 5 +- .../src/ui/components/ShowMoreLines.test.tsx | 15 +- .../components/ShowMoreLinesLayout.test.tsx | 6 +- .../src/ui/components/StatsDisplay.test.tsx | 60 +- .../src/ui/components/StatusDisplay.test.tsx | 3 +- .../src/ui/components/StickyHeader.test.tsx | 3 +- .../ui/components/SuggestionsDisplay.test.tsx | 21 +- packages/cli/src/ui/components/Table.test.tsx | 27 +- .../src/ui/components/ThemeDialog.test.tsx | 21 +- .../src/ui/components/ThemedGradient.test.tsx | 3 +- packages/cli/src/ui/components/Tips.test.tsx | 5 +- .../src/ui/components/ToastDisplay.test.tsx | 30 +- .../components/ToolConfirmationQueue.test.tsx | 46 +- .../ui/components/ToolStatsDisplay.test.tsx | 3 +- .../ui/components/UpdateNotification.test.tsx | 3 +- .../src/ui/components/UserIdentity.test.tsx | 21 +- .../ui/components/ValidationDialog.test.tsx | 24 +- .../ConfigInitDisplay.test.tsx.snap | 12 + .../messages/CompressionMessage.test.tsx | 42 +- .../components/messages/ErrorMessage.test.tsx | 6 +- .../messages/GeminiMessage.test.tsx | 9 +- .../components/messages/InfoMessage.test.tsx | 11 +- .../messages/RedirectionConfirmation.test.tsx | 3 +- .../messages/ShellToolMessage.test.tsx | 53 +- .../messages/SubagentGroupDisplay.test.tsx | 6 +- .../messages/SubagentProgressDisplay.test.tsx | 24 +- .../src/ui/components/messages/Todo.test.tsx | 6 +- .../messages/ToolConfirmationMessage.test.tsx | 96 +- .../messages/ToolGroupMessage.test.tsx | 102 +- .../components/messages/ToolMessage.test.tsx | 72 +- .../messages/ToolMessageFocusHint.test.tsx | 3 - .../messages/ToolMessageRawMarkdown.test.tsx | 3 +- .../components/messages/ToolShared.test.tsx | 15 +- .../components/messages/UserMessage.test.tsx | 12 +- .../messages/WarningMessage.test.tsx | 6 +- .../shared/BaseSelectionList.test.tsx | 2 - .../components/shared/EnumSelector.test.tsx | 25 +- .../components/shared/ExpandableText.test.tsx | 35 +- .../shared/HalfLinePaddedBox.test.tsx | 12 +- .../ui/components/shared/MaxSizedBox.test.tsx | 24 +- .../ui/components/shared/Scrollable.test.tsx | 45 +- .../components/shared/SearchableList.test.tsx | 9 +- .../components/shared/SectionHeader.test.tsx | 3 +- .../shared/SlicingMaxSizedBox.test.tsx | 15 +- .../ui/components/shared/TabHeader.test.tsx | 39 +- .../ui/components/shared/TextInput.test.tsx | 39 +- .../shared/VirtualizedList.test.tsx | 31 +- .../ui/components/shared/performance.test.ts | 8 +- .../ui/components/shared/text-buffer.test.ts | 522 +++++----- .../src/ui/components/views/ChatList.test.tsx | 13 +- .../views/ExtensionDetails.test.tsx | 9 +- .../views/ExtensionRegistryView.test.tsx | 3 +- .../components/views/ExtensionsList.test.tsx | 15 +- .../ui/components/views/McpStatus.test.tsx | 52 +- .../ui/components/views/SkillsList.test.tsx | 18 +- .../ui/components/views/ToolsList.test.tsx | 9 +- .../ui/contexts/ScrollProvider.drag.test.tsx | 12 +- .../src/ui/contexts/ScrollProvider.test.tsx | 22 +- .../src/ui/contexts/SessionContext.test.tsx | 20 +- .../src/ui/contexts/SettingsContext.test.tsx | 20 +- .../src/ui/contexts/TerminalContext.test.tsx | 6 +- .../ui/contexts/ToolActionsContext.test.tsx | 40 +- .../ui/hooks/shellCommandProcessor.test.tsx | 74 +- .../ui/hooks/slashCommandProcessor.test.tsx | 2 +- .../src/ui/hooks/useAlternateBuffer.test.ts | 12 +- .../ui/hooks/useAnimatedScrollbar.test.tsx | 26 +- .../ui/hooks/useApprovalModeIndicator.test.ts | 84 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 122 ++- .../ui/hooks/useAtCompletion_agents.test.ts | 4 +- .../hooks/useBackgroundShellManager.test.tsx | 28 +- packages/cli/src/ui/hooks/useBanner.test.ts | 20 +- .../cli/src/ui/hooks/useBatchedScroll.test.ts | 28 +- .../src/ui/hooks/useConsoleMessages.test.tsx | 24 +- .../src/ui/hooks/useEditorSettings.test.tsx | 40 +- .../src/ui/hooks/useExtensionUpdates.test.tsx | 8 +- .../src/ui/hooks/useFlickerDetector.test.ts | 26 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 40 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 4 + .../src/ui/hooks/useGitBranchName.test.tsx | 143 +-- .../src/ui/hooks/useHistoryManager.test.ts | 44 +- .../src/ui/hooks/useHookDisplayState.test.ts | 24 +- .../src/ui/hooks/useIdeTrustListener.test.tsx | 29 +- .../src/ui/hooks/useIncludeDirsTrust.test.tsx | 18 +- .../src/ui/hooks/useInlineEditBuffer.test.ts | 36 +- .../cli/src/ui/hooks/useInputHistory.test.ts | 56 +- .../src/ui/hooks/useInputHistoryStore.test.ts | 40 +- .../src/ui/hooks/useLoadingIndicator.test.tsx | 36 +- packages/cli/src/ui/hooks/useLogger.test.tsx | 33 +- .../cli/src/ui/hooks/useMcpStatus.test.tsx | 20 +- .../src/ui/hooks/useMemoryMonitor.test.tsx | 12 +- .../cli/src/ui/hooks/useMessageQueue.test.tsx | 58 +- .../cli/src/ui/hooks/useModelCommand.test.tsx | 12 +- packages/cli/src/ui/hooks/useMouse.test.ts | 16 +- .../cli/src/ui/hooks/useMouseClick.test.ts | 4 +- .../hooks/usePermissionsModifyTrust.test.ts | 34 +- .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 41 +- .../src/ui/hooks/usePrivacySettings.test.tsx | 34 +- .../src/ui/hooks/useQuotaAndFallback.test.ts | 62 +- packages/cli/src/ui/hooks/useRewind.test.ts | 20 +- .../src/ui/hooks/useSelectionList.test.tsx | 6 +- .../src/ui/hooks/useSessionBrowser.test.ts | 6 +- .../cli/src/ui/hooks/useSessionResume.test.ts | 46 +- .../ui/hooks/useSettingsNavigation.test.ts | 32 +- .../cli/src/ui/hooks/useShellHistory.test.ts | 14 +- .../ui/hooks/useShellInactivityStatus.test.ts | 14 +- .../src/ui/hooks/useSlashCompletion.test.ts | 533 +++++----- packages/cli/src/ui/hooks/useSuspend.test.ts | 12 +- .../src/ui/hooks/useTabbedNavigation.test.ts | 104 +- .../src/ui/hooks/useTerminalTheme.test.tsx | 33 +- packages/cli/src/ui/hooks/useTimer.test.tsx | 36 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 42 +- .../ui/hooks/useTurnActivityMonitor.test.ts | 19 +- .../cli/src/ui/hooks/vim-passthrough.test.tsx | 4 +- packages/cli/src/ui/hooks/vim.test.tsx | 498 ++++----- .../src/ui/layouts/DefaultAppLayout.test.tsx | 9 +- .../privacy/CloudFreePrivacyNotice.test.tsx | 9 +- .../privacy/CloudPaidPrivacyNotice.test.tsx | 6 +- .../ui/privacy/GeminiPrivacyNotice.test.tsx | 6 +- .../cli/src/ui/privacy/PrivacyNotice.test.tsx | 3 +- .../cli/src/ui/utils/CodeColorizer.test.tsx | 5 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 48 +- .../cli/src/ui/utils/TableRenderer.test.tsx | 47 +- 198 files changed, 3592 insertions(+), 4802 deletions(-) diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 76d7227ab4..8de884cdd5 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -59,8 +59,9 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); async function expectConsentSnapshot(consentString: string) { - const renderResult = render(React.createElement(Text, null, consentString)); - await renderResult.waitUntilReady(); + const renderResult = await render( + React.createElement(Text, null, consentString), + ); await expect(renderResult).toMatchSvgSnapshot(); } diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index 7172a99119..3c3f4102a4 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -12,24 +12,18 @@ import { waitFor } from './async.js'; describe('render', () => { it('should render a component', async () => { - const { lastFrame, waitUntilReady, unmount } = render( - Hello World, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await render(Hello World); expect(lastFrame()).toBe('Hello World\n'); unmount(); }); it('should support rerender', async () => { - const { lastFrame, rerender, waitUntilReady, unmount } = render( + const { lastFrame, rerender, waitUntilReady, unmount } = await render( Hello, ); - await waitUntilReady(); expect(lastFrame()).toBe('Hello\n'); - await act(async () => { - rerender(World); - }); + await act(async () => rerender(World)); await waitUntilReady(); expect(lastFrame()).toBe('World\n'); unmount(); @@ -42,10 +36,8 @@ describe('render', () => { return Hello; } - const { unmount, waitUntilReady } = render(); - await waitUntilReady(); + const { unmount } = await render(); unmount(); - expect(cleanupMock).toHaveBeenCalled(); }); }); @@ -54,36 +46,27 @@ describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { const useTestHook = ({ value }: { value: number }) => { const [count, setCount] = useState(0); - useEffect(() => { - setCount((c) => c + 1); - }, [value]); + useEffect(() => setCount((c) => c + 1), [value]); return { count, value }; }; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: { value: 1 }, - }, + { initialProps: { value: 1 } }, ); - await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props - await act(async () => { - rerender({ value: 2 }); - }); + await act(async () => rerender({ value: 2 })); await waitUntilReady(); expect(result.current.value).toBe(2); await waitFor(() => expect(result.current.count).toBe(2)); // Rerender without arguments should use previous props (value: 2) // This would previously crash or pass undefined if not fixed - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change @@ -98,14 +81,11 @@ describe('renderHook', () => { }; const { result, rerender, waitUntilReady, unmount } = - renderHook(useTestHook); - await waitUntilReady(); + await renderHook(useTestHook); expect(result.current.count).toBe(0); - await act(async () => { - rerender(); - }); + await act(async () => rerender()); await waitUntilReady(); expect(result.current.count).toBe(0); unmount(); @@ -113,19 +93,14 @@ describe('renderHook', () => { it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; - const { result, rerender, waitUntilReady, unmount } = renderHook( + const { result, rerender, waitUntilReady, unmount } = await renderHook( useTestHook, - { - initialProps: 'initial' as string | undefined, - }, + { initialProps: 'initial' }, ); - await waitUntilReady(); expect(result.current).toBe('initial'); - await act(async () => { - rerender(undefined); - }); + await act(async () => rerender(undefined)); await waitUntilReady(); expect(result.current).toBeUndefined(); unmount(); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 7d298b120d..ea889181c6 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -257,13 +257,9 @@ class XtermStdout extends EventEmitter { return currentFrame !== ''; } - // If both are empty, it's a match. - // We consider undefined lastRenderOutput as effectively empty for this check - // to support hook testing where Ink may skip rendering completely. - if ( - (this.lastRenderOutput === undefined || expectedFrame === '') && - currentFrame === '' - ) { + // If Ink expects nothing (no new static content and no dynamic output), + // we consider it a match because the terminal buffer will just hold the historical static content. + if (expectedFrame === '') { return true; } @@ -271,8 +267,8 @@ class XtermStdout extends EventEmitter { return false; } - // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. - if (expectedFrame === '' || currentFrame === '') { + // If the terminal is empty but Ink expects something, it's not a match. + if (currentFrame === '') { return false; } @@ -382,13 +378,11 @@ export type RenderInstance = { const instances: InkInstance[] = []; -// Wrapper around ink's render that ensures act() is called and uses Xterm for output -export const render = ( +export const render = async ( tree: React.ReactElement, terminalWidth?: number, -): Omit< - RenderInstance, - 'capturedOverflowState' | 'capturedOverflowActions' +): Promise< + Omit > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct @@ -437,6 +431,8 @@ export const render = ( instances.push(instance); + await stdout.waitUntilReady(); + return { rerender: (newTree: React.ReactElement) => { act(() => { @@ -751,7 +747,10 @@ export const renderWithProviders = async ( ); - const renderResult = render(wrapWithProviders(component), terminalWidth); + const renderResult = await render( + wrapWithProviders(component), + terminalWidth, + ); return { ...renderResult, @@ -765,19 +764,19 @@ export const renderWithProviders = async ( }; }; -export function renderHook( +export async function renderHook( renderCallback: (props: Props) => Result, options?: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; }, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { +}> { const result = { current: undefined as unknown as Result }; let currentProps = options?.initialProps as Props; @@ -800,17 +799,15 @@ export function renderHook( let waitUntilReady: () => Promise = async () => {}; let generateSvg: () => string = () => ''; - act(() => { - const renderResult = render( - - - , - ); - inkRerender = renderResult.rerender; - unmount = renderResult.unmount; - waitUntilReady = renderResult.waitUntilReady; - generateSvg = renderResult.generateSvg; - }); + const renderResult = await render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; function rerender(props?: Props) { if (arguments.length > 0) { @@ -864,7 +861,13 @@ export async function renderHookWithProviders( const Wrapper = options.wrapper || (({ children }) => <>{children}); - let renderResult: ReturnType; + let renderResult: RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; + }; await act(async () => { renderResult = await renderWithProviders( diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 7f5e55c022..950363f6a8 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -94,14 +94,10 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -115,14 +111,10 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + }); expect(lastFrame()).toContain('Quitting...'); unmount(); @@ -136,14 +128,10 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: quittingUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); @@ -156,14 +144,10 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -183,14 +167,10 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); unmount(); @@ -200,14 +180,10 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); @@ -219,14 +195,10 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -274,15 +246,11 @@ describe('App', () => { vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: stateWithConfirmingTool, - config: configWithExperiment, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -295,28 +263,20 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: mockUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); @@ -326,14 +286,10 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: dialogUIState, + settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + }); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 650804025b..313573a573 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -16,7 +16,7 @@ import { } from 'vitest'; import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; -import { act, useContext, type ReactElement } from 'react'; +import { act, useContext } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type TrackedToolCall } from './hooks/useToolScheduler.js'; @@ -250,6 +250,15 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; + type AppContainerProps = { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: StartupWarning[]; + resumedSessionData?: ResumedSessionData; + }; + // Helper to generate the AppContainer JSX for render and rerender const getAppContainer = ({ settings = mockSettings, @@ -258,14 +267,7 @@ describe('AppContainer State Management', () => { initResult = mockInitResult, startupWarnings, resumedSessionData, - }: { - settings?: LoadedSettings; - config?: Config; - version?: string; - initResult?: InitializationResult; - startupWarnings?: StartupWarning[]; - resumedSessionData?: ResumedSessionData; - } = {}) => ( + }: AppContainerProps = {}) => ( @@ -282,7 +284,7 @@ describe('AppContainer State Management', () => { ); // Helper to render the AppContainer - const renderAppContainer = (props?: Parameters[0]) => + const renderAppContainer = async (props?: AppContainerProps) => render(getAppContainer(props)); // Create typed mocks for all hooks @@ -514,13 +516,9 @@ describe('AppContainer State Management', () => { describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('renders with startup warnings', async () => { @@ -537,44 +535,32 @@ describe('AppContainer State Management', () => { }, ]; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ startupWarnings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ startupWarnings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('shows full UI details by default', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(true); - }); - unmount!(); + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + unmount(); }); it('starts in minimal UI mode when Focus UI preference is persisted', async () => { persistentStateMock.get.mockReturnValueOnce(true); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettings, - }); - unmount = result.unmount; - }); + }), + ); - await waitFor(() => { - expect(capturedUIState.cleanUiDetailsVisible).toBe(false); - }); + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); - unmount!(); + unmount(); }); }); @@ -609,15 +595,9 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( @@ -626,9 +606,7 @@ describe('AppContainer State Management', () => { }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send attention notification when terminal is focused', async () => { @@ -661,19 +639,13 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends attention notification when focus reporting is unavailable', async () => { @@ -706,19 +678,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: (() => void) | undefined; - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), - ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends a macOS notification when a response completes while unfocused', async () => { @@ -732,35 +696,24 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('sends completion notification when focus reporting is unavailable', async () => { @@ -774,34 +727,23 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), - ), + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), ); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('does not send completion notification when another action-required dialog is pending', async () => { @@ -819,27 +761,18 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; - - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); + const { unmount, rerender } = await act(async () => renderAppContainer()); currentStreamingState = 'idle'; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('can send repeated attention notifications for the same key after pending state clears', async () => { @@ -875,24 +808,15 @@ describe('AppContainer State Management', () => { pendingHistoryItems, })); - let unmount: (() => void) | undefined; - let rerender: ((tree: ReactElement) => void) | undefined; + const { unmount, rerender } = await act(async () => renderAppContainer()); - await act(async () => { - const rendered = renderAppContainer(); - unmount = rendered.unmount; - rerender = rendered.rerender; - }); - - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(1), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(1); pendingHistoryItems = []; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); pendingHistoryItems = [ @@ -917,18 +841,14 @@ describe('AppContainer State Management', () => { }, ]; await act(async () => { - rerender?.(getAppContainer()); + rerender(getAppContainer()); }); - await waitFor(() => - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(2), - ); + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2); - await act(async () => { - unmount?.(); - }); + unmount(); }); it('initializes with theme error from initialization result', async () => { @@ -937,68 +857,53 @@ describe('AppContainer State Management', () => { themeError: 'Failed to load theme', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ initResult: initResultWithError, - }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); - it('handles debug mode state', () => { + it('handles debug mode state', async () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); - expect(() => { - renderAppContainer({ config: debugConfig }); - }).not.toThrow(); + const { unmount } = await act(async () => + renderAppContainer({ config: debugConfig }), + ); + unmount(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version: '2.0.0' }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => + renderAppContainer({ version: '2.0.0' }), + ); + expect(capturedUIState).toBeTruthy(); // Should render and unmount cleanly - expect(() => unmount!()).not.toThrow(); + unmount(); }); it('provides UIStateContext with state management', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides UIActionsContext with action handlers', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('provides ConfigContext with config object', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); @@ -1011,13 +916,11 @@ describe('AppContainer State Management', () => { showMemoryUsage: false, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsAllHidden }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsAllHidden }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); it('handles settings with memory usage enabled', async () => { @@ -1025,13 +928,11 @@ describe('AppContainer State Management', () => { showMemoryUsage: true, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: settingsWithMemory }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithMemory }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); @@ -1039,13 +940,11 @@ describe('AppContainer State Management', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ version }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ version }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }, ); }); @@ -1058,30 +957,30 @@ describe('AppContainer State Management', () => { }); // Should still render without crashing - errors should be handled internally - const { unmount } = renderAppContainer({ config: errorConfig }); + const { unmount } = await act(async () => + renderAppContainer({ config: errorConfig }), + ); unmount(); }); it('handles undefined settings gracefully', async () => { const undefinedSettings = createMockSettings(); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ settings: undefinedSettings }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - unmount!(); + const { unmount } = await act(async () => + renderAppContainer({ settings: undefinedSettings }), + ); + expect(capturedUIState).toBeTruthy(); + unmount(); }); }); describe('Provider Hierarchy', () => { - it('establishes correct provider nesting order', () => { + it('establishes correct provider nesting order', async () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies - const { unmount } = renderAppContainer(); + const { unmount } = await act(async () => renderAppContainer()); - expect(() => unmount()).not.toThrow(); + unmount(); }); }); @@ -1113,40 +1012,32 @@ describe('AppContainer State Management', () => { filePath: '/tmp/test-session.json', }; - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: mockResumedSessionData, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); it('renders without resumed session data', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: undefined, - }); - unmount = result.unmount; - }); - await act(async () => { - unmount(); - }); + }), + ); + unmount(); }); - it('initializes chat recording service when config has it', () => { + it('initializes chat recording service when config has it', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1166,18 +1057,19 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); + unmount(); }); }); describe('Session Recording Integration', () => { - it('provides chat recording service configuration', () => { + it('provides chat recording service configuration', async () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1203,23 +1095,24 @@ describe('AppContainer State Management', () => { 'test-session-123', ); - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }); - }).not.toThrow(); + }), + ); // Verify the recording service structure is correct expect(configWithRecording.getGeminiClient).toBeDefined(); expect(mockGeminiClient.getChatRecordingService).toBeDefined(); expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); - it('handles session recording when messages are added', () => { + it('handles session recording when messages are added', async () => { const mockRecordMessage = vi.fn(); const mockRecordMessageTokens = vi.fn(); @@ -1242,22 +1135,25 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // The actual recording happens through the useHistory hook // which would be triggered by user interactions expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); + unmount(); }); }); describe('Session Resume Flow', () => { - it('accepts resumed session data', () => { + it('accepts resumed session data', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => true), @@ -1303,22 +1199,23 @@ describe('AppContainer State Management', () => { filePath: '/tmp/resumed-session.json', }; - expect(() => { + const { unmount } = await act(async () => renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, - }); - }).not.toThrow(); + }), + ); // Verify the resume functionality structure is in place expect(mockGeminiClient.resumeChat).toBeDefined(); expect(resumedData.conversation.messages).toHaveLength(2); + unmount(); }); - it('does not attempt resume when client is not initialized', () => { + it('does not attempt resume when client is not initialized', async () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => false), // Not initialized @@ -1343,21 +1240,24 @@ describe('AppContainer State Management', () => { filePath: '/tmp/session.json', }; - renderAppContainer({ - config: configWithClient, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - resumedSessionData: resumedData, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }), + ); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); + unmount(); }); }); describe('Token Counting from Session Stats', () => { - it('tracks token counts from session messages', () => { + it('tracks token counts from session messages', async () => { // Session stats are provided through the SessionStatsProvider context // in the real app, not through the config directly const mockChatRecordingService = { @@ -1385,33 +1285,30 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }); + const { unmount } = await act(async () => + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }), + ); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); + unmount(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert that the context value is as expected - expect(capturedUIState.quota.proQuotaRequest).toBeNull(); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert that the context value is as expected + expect(capturedUIState.quota.proQuotaRequest).toBeNull(); + unmount(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { @@ -1427,16 +1324,10 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The mock request is correctly passed through the context - expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); - }); - unmount!(); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The mock request is correctly passed through the context + expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); + unmount(); }); it('passes the handleProQuotaChoice function to UIActionsContext', async () => { @@ -1448,22 +1339,16 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => { - // Assert: The action in the context is the mock handler we provided - expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); - }); + const { unmount } = await act(async () => renderAppContainer()); + // Assert: The action in the context is the mock handler we provided + expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); // You can even verify that the plumbed function is callable act(() => { capturedUIActions.handleProQuotaChoice('retry_later'); }); expect(mockHandler).toHaveBeenCalledWith('retry_later'); - unmount!(); + unmount(); }); }); @@ -1479,7 +1364,7 @@ describe('AppContainer State Management', () => { expect(stdout).toBe(mocks.mockStdout); }); - it('should update terminal title with Working… when showStatusInTitle is false', () => { + it('should update terminal title with Working… when showStatusInTitle is false', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled const mockSettingsWithShowStatusFalse = createMockSettings({ ui: { @@ -1496,9 +1381,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithShowStatusFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }), + ); // Assert: Check that title was updated with "Working…" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1512,7 +1399,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use legacy terminal title when dynamicWindowTitle is false', () => { + it('should use legacy terminal title when dynamicWindowTitle is false', async () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled const mockSettingsWithDynamicTitleFalse = createMockSettings({ ui: { @@ -1529,9 +1416,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithDynamicTitleFalse, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }), + ); // Assert: Check that legacy title was used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1545,7 +1434,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should not update terminal title when hideWindowTitle is true', () => { + it('should not update terminal title when hideWindowTitle is true', async () => { // Arrange: Set up mock settings with hideWindowTitle enabled const mockSettingsWithHideTitleTrue = createMockSettings({ ui: { @@ -1555,9 +1444,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithHideTitleTrue, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }), + ); // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1568,7 +1459,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with thought subject when in active state', () => { + it('should update terminal title with thought subject when in active state', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1586,9 +1477,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1602,7 +1495,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with default text when in Idle state and no thought subject', () => { + it('should update terminal title with default text when in Idle state and no thought subject', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1615,9 +1508,11 @@ describe('AppContainer State Management', () => { mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1649,13 +1544,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: mockSettingsWithTitleEnabled, - }); - unmount = result.unmount; - }); + }), + ); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1666,7 +1559,7 @@ describe('AppContainer State Management', () => { expect(titleWrites[0][0]).toBe( `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount!(); + unmount(); }); describe('Shell Focus Action Required', () => { @@ -1712,9 +1605,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container (embeddedShellFocused is false by default in state) - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Initially it should show the working status const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1773,9 +1668,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds - should still NOT be Action Required await act(async () => { @@ -1830,9 +1727,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 65 seconds await act(async () => { @@ -1875,9 +1774,11 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container - const { unmount, rerender } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount, rerender } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Fast-forward time by 20 seconds await act(async () => { @@ -1931,7 +1832,7 @@ describe('AppContainer State Management', () => { }); }); - it('should pad title to exactly 80 characters', () => { + it('should pad title to exactly 80 characters', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1949,9 +1850,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1966,7 +1869,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use correct ANSI escape code format', () => { + it('should use correct ANSI escape code format', async () => { // Arrange: Set up mock settings with showStatusInTitle enabled const mockSettingsWithTitleEnabled = createMockSettings({ ui: { @@ -1984,9 +1887,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }), + ); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1999,7 +1904,7 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use CLI_TITLE environment variable when set', () => { + it('should use CLI_TITLE environment variable when set', async () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) const mockSettingsWithTitleDisabled = createMockSettings({ ui: { @@ -2018,9 +1923,11 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = renderAppContainer({ - settings: mockSettingsWithTitleDisabled, - }); + const { unmount } = await act(async () => + renderAppContainer({ + settings: mockSettingsWithTitleDisabled, + }), + ); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -2046,7 +1953,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2068,7 +1975,7 @@ describe('AppContainer State Management', () => { }); it('should reset the timer if a new error message is set', async () => { - const { rerender, unmount } = renderAppContainer(); + const { rerender, unmount } = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2110,11 +2017,11 @@ describe('AppContainer State Management', () => { let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2328,7 +2235,7 @@ describe('AppContainer State Management', () => { activePtyId: 1, }); - const renderResult = render(getAppContainer()); + const renderResult = await act(async () => render(getAppContainer())); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2446,7 +2353,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; const setupShortcutsVisibilityTest = async () => { - const renderResult = renderAppContainer(); + const renderResult = await act(async () => renderAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2522,9 +2429,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2553,9 +2458,7 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - await waitFor(() => { - expect(capturedUIState.shortcutsHelpVisible).toBe(false); - }); + expect(capturedUIState.shortcutsHelpVisible).toBe(false); unmount(); }); @@ -2564,7 +2467,7 @@ describe('AppContainer State Management', () => { describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; - let stdin: ReturnType['stdin']; + let stdin: Awaited>['stdin']; const setupCopyModeTest = async ( isAlternateMode = false, @@ -2602,7 +2505,7 @@ describe('AppContainer State Management', () => { ); - const renderResult = render(getTree(testSettings)); + const renderResult = await act(async () => render(getTree(testSettings))); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2792,15 +2695,10 @@ describe('AppContainer State Management', () => { closeModelDialog: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isModelDialogOpen).toBe(true); - unmount!(); + unmount(); }); it('should provide model dialog actions in the UIActionsContext', async () => { @@ -2812,45 +2710,29 @@ describe('AppContainer State Management', () => { closeModelDialog: mockCloseModelDialog, }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); // Verify that the actions are correctly passed through context act(() => { capturedUIActions.closeModelDialog(); }); expect(mockCloseModelDialog).toHaveBeenCalled(); - unmount!(); + unmount(); }); }); describe('Agent Configuration Dialog Integration', () => { it('should initialize with dialog closed and no agent selected', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); - + const { unmount } = await act(async () => renderAppContainer()); expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); it('should update state when openAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2865,16 +2747,11 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBe('test-agent'); expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); - unmount!(); + unmount(); }); it('should clear state when closeAgentConfigDialog is called', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2895,31 +2772,26 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount!(); + unmount(); }); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); - unmount!(); + unmount(); }); it('unsubscribes from UserFeedback on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2935,7 +2807,7 @@ describe('AppContainer State Management', () => { it('adds history item when UserFeedback event is received', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2971,7 +2843,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3004,7 +2876,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3016,7 +2888,7 @@ describe('AppContainer State Management', () => { it('handles consent request events', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3053,7 +2925,7 @@ describe('AppContainer State Management', () => { it('unsubscribes from ConsentRequest on unmount', async () => { let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -3076,7 +2948,7 @@ describe('AppContainer State Management', () => { }); let unmount: () => void; await act(async () => { - const result = renderAppContainer(); + const result = await renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -3104,12 +2976,7 @@ describe('AppContainer State Management', () => { }); it('preserves buffer when cancelling, even if empty (user is in control)', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3122,7 +2989,7 @@ describe('AppContainer State Management', () => { // Should NOT modify buffer when cancelling - user is in control expect(mockSetText).not.toHaveBeenCalled(); - unmount!(); + unmount(); }); it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { @@ -3140,12 +3007,7 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3159,7 +3021,7 @@ describe('AppContainer State Management', () => { // Should NOT call setText - prompt should be preserved regardless of content expect(mockSetText).not.toHaveBeenCalled(); - unmount!(); + unmount(); }); it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { @@ -3170,14 +3032,8 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => - expect(capturedUIState.userMessages).toContain('previous message'), - ); + const { unmount } = await act(async () => renderAppContainer()); + expect(capturedUIState.userMessages).toContain('previous message'); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3187,11 +3043,9 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - await waitFor(() => { - expect(mockSetText).toHaveBeenCalledWith('previous message'); - }); + expect(mockSetText).toHaveBeenCalledWith('previous message'); - unmount!(); + unmount(); }); it('input history is independent from conversation history (survives /clear)', async () => { @@ -3204,18 +3058,10 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - let rerender: (tree: ReactElement) => void; - let unmount; - await act(async () => { - const result = renderAppContainer(); - rerender = result.rerender; - unmount = result.unmount; - }); + const { rerender, unmount } = await act(async () => renderAppContainer()); // Verify userMessages is populated from inputHistory - await waitFor(() => - expect(capturedUIState.userMessages).toContain('first prompt'), - ); + expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) @@ -3238,7 +3084,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); - unmount!(); + unmount(); }); }); @@ -3253,14 +3099,10 @@ describe('AppContainer State Management', () => { // Clear previous calls mocks.mockStdout.write.mockClear(); - let compUnmount: () => void = () => {}; - await act(async () => { - const { unmount } = renderAppContainer(); - compUnmount = unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); // Allow async effects to run - await waitFor(() => expect(capturedUIState).toBeTruthy()); + expect(capturedUIState).toBeTruthy(); // Wait for fetchBannerTexts to complete await act(async () => { @@ -3273,7 +3115,7 @@ describe('AppContainer State Management', () => { ); expect(clearTerminalCalls).toHaveLength(0); - compUnmount(); + unmount(); }); }); @@ -3284,14 +3126,13 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }).unmount; - }); + }), + ); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3309,7 +3150,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { @@ -3320,14 +3161,13 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - let unmount: () => void; - await act(async () => { - unmount = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }).unmount; - }); + }), + ); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3345,7 +3185,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount!(); + unmount(); }); }); @@ -3358,13 +3198,9 @@ describe('AppContainer State Management', () => { vi.useRealTimers(); }); - it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { @@ -3390,16 +3226,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // 1. Trigger first overflow act(() => { @@ -3447,18 +3279,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3483,10 +3309,8 @@ describe('AppContainer State Management', () => { stdin.write('\x0f'); // \x0f is Ctrl+O }); - await waitFor(() => { - // constrainHeight should toggle - expect(capturedUIState.constrainHeight).toBe(false); - }); + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); // Advance enough that the original timer would have expired if it hadn't reset act(() => { @@ -3505,18 +3329,12 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { - let unmount: () => void; - let stdin: ReturnType['stdin']; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - stdin = result.stdin; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + const { stdin, unmount } = await act(async () => renderAppContainer()); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3540,9 +3358,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Wait 1 second act(() => { @@ -3554,9 +3370,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(true); - }); + expect(capturedUIState.constrainHeight).toBe(true); // Wait 1 second act(() => { @@ -3568,9 +3382,7 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - await waitFor(() => { - expect(capturedUIState.constrainHeight).toBe(false); - }); + expect(capturedUIState.constrainHeight).toBe(false); // Now we wait just before the timeout from the LAST toggle. // It should still be true. @@ -3588,7 +3400,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount!(); + unmount(); }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { @@ -3598,14 +3410,12 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer({ + const { unmount } = await act(async () => + renderAppContainer({ settings: settingsWithAlternateBuffer, - }); - unmount = result.unmount; - }); - await waitFor(() => expect(capturedUIState).toBeTruthy()); + }), + ); + await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); // Trigger overflow act(() => { @@ -3617,7 +3427,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); - unmount!(); + unmount(); }); }); @@ -3628,10 +3438,9 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3641,7 +3450,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ '/test/file.txt', ]); - await act(async () => unmount!()); + unmount(); }); it.each([true, false])( @@ -3657,10 +3466,9 @@ describe('AppContainer State Management', () => { ); const { submitQuery } = mockedUseGeminiStream(); - let unmount: () => void; - await act(async () => (unmount = renderAppContainer().unmount)); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedUIActions).toBeTruthy()); + expect(capturedUIActions).toBeTruthy(); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3679,7 +3487,7 @@ describe('AppContainer State Management', () => { } expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); expect(capturedUIState.permissionConfirmationRequest).toBeNull(); - await act(async () => unmount!()); + unmount(); }, ); }); @@ -3692,17 +3500,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(true); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + unmount(); }); it('should NOT allow plan mode when disabled in config', async () => { @@ -3712,17 +3514,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when streaming', async () => { @@ -3733,17 +3529,11 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { @@ -3764,17 +3554,11 @@ describe('AppContainer State Management', () => { ], }); - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); + const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => { - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - }); - unmount!(); + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + unmount(); }); }); }); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 5df3534f12..eb3e6a3e4c 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -53,10 +53,9 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); @@ -72,8 +71,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); @@ -93,8 +90,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -119,8 +114,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -150,8 +143,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - // Press Escape await act(async () => { stdin.write('\u001B'); @@ -178,8 +169,6 @@ describe('IdeIntegrationNudge', () => { , ); - await waitUntilReady(); - const frame = lastFrame(); expect(frame).toContain( diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index b8de6adb0b..d46e0295a1 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -73,23 +73,21 @@ describe('ApiAuthDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with a defaultValue', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', @@ -113,10 +111,9 @@ describe('ApiAuthDialog', () => { 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[1] is the TextInput's useKeypress (typing handler) const keypressHandler = mockedUseKeypress.mock.calls[1][0]; @@ -136,24 +133,22 @@ describe('ApiAuthDialog', () => { ); it('displays an error message', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 878b2a8ee0..4837a71490 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -143,10 +143,9 @@ describe('AuthDialog', () => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); @@ -161,10 +160,7 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); @@ -173,10 +169,7 @@ describe('AuthDialog', () => { it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); unmount(); @@ -213,10 +206,7 @@ describe('AuthDialog', () => { }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); unmount(); @@ -226,10 +216,7 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); @@ -245,10 +232,7 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); @@ -261,10 +245,7 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -278,10 +259,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -297,10 +275,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -316,10 +291,7 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -337,10 +309,7 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -360,10 +329,7 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { @@ -383,10 +349,9 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); unmount(); }); @@ -429,10 +394,7 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); @@ -442,30 +404,27 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/AuthInProgress.test.tsx b/packages/cli/src/ui/auth/AuthInProgress.test.tsx index bd6a3cb126..1c392be28d 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.test.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -55,20 +55,18 @@ describe('AuthInProgress', () => { }); it('renders initial state with spinner', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); unmount(); }); it('calls onTimeout when ESC is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -84,10 +82,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -100,10 +97,9 @@ describe('AuthInProgress', () => { }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); @@ -116,10 +112,7 @@ describe('AuthInProgress', () => { }); it('clears timer on unmount', async () => { - const { waitUntilReady, unmount } = render( - , - ); - await waitUntilReady(); + const { unmount } = await render(); await act(async () => { unmount(); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx index 0670c81bc9..4b5d44e6d5 100644 --- a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -73,14 +73,13 @@ describe('BannedAccountDialog', () => { }); it('renders the suspension message from accountSuspensionInfo', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Account Suspended'); expect(frame).toContain('violation of Terms of Service'); @@ -89,14 +88,13 @@ describe('BannedAccountDialog', () => { }); it('renders menu options with appeal link text from response', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(3); expect(items[0].label).toBe('Appeal Here'); @@ -109,14 +107,13 @@ describe('BannedAccountDialog', () => { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(2); expect(items[0].label).toBe('Change authentication'); @@ -129,28 +126,26 @@ describe('BannedAccountDialog', () => { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items[0].label).toBe('Open the Google Form'); unmount(); }); it('opens browser when appeal option is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('open_form'); expect(mockedOpenBrowser).toHaveBeenCalledWith( @@ -162,14 +157,13 @@ describe('BannedAccountDialog', () => { it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('open_form'); await waitFor(() => { @@ -180,14 +174,13 @@ describe('BannedAccountDialog', () => { }); it('calls onExit when "Exit" is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('exit'); expect(mockedRunExitCleanup).toHaveBeenCalled(); @@ -196,14 +189,13 @@ describe('BannedAccountDialog', () => { }); it('calls onChangeAuth when "Change authentication" is selected', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('change_auth'); expect(onChangeAuth).toHaveBeenCalled(); @@ -212,14 +204,13 @@ describe('BannedAccountDialog', () => { }); it('exits on escape key', async () => { - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; const result = keypressHandler({ name: 'escape' }); expect(result).toBe(true); @@ -227,14 +218,13 @@ describe('BannedAccountDialog', () => { }); it('renders snapshot correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index 77310e3069..4dd13a3334 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -45,25 +45,23 @@ describe('LoginWithGoogleRestartDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('calls onDismiss when escape is pressed', async () => { - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -83,13 +81,12 @@ describe('LoginWithGoogleRestartDialog', () => { async (keyName) => { vi.useFakeTimers(); - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index f236428ff1..8d51e46a64 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -4,15 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - describe, - it, - expect, - vi, - beforeEach, - afterEach, - type Mock, -} from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; import { @@ -22,7 +15,6 @@ import { } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; -import { waitFor } from '../../test-utils/async.js'; // Mock dependencies const mockLoadApiKey = vi.fn(); @@ -142,171 +134,202 @@ describe('useAuth', () => { }, }) as LoadedSettings; + let deferredRefreshAuth: { + resolve: () => void; + reject: (e: Error) => void; + }; + + beforeEach(() => { + vi.mocked(mockConfig.refreshAuth).mockImplementation( + () => + new Promise((resolve, reject) => { + deferredRefreshAuth = { resolve, reject }; + }), + ); + }); + it('should initialize with Unauthenticated state', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); + // Because we defer refreshAuth, the initial state is safely caught here expect(result.current.authState).toBe(AuthState.Unauthenticated); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.Authenticated); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(result.current.authState).toBe(AuthState.Authenticated); }); it('should set error if no auth type is selected and no env key', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe( - 'No authentication method selected.', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + // This happens synchronously, no deferred promise + expect(result.current.authError).toBe( + 'No authentication method selected.', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if no auth type is selected but env key exists', async () => { process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Existing API key detected (GEMINI_API_KEY)', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { - mockLoadApiKey.mockResolvedValue(null); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); + await act(async () => { + deferredLoadKey.resolve(null); }); + + expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); it('should authenticate if USE_GEMINI and key is found', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); - const { result } = renderHook(() => + let deferredLoadKey: { resolve: (k: string | null) => void }; + mockLoadApiKey.mockImplementation( + () => + new Promise((resolve) => { + deferredLoadKey = { resolve }; + }), + ); + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('stored-key'); + await act(async () => { + deferredLoadKey.resolve('stored-key'); }); + + await act(async () => { + deferredRefreshAuth.resolve(); + }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); it('should authenticate if USE_GEMINI and env key is found', async () => { - mockLoadApiKey.mockResolvedValue(null); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should prioritize env key over stored key when both are present', async () => { - mockLoadApiKey.mockResolvedValue('stored-key'); process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = renderHook(() => + + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.USE_GEMINI, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - // The environment key should take precedence - expect(result.current.apiKeyDefaultValue).toBe('env-key'); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should set error if validation fails', async () => { mockValidateAuthMethod.mockReturnValue('Validation Failed'); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe('Validation Failed'); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toBe('Validation Failed'); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain( - 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', - ); - expect(result.current.authState).toBe(AuthState.Updating); - }); + expect(result.current.authError).toContain( + 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', + ); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should authenticate successfully for valid auth type', async () => { - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.authError).toBeNull(); + await act(async () => { + deferredRefreshAuth.resolve(); }); + + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.authError).toBeNull(); }); it('should handle refreshAuth failure', async () => { - (mockConfig.refreshAuth as Mock).mockRejectedValue( - new Error('Auth Failed'), - ); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toContain('Failed to sign in'); - expect(result.current.authState).toBe(AuthState.Updating); + await act(async () => { + deferredRefreshAuth.reject(new Error('Auth Failed')); }); + + expect(result.current.authError).toContain('Failed to sign in'); + expect(result.current.authState).toBe(AuthState.Updating); }); it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { const projectIdError = new ProjectIdRequiredError(); - (mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError); - const { result } = renderHook(() => + const { result } = await renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await waitFor(() => { - expect(result.current.authError).toBe( - 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', - ); - expect(result.current.authError).not.toContain('Failed to login'); - expect(result.current.authState).toBe(AuthState.Updating); + await act(async () => { + deferredRefreshAuth.reject(projectIdError); }); + + expect(result.current.authError).toBe( + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + ); + expect(result.current.authError).not.toContain('Failed to login'); + expect(result.current.authState).toBe(AuthState.Updating); }); }); }); diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index 1db36b1f60..9115ca31c1 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -25,10 +25,9 @@ describe('AboutBox', () => { }; it('renders with required props', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -46,10 +45,9 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); @@ -58,10 +56,9 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Signed in with Google (test@example.com)'); unmount(); @@ -69,10 +66,9 @@ describe('AboutBox', () => { it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('api-key'); unmount(); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 19db058b87..76a36fe4dc 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,15 +17,14 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { - const { stdin, waitUntilReady } = await renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -33,7 +32,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - await waitUntilReady(); act(() => { stdin.write('r'); @@ -43,7 +41,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin, waitUntilReady } = await renderWithProviders( + const { stdin } = await renderWithProviders( , { uiActions: { @@ -51,7 +49,6 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); - await waitUntilReady(); act(() => { stdin.write(key); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index a2bfe052bb..2c6ea454db 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -126,7 +126,6 @@ describe('AgentConfigDialog', () => { />, { settings, uiState: { mainAreaWidth: 100 } }, ); - await result.waitUntilReady(); return result; }; diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index da71895485..571e0d36d3 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -118,14 +118,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); unmount(); }); it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -135,14 +134,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); unmount(); }); it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -152,14 +150,13 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); unmount(); }); it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -169,7 +166,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); unmount(); }); @@ -195,7 +191,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -205,7 +201,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); @@ -220,7 +215,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -230,7 +225,6 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); unmount(); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index ac824fefe6..758361be0a 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,10 +29,9 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); @@ -47,10 +46,9 @@ describe('', () => { { style: { inverse: true }, text: 'Inverse' }, ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -61,10 +59,9 @@ describe('', () => { { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -76,10 +73,9 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output.split('\n'); @@ -96,10 +92,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -115,10 +110,9 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -135,7 +129,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { width={80} />, ); - await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); @@ -156,10 +149,9 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); unmount(); diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 0d7e2b3a7b..8ff4caaacf 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -27,13 +27,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -50,13 +49,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); @@ -72,13 +70,12 @@ describe('', () => { }, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); @@ -103,13 +100,12 @@ describe('', () => { }, }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -129,13 +125,12 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -159,13 +154,12 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); @@ -183,13 +177,12 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); @@ -206,13 +199,12 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState, }, ); - await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); @@ -234,7 +226,6 @@ describe('', () => { const session1 = await renderWithProviders(, { uiState, }); - await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); @@ -245,7 +236,6 @@ describe('', () => { , {}, ); - await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index 4386891c7a..1b2decbe16 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -11,56 +11,50 @@ import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for PLAN mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 8ed240389c..864800a061 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -48,7 +48,7 @@ describe('AskUserDialog', () => { ]; it('renders question and options', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -397,7 +396,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -471,7 +467,6 @@ describe('AskUserDialog', () => { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow @@ -582,7 +577,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -736,7 +730,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -759,7 +752,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -820,7 +812,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame } = await renderWithProviders( { { width: 120 }, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx index 847dcd9a87..c097028a0d 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -145,7 +145,7 @@ describe('', () => { it('renders the output of the active shell', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -166,7 +165,7 @@ describe('', () => { it('renders tabs for multiple shells', async () => { const width = 100; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -187,7 +185,7 @@ describe('', () => { it('highlights the focused state', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -208,7 +205,7 @@ describe('', () => { it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; - const { rerender, waitUntilReady, unmount } = render( + const { rerender, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -241,7 +237,6 @@ describe('', () => { /> , ); - await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -253,7 +248,7 @@ describe('', () => { it('renders the process list when isListOpenProp is true', async () => { const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -274,7 +268,7 @@ describe('', () => { it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); - await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); @@ -308,7 +299,7 @@ describe('', () => { it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); // Initial state: shell1 (active) is highlighted @@ -329,13 +319,11 @@ describe('', () => { await act(async () => { simulateKey({ name: 'down' }); }); - await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); @@ -343,7 +331,7 @@ describe('', () => { it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( ', () => { , width, ); - await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); - await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); @@ -370,7 +356,7 @@ describe('', () => { it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -402,7 +387,7 @@ describe('', () => { mockShells.set(exitedShell.pid, exitedShell); const width = 80; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( ', () => { , width, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); diff --git a/packages/cli/src/ui/components/Checklist.test.tsx b/packages/cli/src/ui/components/Checklist.test.tsx index 442ee0400f..329a560aec 100644 --- a/packages/cli/src/ui/components/Checklist.test.tsx +++ b/packages/cli/src/ui/components/Checklist.test.tsx @@ -18,10 +18,9 @@ describe('', () => { ]; it('renders nothing when list is empty', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); @@ -30,15 +29,14 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders summary view correctly (collapsed)', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders expanded view correctly', async () => { - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( ', () => { toggleHint="toggle me" />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -68,10 +64,9 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index 4176f7914b..c71af523e1 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -17,8 +17,7 @@ describe('', () => { { status: 'cancelled', label: 'Skipped this' }, { status: 'blocked', label: 'Blocked this' }, ] as ChecklistItemData[])('renders %s item correctly', async (item) => { - const { lastFrame, waitUntilReady } = render(); - await waitUntilReady(); + const { lastFrame } = await render(); expect(lastFrame()).toMatchSnapshot(); }); @@ -28,12 +27,11 @@ describe('', () => { label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -43,12 +41,11 @@ describe('', () => { label: 'This is a very long text that should wrap because the default behavior is wrapping', }; - const { lastFrame, waitUntilReady } = render( + const { lastFrame } = await render( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx index cca997f370..4da6abb199 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -17,10 +17,7 @@ describe('', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { waitUntilReady, unmount } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { unmount } = await renderWithProviders(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); @@ -28,11 +25,9 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( - , - { settings }, - ); - await waitUntilReady(); + const { lastFrame, unmount } = await renderWithProviders(, { + settings, + }); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx index fdd08fd653..d934831c0e 100644 --- a/packages/cli/src/ui/components/ColorsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -96,10 +96,9 @@ describe('ColorsDisplay', () => { it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); // Check for title and description diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 641fc24810..8df5f690e7 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -251,7 +251,7 @@ const renderComposer = async ( config = createMockConfig(), uiActions = createMockUIActions(), ) => { - const result = render( + const result = await render( @@ -262,7 +262,6 @@ const renderComposer = async ( , ); - await result.waitUntilReady(); // Wait for shortcuts hint debounce if using fake timers if (vi.isFakeTimers()) { diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 45ead4862e..b4ae8b93b1 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -43,10 +43,7 @@ describe('ConfigInitDisplay', () => { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = await renderWithProviders( - , - ); - await waitUntilReady(); + const { lastFrame } = await renderWithProviders(); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index dd69c44dd5..09a2dde16e 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -33,14 +33,13 @@ describe('ConsentPrompt', () => { it('renders a string prompt with MarkdownDisplay', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).toHaveBeenCalledWith( { @@ -55,14 +54,13 @@ describe('ConsentPrompt', () => { it('renders a ReactNode prompt directly', async () => { const prompt = Are you sure?; - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); @@ -71,14 +69,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -92,14 +89,13 @@ describe('ConsentPrompt', () => { it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { waitUntilReady, unmount } = await render( , ); - await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -113,14 +109,13 @@ describe('ConsentPrompt', () => { it('passes correct items to RadioButtonSelect', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = render( + const { unmount } = await render( , ); - await waitUntilReady(); expect(MockedRadioButtonSelect).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx index cb8db1a895..b7662c3a26 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -10,10 +10,9 @@ import { describe, it, expect } from 'vitest'; describe('ConsoleSummaryDisplay', () => { it('renders nothing when errorCount is 0', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -22,10 +21,9 @@ describe('ConsoleSummaryDisplay', () => { [1, '1 error'], [5, '5 errors'], ])('renders correct message for %i errors', async (count, expectedText) => { - const { lastFrame, waitUntilReady, unmount } = render( + const { lastFrame, unmount } = await render( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain(expectedText); expect(output).toContain('✖'); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index f48cfb2a31..1049e97912 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -26,8 +26,7 @@ const renderWithWidth = async ( props: React.ComponentProps, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - const result = render(); - await result.waitUntilReady(); + const result = await render(); return result; }; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index 904e06635c..d8ec1650ee 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -19,35 +19,33 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { describe('ContextUsageDisplay', () => { it('renders correct percentage used', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('50% used'); unmount(); }); it('renders correctly when usage is 0%', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('0% used'); unmount(); }); it('renders abbreviated label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { />, { width: 80 }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('20%'); expect(output).not.toContain('context used'); @@ -63,28 +60,26 @@ describe('ContextUsageDisplay', () => { }); it('renders 80% correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('80% used'); unmount(); }); it('renders 100% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('100% used'); unmount(); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index 6f202ced4a..cc20a142dd 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -22,8 +22,7 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -32,8 +31,7 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('In Copy Mode'); expect(lastFrame()).toContain('Use Page Up/Down to scroll'); expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index d4c0e28902..a014c740f0 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -242,8 +242,7 @@ describe('DebugProfiler Component', () => { showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -257,8 +256,7 @@ describe('DebugProfiler Component', () => { profiler.totalIdleFrames = 5; profiler.totalFlickerFrames = 2; - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); const output = lastFrame(); expect(output).toContain('Renders: 10 (total)'); @@ -275,8 +273,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { coreEvents.emitModelChanged('new-model'); @@ -295,8 +292,7 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { waitUntilReady, unmount } = await render(); await act(async () => { appEvents.emit(AppEvent.SelectionWarning); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index b2f4185842..30f98a6eda 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -41,13 +41,12 @@ describe('DetailedMessagesDisplay', () => { }); }); it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -64,13 +63,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); @@ -86,13 +84,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), }, ); - await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -106,13 +103,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -126,13 +122,12 @@ describe('DetailedMessagesDisplay', () => { clearConsoleMessages: vi.fn(), }); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 6f6dbb0289..31b28f5223 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -104,11 +104,10 @@ describe('DialogManager', () => { }; it('renders nothing by default', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); - await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -197,7 +196,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , { uiState: { @@ -206,7 +205,6 @@ describe('DialogManager', () => { } as Partial as UIState, }, ); - await waitUntilReady(); expect(lastFrame()).toContain(expectedComponent); unmount(); }, diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index bd995652b1..18b47def7b 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -55,27 +55,25 @@ describe('EditorSettingsDialog', () => { renderWithProviders(ui); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); expect(lastFrame()).toContain('VS Code'); }); @@ -88,7 +86,6 @@ describe('EditorSettingsDialog', () => { onExit={vi.fn()} />, ); - await waitUntilReady(); // Initial focus on editor expect(lastFrame()).toContain('> Select Editor'); @@ -134,7 +131,6 @@ describe('EditorSettingsDialog', () => { onExit={onExit} />, ); - await waitUntilReady(); await act(async () => { stdin.write('\u001B'); // Escape @@ -162,14 +158,13 @@ describe('EditorSettingsDialog', () => { }, } as unknown as LoadedSettings; - const { lastFrame, waitUntilReady } = await renderWithProvider( + const { lastFrame } = await renderWithProvider( , ); - await waitUntilReady(); const frame = lastFrame() || ''; if (!frame.includes('(Also modified')) { diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx index 23a2038b10..74de1a8a41 100644 --- a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => { describe('rendering', () => { it('should match snapshot with fallback available', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { onChoice={mockOnChoice} />, ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should match snapshot without fallback', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should display the model name and usage limit message', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('gemini-2.5-pro'); @@ -73,13 +70,12 @@ describe('EmptyWalletDialog', () => { }); it('should display purchase prompt and credits update notice', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('purchase more AI Credits'); @@ -90,14 +86,13 @@ describe('EmptyWalletDialog', () => { }); it('should display reset time when provided', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('3:45 PM'); @@ -106,13 +101,12 @@ describe('EmptyWalletDialog', () => { }); it('should not display reset time when not provided', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).not.toContain('Access resets at'); @@ -120,13 +114,12 @@ describe('EmptyWalletDialog', () => { }); it('should display slash command hints', async () => { - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('/stats'); @@ -139,14 +132,13 @@ describe('EmptyWalletDialog', () => { describe('onChoice handling', () => { it('should call onGetCredits and onChoice when get_credits is selected', async () => { // get_credits is the first item, so just press Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\r'); @@ -158,13 +150,12 @@ describe('EmptyWalletDialog', () => { }); it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\r'); @@ -177,14 +168,13 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with use_fallback when selected', async () => { // With fallback: items are [get_credits, use_fallback, stop] // use_fallback is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); @@ -198,13 +188,12 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with stop when selected', async () => { // Without fallback: items are [get_credits, stop] // stop is the second item: Down + Enter - const { unmount, stdin, waitUntilReady } = await renderWithProviders( + const { unmount, stdin } = await renderWithProviders( , ); - await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index f369e7ff8e..d6fc23dd70 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -440,36 +440,38 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = await renderWithProviders( - - - , - { - config: { - getTargetDir: () => mockTargetDir, - getIdeMode: () => false, - isTrustedFolder: () => true, - storage: { - getPlansDir: () => mockPlansDir, - }, - getFileSystemService: (): FileSystemService => ({ - readTextFile: vi.fn(), - writeTextFile: vi.fn(), + const { stdin, lastFrame } = await act(async () => + renderWithProviders( + + + , + { + config: { + getTargetDir: () => mockTargetDir, + getIdeMode: () => false, + isTrustedFolder: () => true, + storage: { + getPlansDir: () => mockPlansDir, + }, + getFileSystemService: (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + }), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, + } as unknown as import('@google/gemini-cli-core').Config, + settings: createMockSettings({ + ui: { useAlternateBuffer: useAlternateBuffer ?? true }, }), - getUseAlternateBuffer: () => useAlternateBuffer ?? true, - } as unknown as import('@google/gemini-cli-core').Config, - settings: createMockSettings({ - ui: { useAlternateBuffer: useAlternateBuffer ?? true }, - }), - }, + }, + ), ); await act(async () => { diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index 6d495a5e21..a504670d03 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -24,8 +24,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -36,8 +35,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+C again to exit'); unmount(); }); @@ -48,8 +46,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame()).toContain('Press Ctrl+D again to exit'); unmount(); }); @@ -60,8 +57,7 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); + const { lastFrame, unmount } = await render(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index c1d04b3ff9..de6e8096ec 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -48,10 +48,9 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( @@ -72,7 +71,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('hidden'); unmount(); @@ -103,7 +101,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); // With maxHeight=4, the intro text (4 lines) will take most of the space. // The discovery results will likely be hidden. expect(lastFrame()).toContain('hidden'); @@ -135,7 +132,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); expect(lastFrame()).toContain('hidden'); unmount(); }); @@ -182,9 +178,7 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - // In standard terminal mode, the expansion hint is handled globally by ToastDisplay - // via AppContainer, so it should not be present in the dialog's local frame. - expect(lastFrame()).not.toContain('Press Ctrl+O'); + expect(lastFrame()).toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); @@ -221,7 +215,6 @@ describe('FolderTrustDialog', () => { await renderWithProviders( , ); - await waitUntilReady(); await act(async () => { stdin.write('\u001b[27u'); // Press kitty escape key @@ -246,10 +239,9 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Gemini CLI is restarting'); unmount(); @@ -260,10 +252,9 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); await vi.advanceTimersByTimeAsync(250); expect(relaunchApp).toHaveBeenCalled(); unmount(); @@ -275,10 +266,9 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = await renderWithProviders( + const { unmount } = await renderWithProviders( , ); - await waitUntilReady(); // Unmount immediately (before 250ms) unmount(); @@ -292,7 +282,6 @@ describe('FolderTrustDialog', () => { const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); - await waitUntilReady(); await act(async () => { stdin.write('r'); @@ -308,30 +297,27 @@ describe('FolderTrustDialog', () => { describe('directory display', () => { it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust folder (project)'); unmount(); }); it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder (user)'); unmount(); }); it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); @@ -348,7 +334,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 80 }, ); - await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('• Commands (2):'); expect(lastFrame()).toContain('- cmd1'); @@ -386,14 +371,13 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Security Warnings:'); expect(lastFrame()).toContain('Dangerous setting detected!'); unmount(); @@ -410,14 +394,13 @@ describe('FolderTrustDialog', () => { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( , ); - await waitUntilReady(); expect(lastFrame()).toContain('Discovery Errors:'); expect(lastFrame()).toContain('Failed to load custom commands'); unmount(); @@ -434,7 +417,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { }, ); - await waitUntilReady(); // In alternate buffer + expanded, the title should be visible (StickyHeader) expect(lastFrame()).toContain('Do you trust the files in this folder?'); // And it should NOT use MaxSizedBox truncation @@ -470,7 +452,7 @@ describe('FolderTrustDialog', () => { securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { { width: 100, uiState: { terminalHeight: 40 } }, ); - await waitUntilReady(); const output = lastFrame(); expect(output).toContain('cmd-with-ansi'); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index 39f20e1c86..c0a52af868 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -138,33 +138,25 @@ describe('