From 4dcca1ca10b097409a81fc9b1bd91b54f776a3f8 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Wed, 18 Mar 2026 11:39:12 -0700 Subject: [PATCH 001/110] feat(ui): format multi-line banner warnings with a bold title (#22955) Co-authored-by: Sehoon Shon --- .../cli/src/ui/components/Banner.test.tsx | 19 +++++++-------- packages/cli/src/ui/components/Banner.tsx | 15 ++++++------ ...r-Banner-handles-newlines-in-text.snap.svg | 20 ++++++++++++++++ ...anner-Banner-renders-in-info-mode.snap.svg | 23 +++++++++++++++++++ ...ner-renders-in-multi-line-warning.snap.svg | 19 +++++++++++++++ ...er-Banner-renders-in-warning-mode.snap.svg | 13 +++++++++++ .../__snapshots__/Banner.test.tsx.snap | 17 +++++++++----- 7 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg create mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg create mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg create mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx index 46c47b8a71..00a2bf609f 100644 --- a/packages/cli/src/ui/components/Banner.test.tsx +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { Banner } from './Banner.js'; import { describe, it, expect } from 'vitest'; @@ -12,22 +12,23 @@ describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], + ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = renderWithProviders( , ); - await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx index 99f573a68e..3f9777aa45 100644 --- a/packages/cli/src/ui/components/Banner.tsx +++ b/packages/cli/src/ui/components/Banner.tsx @@ -14,20 +14,21 @@ export function getFormattedBannerContent( isWarning: boolean, subsequentLineColor: string, ): ReactNode { - if (isWarning) { - return ( - {rawText.replace(/\\n/g, '\n')} - ); - } - const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { + if (isWarning) { + return ( + + {line} + + ); + } return ( - {line} + {line} ); } diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg new file mode 100644 index 0000000000..a6272e0fa9 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg @@ -0,0 +1,20 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + L + i + n + e + 1 + + + Line 2 + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg new file mode 100644 index 0000000000..89d219005d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg @@ -0,0 +1,23 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + I + n + f + o + M + e + s + s + a + g + e + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg new file mode 100644 index 0000000000..6b3250fc6b --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg @@ -0,0 +1,19 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + Title Line + + + Body Line 1 + + + Body Line 2 + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg new file mode 100644 index 0000000000..4f3ee74723 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg @@ -0,0 +1,13 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + Warning Message + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap index 7766c808ae..6df246dede 100644 --- a/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Banner.test.tsx.snap @@ -4,20 +4,25 @@ exports[`Banner > handles newlines in text 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Line 1 │ │ Line 2 │ -╰──────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────╯" `; exports[`Banner > renders in info mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Info Message │ -╰──────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`Banner > renders in multi-line warning 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Title Line │ +│ Body Line 1 │ +│ Body Line 2 │ +╰──────────────────────────────────────────────────────────────────────────────╯" `; exports[`Banner > renders in warning mode 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Warning Message │ -╰──────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────╯" `; From adf21df71ec9f73201952c751e6cbc23f0ae062f Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:43:56 -0700 Subject: [PATCH 002/110] Docs: Remove references to stale Gemini CLI file structure info (#22976) --- CONTRIBUTING.md | 15 --------------- README.md | 1 - 2 files changed, 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c71fbe2e22..c6c619219c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -352,21 +352,6 @@ npm run lint - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. -### Project structure - -- `packages/`: Contains the individual sub-packages of the project. - - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) - - `cli/`: The command-line interface. - - `core/`: The core backend logic for the Gemini CLI. - - `test-utils` Utilities for creating and cleaning temporary file systems for - testing. - - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with - Gemini CLI. -- `docs/`: Contains all project documentation. -- `scripts/`: Utility scripts for building, testing, and development tasks. - -For more detailed architecture, see `docs/architecture.md`. - ### Debugging #### VS Code diff --git a/README.md b/README.md index 93485498ed..03a7be1296 100644 --- a/README.md +++ b/README.md @@ -314,7 +314,6 @@ gemini - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. -- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. From 0ed9f1e7f5a853783abcedc552bfeb6141891917 Mon Sep 17 00:00:00 2001 From: ANIRUDDHA ADAK Date: Thu, 19 Mar 2026 00:36:42 +0530 Subject: [PATCH 003/110] feat(ui): remove write todo list tool from UI tips (#22281) Co-authored-by: Aniruddha Adak Co-authored-by: anj-s <32556631+anj-s@users.noreply.github.com> --- packages/cli/src/ui/constants/tips.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index a1ed09de3e..15aa86c118 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -75,7 +75,6 @@ export const INFORMATIVE_TIPS = [ 'Set the character threshold for truncating tool outputs (/settings)…', 'Set the number of lines to keep when truncating outputs (/settings)…', 'Enable policy-based tool confirmation via message bus (/settings)…', - 'Enable write_todos_list tool to generate task lists (/settings)…', 'Enable experimental subagents for task delegation (/settings)…', 'Enable extension management features (settings.json)…', 'Enable extension reloading within the CLI session (settings.json)…', From 0082e1ec9727be2f39b5a5264cc37eecb93edbe3 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Wed, 18 Mar 2026 19:20:31 +0000 Subject: [PATCH 004/110] Fix issue where subagent thoughts are appended. (#22975) --- packages/core/src/agents/local-invocation.test.ts | 12 +++++++++--- packages/core/src/agents/local-invocation.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 0cd77176ba..39c3ea1fe5 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -230,7 +230,7 @@ describe('LocalSubagentInvocation', () => { expect(display.terminateReason).toBe(AgentTerminateMode.TIMEOUT); }); - it('should stream THOUGHT_CHUNK activities from the executor', async () => { + it('should stream THOUGHT_CHUNK activities from the executor, replacing the last running thought', async () => { mockExecutorInstance.run.mockImplementation(async () => { const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; @@ -245,7 +245,7 @@ describe('LocalSubagentInvocation', () => { isSubagentActivityEvent: true, agentName: 'MockAgent', type: 'THOUGHT_CHUNK', - data: { text: ' Still thinking.' }, + data: { text: 'Thinking about next steps.' }, } as SubagentActivityEvent); } return { result: 'Done', terminate_reason: AgentTerminateMode.GOAL }; @@ -258,7 +258,13 @@ describe('LocalSubagentInvocation', () => { expect(lastCall.recentActivity).toContainEqual( expect.objectContaining({ type: 'thought', - content: 'Analyzing... Still thinking.', + content: 'Thinking about next steps.', + }), + ); + expect(lastCall.recentActivity).not.toContainEqual( + expect.objectContaining({ + type: 'thought', + content: 'Analyzing...', }), ); }); diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 142a0bc518..f78faf32c0 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -120,7 +120,7 @@ export class LocalSubagentInvocation extends BaseToolInvocation< lastItem.type === 'thought' && lastItem.status === 'running' ) { - lastItem.content += text; + lastItem.content = text; } else { recentActivity.push({ id: randomUUID(), From b6d5374fb767e4fcd1dc9ddf2c3b4d941dff6d8f Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Thu, 19 Mar 2026 01:03:24 +0530 Subject: [PATCH 005/110] Feat/browser privacy consent (#21119) --- .../src/agents/browser/browserManager.test.ts | 44 +++++++ .../core/src/agents/browser/browserManager.ts | 16 +++ .../core/src/utils/browserConsent.test.ts | 119 ++++++++++++++++++ packages/core/src/utils/browserConsent.ts | 92 ++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 packages/core/src/utils/browserConsent.test.ts create mode 100644 packages/core/src/utils/browserConsent.ts diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index 18ea162df9..9931d6d7ca 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -44,6 +44,11 @@ vi.mock('../../utils/debugLogger.js', () => ({ }, })); +// Mock browser consent to always grant consent by default +vi.mock('../../utils/browserConsent.js', () => ({ + getBrowserConsentIfNeeded: vi.fn().mockResolvedValue(true), +})); + vi.mock('./automationOverlay.js', () => ({ injectAutomationOverlay: vi.fn().mockResolvedValue(undefined), })); @@ -64,6 +69,7 @@ vi.mock('node:fs', async (importOriginal) => { import * as fs from 'node:fs'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js'; describe('BrowserManager', () => { let mockConfig: Config; @@ -72,6 +78,9 @@ describe('BrowserManager', () => { vi.resetAllMocks(); vi.mocked(injectAutomationOverlay).mockClear(); + // Re-establish consent mock after resetAllMocks + vi.mocked(getBrowserConsentIfNeeded).mockResolvedValue(true); + // Setup mock config mockConfig = makeFakeConfig({ agents: { @@ -527,6 +536,41 @@ describe('BrowserManager', () => { /sessionMode: persistent/, ); }); + + it('should pass --no-usage-statistics and --no-performance-crux when privacy is disabled', async () => { + const privacyDisabledConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + }, + }, + usageStatisticsEnabled: false, + }); + + const manager = new BrowserManager(privacyDisabledConfig); + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + expect(args).toContain('--no-usage-statistics'); + expect(args).toContain('--no-performance-crux'); + }); + + it('should NOT pass privacy flags when usage statistics are enabled', async () => { + // Default config has usageStatisticsEnabled: true (or undefined) + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + const args = vi.mocked(StdioClientTransport).mock.calls[0]?.[0] + ?.args as string[]; + expect(args).not.toContain('--no-usage-statistics'); + expect(args).not.toContain('--no-performance-crux'); + }); }); describe('MCP isolation', () => { diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 08e9597755..f1d149f838 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -23,6 +23,7 @@ import type { Tool as McpTool } from '@modelcontextprotocol/sdk/types.js'; import { debugLogger } from '../../utils/debugLogger.js'; import type { Config } from '../../config/config.js'; import { Storage } from '../../config/storage.js'; +import { getBrowserConsentIfNeeded } from '../../utils/browserConsent.js'; import { injectInputBlocker } from './inputBlocker.js'; import * as path from 'node:path'; import * as fs from 'node:fs'; @@ -260,6 +261,16 @@ export class BrowserManager { if (this.rawMcpClient) { return; } + + // Request browser consent if needed (first-run privacy notice) + const consentGranted = await getBrowserConsentIfNeeded(); + if (!consentGranted) { + throw new Error( + 'Browser agent requires user consent to proceed. ' + + 'Please re-run and accept the privacy notice.', + ); + } + await this.connectMcp(); } @@ -352,6 +363,11 @@ export class BrowserManager { mcpArgs.push('--userDataDir', defaultProfilePath); } + // Respect the user's privacy.usageStatisticsEnabled setting + if (!this.config.getUsageStatisticsEnabled()) { + mcpArgs.push('--no-usage-statistics', '--no-performance-crux'); + } + if ( browserConfig.customConfig.allowedDomains && browserConfig.customConfig.allowedDomains.length > 0 diff --git a/packages/core/src/utils/browserConsent.test.ts b/packages/core/src/utils/browserConsent.test.ts new file mode 100644 index 0000000000..f145632068 --- /dev/null +++ b/packages/core/src/utils/browserConsent.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import { coreEvents } from './events.js'; +import { Storage } from '../config/storage.js'; + +// Mock fs/promises before importing the module under test +vi.mock('node:fs/promises', () => ({ + access: vi.fn(), + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), +})); + +// Mock Storage to return a predictable directory +vi.mock('../config/storage.js', () => ({ + Storage: { + getGlobalGeminiDir: vi.fn(), + }, +})); + +import { getBrowserConsentIfNeeded } from './browserConsent.js'; + +describe('browserConsent', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue('/mock/.gemini'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return true if consent file already exists', async () => { + // Consent file exists — fs.access resolves + vi.mocked(fs.access).mockResolvedValue(undefined); + + const result = await getBrowserConsentIfNeeded(); + + expect(result).toBe(true); + // Should not emit a consent request + const emitSpy = vi.spyOn(coreEvents, 'emitConsentRequest'); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should auto-accept in non-interactive mode (no listeners)', async () => { + // Consent file does not exist + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + // No listeners registered + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); + + const result = await getBrowserConsentIfNeeded(); + + expect(result).toBe(true); + // Should persist the consent + expect(fs.writeFile).toHaveBeenCalledWith( + expect.stringContaining('browser-consent-acknowledged.txt'), + expect.stringContaining('consent acknowledged'), + ); + }); + + it('should request consent interactively and return true when accepted', async () => { + // Consent file does not exist + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + // Simulate interactive mode: there is at least one listener + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + // Mock emitConsentRequest to auto-confirm + vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { + payload.onConfirm(true); + }); + + const result = await getBrowserConsentIfNeeded(); + + expect(result).toBe(true); + expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('Privacy Notice'), + }), + ); + expect(fs.writeFile).toHaveBeenCalled(); + }); + + it('should return false when user declines consent', async () => { + // Consent file does not exist + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + // Simulate interactive mode + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + // Mock emitConsentRequest to decline + vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { + payload.onConfirm(false); + }); + + const result = await getBrowserConsentIfNeeded(); + + expect(result).toBe(false); + // Should NOT persist consent + expect(fs.writeFile).not.toHaveBeenCalled(); + }); + + it('should include privacy policy link in the prompt', async () => { + vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { + payload.onConfirm(true); + }); + + await getBrowserConsentIfNeeded(); + + expect(coreEvents.emitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining('policies.google.com/privacy'), + }), + ); + }); +}); diff --git a/packages/core/src/utils/browserConsent.ts b/packages/core/src/utils/browserConsent.ts new file mode 100644 index 0000000000..097c3b683e --- /dev/null +++ b/packages/core/src/utils/browserConsent.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreEvent, coreEvents } from './events.js'; +import { Storage } from '../config/storage.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +/** Sentinel file written after the user acknowledges the browser privacy notice. */ +const BROWSER_CONSENT_FLAG_FILE = 'browser-consent-acknowledged.txt'; + +/** Default browser profile directory name within ~/.gemini/ */ +const BROWSER_PROFILE_DIR = 'cli-browser-profile'; + +/** + * Ensures the user has acknowledged the browser agent privacy notice. + * + * On first invocation (per profile), an interactive consent dialog is shown + * describing chrome-devtools-mcp's data collection and the fact that browser + * content is exposed to the AI model. A sentinel file is written to the + * browser profile directory once the user accepts. + * + * @returns `true` if consent was already given or the user accepted, + * `false` if the user declined. + */ +export async function getBrowserConsentIfNeeded(): Promise { + const consentFilePath = path.join( + Storage.getGlobalGeminiDir(), + BROWSER_PROFILE_DIR, + BROWSER_CONSENT_FLAG_FILE, + ); + + // Fast path: consent already persisted. + try { + await fs.access(consentFilePath); + return true; + } catch { + // File doesn't exist — need to request consent. + void 0; + } + + // Non-interactive mode (no UI listeners): auto-accept. + if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { + await markConsentAsAcknowledged(consentFilePath); + return true; + } + + const prompt = + '🔒 Browser Agent Privacy Notice\n\n' + + 'The Browser Agent uses chrome-devtools-mcp to control your browser. ' + + 'Please note:\n\n' + + '• Chrome DevTools MCP collects usage statistics by default ' + + '(can be disabled via privacy settings)\n' + + '• Performance tools may send trace URLs to Google CrUX API\n' + + '• Browser content will be exposed to the AI model for analysis\n' + + '• All data is handled per the Google Privacy Policy ' + + '(https://policies.google.com/privacy)\n\n' + + 'Do you understand and consent to proceed?'; + + return new Promise((resolve) => { + coreEvents.emitConsentRequest({ + prompt, + onConfirm: async (confirmed: boolean) => { + if (confirmed) { + await markConsentAsAcknowledged(consentFilePath); + } + resolve(confirmed); + }, + }); + }); +} + +/** + * Persists a sentinel file so consent is not requested again. + */ +async function markConsentAsAcknowledged( + consentFilePath: string, +): Promise { + try { + await fs.mkdir(path.dirname(consentFilePath), { recursive: true }); + await fs.writeFile( + consentFilePath, + `Browser privacy consent acknowledged at ${new Date().toISOString()}\n`, + ); + } catch { + // Best-effort: if we can't persist, the dialog will appear again next time. + void 0; + } +} From c12fc340c11649b45dc7ea80130bd7ffacc6aa78 Mon Sep 17 00:00:00 2001 From: AK Date: Wed, 18 Mar 2026 12:54:48 -0700 Subject: [PATCH 006/110] fix(core): explicitly map execution context in LocalAgentExecutor (#22949) Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> --- .../core/src/agents/local-executor.test.ts | 70 +++++++++++++++++++ packages/core/src/agents/local-executor.ts | 5 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index f0afa73e6a..8fc189e961 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -365,6 +365,76 @@ describe('LocalAgentExecutor', () => { }); describe('create (Initialization and Validation)', () => { + it('should explicitly map execution context properties to prevent unintended propagation', async () => { + const definition = createTestDefinition([LS_TOOL_NAME]); + const mockGeminiClient = + {} as unknown as import('../core/client.js').GeminiClient; + const mockSandboxManager = + {} as unknown as import('../services/sandboxManager.js').SandboxManager; + const extendedContext = { + config: mockConfig, + promptId: mockConfig.promptId, + toolRegistry: parentToolRegistry, + promptRegistry: mockConfig.promptRegistry, + resourceRegistry: mockConfig.resourceRegistry, + messageBus: mockConfig.messageBus, + geminiClient: mockGeminiClient, + sandboxManager: mockSandboxManager, + unintendedProperty: 'should not be here', + } as unknown as import('../config/agent-loop-context.js').AgentLoopContext; + + const executor = await LocalAgentExecutor.create( + definition, + extendedContext, + onActivity, + ); + + mockModelResponse([ + { + name: TASK_COMPLETE_TOOL_NAME, + args: { finalResult: 'done' }, + id: 'call1', + }, + ]); + + await executor.run({ goal: 'test' }, signal); + + const chatConstructorArgs = MockedGeminiChat.mock.calls[0]; + const executionContext = chatConstructorArgs[0]; + + expect(executionContext).toBeDefined(); + expect(executionContext.config).toBe(extendedContext.config); + expect(executionContext.promptId).toBe(extendedContext.promptId); + expect(executionContext.geminiClient).toBe(extendedContext.geminiClient); + expect(executionContext.sandboxManager).toBe( + extendedContext.sandboxManager, + ); + + const agentToolRegistry = executor['toolRegistry']; + const agentPromptRegistry = executor['promptRegistry']; + const agentResourceRegistry = executor['resourceRegistry']; + + expect(executionContext.toolRegistry).toBe(agentToolRegistry); + expect(executionContext.promptRegistry).toBe(agentPromptRegistry); + expect(executionContext.resourceRegistry).toBe(agentResourceRegistry); + + expect(executionContext.messageBus).toBe( + agentToolRegistry.getMessageBus(), + ); + + // Ensure the unintended property was not spread + expect( + (executionContext as unknown as { unintendedProperty?: string }) + .unintendedProperty, + ).toBeUndefined(); + + // Ensure registries and message bus are not the parent's + expect(executionContext.toolRegistry).not.toBe( + extendedContext.toolRegistry, + ); + expect(executionContext.messageBus).not.toBe(extendedContext.messageBus); + }); + it('should create successfully with allowed tools', async () => { const definition = createTestDefinition([LS_TOOL_NAME]); const executor = await LocalAgentExecutor.create( diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index a9adeb2e2d..c41ae801c4 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -113,7 +113,10 @@ export class LocalAgentExecutor { private get executionContext(): AgentLoopContext { return { - ...this.context, + config: this.context.config, + promptId: this.context.promptId, + geminiClient: this.context.geminiClient, + sandboxManager: this.context.sandboxManager, toolRegistry: this.toolRegistry, promptRegistry: this.promptRegistry, resourceRegistry: this.resourceRegistry, From 1725ec346b5fe9ce889a1df8633d9cdc9d6bb587 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Wed, 18 Mar 2026 16:00:26 -0400 Subject: [PATCH 007/110] feat(plan): support plan mode in non-interactive mode (#22670) --- docs/cli/plan-mode.md | 20 +++ docs/reference/policy-engine.md | 15 +++ evals/plan_mode.eval.ts | 117 ++++++++++++++---- packages/core/src/policy/policies/plan.toml | 14 +++ packages/core/src/policy/policies/yolo.toml | 1 + .../core/src/policy/policy-engine.test.ts | 117 ++++++++++++++++++ packages/core/src/policy/policy-engine.ts | 14 +++ packages/core/src/policy/toml-loader.ts | 2 + packages/core/src/policy/types.ts | 7 ++ packages/core/src/prompts/promptProvider.ts | 1 + packages/core/src/prompts/snippets.ts | 7 +- .../core/src/tools/exit-plan-mode.test.ts | 36 +++++- packages/core/src/tools/exit-plan-mode.ts | 32 +++-- 13 files changed, 343 insertions(+), 40 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 379eb71030..9550e2a918 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -460,6 +460,26 @@ Manual deletion also removes all associated artifacts: If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. +## Non-interactive execution + +When running Gemini CLI in non-interactive environments (such as headless +scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: + +- **Automatic transitions:** The policy engine automatically approves the + `enter_plan_mode` and `exit_plan_mode` tools without prompting for user + confirmation. +- **Automated implementation:** When exiting Plan Mode to execute the plan, + Gemini CLI automatically switches to + [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the + standard Default mode. This allows the CLI to execute the implementation steps + automatically without hanging on interactive tool approvals. + +**Example:** + +```bash +gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" +``` + [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [Conductor]: https://github.com/gemini-cli-extensions/conductor diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 495a4584e1..e26c080a50 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -90,6 +90,17 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. +#### Execution environment + +If `interactive` is specified, the rule will only apply if the CLI's execution +environment matches the specified boolean value: + +- `true`: The rule applies only in interactive mode. +- `false`: The rule applies only in non-interactive (headless) mode. + +If omitted, the rule applies to both interactive and non-interactive +environments. + ### Decisions There are three possible decisions a rule can enforce: @@ -286,6 +297,10 @@ deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] + +# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. +# If omitted, the rule applies to both. +interactive = true ``` ### Using arrays (lists) diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index 29566eab86..a37e5f91b4 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,6 +18,18 @@ describe('plan_mode', () => { experimental: { plan: true }, }; + const getWriteTargets = (logs: any[]) => + logs + .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path as string; + } catch { + return ''; + } + }) + .filter(Boolean); + evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, @@ -32,27 +44,23 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExitPlan = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); expect( - writeTargets, + writeTargetsBeforeExitPlan, 'Should not attempt to modify README.md in plan mode', ).not.toContain('README.md'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], - testName: `${TEST_PREFIX}should refuse file modification`, + testName: `${TEST_PREFIX}should refuse file modification in plan mode`, }); }, }); @@ -69,24 +77,20 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const writeTargets = toolLogs - .filter((log) => - ['write_file', 'replace'].includes(log.toolRequest.name), - ) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path; - } catch { - return null; - } - }); + const exitPlanIndex = toolLogs.findIndex( + (log) => log.toolRequest.name === 'exit_plan_mode', + ); + + const writeTargetsBeforeExit = getWriteTargets( + toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), + ); // It should NOT write to the docs folder or any other repo path - const hasRepoWrite = writeTargets.some( + const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( (path) => path && !path.includes('/plans/'), ); expect( - hasRepoWrite, + hasRepoWriteBeforeExit, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); @@ -166,4 +170,65 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); + + evalTest('USUALLY_PASSES', { + name: 'should create a plan in plan mode and implement it for a refactoring task', + params: { + settings, + }, + files: { + 'src/mathUtils.ts': + 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', + 'src/main.ts': + 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', + }, + prompt: + 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', + assert: async (rig, result) => { + const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); + expect( + enterPlanCalled, + 'Expected enter_plan_mode tool to be called', + ).toBe(true); + + const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); + expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( + true, + ); + + await rig.waitForTelemetryReady(); + const toolLogs = rig.readToolLogs(); + + // Check if plan was written + const planWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('/plans/'), + ); + expect( + planWrite, + 'Expected a plan file to be written in the plans directory', + ).toBeDefined(); + + // Check for implementation files + const newFileWrite = toolLogs.find( + (log) => + log.toolRequest.name === 'write_file' && + log.toolRequest.args.includes('src/basicMath.ts'), + ); + expect( + newFileWrite, + 'Expected src/basicMath.ts to be created', + ).toBeDefined(); + + const mainUpdate = toolLogs.find( + (log) => + ['write_file', 'replace'].includes(log.toolRequest.name) && + log.toolRequest.args.includes('src/main.ts'), + ); + expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); + + assertModelHasOutput(result); + }, + }); }); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index e0c70dc219..5a7ee6e59f 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -33,6 +33,13 @@ toolName = "enter_plan_mode" decision = "ask_user" priority = 50 +interactive = true + +[[rule]] +toolName = "enter_plan_mode" +decision = "allow" +priority = 50 +interactive = false [[rule]] toolName = "enter_plan_mode" @@ -46,6 +53,13 @@ toolName = "exit_plan_mode" decision = "ask_user" priority = 70 modes = ["plan"] +interactive = true + +[[rule]] +toolName = "exit_plan_mode" +decision = "allow" +priority = 70 +interactive = false [[rule]] toolName = "exit_plan_mode" diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index d326e163f5..230b4c2670 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -45,6 +45,7 @@ toolName = ["enter_plan_mode", "exit_plan_mode"] decision = "deny" priority = 999 modes = ["yolo"] +interactive = true # Allow everything else in YOLO mode [[rule]] diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index b8865ba587..5e03443722 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -3343,4 +3343,121 @@ describe('PolicyEngine', () => { expect(excluded.has('test-tool')).toBe(false); }); }); + + describe('interactive matching', () => { + it('should ignore interactive rules in non-interactive mode', async () => { + const engine = new PolicyEngine({ + rules: [ + { + toolName: 'my_tool', + decision: PolicyDecision.ALLOW, + interactive: true, + }, + ], + nonInteractive: true, + defaultDecision: PolicyDecision.DENY, + }); + + const result = await engine.check( + { name: 'my_tool', args: {} }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.DENY); + }); + + it('should allow interactive rules in interactive mode', async () => { + const engine = new PolicyEngine({ + rules: [ + { + toolName: 'my_tool', + decision: PolicyDecision.ALLOW, + interactive: true, + }, + ], + nonInteractive: false, + defaultDecision: PolicyDecision.DENY, + }); + + const result = await engine.check( + { name: 'my_tool', args: {} }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should ignore non-interactive rules in interactive mode', async () => { + const engine = new PolicyEngine({ + rules: [ + { + toolName: 'my_tool', + decision: PolicyDecision.ALLOW, + interactive: false, + }, + ], + nonInteractive: false, + defaultDecision: PolicyDecision.DENY, + }); + + const result = await engine.check( + { name: 'my_tool', args: {} }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.DENY); + }); + + it('should allow non-interactive rules in non-interactive mode', async () => { + const engine = new PolicyEngine({ + rules: [ + { + toolName: 'my_tool', + decision: PolicyDecision.ALLOW, + interactive: false, + }, + ], + nonInteractive: true, + defaultDecision: PolicyDecision.DENY, + }); + + const result = await engine.check( + { name: 'my_tool', args: {} }, + undefined, + ); + expect(result.decision).toBe(PolicyDecision.ALLOW); + }); + + it('should apply rules without interactive flag to both', async () => { + const rule: PolicyRule = { + toolName: 'my_tool', + decision: PolicyDecision.ALLOW, + }; + + const engineInteractive = new PolicyEngine({ + rules: [rule], + nonInteractive: false, + defaultDecision: PolicyDecision.DENY, + }); + const engineNonInteractive = new PolicyEngine({ + rules: [rule], + nonInteractive: true, + defaultDecision: PolicyDecision.DENY, + }); + + expect( + ( + await engineInteractive.check( + { name: 'my_tool', args: {} }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + ( + await engineNonInteractive.check( + { name: 'my_tool', args: {} }, + undefined, + ) + ).decision, + ).toBe(PolicyDecision.ALLOW); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index ec84eb23aa..53bca3f531 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -74,6 +74,7 @@ function ruleMatches( stringifiedArgs: string | undefined, serverName: string | undefined, currentApprovalMode: ApprovalMode, + nonInteractive: boolean, toolAnnotations?: Record, subagent?: string, ): boolean { @@ -146,6 +147,16 @@ function ruleMatches( } } + // Check interactive if specified + if ('interactive' in rule && rule.interactive !== undefined) { + if (rule.interactive && nonInteractive) { + return false; + } + if (!rule.interactive && !nonInteractive) { + return false; + } + } + return true; } @@ -443,6 +454,7 @@ export class PolicyEngine { stringifiedArgs, serverName, this.approvalMode, + this.nonInteractive, toolAnnotations, subagent, ), @@ -521,6 +533,7 @@ export class PolicyEngine { stringifiedArgs, serverName, this.approvalMode, + this.nonInteractive, toolAnnotations, subagent, ) @@ -713,6 +726,7 @@ export class PolicyEngine { undefined, // stringifiedArgs serverName, this.approvalMode, + this.nonInteractive, annotations, ); diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index f5c176dc25..f5210954f7 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -61,6 +61,7 @@ const PolicyRuleSchema = z.object({ 'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.', }), modes: z.array(z.nativeEnum(ApprovalMode)).optional(), + interactive: z.boolean().optional(), toolAnnotations: z.record(z.any()).optional(), allow_redirection: z.boolean().optional(), deny_message: z.string().optional(), @@ -475,6 +476,7 @@ export async function loadPoliciesFromToml( decision: rule.decision, priority: transformPriority(rule.priority, tier), modes: rule.modes, + interactive: rule.interactive, toolAnnotations: rule.toolAnnotations, allowRedirection: rule.allow_redirection, source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`, diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index a3a919e1cd..5cd668ef4e 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -152,6 +152,13 @@ export interface PolicyRule { */ modes?: ApprovalMode[]; + /** + * If true, this rule only applies to interactive environments. + * If false, this rule only applies to non-interactive environments. + * If undefined, it applies to both interactive and non-interactive environments. + */ + interactive?: boolean; + /** * If true, allows command redirection even if the policy engine would normally * downgrade ALLOW to ASK_USER for redirected commands. diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index d9e671a94b..a2e1333895 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -175,6 +175,7 @@ export class PromptProvider { planningWorkflow: this.withSection( 'planningWorkflow', () => ({ + interactive: interactiveMode, planModeToolsList, plansDir: context.config.storage.getPlansDir(), approvedPlanPath: context.config.getApprovedPlanPath(), diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 11b559d116..225fa21c4a 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -88,6 +88,7 @@ export interface GitRepoOptions { } export interface PlanningWorkflowOptions { + interactive: boolean; planModeToolsList: string; plansDir: string; approvedPlanPath?: string; @@ -513,7 +514,7 @@ export function renderPlanningWorkflow( return ` # Active Approval Mode: Plan -You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and get user approval before editing source code. +You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and ${options.interactive ? 'get user approval before editing source code.' : 'create a design document before proceeding autonomously.'} ## Available Tools The following tools are available in Plan Mode: @@ -550,7 +551,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure - **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. ### 4. Review & Approval -Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and formally request approval. +Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'} ${renderApprovedPlanSection(options.approvedPlanPath)}`.trim(); } @@ -711,7 +712,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string { // standard 'Execution' loop handle implementation once the plan is approved. if (options.enableEnterPlanModeTool) { return ` -1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document and obtain user approval before writing any code. +1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document${options.interactive ? ' and obtain user approval' : ''} before writing any code. 2. **Design Constraints:** When drafting your plan, adhere to these defaults unless explicitly overridden by the user: - **Goal:** Autonomously design a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, typography, and interactive feedback. - **Visuals:** Describe your strategy for sourcing or generating placeholders (e.g., stylized CSS shapes, gradients, procedurally generated patterns) to ensure a visually complete prototype. Never plan for assets that cannot be locally generated. diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 4b6b537d00..88e327ab34 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -47,6 +47,7 @@ describe('ExitPlanModeTool', () => { storage: { getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], + isInteractive: vi.fn().mockReturnValue(true), }; tool = new ExitPlanModeTool( mockConfig as Config, @@ -359,6 +360,36 @@ Ask the user for specific feedback on how to improve the plan.`, }); }); + describe('getAllowApprovalMode (internal)', () => { + it('should return YOLO when config.isInteractive() is false', async () => { + mockConfig.isInteractive = vi.fn().mockReturnValue(false); + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + // Directly call execute to trigger the internal getAllowApprovalMode + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('YOLO mode'); + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.YOLO, + ); + }); + + it('should return DEFAULT when config.isInteractive() is true', async () => { + mockConfig.isInteractive = vi.fn().mockReturnValue(true); + const planRelativePath = createPlanFile('test.md', '# Content'); + const invocation = tool.build({ plan_path: planRelativePath }); + + // Directly call execute to trigger the internal getAllowApprovalMode + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Default mode'); + expect(mockConfig.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.DEFAULT, + ); + }); + }); + describe('getApprovalModeDescription (internal)', () => { it('should handle all valid approval modes', async () => { const planRelativePath = createPlanFile('test.md', '# Content'); @@ -387,6 +418,10 @@ Ask the user for specific feedback on how to improve the plan.`, ApprovalMode.DEFAULT, 'Default mode (edits will require confirmation)', ); + await testMode( + ApprovalMode.YOLO, + 'YOLO mode (all tool calls auto-approved)', + ); }); it('should throw for invalid post-planning modes', async () => { @@ -409,7 +444,6 @@ Ask the user for specific feedback on how to improve the plan.`, ).rejects.toThrow(/Unexpected approval mode/); }; - await testInvalidMode(ApprovalMode.YOLO); await testInvalidMode(ApprovalMode.PLAN); }); }); diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index b1615b18b4..aad95492c2 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -7,12 +7,12 @@ import { BaseDeclarativeTool, BaseToolInvocation, - type ToolResult, Kind, - type ToolExitPlanModeConfirmationDetails, - type ToolConfirmationPayload, - type ToolExitPlanModeConfirmationPayload, ToolConfirmationOutcome, + type ToolConfirmationPayload, + type ToolExitPlanModeConfirmationDetails, + type ToolExitPlanModeConfirmationPayload, + type ToolResult, } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import path from 'node:path'; @@ -151,7 +151,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce; this.approvalPayload = { approved: true, - approvalMode: ApprovalMode.DEFAULT, + approvalMode: this.getAllowApprovalMode(), }; return false; } @@ -205,17 +205,15 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< // When a user policy grants `allow` for exit_plan_mode, the scheduler // skips the confirmation phase entirely and shouldConfirmExecute is never - // called, leaving approvalPayload null. Treat that as an approval with - // the default mode — consistent with the ALLOW branch inside - // shouldConfirmExecute. + // called, leaving approvalPayload null. const payload = this.approvalPayload ?? { approved: true, - approvalMode: ApprovalMode.DEFAULT, + approvalMode: this.getAllowApprovalMode(), }; if (payload.approved) { const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT; - if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) { + if (newMode === ApprovalMode.PLAN) { throw new Error(`Unexpected approval mode: ${newMode}`); } @@ -254,4 +252,18 @@ Ask the user for specific feedback on how to improve the plan.`, } } } + + /** + * Determines the approval mode to switch to when plan mode is exited via a policy ALLOW. + * In non-interactive environments, this defaults to YOLO to allow automated execution. + */ + private getAllowApprovalMode(): ApprovalMode { + if (!this.config.isInteractive()) { + // For non-interactive environment requires minimal user action, exit as YOLO mode for plan implementation. + return ApprovalMode.YOLO; + } + // By default, YOLO mode in interactive environment cannot enter/exit plan mode. + // Always exit plan mode and move to default approval mode if exit_plan_mode tool is configured with allow decision. + return ApprovalMode.DEFAULT; + } } From f6e21f50fd245c34a2eb4b2dd233d71c1a9035c2 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 18 Mar 2026 16:07:54 -0400 Subject: [PATCH 008/110] feat(core): implement strict macOS sandboxing using Seatbelt allowlist (#22832) --- .../MacOsSandboxManager.integration.test.ts | 202 ++++++++++++++++++ .../sandbox/macos/MacOsSandboxManager.test.ts | 107 ++++++++++ .../src/sandbox/macos/MacOsSandboxManager.ts | 60 ++++++ .../core/src/sandbox/macos/baseProfile.ts | 94 ++++++++ .../sandbox/macos/seatbeltArgsBuilder.test.ts | 97 +++++++++ .../src/sandbox/macos/seatbeltArgsBuilder.ts | 80 +++++++ .../core/src/services/sandboxManager.test.ts | 36 ++-- packages/core/src/services/sandboxManager.ts | 4 + 8 files changed, 661 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts create mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts create mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.ts create mode 100644 packages/core/src/sandbox/macos/baseProfile.ts create mode 100644 packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts create mode 100644 packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts new file mode 100644 index 0000000000..d9776bc715 --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts @@ -0,0 +1,202 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { MacOsSandboxManager } from './MacOsSandboxManager.js'; +import { ShellExecutionService } from '../../services/shellExecutionService.js'; +import { getSecureSanitizationConfig } from '../../services/environmentSanitization.js'; +import { type SandboxedCommand } from '../../services/sandboxManager.js'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import os from 'node:os'; +import fs from 'node:fs'; +import path from 'node:path'; +import http from 'node:http'; + +/** + * A simple asynchronous wrapper for execFile that returns the exit status, + * stdout, and stderr. Unlike spawnSync, this does not block the Node.js + * event loop, allowing the local HTTP test server to function. + */ +async function runCommand(command: SandboxedCommand) { + try { + const { stdout, stderr } = await promisify(execFile)( + command.program, + command.args, + { + cwd: command.cwd, + env: command.env, + encoding: 'utf-8', + }, + ); + return { status: 0, stdout, stderr }; + } catch (error: unknown) { + const err = error as { + code?: number; + stdout?: string; + stderr?: string; + }; + return { + status: err.code ?? 1, + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + }; + } +} + +describe.skipIf(os.platform() !== 'darwin')( + 'MacOsSandboxManager Integration', + () => { + describe('Basic Execution', () => { + it('should execute commands within the workspace', async () => { + const manager = new MacOsSandboxManager({ workspace: process.cwd() }); + const command = await manager.prepareCommand({ + command: 'echo', + args: ['sandbox test'], + cwd: process.cwd(), + env: process.env, + }); + + const execResult = await runCommand(command); + + expect(execResult.status).toBe(0); + expect(execResult.stdout.trim()).toBe('sandbox test'); + }); + + it('should support interactive pseudo-terminals (node-pty)', async () => { + const manager = new MacOsSandboxManager({ workspace: process.cwd() }); + const abortController = new AbortController(); + + // Verify that node-pty file descriptors are successfully allocated inside the sandbox + // by using the bash [ -t 1 ] idiom to check if stdout is a TTY. + const handle = await ShellExecutionService.execute( + 'bash -c "if [ -t 1 ]; then echo True; else echo False; fi"', + process.cwd(), + () => {}, + abortController.signal, + true, + { + sanitizationConfig: getSecureSanitizationConfig(), + sandboxManager: manager, + }, + ); + + const result = await handle.result; + expect(result.error).toBeNull(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain('True'); + }); + }); + + describe('File System Access', () => { + it('should block file system access outside the workspace', async () => { + const manager = new MacOsSandboxManager({ workspace: process.cwd() }); + const blockedPath = '/Users/Shared/.gemini_test_sandbox_blocked'; + + const command = await manager.prepareCommand({ + command: 'touch', + args: [blockedPath], + cwd: process.cwd(), + env: process.env, + }); + const execResult = await runCommand(command); + + expect(execResult.status).not.toBe(0); + expect(execResult.stderr).toContain('Operation not permitted'); + }); + + it('should grant file system access to explicitly allowed paths', async () => { + // Create a unique temporary directory to prevent artifacts and test flakiness + const allowedDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-sandbox-test-'), + ); + + try { + const manager = new MacOsSandboxManager({ + workspace: process.cwd(), + allowedPaths: [allowedDir], + }); + const testFile = path.join(allowedDir, 'test.txt'); + + const command = await manager.prepareCommand({ + command: 'touch', + args: [testFile], + cwd: process.cwd(), + env: process.env, + }); + + const execResult = await runCommand(command); + + expect(execResult.status).toBe(0); + } finally { + fs.rmSync(allowedDir, { recursive: true, force: true }); + } + }); + }); + + describe('Network Access', () => { + let testServer: http.Server; + let testServerUrl: string; + + beforeAll(async () => { + testServer = http.createServer((_, res) => { + // Ensure connections are closed immediately to prevent hanging + res.setHeader('Connection', 'close'); + res.writeHead(200); + res.end('ok'); + }); + + await new Promise((resolve, reject) => { + testServer.on('error', reject); + testServer.listen(0, '127.0.0.1', () => { + const address = testServer.address() as import('net').AddressInfo; + testServerUrl = `http://127.0.0.1:${address.port}`; + resolve(); + }); + }); + }); + + afterAll(async () => { + if (testServer) { + await new Promise((resolve) => { + testServer.close(() => resolve()); + }); + } + }); + + it('should block network access by default', async () => { + const manager = new MacOsSandboxManager({ workspace: process.cwd() }); + const command = await manager.prepareCommand({ + command: 'curl', + args: ['-s', '--connect-timeout', '1', testServerUrl], + cwd: process.cwd(), + env: process.env, + }); + + const execResult = await runCommand(command); + + expect(execResult.status).not.toBe(0); + }); + + it('should grant network access when explicitly allowed', async () => { + const manager = new MacOsSandboxManager({ + workspace: process.cwd(), + networkAccess: true, + }); + const command = await manager.prepareCommand({ + command: 'curl', + args: ['-s', '--connect-timeout', '1', testServerUrl], + cwd: process.cwd(), + env: process.env, + }); + + const execResult = await runCommand(command); + + expect(execResult.status).toBe(0); + expect(execResult.stdout.trim()).toBe('ok'); + }); + }); + }, +); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts new file mode 100644 index 0000000000..69946daade --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { MacOsSandboxManager } from './MacOsSandboxManager.js'; +import * as seatbeltArgsBuilder from './seatbeltArgsBuilder.js'; + +describe('MacOsSandboxManager', () => { + const mockWorkspace = '/test/workspace'; + const mockAllowedPaths = ['/test/allowed']; + const mockNetworkAccess = true; + + let manager: MacOsSandboxManager; + let buildArgsSpy: MockInstance; + + beforeEach(() => { + manager = new MacOsSandboxManager({ + workspace: mockWorkspace, + allowedPaths: mockAllowedPaths, + networkAccess: mockNetworkAccess, + }); + + buildArgsSpy = vi + .spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs') + .mockReturnValue([ + '-p', + '(mock profile)', + '-D', + 'WORKSPACE=/test/workspace', + ]); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should correctly invoke buildSeatbeltArgs with the configured options', async () => { + await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: {}, + }); + + expect(buildArgsSpy).toHaveBeenCalledWith({ + workspace: mockWorkspace, + allowedPaths: mockAllowedPaths, + networkAccess: mockNetworkAccess, + }); + }); + + it('should format the executable and arguments correctly for sandbox-exec', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: {}, + }); + + expect(result.program).toBe('/usr/bin/sandbox-exec'); + expect(result.args).toEqual([ + '-p', + '(mock profile)', + '-D', + 'WORKSPACE=/test/workspace', + '--', + 'echo', + 'hello', + ]); + }); + + it('should correctly pass through the cwd to the resulting command', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: '/test/different/cwd', + env: {}, + }); + + expect(result.cwd).toBe('/test/different/cwd'); + }); + + it('should apply environment sanitization via the default mechanisms', async () => { + const result = await manager.prepareCommand({ + command: 'echo', + args: ['hello'], + cwd: mockWorkspace, + env: { + SAFE_VAR: '1', + GITHUB_TOKEN: 'sensitive', + }, + }); + + expect(result.env['SAFE_VAR']).toBe('1'); + expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts new file mode 100644 index 0000000000..a212b310b2 --- /dev/null +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type SandboxManager, + type SandboxRequest, + type SandboxedCommand, +} from '../../services/sandboxManager.js'; +import { + sanitizeEnvironment, + getSecureSanitizationConfig, + type EnvironmentSanitizationConfig, +} from '../../services/environmentSanitization.js'; +import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; + +/** + * Options for configuring the MacOsSandboxManager. + */ +export interface MacOsSandboxOptions { + /** The primary workspace path to allow access to within the sandbox. */ + workspace: string; + /** Additional paths to allow access to within the sandbox. */ + allowedPaths?: string[]; + /** Whether network access is allowed. */ + networkAccess?: boolean; + /** Optional base sanitization config. */ + sanitizationConfig?: EnvironmentSanitizationConfig; +} + +/** + * A SandboxManager implementation for macOS that uses Seatbelt. + */ +export class MacOsSandboxManager implements SandboxManager { + constructor(private readonly options: MacOsSandboxOptions) {} + + async prepareCommand(req: SandboxRequest): Promise { + const sanitizationConfig = getSecureSanitizationConfig( + req.config?.sanitizationConfig, + this.options.sanitizationConfig, + ); + + const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + + const sandboxArgs = buildSeatbeltArgs({ + workspace: this.options.workspace, + allowedPaths: this.options.allowedPaths, + networkAccess: this.options.networkAccess, + }); + + return { + program: '/usr/bin/sandbox-exec', + args: [...sandboxArgs, '--', req.command, ...req.args], + env: sanitizedEnv, + cwd: req.cwd, + }; + } +} diff --git a/packages/core/src/sandbox/macos/baseProfile.ts b/packages/core/src/sandbox/macos/baseProfile.ts new file mode 100644 index 0000000000..b331b7c58e --- /dev/null +++ b/packages/core/src/sandbox/macos/baseProfile.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * The base macOS Seatbelt (SBPL) profile for tool execution. + * + * This uses a strict allowlist (deny default) but imports Apple's base system profile + * to handle undocumented internal dependencies, sysctls, and IPC mach ports required + * by standard tools to avoid "Abort trap: 6". + */ +export const BASE_SEATBELT_PROFILE = `(version 1) +(deny default) + +(import "system.sb") + +; Core execution requirements +(allow process-exec) +(allow process-fork) +(allow signal (target same-sandbox)) +(allow process-info* (target same-sandbox)) + +; Allow basic read access to system frameworks and libraries required to run +(allow file-read* + (subpath "/System") + (subpath "/usr/lib") + (subpath "/usr/share") + (subpath "/usr/bin") + (subpath "/bin") + (subpath "/sbin") + (subpath "/usr/local/bin") + (subpath "/opt/homebrew") + (subpath "/Library") + (subpath "/private/var/run") + (subpath "/private/var/db") + (subpath "/private/etc") +) + +; PTY and Terminal support +(allow pseudo-tty) +(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+")) + +; Allow read/write access to temporary directories and common device nodes +(allow file-read* file-write* + (literal "/dev/null") + (literal "/dev/zero") + (subpath "/tmp") + (subpath "/private/tmp") + (subpath (param "TMPDIR")) +) + +; Workspace access using parameterized paths +(allow file-read* file-write* + (subpath (param "WORKSPACE")) +) +`; + +/** + * The network-specific macOS Seatbelt (SBPL) profile rules. + * + * These rules are appended to the base profile when network access is enabled, + * allowing standard socket creation, DNS resolution, and TLS certificate validation. + */ +export const NETWORK_SEATBELT_PROFILE = ` +; Network Access +(allow network*) + +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) + +(allow mach-lookup + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.mDNSResponder") + (global-name "com.apple.mDNSResponderHelper") + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) + +(allow sysctl-read + (sysctl-name-regex #"^net.routetable") +) +`; diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts new file mode 100644 index 0000000000..340eaead60 --- /dev/null +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi } from 'vitest'; +import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; +import fs from 'node:fs'; +import os from 'node:os'; + +describe('seatbeltArgsBuilder', () => { + it('should build a strict allowlist profile allowing the workspace via param', () => { + // Mock realpathSync to just return the path for testing + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string); + + const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace' }); + + expect(args[0]).toBe('-p'); + const profile = args[1]; + expect(profile).toContain('(version 1)'); + expect(profile).toContain('(deny default)'); + expect(profile).toContain('(allow process-exec)'); + expect(profile).toContain('(subpath (param "WORKSPACE"))'); + expect(profile).not.toContain('(allow network*)'); + + expect(args).toContain('-D'); + expect(args).toContain('WORKSPACE=/Users/test/workspace'); + expect(args).toContain(`TMPDIR=${os.tmpdir()}`); + + vi.restoreAllMocks(); + }); + + it('should allow network when networkAccess is true', () => { + const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true }); + const profile = args[1]; + expect(profile).toContain('(allow network*)'); + }); + + it('should parameterize allowed paths and normalize them', () => { + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { + if (p === '/test/symlink') return '/test/real_path'; + return p as string; + }); + + const args = buildSeatbeltArgs({ + workspace: '/test', + allowedPaths: ['/custom/path1', '/test/symlink'], + }); + + const profile = args[1]; + expect(profile).toContain('(subpath (param "ALLOWED_PATH_0"))'); + expect(profile).toContain('(subpath (param "ALLOWED_PATH_1"))'); + + expect(args).toContain('-D'); + expect(args).toContain('ALLOWED_PATH_0=/custom/path1'); + expect(args).toContain('ALLOWED_PATH_1=/test/real_path'); + + vi.restoreAllMocks(); + }); + + it('should resolve parent directories if a file does not exist', () => { + vi.spyOn(fs, 'realpathSync').mockImplementation((p) => { + if (p === '/test/symlink/nonexistent.txt') { + const error = new Error('ENOENT'); + Object.assign(error, { code: 'ENOENT' }); + throw error; + } + if (p === '/test/symlink') { + return '/test/real_path'; + } + return p as string; + }); + + const args = buildSeatbeltArgs({ + workspace: '/test/symlink/nonexistent.txt', + }); + + expect(args).toContain('WORKSPACE=/test/real_path/nonexistent.txt'); + vi.restoreAllMocks(); + }); + + it('should throw if realpathSync throws a non-ENOENT error', () => { + vi.spyOn(fs, 'realpathSync').mockImplementation(() => { + const error = new Error('Permission denied'); + Object.assign(error, { code: 'EACCES' }); + throw error; + }); + + expect(() => + buildSeatbeltArgs({ + workspace: '/test/workspace', + }), + ).toThrow('Permission denied'); + + vi.restoreAllMocks(); + }); +}); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts new file mode 100644 index 0000000000..0e162f22dd --- /dev/null +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + BASE_SEATBELT_PROFILE, + NETWORK_SEATBELT_PROFILE, +} from './baseProfile.js'; + +/** + * Options for building macOS Seatbelt arguments. + */ +export interface SeatbeltArgsOptions { + /** The primary workspace path to allow access to. */ + workspace: string; + /** Additional paths to allow access to. */ + allowedPaths?: string[]; + /** Whether to allow network access. */ + networkAccess?: boolean; +} + +/** + * Resolves symlinks for a given path to prevent sandbox escapes. + * If a file does not exist (ENOENT), it recursively resolves the parent directory. + * Other errors (e.g. EACCES) are re-thrown. + */ +function tryRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch (e) { + if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { + const parentDir = path.dirname(p); + if (parentDir === p) { + return p; + } + return path.join(tryRealpath(parentDir), path.basename(p)); + } + throw e; + } +} + +/** + * Builds the arguments array for sandbox-exec using a strict allowlist profile. + * It relies on parameters passed to sandbox-exec via the -D flag to avoid + * string interpolation vulnerabilities, and normalizes paths against symlink escapes. + * + * Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '', '-D', ...]) + * Does not include the final '--' separator or the command to run. + */ +export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { + let profile = BASE_SEATBELT_PROFILE + '\n'; + const args: string[] = []; + + const workspacePath = tryRealpath(options.workspace); + args.push('-D', `WORKSPACE=${workspacePath}`); + + const tmpPath = tryRealpath(os.tmpdir()); + args.push('-D', `TMPDIR=${tmpPath}`); + + if (options.allowedPaths) { + for (let i = 0; i < options.allowedPaths.length; i++) { + const allowedPath = tryRealpath(options.allowedPaths[i]); + args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`); + profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`; + } + } + + if (options.networkAccess) { + profile += NETWORK_SEATBELT_PROFILE; + } + + args.unshift('-p', profile); + + return args; +} diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 44d52aa83c..1c351ce483 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -12,6 +12,7 @@ import { createSandboxManager, } from './sandboxManager.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; +import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; describe('NoopSandboxManager', () => { const sandboxManager = new NoopSandboxManager(); @@ -124,23 +125,20 @@ describe('createSandboxManager', () => { expect(manager).toBeInstanceOf(NoopSandboxManager); }); - it('should return LinuxSandboxManager if sandboxing is enabled and platform is linux', () => { - const osSpy = vi.spyOn(os, 'platform').mockReturnValue('linux'); - try { - const manager = createSandboxManager(true, '/workspace'); - expect(manager).toBeInstanceOf(LinuxSandboxManager); - } finally { - osSpy.mockRestore(); - } - }); - - it('should return LocalSandboxManager if sandboxing is enabled and platform is not linux', () => { - const osSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin'); - try { - const manager = createSandboxManager(true, '/workspace'); - expect(manager).toBeInstanceOf(LocalSandboxManager); - } finally { - osSpy.mockRestore(); - } - }); + it.each([ + { platform: 'linux', expected: LinuxSandboxManager }, + { platform: 'darwin', expected: MacOsSandboxManager }, + { platform: 'win32', expected: LocalSandboxManager }, + ] 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'); + expect(manager).toBeInstanceOf(expected); + } finally { + osSpy.mockRestore(); + } + }, + ); }); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index ff1f83dde5..b48f010cea 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -11,6 +11,7 @@ import { 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. @@ -98,6 +99,9 @@ export function createSandboxManager( if (os.platform() === 'linux') { return new LinuxSandboxManager({ workspace }); } + if (os.platform() === 'darwin') { + return new MacOsSandboxManager({ workspace }); + } return new LocalSandboxManager(); } return new NoopSandboxManager(); From fd44718bfe5a9bef2a0727611781d9af2ec20eda Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 18 Mar 2026 16:24:51 -0400 Subject: [PATCH 009/110] docs: add additional notes (#23008) --- docs/reference/policy-engine.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index e26c080a50..fb97b5e071 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -375,6 +375,8 @@ priority = 200 Specify only the `mcpName` to apply a rule to every tool provided by that server. +**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). + ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] From 94e6bf8591244fc29416fe52518c03c3ddbc0133 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Wed, 18 Mar 2026 16:27:38 -0400 Subject: [PATCH 010/110] fix(cli): resolve duplicate footer on tool cancel via ESC (#21743) (#21781) --- .../messages/ToolConfirmationMessage.test.tsx | 71 +++++++++++++++++-- .../messages/ToolConfirmationMessage.tsx | 19 ++++- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 92c8b5743c..24332e83c2 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -4,16 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; -import type { - SerializableConfirmationDetails, - ToolCallConfirmationDetails, - Config, +import { + type SerializableConfirmationDetails, + type ToolCallConfirmationDetails, + type Config, + ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; import { createMockSettings } from '../../../test-utils/settings.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; +import { act } from 'react'; vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => { const actual = @@ -646,4 +648,63 @@ describe('ToolConfirmationMessage', () => { expect(output).not.toContain('Invocation Arguments:'); unmount(); }); + + describe('ESCAPE key behavior', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should call confirm(Cancel) asynchronously via useEffect when ESC is pressed', async () => { + const mockConfirm = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useToolActions).mockReturnValue({ + confirm: mockConfirm, + cancel: vi.fn(), + isDiffingEnabled: false, + }); + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'https://example.com', + urls: ['https://example.com'], + }; + + const { stdin, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + stdin.write('\x1b'); + + // To assert that the confirmation happens asynchronously (via useEffect) rather than + // synchronously (directly inside the keystroke handler), we must run our assertion + // *inside* the act() block. + await act(async () => { + await vi.runAllTimersAsync(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + // Now that the act() block has returned, React flushes the useEffect, calling handleConfirm. + expect(mockConfirm).toHaveBeenCalledWith( + 'test-call-id', + ToolConfirmationOutcome.Cancel, + undefined, + ); + + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 2e9e133a35..45584a9d46 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useMemo, useCallback, useState } from 'react'; +import { useEffect, useMemo, useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; @@ -79,6 +79,7 @@ export const ToolConfirmationMessage: React.FC< callId, expanded: false, }); + const [isCancelling, setIsCancelling] = useState(false); const isMcpToolDetailsExpanded = mcpDetailsExpansionState.callId === callId ? mcpDetailsExpansionState.expanded @@ -183,7 +184,7 @@ export const ToolConfirmationMessage: React.FC< return true; } if (keyMatchers[Command.ESCAPE](key)) { - handleConfirm(ToolConfirmationOutcome.Cancel); + setIsCancelling(true); return true; } if (keyMatchers[Command.QUIT](key)) { @@ -196,6 +197,20 @@ export const ToolConfirmationMessage: React.FC< { isActive: isFocused, priority: true }, ); + // TODO(#23009): Remove this hack once we migrate to the new renderer. + // Why useEffect is used here instead of calling handleConfirm directly: + // There is a race condition where calling handleConfirm immediately upon + // keypress removes the tool UI component while the UI is in an expanded state. + // This simultaneously triggers setConstrainHeight, causing render two footers. + // By bridging the cancel action through state (isCancelling) and this useEffect, + // we delay handleConfirm until the next render cycle, ensuring setConstrainHeight + // resolves properly first. + useEffect(() => { + if (isCancelling) { + handleConfirm(ToolConfirmationOutcome.Cancel); + } + }, [isCancelling, handleConfirm]); + const handleSelect = useCallback( (item: ToolConfirmationOutcome) => handleConfirm(item), [handleConfirm], From d68100e6bcf8df4326a6e6f791a33b983e9d680a Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 18 Mar 2026 13:55:55 -0700 Subject: [PATCH 011/110] Changelog for v0.35.0-preview.1 (#23012) Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com> --- docs/changelogs/preview.md | 815 ++++++++++++++++--------------------- 1 file changed, 354 insertions(+), 461 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 370ee8010a..91d0c09a0b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.34.0-preview.4 +# Preview release: v0.35.0-preview.1 -Released: March 16, 2026 +Released: March 17, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,471 +13,364 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box, - providing a structured planning workflow and keeping approved plans during - chat compression. -- **Sandboxing Enhancements:** Added experimental LXC container sandbox support - and native gVisor (`runsc`) sandboxing for improved security and isolation. -- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization - for trackers, along with task tracker strategy improvements. -- **Browser Agent Improvements:** Enhanced the browser agent with progress - emission, a new automation overlay, and additional integration tests. -- **CLI and UI Updates:** Standardized semantic focus colors, polished shell - autocomplete rendering, unified keybinding infrastructure, and added custom - footer configuration options. +- **Subagents & Architecture Enhancements**: Enabled subagents and laid the + foundation for subagent tool isolation. Added proxy routing support for remote + A2A subagents and integrated `SandboxManager` to sandbox all process-spawning + tools. +- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and + support for literal character keybindings. Added missing vim mode motions and + CJK input support. Enabled code splitting and deferred UI loading for improved + performance. +- **Context & Tools Optimization**: JIT context loading is now enabled by + default with deduplication for project memory. Introduced a model-driven + parallel tool scheduler and allowed safe tools to execute concurrently. +- **Security & Extensions**: Implemented cryptographic integrity verification + for extension updates and added a `disableAlwaysAllow` setting to prevent + auto-approvals for enhanced security. +- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for + multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 + security and consistency improvements for the `web_fetch` tool. ## What's Changed -- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch - version v0.34.0-preview.3 and create version 0.34.0-preview.4 by +- 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 + [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) +- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by @gemini-cli-robot in - [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) -- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch - version v0.34.0-preview.2 and create version 0.34.0-preview.3 by - @gemini-cli-robot in - [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) -- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch - version v0.34.0-preview.1 and create version 0.34.0-preview.2 by - @gemini-cli-robot in - [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) -- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 - [CONFLICTS] by @gemini-cli-robot in - [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) -- feat(cli): add chat resume footer on session quit by @lordshashank in - [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) -- Support bold and other styles in svg snapshots by @jacob314 in - [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) -- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in - [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) -- Cleanup old branches. by @jacob314 in - [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) -- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by - @gemini-cli-robot in - [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) -- feat(ui): standardize semantic focus colors and enhance history visibility by - @keithguerin in - [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) -- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in - [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) -- Add extra safety checks for proto pollution by @jacob314 in - [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) -- feat(core): Add tracker CRUD tools & visualization by @anj-s in - [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) -- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" - by @jacob314 in - [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) -- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in - [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) -- fix: model persistence for all scenarios by @sripasg in - [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) -- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by - @gemini-cli-robot in - [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) -- Consistently guard restarts against concurrent auto updates by @scidomino in - [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) -- Defensive coding to reduce the risk of Maximum update depth errors by - @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) -- fix(cli): Polish shell autocomplete rendering to be a little more shell native - feeling. by @jacob314 in - [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) -- Docs: Update plan mode docs by @jkcinouye in - [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) -- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in - [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) -- fix(cli): register extension lifecycle events in DebugProfiler by - @fayerman-source in - [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) -- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in - [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) -- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in - [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) -- Changelog for v0.32.0 by @gemini-cli-robot in - [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) -- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in - [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) -- feat(core): improve @scripts/copy_files.js autocomplete to prioritize - filenames by @sehoon38 in - [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) -- feat(sandbox): add experimental LXC container sandbox support by @h30s in - [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) -- feat(evals): add overall pass rate row to eval nightly summary table by - @gundermanc in - [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) -- feat(telemetry): include language in telemetry and fix accepted lines - computation by @gundermanc in - [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) -- Changelog for v0.32.1 by @gemini-cli-robot in - [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) -- feat(core): add robustness tests, logging, and metrics for CodeAssistServer - SSE parsing by @yunaseoul in - [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) -- feat: add issue assignee workflow by @kartikangiras in - [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) -- fix: improve error message when OAuth succeeds but project ID is required by - @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) -- feat(loop-reduction): implement iterative loop detection and model feedback by - @aishaneeshah in - [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) -- chore(github): require prompt approvers for agent prompt files by @gundermanc - in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) -- Docs: Create tools reference by @jkcinouye in - [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) -- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions - by @spencer426 in - [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) -- chore(cli): enable deprecated settings removal by default by @yashodipmore in - [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) -- feat(core): Disable fast ack helper for hints. by @joshualitt in - [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) -- fix(ui): suppress redundant failure note when tool error note is shown by - @NTaylorMullen in - [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) -- docs: document planning workflows with Conductor example by @jerop in - [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) -- feat(release): ship esbuild bundle in npm package by @genneth in - [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) -- fix(extensions): preserve symlinks in extension source path while enforcing - folder trust by @galz10 in - [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) -- fix(cli): defer tool exclusions to policy engine in non-interactive mode by - @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) -- fix(ui): removed double padding on rendered content by @devr0306 in - [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) -- fix(core): truncate excessively long lines in grep search output by - @gundermanc in - [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) -- feat: add custom footer configuration via `/footer` by @jackwotherspoon in - [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) -- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in - [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) -- refactor(cli): categorize built-in themes into dark/ and light/ directories by - @JayadityaGit in - [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) -- fix(core): explicitly allow codebase_investigator and cli_help in read-only - mode by @Adib234 in - [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) -- test: add browser agent integration tests by @kunal-10-cloud in - [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) -- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in - [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) -- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by - @SandyTao520 in - [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) -- fix(ui): add partial output to cancelled shell UI by @devr0306 in - [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) -- fix(cli): replace hardcoded keybinding strings with dynamic formatters by - @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) -- DOCS: Update quota and pricing page by @g-samroberts in - [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) -- feat(telemetry): implement Clearcut logging for startup statistics by - @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) -- feat(triage): add area/documentation to issue triage by @g-samroberts in - [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) -- Fix so shell calls are formatted by @jacob314 in - [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) -- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in - [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) -- docs: use absolute paths for internal links in plan-mode.md by @jerop in - [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) -- fix(core): prevent unhandled AbortError crash during stream loop detection by - @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) -- fix:reorder env var redaction checks to scan values first by @kartikangiras in - [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) -- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences - by @skeshive in - [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) -- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 - in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) -- test(core): improve testing for API request/response parsing by @sehoon38 in - [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) -- docs(links): update docs-writer skill and fix broken link by @g-samroberts in - [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) -- Fix code colorizer ansi escape bug. by @jacob314 in - [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) -- remove wildcard behavior on keybindings by @scidomino in - [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) -- feat(acp): Add support for AI Gateway auth by @skeshive in - [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) -- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in - [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) -- feat (core): Implement tracker related SI changes by @anj-s in - [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) -- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in - [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) -- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in - [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) -- docs: format release times as HH:MM UTC by @pavan-sh in - [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) -- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in - [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) -- docs: fix incorrect relative links to command reference by @kanywst in - [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) -- documentiong ensures ripgrep by @Jatin24062005 in - [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) -- fix(core): handle AbortError thrown during processTurn by @MumuTW in - [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) -- docs(cli): clarify ! command output visibility in shell commands tutorial by - @MohammedADev in - [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) -- fix: logic for task tracker strategy and remove tracker tools by @anj-s in - [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) -- fix(partUtils): display media type and size for inline data parts by @Aboudjem - in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) -- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in - [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) -- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by - @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) -- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive - filesystems (#19904) by @Nixxx19 in - [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) -- feat(core): add concurrency safety guidance for subagent delegation (#17753) - by @abhipatel12 in - [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) -- feat(ui): dynamically generate all keybinding hints by @scidomino in - [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) -- feat(core): implement unified KeychainService and migrate token storage by - @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) -- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in - [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) -- fix(plan): keep approved plan during chat compression by @ruomengz in - [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) -- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 - in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) -- Update quota and pricing documentation with subscription tiers by @srithreepo - in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) -- fix(core): append correct OTLP paths for HTTP exporters by - @sebastien-prudhomme in - [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) -- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in - [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) -- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in - [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) -- refactor(core): standardize MCP tool naming to mcp\_ FQN format by - @abhipatel12 in - [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) -- feat(cli): hide gemma settings from display and mark as experimental by - @abhipatel12 in - [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) -- feat(skills): refine string-reviewer guidelines and description by @clocky in - [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) -- fix(core): whitelist TERM and COLORTERM in environment sanitization by - @deadsmash07 in - [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) -- fix(billing): fix overage strategy lifecycle and settings integration by - @gsquared94 in - [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) -- fix: expand paste placeholders in TextInput on submit by @Jefftree in - [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) -- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by - @SandyTao520 in - [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) -- feat(cli): overhaul thinking UI by @keithguerin in - [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) -- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by - @jwhelangoog in - [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) -- fix(cli): correct shell height reporting by @jacob314 in - [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) -- Make test suite pass when the GEMINI_SYSTEM_MD env variable or - GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in - [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) -- Disallow underspecified types by @gundermanc in - [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) -- refactor(cli): standardize on 'reload' verb for all components by @keithguerin - in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) -- feat(cli): Invert quota language to 'percent used' by @keithguerin in - [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) -- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye - in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) -- Code review comments as a pr by @jacob314 in - [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) -- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in - [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) -- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) +- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in + [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) +- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) + by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) +- Feat/retry fetch notifications by @aishaneeshah in + [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) +- fix(core): remove OAuth check from handleFallback and clean up stray file by + @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) +- feat(cli): support literal character keybindings and extended Kitty protocol + keys by @scidomino in + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) +- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in + [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) +- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in + [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) +- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by + @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) +- docs(cli): add custom keybinding documentation by @scidomino in + [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) +- docs: fix misleading YOLO mode description in defaultApprovalMode by @Gyanranjan-Priyam in - [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) -- fix(core): display actual graph output in tracker_visualize tool by @anj-s in - [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) -- fix(core): sanitize SSE-corrupted JSON and domain strings in error - classification by @gsquared94 in - [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) -- Docs: Make documentation links relative by @diodesign in - [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) -- feat(cli): expose /tools desc as explicit subcommand for discoverability by - @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) -- feat(cli): add /compact alias for /compress command by @jackwotherspoon in - [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) -- feat(plan): enable Plan Mode by default by @jerop in - [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) -- feat(core): Introduce `AgentLoopContext`. by @joshualitt in - [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) -- fix(core): resolve symlinks for non-existent paths during validation by - @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) -- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in - [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) -- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 - in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) -- feat(cli): implement /upgrade command by @sehoon38 in - [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) -- Feat/browser agent progress emission by @kunal-10-cloud in - [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) -- fix(settings): display objects as JSON instead of [object Object] by - @Zheyuan-Lin in - [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) -- Unmarshall update by @DavidAPierce in - [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) -- Update mcp's list function to check for disablement. by @DavidAPierce in - [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) -- robustness(core): static checks to validate history is immutable by @jacob314 - in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) -- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in - [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) -- feat(security): implement robust IP validation and safeFetch foundation by - @alisa-alisa in - [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) -- feat(core): improve subagent result display by @joshualitt in - [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) -- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in - [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) -- feat(policy): support subagent-specific policies in TOML by @akh64bit in - [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) -- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in - [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) -- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in - [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) -- fix(docs): fix headless mode docs by @ame2en in - [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) -- feat/redesign header compact by @jacob314 in - [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) -- refactor: migrate to useKeyMatchers hook by @scidomino in - [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) -- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by - @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) -- fix(core): resolve Windows line ending and path separation bugs across CLI by - @muhammadusman586 in - [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) -- docs: fix heading formatting in commands.md and phrasing in tools-api.md by - @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) -- refactor(ui): unify keybinding infrastructure and support string - initialization by @scidomino in - [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) -- Add support for updating extension sources and names by @chrstnb in - [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) -- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed - in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) -- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy - in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) -- fix(docs): update theme screenshots and add missing themes by @ashmod in - [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) -- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in - [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) -- build(release): restrict npm bundling to non-stable tags by @sehoon38 in - [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) -- fix(core): override toolRegistry property for sub-agent schedulers by - @gsquared94 in - [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) -- fix(cli): make footer items equally spaced by @jacob314 in - [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) -- docs: clarify global policy rules application in plan mode by @jerop in - [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) -- fix(core): ensure correct flash model steering in plan mode implementation - phase by @jerop in - [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) -- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in - [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) -- refactor(core): improve API response error logging when retry by @yunaseoul in - [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) -- fix(ui): handle headless execution in credits and upgrade dialogs by - @gsquared94 in - [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) -- fix(core): treat retryable errors with >5 min delay as terminal quota errors - by @gsquared94 in - [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) -- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub - Actions by @cocosheng-g in - [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) -- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by - @SandyTao520 in - [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) -- feat(cli): give visibility to /tools list command in the TUI and follow the - subcommand pattern of other commands by @JayadityaGit in - [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) -- Handle dirty worktrees better and warn about running scripts/review.sh on - untrusted code. by @jacob314 in - [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) -- feat(policy): support auto-add to policy by default and scoped persistence by + [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) +- fix: clean up /clear and /resume by @jackwotherspoon in + [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) +- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax + in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) +- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul + in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) +- feat(cli): support removing keybindings via '-' prefix by @scidomino in + [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) +- feat(policy): add --admin-policy flag for supplemental admin policies by + @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) +- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in + [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) +- perf(core): parallelize user quota and experiments fetching in refreshAuth by + @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) +- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in + [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) +- Changelog for v0.33.0 by @gemini-cli-robot in + [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) +- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in + [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) +- feat(core): include initiationMethod in conversation interaction telemetry by + @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) +- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) +- fix(core): enable numerical routing for api key users by @sehoon38 in + [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) +- feat(telemetry): implement retry attempt telemetry for network related retries + by @aishaneeshah in + [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) +- fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in - [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) -- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 - in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) -- fix(release): Improve Patch Release Workflow Comments: Clearer Approval - Guidance by @jerop in - [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) -- docs: clarify telemetry setup and comprehensive data map by @jerop in - [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) -- feat(core): add per-model token usage to stream-json output by @yongruilin in - [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) -- docs: remove experimental badge from plan mode in sidebar by @jerop in - [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) -- fix(cli): prevent race condition in loop detection retry by @skyvanguard in - [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) -- Add behavioral evals for tracker by @anj-s in - [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) -- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in - [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) -- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in - [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) -- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in - [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) -- make command names consistent by @scidomino in - [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) -- refactor: remove agent_card_requires_auth config flag by @adamfweidman in - [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) -- feat(a2a): implement standardized normalization and streaming reassembly by - @alisa-alisa in - [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) -- feat(cli): enable skill activation via slash commands by @NTaylorMullen in - [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) -- docs(cli): mention per-model token usage in stream-json result event by - @yongruilin in - [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) -- fix(plan): prevent plan truncation in approval dialog by supporting - unconstrained heights by @Adib234 in - [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) -- feat(a2a): switch from callback-based to event-driven tool scheduler by - @cocosheng-g in - [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) -- feat(voice): implement speech-friendly response formatter by @Solventerritory - in [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) -- feat: add pulsating blue border automation overlay to browser agent by - @kunal-10-cloud in - [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) -- Add extensionRegistryURI setting to change where the registry is read from by - @kevinjwang1 in - [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) -- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in - [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) -- fix: prevent hangs in non-interactive mode and improve agent guidance by - @cocosheng-g in - [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) -- Add ExtensionDetails dialog and support install by @chrstnb in - [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) -- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by - @gemini-cli-robot in - [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) -- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in - [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) -- fix(cli): stabilize prompt layout to prevent jumping when typing by + [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) +- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 + in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) +- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in + [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) +- feat(core): support custom base URL via env vars by @junaiddshaukat in + [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) +- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in + [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) +- fix(core): silently retry API errors up to 3 times before halting session by + @spencer426 in + [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) +- feat(core): simplify subagent success UI and improve early termination display + by @abhipatel12 in + [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) +- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in + [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) +- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 + in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) +- feat(core): implement SandboxManager interface and config schema by @galz10 in + [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) +- docs: document npm deprecation warnings as safe to ignore by @h30s in + [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) +- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in + [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) +- fix(core): propagate subagent context to policy engine by @NTaylorMullen in + [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) +- fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in - [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) -- fix: preserve prompt text when cancelling streaming by @Nixxx19 in - [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) -- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in - [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) -- feat: implement background process logging and cleanup by @galz10 in - [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) -- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in - [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) + [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) +- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in + [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) +- fix(policy): ensure user policies are loaded when policyPaths is empty by + @NTaylorMullen in + [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) +- Docs: Add documentation for model steering (experimental). by @jkcinouye in + [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) +- Add issue for automated changelogs by @g-samroberts in + [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) +- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + @spencer426 in + [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) +- feat(core): differentiate User-Agent for a2a-server and ACP clients by + @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) +- refactor(core): extract ExecutionLifecycleService for tool backgrounding by + @adamfweidman in + [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) +- feat: Display pending and confirming tool calls by @sripasg in + [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) +- feat(browser): implement input blocker overlay during automation by + @kunal-10-cloud in + [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) +- fix: register themes on extension load not start by @jackwotherspoon in + [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) +- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in + [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) +- chore: remove unnecessary log for themes by @jackwotherspoon in + [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) +- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in + subagents by @abhipatel12 in + [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) +- fix(cli): validate --model argument at startup by @JaisalJain in + [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) +- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in + [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) +- feat(telemetry): add Clearcut instrumentation for AI credits billing events by + @gsquared94 in + [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) +- feat(core): add google credentials provider for remote agents by @adamfweidman + in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) +- test(cli): add integration test for node deprecation warnings by @Nixxx19 in + [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) +- feat(cli): allow safe tools to execute concurrently while agent is busy by + @spencer426 in + [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) +- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in + [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) +- update vulnerable deps by @scidomino in + [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) +- fix(core): fix startup stats to use int values for timestamps and durations by + @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) +- fix(core): prevent duplicate tool schemas for instantiated tools by + @abhipatel12 in + [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) +- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman + in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) +- fix(core/ide): add Antigravity CLI fallbacks by @apfine in + [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) +- fix(browser): fix duplicate function declaration error in browser agent by + @gsquared94 in + [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) +- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah + in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) +- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in + [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) +- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in + [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) +- fix: remove unused img.png from project root by @SandyTao520 in + [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) +- docs(local model routing): add docs on how to use Gemma for local model + routing by @douglas-reid in + [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) +- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in + [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) +- fix(cli): escape @ symbols on paste to prevent unintended file expansion by + @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) +- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in + [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) +- docs: clarify that tools.core is an allowlist for ALL built-in tools by + @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) +- docs(plan): document hooks with plan mode by @ruomengz in + [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) +- Changelog for v0.33.1 by @gemini-cli-robot in + [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) +- build(ci): fix false positive evals trigger on merge commits by @gundermanc in + [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) +- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by + @abhipatel12 in + [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) +- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in + [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) +- feat(core): increase sub-agent turn and time limits by @bdmorgan in + [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) +- feat(core): instrument file system tools for JIT context discovery by + @SandyTao520 in + [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) +- refactor(ui): extract pure session browser utilities by @abhipatel12 in + [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) +- fix(plan): Fix AskUser evals by @Adib234 in + [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) +- fix(settings): prevent j/k navigation keys from intercepting edit buffer input + by @student-ankitpandit in + [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) +- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in + [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) +- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in + [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) +- fix(core): show descriptive error messages when saving settings fails by + @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) +- docs(core): add authentication guide for remote subagents by @adamfweidman in + [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) +- docs: overhaul subagents documentation and add /agents command by @abhipatel12 + in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) +- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in + [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) +- test: add Object.create context regression test and tool confirmation + integration test by @gsquared94 in + [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) +- feat(tracker): return TodoList display for tracker tools by @anj-s in + [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) +- feat(agent): add allowed domain restrictions for browser agent by + @cynthialong0-0 in + [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) +- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + @gemini-cli-robot in + [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) +- Move keychain fallback to keychain service by @chrstnb in + [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) +- feat(core): integrate SandboxManager to sandbox all process-spawning tools by + @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) +- fix(cli): support CJK input and full Unicode scalar values in terminal + protocols by @scidomino in + [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) +- Promote stable tests. by @gundermanc in + [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) +- feat(tracker): add tracker policy by @anj-s in + [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) +- feat(security): add disableAlwaysAllow setting to disable auto-approvals by + @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) +- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in + [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) +- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 + in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) +- fix(core): use session-specific temp directory for task tracker by @anj-s in + [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) +- Fix issue where config was undefined. by @gundermanc in + [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) +- fix(core): deduplicate project memory when JIT context is enabled by + @SandyTao520 in + [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) +- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by + @Abhijit-2592 in + [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) +- fix(core): fix manual deletion of subagent histories by @abhipatel12 in + [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) +- Add registry var by @kevinjwang1 in + [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) +- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in + [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) +- fix(cli): improve command conflict handling for skills by @NTaylorMullen in + [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) +- fix(core): merge user settings with extension-provided MCP servers by + @abhipatel12 in + [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) +- fix(core): skip discovery for incomplete MCP configs and resolve merge race + condition by @abhipatel12 in + [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) +- fix(automation): harden stale PR closer permissions and maintainer detection + by @bdmorgan in + [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) +- fix(automation): evaluate staleness before checking protected labels by + @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) +- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with + pre-built bundle by @cynthialong0-0 in + [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) +- perf: optimize TrackerService dependency checks by @anj-s in + [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) +- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 + in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) +- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in + [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) +- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled + tool calls. by @sripasg in + [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) +- Disallow Object.create() and reflect. by @gundermanc in + [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) +- Guard pro model usage by @sehoon38 in + [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) +- refactor(core): Creates AgentSession abstraction for consolidated agent + interface. by @mbleigh in + [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) +- docs(changelog): remove internal commands from release notes by + @jackwotherspoon in + [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) +- feat: enable subagents by @abhipatel12 in + [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) +- feat(extensions): implement cryptographic integrity verification for extension + updates by @ehedlund in + [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) +- feat(tracker): polish UI sorting and formatting by @anj-s in + [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) +- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in + [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) +- fix(core): fix three JIT context bugs in read_file, read_many_files, and + memoryDiscovery by @SandyTao520 in + [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) +- refactor(core): introduce InjectionService with source-aware injection and + backend-native background completions by @adamfweidman in + [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) +- Linux sandbox bubblewrap by @DavidAPierce in + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) +- feat(core): increase thought signature retry resilience by @bdmorgan in + [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) +- feat(core): implement Stage 2 security and consistency improvements for + web_fetch by @aishaneeshah in + [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) +- refactor(core): replace positional execute params with ExecuteOptions bag by + @adamfweidman in + [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) +- feat(config): enable JIT context loading by default by @SandyTao520 in + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) +- fix(config): ensure discoveryMaxDirs is passed to global config during + initialization by @kevin-ramdass in + [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) +- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in + [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) +- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in + [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) +- feat(core): add foundation for subagent tool isolation by @akh64bit in + [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) +- fix(core): handle surrogate pairs in truncateString by @sehoon38 in + [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) +- fix(cli): override j/k navigation in settings dialog to fix search input + conflict by @sehoon38 in + [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) +- feat(plan): add 'All the above' option to multi-select AskUser questions by + @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) +- docs: distribute package-specific GEMINI.md context to each package by + @SandyTao520 in + [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) +- fix(cli): clean up stale pasted placeholder metadata after word/line deletions + by @Jomak-x in + [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) +- refactor(core): align JIT memory placement with tiered context model by + @SandyTao520 in + [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) +- Linux sandbox seccomp by @DavidAPierce in + [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.1 From 34f271504a20d47c6dc7309d5e16b74da64dee83 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 18 Mar 2026 17:28:21 -0400 Subject: [PATCH 012/110] fix(ui): fix flickering on small terminal heights (#21416) Co-authored-by: Jacob Richman --- packages/cli/src/ui/components/AnsiOutput.tsx | 6 +- .../cli/src/ui/components/MainContent.tsx | 5 +- .../__snapshots__/MainContent.test.tsx.snap | 10 +- .../messages/ShellToolMessage.test.tsx | 2 +- .../components/shared/SlicingMaxSizedBox.tsx | 4 +- .../cli/src/ui/utils/toolLayoutUtils.test.ts | 208 ++++++++++++++++++ packages/cli/src/ui/utils/toolLayoutUtils.ts | 18 +- 7 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/ui/utils/toolLayoutUtils.test.ts diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index cc17b6b6b0..a1b30b0856 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); + const lastLines = disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index d7e04bd351..0530e171b8 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -48,6 +48,7 @@ export const MainContent = () => { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, + availableTerminalHeight, cleanUiDetailsVisible, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; @@ -141,7 +142,7 @@ export const MainContent = () => { { [ pendingHistoryItems, uiState.constrainHeight, - staticAreaMaxItemHeight, + availableTerminalHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index c0043bf6f9..785dc6b6f0 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -6,11 +6,12 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ +│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ -│ Line 14 │ +│ Line 14 █ │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -27,11 +28,12 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ +│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ -│ Line 14 │ +│ Line 14 █ │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -47,7 +49,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ -│ ... first 11 lines hidden (Ctrl+O to show) ... │ +│ ... first 9 lines hidden (Ctrl+O to show) ... │ +│ Line 10 │ +│ Line 11 │ │ Line 12 │ │ Line 13 │ │ Line 14 │ diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 2aa285003f..7ee726a609 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -199,7 +199,7 @@ describe('', () => { [ 'uses full availableTerminalHeight when focused in alternate buffer mode', 100, - 98, // 100 - 2 + 98, true, false, ], diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx index b756c40ee2..f8f851aed3 100644 --- a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx @@ -46,7 +46,7 @@ export function SlicingMaxSizedBox({ text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); } } - if (maxLines) { + if (maxLines !== undefined) { const hasTrailingNewline = text.endsWith('\n'); const contentText = hasTrailingNewline ? text.slice(0, -1) : text; const lines = contentText.split('\n'); @@ -71,7 +71,7 @@ export function SlicingMaxSizedBox({ }; } - if (Array.isArray(data) && !isAlternateBuffer && maxLines) { + if (Array.isArray(data) && !isAlternateBuffer && maxLines !== undefined) { if (data.length > maxLines) { // We will have a label from MaxSizedBox. Reserve space for it. const targetLines = Math.max(1, maxLines - 1); diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.test.ts b/packages/cli/src/ui/utils/toolLayoutUtils.test.ts new file mode 100644 index 0000000000..57e1e3f190 --- /dev/null +++ b/packages/cli/src/ui/utils/toolLayoutUtils.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + calculateToolContentMaxLines, + calculateShellMaxLines, + SHELL_CONTENT_OVERHEAD, +} from './toolLayoutUtils.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + ACTIVE_SHELL_MAX_LINES, + COMPLETED_SHELL_MAX_LINES, +} from '../constants.js'; + +describe('toolLayoutUtils', () => { + describe('calculateToolContentMaxLines', () => { + interface CalculateToolContentMaxLinesTestCase { + desc: string; + options: Parameters[0]; + expected: number | undefined; + } + + const testCases: CalculateToolContentMaxLinesTestCase[] = [ + { + desc: 'returns undefined if availableTerminalHeight is undefined', + options: { + availableTerminalHeight: undefined, + isAlternateBuffer: false, + }, + expected: undefined, + }, + { + desc: 'returns maxLinesLimit if maxLinesLimit applies but availableTerminalHeight is undefined', + options: { + availableTerminalHeight: undefined, + isAlternateBuffer: false, + maxLinesLimit: 10, + }, + expected: 10, + }, + { + desc: 'returns available space directly in constrained terminal (Standard mode)', + options: { + availableTerminalHeight: 2, + isAlternateBuffer: false, + }, + expected: 3, + }, + { + desc: 'returns available space directly in constrained terminal (ASB mode)', + options: { + availableTerminalHeight: 4, + isAlternateBuffer: true, + }, + expected: 3, + }, + { + desc: 'returns remaining space if sufficient space exists (Standard mode)', + options: { + availableTerminalHeight: 20, + isAlternateBuffer: false, + }, + expected: 17, + }, + { + desc: 'returns remaining space if sufficient space exists (ASB mode)', + options: { + availableTerminalHeight: 20, + isAlternateBuffer: true, + }, + expected: 13, + }, + ]; + + it.each(testCases)('$desc', ({ options, expected }) => { + const result = calculateToolContentMaxLines(options); + expect(result).toBe(expected); + }); + }); + + describe('calculateShellMaxLines', () => { + interface CalculateShellMaxLinesTestCase { + desc: string; + options: Parameters[0]; + expected: number | undefined; + } + + const testCases: CalculateShellMaxLinesTestCase[] = [ + { + desc: 'returns undefined when not constrained and is expandable', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 20, + constrainHeight: false, + isExpandable: true, + }, + expected: undefined, + }, + { + desc: 'returns ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for ASB mode when availableTerminalHeight is undefined', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: false, + availableTerminalHeight: undefined, + constrainHeight: true, + isExpandable: false, + }, + expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + { + desc: 'returns undefined for Standard mode when availableTerminalHeight is undefined', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: undefined, + constrainHeight: true, + isExpandable: false, + }, + expected: undefined, + }, + { + desc: 'handles small availableTerminalHeight gracefully without overflow in Standard mode', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 2, + constrainHeight: true, + isExpandable: false, + }, + expected: 1, + }, + { + desc: 'handles small availableTerminalHeight gracefully without overflow in ASB mode', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: false, + availableTerminalHeight: 6, + constrainHeight: true, + isExpandable: false, + }, + expected: 4, + }, + { + desc: 'handles negative availableTerminalHeight gracefully', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: -5, + constrainHeight: true, + isExpandable: false, + }, + expected: 1, + }, + { + desc: 'returns maxLinesBasedOnHeight for focused ASB shells', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: true, + availableTerminalHeight: 30, + constrainHeight: false, + isExpandable: false, + }, + expected: 28, + }, + { + desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows', + options: { + status: CoreToolCallStatus.Success, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 100, + constrainHeight: true, + isExpandable: false, + }, + expected: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + { + desc: 'falls back to ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for executing shells if space allows', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 100, + constrainHeight: true, + isExpandable: false, + }, + expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + ]; + + it.each(testCases)('$desc', ({ options, expected }) => { + const result = calculateShellMaxLines(options); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index c91919cffa..9f391dca4e 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -46,12 +46,13 @@ export function calculateToolContentMaxLines(options: { ? TOOL_RESULT_ASB_RESERVED_LINE_COUNT : TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT; - let contentHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, - TOOL_RESULT_MIN_LINES_SHOWN + 1, - ) - : undefined; + let contentHeight = + availableTerminalHeight !== undefined + ? Math.max( + availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, + TOOL_RESULT_MIN_LINES_SHOWN + 1, + ) + : undefined; if (maxLinesLimit !== undefined) { contentHeight = @@ -100,7 +101,10 @@ export function calculateShellMaxLines(options: { : undefined; } - const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); + const maxLinesBasedOnHeight = Math.max( + 1, + availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, + ); // 3. Handle ASB mode focus expansion. // We allow a focused shell in ASB mode to take up the full available height, From c9d48026c418ddc5a6fe177a5f2e4d9169ef7ada Mon Sep 17 00:00:00 2001 From: Valery Teplyakov <31941254+Mervap@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:02:07 +0100 Subject: [PATCH 013/110] fix(acp): provide more meta in tool_call_update (#22663) Co-authored-by: Mervap Co-authored-by: Sri Pasumarthi --- packages/cli/src/acp/acpClient.test.ts | 75 ++++++++++++++++++++++++++ packages/cli/src/acp/acpClient.ts | 8 +++ 2 files changed, 83 insertions(+) diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 65b23247ef..abad9d374d 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -894,6 +894,9 @@ describe('Session', () => { update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', + title: 'Test Tool', + locations: [], + kind: 'read', }), }), ); @@ -1306,6 +1309,18 @@ describe('Session', () => { expect(path.resolve).toHaveBeenCalled(); expect(fs.stat).toHaveBeenCalled(); + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'completed', + title: 'Read files', + locations: [], + kind: 'read', + }), + }), + ); + // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream expect(mockChat.sendMessageStream).toHaveBeenCalledWith( @@ -1321,6 +1336,65 @@ describe('Session', () => { ); }); + it('should handle @path resolution error', async () => { + (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt'); + (fs.stat as unknown as Mock).mockResolvedValue({ + isDirectory: () => false, + }); + (isWithinRoot as unknown as Mock).mockReturnValue(true); + + const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; + MockReadManyFilesTool.mockImplementationOnce(() => ({ + name: 'read_many_files', + kind: 'read', + build: vi.fn().mockReturnValue({ + getDescription: () => 'Read files', + toolLocations: () => [], + execute: vi.fn().mockRejectedValue(new Error('File read failed')), + }), + })); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await expect( + session.prompt({ + sessionId: 'session-1', + prompt: [ + { type: 'text', text: 'Read' }, + { + type: 'resource_link', + uri: 'file://error.txt', + mimeType: 'text/plain', + name: 'error.txt', + }, + ], + }), + ).rejects.toThrow('File read failed'); + + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'tool_call_update', + status: 'failed', + content: expect.arrayContaining([ + expect.objectContaining({ + content: expect.objectContaining({ + text: expect.stringMatching(/File read failed/), + }), + }), + ]), + kind: 'read', + }), + }), + ); + }); + it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ @@ -1434,6 +1508,7 @@ describe('Session', () => { content: expect.objectContaining({ text: 'Tool failed' }), }), ]), + kind: 'read', }), }), ); diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 072d91c20a..44c0373515 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -966,7 +966,10 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(tool.kind), }); const durationMs = Date.now() - startTime; @@ -1030,6 +1033,7 @@ export class Session { content: [ { type: 'content', content: { type: 'text', text: error.message } }, ], + kind: toAcpToolKind(tool.kind), }); this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ @@ -1324,7 +1328,10 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', + title: invocation.getDescription(), content: content ? [content] : [], + locations: invocation.toolLocations(), + kind: toAcpToolKind(readManyFilesTool.kind), }); if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; @@ -1368,6 +1375,7 @@ export class Session { }, }, ], + kind: toAcpToolKind(readManyFilesTool.kind), }); throw error; From e6cd5d208c71554c55fe3c6f2c3f300481e778d9 Mon Sep 17 00:00:00 2001 From: Suraj Sahani Date: Thu, 19 Mar 2026 05:55:33 +0530 Subject: [PATCH 014/110] docs: add FAQ entry for checking Gemini CLI version (#21271) --- docs/resources/faq.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/resources/faq.md b/docs/resources/faq.md index 580d7875f3..8d1b42d032 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -58,6 +58,19 @@ your total token usage using the `/stats` command in Gemini CLI. ## Installation and updates +### How do I check which version of Gemini CLI I'm currently running? + +You can check your current Gemini CLI version using one of these methods: + +- Run `gemini --version` or `gemini -v` from your terminal +- Check the globally installed version using your package manager: + - npm: `npm list -g @google/gemini-cli` + - pnpm: `pnpm list -g @google/gemini-cli` + - yarn: `yarn global list @google/gemini-cli` + - bun: `bun pm ls -g @google/gemini-cli` + - homebrew: `brew list --versions gemini-cli` +- Inside an active Gemini CLI session, use the `/about` command + ### How do I update Gemini CLI to the latest version? If you installed it globally via `npm`, update it using the command From 5fa14dbe4283a9cb6b9c49825db9b6395065ca7c Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:09:37 -0400 Subject: [PATCH 015/110] feat(core): resilient subagent tool rejection with contextual feedback (#22951) --- .../core/src/agents/local-executor.test.ts | 184 +++++++++++++++++- packages/core/src/agents/local-executor.ts | 72 +++++-- .../core/src/agents/local-invocation.test.ts | 44 +++++ packages/core/src/agents/local-invocation.ts | 34 +++- packages/core/src/agents/types.ts | 12 ++ 5 files changed, 323 insertions(+), 23 deletions(-) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index 8fc189e961..e0a21e01e3 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -91,9 +91,18 @@ import { type LocalAgentDefinition, type SubagentActivityEvent, type OutputConfig, + SubagentActivityErrorType, } from './types.js'; -import type { AnyDeclarativeTool, AnyToolInvocation } from '../tools/tools.js'; -import type { ToolCallRequestInfo } from '../scheduler/types.js'; +import { + ToolConfirmationOutcome, + type AnyDeclarativeTool, + type AnyToolInvocation, +} from '../tools/tools.js'; +import { + type ToolCallRequestInfo, + CoreToolCallStatus, +} from '../scheduler/types.js'; + import { CompressionStatus } from '../core/turn.js'; import { ChatCompressionService } from '../services/chatCompressionService.js'; import type { @@ -1013,6 +1022,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'protocol_violation', error: expectedError, + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1058,6 +1068,7 @@ describe('LocalAgentExecutor', () => { context: 'tool_call', name: TASK_COMPLETE_TOOL_NAME, error: expectedError, + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1161,7 +1172,7 @@ describe('LocalAgentExecutor', () => { if (callsStarted === 2) resolveCalls(); await vi.advanceTimersByTimeAsync(100); return { - status: 'success', + status: CoreToolCallStatus.Success, request: reqInfo, tool: {} as AnyDeclarativeTool, invocation: {} as AnyToolInvocation, @@ -1179,7 +1190,7 @@ describe('LocalAgentExecutor', () => { ], error: undefined, errorType: undefined, - contentLength: undefined, + contentLength: 0, }, }; }), @@ -1217,10 +1228,10 @@ describe('LocalAgentExecutor', () => { expect(parts).toEqual( expect.arrayContaining([ expect.objectContaining({ - functionResponse: expect.objectContaining({ id: 'c1' }), + functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }), }), expect.objectContaining({ - functionResponse: expect.objectContaining({ id: 'c2' }), + functionResponse: expect.objectContaining({ name: LS_TOOL_NAME }), }), ]), ); @@ -1291,6 +1302,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'tool_call_unauthorized', name: READ_FILE_TOOL_NAME, + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1344,6 +1356,7 @@ describe('LocalAgentExecutor', () => { context: 'tool_call', name: TASK_COMPLETE_TOOL_NAME, error: expect.stringContaining('Output validation failed'), + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1390,6 +1403,7 @@ describe('LocalAgentExecutor', () => { type: 'ERROR', data: expect.objectContaining({ error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`, + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1418,7 +1432,7 @@ describe('LocalAgentExecutor', () => { ]); mockScheduleAgentTools.mockResolvedValueOnce([ { - status: 'error', + status: CoreToolCallStatus.Error, request: { callId: 'call1', name: LS_TOOL_NAME, @@ -1469,6 +1483,7 @@ describe('LocalAgentExecutor', () => { context: 'tool_call', name: LS_TOOL_NAME, error: toolErrorMessage, + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1491,6 +1506,157 @@ describe('LocalAgentExecutor', () => { expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL); expect(output.result).toBe('Aborted due to tool failure.'); }); + + it('should handle a soft tool rejection (outcome: Cancel) and provide direct instructions to the model', async () => { + const definition = createTestDefinition([LS_TOOL_NAME]); + const executor = await LocalAgentExecutor.create( + definition, + mockConfig, + onActivity, + ); + + // Turn 1: Model calls a tool that will be rejected + mockModelResponse([ + { name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' }, + ]); + mockScheduleAgentTools.mockResolvedValueOnce([ + { + status: 'cancelled', + request: { + callId: 'call1', + name: LS_TOOL_NAME, + args: { path: '/secret' }, + isClientInitiated: false, + prompt_id: 'test-prompt', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + outcome: ToolConfirmationOutcome.Cancel, // Soft rejection + response: { + callId: 'call1', + resultDisplay: '', + responseParts: [ + { + functionResponse: { + name: LS_TOOL_NAME, + response: { + error: + '[Operation Cancelled] Reason: User denied execution.', + }, + id: 'call1', + }, + }, + ], + error: undefined, + errorType: undefined, + contentLength: 0, + }, + }, + ]); + + // Turn 2: Model sees the rejection + consolidated instructions and completes + mockModelResponse([ + { + name: TASK_COMPLETE_TOOL_NAME, + args: { finalResult: 'User rejected access to /secret.' }, + id: 'call2', + }, + ]); + + const output = await executor.run( + { goal: 'Soft rejection test' }, + signal, + ); + + // Verify the activity stream reported the consolidated instruction + expect(activities).toContainEqual( + expect.objectContaining({ + type: 'ERROR', + data: expect.objectContaining({ + context: 'tool_call', + name: LS_TOOL_NAME, + error: expect.stringContaining('User rejected this operation'), + errorType: SubagentActivityErrorType.REJECTED, + }), + }), + ); + + // Verify the instruction was sent back to the model as the tool error + const turn2Params = getMockMessageParams(1); + const parts = turn2Params.message as Part[]; + const errorMsg = parts[0].functionResponse?.response?.['error']; + expect(typeof errorMsg).toBe('string'); + if (typeof errorMsg === 'string') { + expect(errorMsg).toContain('User rejected this operation'); + expect(errorMsg).toContain('acknowledge this, rethink your strategy'); + } + + expect(output.terminate_reason).toBe(AgentTerminateMode.GOAL); + expect(output.result).toBe('User rejected access to /secret.'); + }); + + it('should handle a hard tool abort (cancelled with no outcome) and terminate the agent', async () => { + const definition = createTestDefinition([LS_TOOL_NAME]); + const executor = await LocalAgentExecutor.create( + definition, + mockConfig, + onActivity, + ); + + // Turn 1: Model calls a tool that will be aborted (e.g. Ctrl+C) + mockModelResponse([ + { name: LS_TOOL_NAME, args: { path: '/secret' }, id: 'call1' }, + ]); + mockScheduleAgentTools.mockResolvedValueOnce([ + { + status: 'cancelled', + request: { + callId: 'call1', + name: LS_TOOL_NAME, + args: { path: '/secret' }, + isClientInitiated: false, + prompt_id: 'test-prompt', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + outcome: undefined, // Hard abort + response: { + callId: 'call1', + resultDisplay: '', + responseParts: [ + { + functionResponse: { + name: LS_TOOL_NAME, + response: { error: 'Request cancelled.' }, + id: 'call1', + }, + }, + ], + error: undefined, + errorType: undefined, + contentLength: 0, + }, + }, + ]); + + const output = await executor.run({ goal: 'Hard abort test' }, signal); + + // Verify the activity stream reported the cancellation + expect(activities).toContainEqual( + expect.objectContaining({ + type: 'ERROR', + data: expect.objectContaining({ + context: 'tool_call', + name: LS_TOOL_NAME, + error: 'Request cancelled.', + errorType: SubagentActivityErrorType.CANCELLED, + }), + }), + ); + + // Agent should terminate with ABORTED status + expect(output.terminate_reason).toBe(AgentTerminateMode.ABORTED); + }); }); describe('Model Routing', () => { @@ -1685,6 +1851,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'timeout', error: 'Agent timed out after 0.5 minutes.', + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1873,6 +2040,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'recovery_turn', error: 'Graceful recovery attempt failed. Reason: stop', + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -1956,6 +2124,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'recovery_turn', error: 'Graceful recovery attempt failed. Reason: stop', + errorType: SubagentActivityErrorType.GENERIC, }), }), ); @@ -2077,6 +2246,7 @@ describe('LocalAgentExecutor', () => { data: expect.objectContaining({ context: 'recovery_turn', error: 'Graceful recovery attempt failed. Reason: stop', + errorType: SubagentActivityErrorType.GENERIC, }), }), ); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index c41ae801c4..9dc92d1321 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -18,7 +18,10 @@ import { import { ToolRegistry } from '../tools/tool-registry.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { ResourceRegistry } from '../resources/resource-registry.js'; -import { type AnyDeclarativeTool } from '../tools/tools.js'; +import { + type AnyDeclarativeTool, + ToolConfirmationOutcome, +} from '../tools/tools.js'; import { DiscoveredMCPTool, isMcpToolName, @@ -46,6 +49,9 @@ import { DEFAULT_QUERY_STRING, DEFAULT_MAX_TURNS, DEFAULT_MAX_TIME_MINUTES, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, + SUBAGENT_CANCELLED_ERROR_MESSAGE, type LocalAgentDefinition, type AgentInputs, type OutputObject, @@ -338,6 +344,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: `Agent stopped calling tools but did not call '${TASK_COMPLETE_TOOL_NAME}' to finalize the session.`, context: 'protocol_violation', + errorType: SubagentActivityErrorType.GENERIC, }); return { status: 'stop', @@ -471,6 +478,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: `Graceful recovery attempt failed. Reason: ${turnResult.status}`, context: 'recovery_turn', + errorType: SubagentActivityErrorType.GENERIC, }); return null; } catch (error) { @@ -478,6 +486,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: `Graceful recovery attempt failed: ${String(error)}`, context: 'recovery_turn', + errorType: SubagentActivityErrorType.GENERIC, }); return null; } finally { @@ -683,12 +692,14 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: finalResult, context: 'timeout', + errorType: SubagentActivityErrorType.GENERIC, }); } else if (terminateReason === AgentTerminateMode.MAX_TURNS) { finalResult = `Agent reached max turns limit (${maxTurns}).`; this.emitActivity('ERROR', { error: finalResult, context: 'max_turns', + errorType: SubagentActivityErrorType.GENERIC, }); } else if ( terminateReason === AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL @@ -700,6 +711,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: finalResult, context: 'protocol_violation', + errorType: SubagentActivityErrorType.GENERIC, }); } } @@ -754,6 +766,7 @@ export class LocalAgentExecutor { this.emitActivity('ERROR', { error: finalResult, context: 'timeout', + errorType: SubagentActivityErrorType.GENERIC, }); return { result: finalResult, @@ -761,7 +774,10 @@ export class LocalAgentExecutor { }; } - this.emitActivity('ERROR', { error: String(error) }); + this.emitActivity('ERROR', { + error: String(error), + errorType: SubagentActivityErrorType.GENERIC, + }); throw error; // Re-throw other errors or external aborts. } finally { deadlineTimer.abort(); @@ -1030,6 +1046,7 @@ export class LocalAgentExecutor { context: 'tool_call', name: toolName, error, + errorType: SubagentActivityErrorType.GENERIC, }); continue; } @@ -1057,6 +1074,7 @@ export class LocalAgentExecutor { context: 'tool_call', name: toolName, error, + errorType: SubagentActivityErrorType.GENERIC, }); continue; } @@ -1099,6 +1117,7 @@ export class LocalAgentExecutor { name: toolName, callId, error, + errorType: SubagentActivityErrorType.GENERIC, }); } } else { @@ -1142,6 +1161,7 @@ export class LocalAgentExecutor { name: toolName, callId, error, + errorType: SubagentActivityErrorType.GENERIC, }); } } @@ -1166,6 +1186,7 @@ export class LocalAgentExecutor { name: toolName, callId, error, + errorType: SubagentActivityErrorType.GENERIC, }); continue; @@ -1213,18 +1234,46 @@ export class LocalAgentExecutor { name: toolName, callId: call.request.callId, error: call.response.error?.message || 'Unknown error', + errorType: SubagentActivityErrorType.GENERIC, }); } else if (call.status === 'cancelled') { - this.emitActivity('ERROR', { - context: 'tool_call', - name: toolName, - callId: call.request.callId, - error: 'Request cancelled.', - }); - aborted = true; + const isSoftRejection = + call.outcome === ToolConfirmationOutcome.Cancel; + + if (isSoftRejection) { + const error = `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`${TASK_COMPLETE_TOOL_NAME}\` to report your findings and the blocker.`; + this.emitActivity('ERROR', { + context: 'tool_call', + name: toolName, + callId: call.request.callId, + error, + errorType: SubagentActivityErrorType.REJECTED, + }); + // Soft rejection: we do NOT set aborted=true, allowing the agent to rethink. + + // Provide the direct instruction to the model as the tool error response. + syncResults.set(call.request.callId, { + functionResponse: { + name: toolName, + id: call.request.callId, + response: { error }, + }, + }); + continue; // Skip the generic syncResults.set below + } else { + // Hard abort (Ctrl+C) + this.emitActivity('ERROR', { + context: 'tool_call', + name: toolName, + callId: call.request.callId, + error: SUBAGENT_CANCELLED_ERROR_MESSAGE, + errorType: SubagentActivityErrorType.CANCELLED, + }); + aborted = true; + } } - // Add result to syncResults to preserve order later + // Add result to syncResults for other statuses (success, error, hard abort) syncResults.set(call.request.callId, call.response.responseParts[0]); } } @@ -1335,7 +1384,8 @@ export class LocalAgentExecutor { Important Rules: * You are running in a non-interactive mode. You CANNOT ask the user for input or clarification. * Work systematically using available tools to complete your task. -* Always use absolute paths for file operations. Construct them using the provided "Environment Context".`; +* Always use absolute paths for file operations. Construct them using the provided "Environment Context". +* If a tool call is rejected by the user, acknowledge the rejection, rethink your strategy, and try a different approach. Do not repeatedly attempt the same rejected operation.`; if (this.definition.outputConfig) { finalPrompt += ` diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index 39c3ea1fe5..34df9844c9 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -19,6 +19,8 @@ import { type SubagentActivityEvent, type AgentInputs, type SubagentProgress, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, } from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; @@ -303,6 +305,48 @@ describe('LocalSubagentInvocation', () => { ); }); + it('should reflect tool rejections in the activity stream as cancelled but not abort the agent', async () => { + mockExecutorInstance.run.mockImplementation(async () => { + const onActivity = MockLocalAgentExecutor.create.mock.calls[0][2]; + + if (onActivity) { + onActivity({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'TOOL_CALL_START', + data: { name: 'ls', args: {}, callId: 'call1' }, + } as SubagentActivityEvent); + onActivity({ + isSubagentActivityEvent: true, + agentName: 'MockAgent', + type: 'ERROR', + data: { + name: 'ls', + callId: 'call1', + error: `${SUBAGENT_REJECTED_ERROR_PREFIX} Please acknowledge this, rethink your strategy, and try a different approach. If you cannot proceed without the rejected operation, summarize the issue and use \`complete_task\` to report your findings and the blocker.`, + errorType: SubagentActivityErrorType.REJECTED, + }, + } as SubagentActivityEvent); + } + return { + result: 'Rethinking...', + terminate_reason: AgentTerminateMode.GOAL, + }; + }); + + await invocation.execute(signal, updateOutput); + + expect(updateOutput).toHaveBeenCalledTimes(4); + const lastCall = updateOutput.mock.calls[3][0] as SubagentProgress; + expect(lastCall.recentActivity).toContainEqual( + expect.objectContaining({ + type: 'tool_call', + content: 'ls', + status: 'cancelled', + }), + ); + }); + it('should run successfully without an updateOutput callback', 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 f78faf32c0..e8b98d4744 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -18,6 +18,9 @@ import { type SubagentProgress, type SubagentActivityItem, AgentTerminateMode, + SubagentActivityErrorType, + SUBAGENT_REJECTED_ERROR_PREFIX, + SUBAGENT_CANCELLED_ERROR_MESSAGE, } from './types.js'; import { randomUUID } from 'node:crypto'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -171,12 +174,19 @@ export class LocalSubagentInvocation extends BaseToolInvocation< } case 'ERROR': { const error = String(activity.data['error']); - const isCancellation = error === 'Request cancelled.'; + const errorType = activity.data['errorType']; + const isCancellation = + errorType === SubagentActivityErrorType.CANCELLED || + error === SUBAGENT_CANCELLED_ERROR_MESSAGE; + const isRejection = + errorType === SubagentActivityErrorType.REJECTED || + error.startsWith(SUBAGENT_REJECTED_ERROR_PREFIX); + const toolName = activity.data['name'] ? String(activity.data['name']) : undefined; - if (toolName && isCancellation) { + if (toolName && (isCancellation || isRejection)) { for (let i = recentActivity.length - 1; i >= 0; i--) { if ( recentActivity[i].type === 'tool_call' && @@ -188,13 +198,27 @@ export class LocalSubagentInvocation extends BaseToolInvocation< break; } } + } else if (toolName) { + // Mark non-rejection/non-cancellation errors as 'error' + for (let i = recentActivity.length - 1; i >= 0; i--) { + if ( + recentActivity[i].type === 'tool_call' && + recentActivity[i].content === toolName && + recentActivity[i].status === 'running' + ) { + recentActivity[i].status = 'error'; + updated = true; + break; + } + } } recentActivity.push({ id: randomUUID(), - type: 'thought', // Treat errors as thoughts for now, or add an error type - content: isCancellation ? error : `Error: ${error}`, - status: isCancellation ? 'cancelled' : 'error', + type: 'thought', + content: + isCancellation || isRejection ? error : `Error: ${error}`, + status: isCancellation || isRejection ? 'cancelled' : 'error', }); updated = true; break; diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 2c703f90fd..7f056c37ab 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -65,6 +65,18 @@ export type RemoteAgentInputs = { query: string }; /** * Structured events emitted during subagent execution for user observability. */ +export enum SubagentActivityErrorType { + REJECTED = 'REJECTED', + CANCELLED = 'CANCELLED', + GENERIC = 'GENERIC', +} + +/** + * Standard error messages for subagent activities. + */ +export const SUBAGENT_REJECTED_ERROR_PREFIX = 'User rejected this operation.'; +export const SUBAGENT_CANCELLED_ERROR_MESSAGE = 'Request cancelled.'; + export interface SubagentActivityEvent { isSubagentActivityEvent: true; agentName: string; From 8db2948361519eb262e681bbc4dfaf6ea97e608a Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Wed, 18 Mar 2026 21:52:23 -0400 Subject: [PATCH 016/110] fix(cli): correctly handle auto-update for standalone binaries (#23038) --- packages/cli/src/utils/handleAutoUpdate.test.ts | 7 ++++++- packages/cli/src/utils/handleAutoUpdate.ts | 9 ++++++--- packages/cli/src/utils/installationInfo.test.ts | 13 +++++++++++++ packages/cli/src/utils/installationInfo.ts | 11 +++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index b10204834b..94795bf94e 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -202,7 +202,12 @@ describe('handleAutoUpdate', () => { expect(mockSpawn).not.toHaveBeenCalled(); }); - it.each([PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX])( + it.each([ + PackageManager.NPX, + PackageManager.PNPX, + PackageManager.BUNX, + PackageManager.BINARY, + ])( 'should suppress update notifications when running via %s', (packageManager) => { mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 348acd33b0..bd0effa53b 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -87,9 +87,12 @@ export function handleAutoUpdate( ); if ( - [PackageManager.NPX, PackageManager.PNPX, PackageManager.BUNX].includes( - installationInfo.packageManager, - ) + [ + PackageManager.NPX, + PackageManager.PNPX, + PackageManager.BUNX, + PackageManager.BINARY, + ].includes(installationInfo.packageManager) ) { return; } diff --git a/packages/cli/src/utils/installationInfo.test.ts b/packages/cli/src/utils/installationInfo.test.ts index ca1120c0e3..fbebec8bf7 100644 --- a/packages/cli/src/utils/installationInfo.test.ts +++ b/packages/cli/src/utils/installationInfo.test.ts @@ -58,6 +58,19 @@ describe('getInstallationInfo', () => { process.argv = originalArgv; }); + it('should detect running as a standalone binary', () => { + vi.stubEnv('IS_BINARY', 'true'); + process.argv[1] = '/path/to/binary'; + const info = getInstallationInfo(projectRoot, true); + expect(info.packageManager).toBe(PackageManager.BINARY); + expect(info.isGlobal).toBe(true); + expect(info.updateMessage).toBe( + 'Running as a standalone binary. Please update by downloading the latest version from GitHub.', + ); + expect(info.updateCommand).toBeUndefined(); + vi.unstubAllEnvs(); + }); + it('should return UNKNOWN when cliPath is not available', () => { process.argv[1] = ''; const info = getInstallationInfo(projectRoot, true); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index a682cc75e1..39d77ba640 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -21,6 +21,7 @@ export enum PackageManager { BUNX = 'bunx', HOMEBREW = 'homebrew', NPX = 'npx', + BINARY = 'binary', UNKNOWN = 'unknown', } @@ -41,6 +42,16 @@ export function getInstallationInfo( } try { + // Check for standalone binary first + if (process.env['IS_BINARY'] === 'true') { + return { + packageManager: PackageManager.BINARY, + isGlobal: true, + updateMessage: + 'Running as a standalone binary. Please update by downloading the latest version from GitHub.', + }; + } + // Normalize path separators to forward slashes for consistent matching. const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/'); const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/'); From 2009fbbd92fcce419b7dc661fcf9480a73a7a889 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:30:48 -0400 Subject: [PATCH 017/110] feat(core): add content-utils (#22984) --- packages/core/src/agent/content-utils.test.ts | 258 ++++++++++++++++++ packages/core/src/agent/content-utils.ts | 139 ++++++++++ 2 files changed, 397 insertions(+) create mode 100644 packages/core/src/agent/content-utils.test.ts create mode 100644 packages/core/src/agent/content-utils.ts diff --git a/packages/core/src/agent/content-utils.test.ts b/packages/core/src/agent/content-utils.test.ts new file mode 100644 index 0000000000..96608c8227 --- /dev/null +++ b/packages/core/src/agent/content-utils.test.ts @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + geminiPartsToContentParts, + contentPartsToGeminiParts, + toolResultDisplayToContentParts, + buildToolResponseData, +} from './content-utils.js'; +import type { Part } from '@google/genai'; +import type { ContentPart } from './types.js'; + +describe('geminiPartsToContentParts', () => { + it('converts text parts', () => { + const parts: Part[] = [{ text: 'hello' }]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'text', text: 'hello' }, + ]); + }); + + it('converts thought parts', () => { + const parts: Part[] = [ + { text: 'thinking...', thought: true, thoughtSignature: 'sig123' }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { + type: 'thought', + thought: 'thinking...', + thoughtSignature: 'sig123', + }, + ]); + }); + + it('converts thought parts without signature', () => { + const parts: Part[] = [{ text: 'thinking...', thought: true }]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'thought', thought: 'thinking...' }, + ]); + }); + + it('converts inlineData parts to media', () => { + const parts: Part[] = [ + { inlineData: { data: 'base64data', mimeType: 'image/png' } }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { type: 'media', data: 'base64data', mimeType: 'image/png' }, + ]); + }); + + it('converts fileData parts to media', () => { + const parts: Part[] = [ + { + fileData: { + fileUri: 'gs://bucket/file.pdf', + mimeType: 'application/pdf', + }, + }, + ]; + expect(geminiPartsToContentParts(parts)).toEqual([ + { + type: 'media', + uri: 'gs://bucket/file.pdf', + mimeType: 'application/pdf', + }, + ]); + }); + + it('skips functionCall parts', () => { + const parts: Part[] = [ + { functionCall: { name: 'myFunc', args: { key: 'value' } } }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toEqual([]); + }); + + it('skips functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + name: 'myFunc', + response: { output: 'result' }, + }, + }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toEqual([]); + }); + + it('serializes unknown part types to text with _meta', () => { + const parts: Part[] = [{ unknownField: 'data' } as Part]; + const result = geminiPartsToContentParts(parts); + expect(result).toHaveLength(1); + expect(result[0]?.type).toBe('text'); + expect(result[0]?._meta).toEqual({ partType: 'unknown' }); + }); + + it('handles empty array', () => { + expect(geminiPartsToContentParts([])).toEqual([]); + }); + + it('handles mixed parts', () => { + const parts: Part[] = [ + { text: 'hello' }, + { inlineData: { data: 'img', mimeType: 'image/jpeg' } }, + { text: 'thought', thought: true }, + ]; + const result = geminiPartsToContentParts(parts); + expect(result).toHaveLength(3); + expect(result[0]?.type).toBe('text'); + expect(result[1]?.type).toBe('media'); + expect(result[2]?.type).toBe('thought'); + }); +}); + +describe('contentPartsToGeminiParts', () => { + it('converts text ContentParts', () => { + const content: ContentPart[] = [{ type: 'text', text: 'hello' }]; + expect(contentPartsToGeminiParts(content)).toEqual([{ text: 'hello' }]); + }); + + it('converts thought ContentParts', () => { + const content: ContentPart[] = [ + { type: 'thought', thought: 'thinking...', thoughtSignature: 'sig' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { text: 'thinking...', thought: true, thoughtSignature: 'sig' }, + ]); + }); + + it('converts thought ContentParts without signature', () => { + const content: ContentPart[] = [ + { type: 'thought', thought: 'thinking...' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { text: 'thinking...', thought: true }, + ]); + }); + + it('converts media ContentParts with data to inlineData', () => { + const content: ContentPart[] = [ + { type: 'media', data: 'base64', mimeType: 'image/png' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { inlineData: { data: 'base64', mimeType: 'image/png' } }, + ]); + }); + + it('converts media ContentParts with uri to fileData', () => { + const content: ContentPart[] = [ + { type: 'media', uri: 'gs://bucket/file', mimeType: 'application/pdf' }, + ]; + expect(contentPartsToGeminiParts(content)).toEqual([ + { + fileData: { fileUri: 'gs://bucket/file', mimeType: 'application/pdf' }, + }, + ]); + }); + + it('converts reference ContentParts to text', () => { + const content: ContentPart[] = [{ type: 'reference', text: '@file.ts' }]; + expect(contentPartsToGeminiParts(content)).toEqual([{ text: '@file.ts' }]); + }); + + it('handles empty array', () => { + expect(contentPartsToGeminiParts([])).toEqual([]); + }); + + it('skips media parts with no data or uri', () => { + const content: ContentPart[] = [{ type: 'media', mimeType: 'image/png' }]; + expect(contentPartsToGeminiParts(content)).toEqual([]); + }); + + it('defaults mimeType for media with data but no mimeType', () => { + const content: ContentPart[] = [{ type: 'media', data: 'base64data' }]; + const result = contentPartsToGeminiParts(content); + expect(result).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'application/octet-stream', + }, + }, + ]); + }); + + it('serializes unknown ContentPart variants', () => { + // Force an unknown variant past the type system + const content = [ + { type: 'custom_widget', payload: 123 }, + ] as unknown as ContentPart[]; + const result = contentPartsToGeminiParts(content); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + text: JSON.stringify({ type: 'custom_widget', payload: 123 }), + }); + }); +}); + +describe('toolResultDisplayToContentParts', () => { + it('returns undefined for undefined', () => { + expect(toolResultDisplayToContentParts(undefined)).toBeUndefined(); + }); + + it('returns undefined for null', () => { + expect(toolResultDisplayToContentParts(null)).toBeUndefined(); + }); + + it('handles string resultDisplay as-is', () => { + const result = toolResultDisplayToContentParts('File written'); + expect(result).toEqual([{ type: 'text', text: 'File written' }]); + }); + + it('stringifies object resultDisplay', () => { + const display = { type: 'FileDiff', oldPath: 'a.ts', newPath: 'b.ts' }; + const result = toolResultDisplayToContentParts(display); + expect(result).toEqual([{ type: 'text', text: JSON.stringify(display) }]); + }); +}); + +describe('buildToolResponseData', () => { + it('preserves outputFile and contentLength', () => { + const result = buildToolResponseData({ + outputFile: '/tmp/result.txt', + contentLength: 256, + }); + expect(result).toEqual({ + outputFile: '/tmp/result.txt', + contentLength: 256, + }); + }); + + it('returns undefined for empty response', () => { + const result = buildToolResponseData({}); + expect(result).toBeUndefined(); + }); + + it('includes errorType when present', () => { + const result = buildToolResponseData({ + errorType: 'permission_denied', + }); + expect(result).toEqual({ errorType: 'permission_denied' }); + }); + + it('merges data with other fields', () => { + const result = buildToolResponseData({ + data: { custom: 'value' }, + outputFile: '/tmp/file.txt', + }); + expect(result).toEqual({ + custom: 'value', + outputFile: '/tmp/file.txt', + }); + }); +}); diff --git a/packages/core/src/agent/content-utils.ts b/packages/core/src/agent/content-utils.ts new file mode 100644 index 0000000000..b117ab69fc --- /dev/null +++ b/packages/core/src/agent/content-utils.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Part } from '@google/genai'; +import type { ContentPart } from './types.js'; + +/** + * Converts Gemini API Part objects to framework-agnostic ContentPart objects. + * Handles text, thought, inlineData, fileData parts and serializes unknown + * part types to text to avoid silent data loss. + */ +export function geminiPartsToContentParts(parts: Part[]): ContentPart[] { + const result: ContentPart[] = []; + for (const part of parts) { + if ('text' in part && part.text !== undefined) { + if ('thought' in part && part.thought) { + result.push({ + type: 'thought', + thought: part.text, + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + } else { + result.push({ type: 'text', text: part.text }); + } + } else if ('inlineData' in part && part.inlineData) { + result.push({ + type: 'media', + data: part.inlineData.data, + mimeType: part.inlineData.mimeType, + }); + } else if ('fileData' in part && part.fileData) { + result.push({ + type: 'media', + uri: part.fileData.fileUri, + mimeType: part.fileData.mimeType, + }); + } else if ('functionCall' in part && part.functionCall) { + continue; // Skip function calls, they are emitted as distinct tool_request events + } else if ('functionResponse' in part && part.functionResponse) { + continue; // Skip function responses, they are tied to tool_response events + } else { + // Fallback: serialize any unrecognized part type to text + result.push({ + type: 'text', + text: JSON.stringify(part), + _meta: { partType: 'unknown' }, + }); + } + } + return result; +} + +/** + * Converts framework-agnostic ContentPart objects to Gemini API Part objects. + */ +export function contentPartsToGeminiParts(content: ContentPart[]): Part[] { + const result: Part[] = []; + for (const part of content) { + switch (part.type) { + case 'text': + result.push({ text: part.text }); + break; + case 'thought': + result.push({ + text: part.thought, + thought: true, + ...(part.thoughtSignature + ? { thoughtSignature: part.thoughtSignature } + : {}), + }); + break; + case 'media': + if (part.data) { + result.push({ + inlineData: { + data: part.data, + mimeType: part.mimeType ?? 'application/octet-stream', + }, + }); + } else if (part.uri) { + result.push({ + fileData: { fileUri: part.uri, mimeType: part.mimeType }, + }); + } + break; + case 'reference': + // References are converted to text for the model + result.push({ text: part.text }); + break; + default: + // Serialize unknown ContentPart variants instead of dropping them + result.push({ text: JSON.stringify(part) }); + break; + } + } + return result; +} + +/** + * Converts a ToolCallResponseInfo.resultDisplay value into ContentPart[]. + * Handles string, object-valued (FileDiff, SubagentProgress, etc.), + * and undefined resultDisplay consistently. + */ +export function toolResultDisplayToContentParts( + resultDisplay: unknown, +): ContentPart[] | undefined { + if (resultDisplay === undefined || resultDisplay === null) { + return undefined; + } + const text = + typeof resultDisplay === 'string' + ? resultDisplay + : JSON.stringify(resultDisplay); + return [{ type: 'text', text }]; +} + +/** + * Builds the data record for a tool_response AgentEvent, preserving + * all available metadata from the ToolCallResponseInfo. + */ +export function buildToolResponseData(response: { + data?: Record; + errorType?: string; + outputFile?: string; + contentLength?: number; +}): Record | undefined { + const parts: Record = {}; + if (response.data) Object.assign(parts, response.data); + if (response.errorType) parts['errorType'] = response.errorType; + if (response.outputFile) parts['outputFile'] = response.outputFile; + if (response.contentLength !== undefined) + parts['contentLength'] = response.contentLength; + return Object.keys(parts).length > 0 ? parts : undefined; +} From e9171fd79256774e9946aa39ea0923e4b144d2ec Mon Sep 17 00:00:00 2001 From: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> Date: Wed, 18 Mar 2026 21:31:02 -0700 Subject: [PATCH 018/110] fix: circumvent genai sdk requirement for api key when using gateway auth via ACP (#23042) --- .../core/src/core/contentGenerator.test.ts | 20 +++++++++++++++++++ packages/core/src/core/contentGenerator.ts | 7 +++++++ 2 files changed, 27 insertions(+) diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 57ce1fed23..4bacd1b488 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -725,6 +725,26 @@ describe('createContentGeneratorConfig', () => { expect(config.apiKey).toBeUndefined(); expect(config.vertexai).toBeUndefined(); }); + it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is set', async () => { + vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key'); + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.GATEWAY, + ); + expect(config.apiKey).toBe('gateway-placeholder-key'); + expect(config.vertexai).toBe(false); + }); + + it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is not set', async () => { + vi.stubEnv('GEMINI_API_KEY', ''); + vi.mocked(loadApiKey).mockResolvedValue(null); + const config = await createContentGeneratorConfig( + mockConfig, + AuthType.GATEWAY, + ); + expect(config.apiKey).toBe('gateway-placeholder-key'); + expect(config.vertexai).toBe(false); + }); }); describe('validateBaseUrl', () => { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 60641abdeb..ff1739c04b 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -150,6 +150,13 @@ export async function createContentGeneratorConfig( return contentGeneratorConfig; } + if (authType === AuthType.GATEWAY) { + contentGeneratorConfig.apiKey = apiKey || 'gateway-placeholder-key'; + contentGeneratorConfig.vertexai = false; + + return contentGeneratorConfig; + } + return contentGeneratorConfig; } From a921bcd9ef1d8945879b1ee5b6d2a45016c18267 Mon Sep 17 00:00:00 2001 From: Jason Matthew Suhari Date: Thu, 19 Mar 2026 14:47:13 +0800 Subject: [PATCH 019/110] fix(core): don't persist browser consent sentinel in non-interactive mode (#23073) --- packages/core/src/utils/browserConsent.test.ts | 10 ++++------ packages/core/src/utils/browserConsent.ts | 6 ++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/core/src/utils/browserConsent.test.ts b/packages/core/src/utils/browserConsent.test.ts index f145632068..307921293e 100644 --- a/packages/core/src/utils/browserConsent.test.ts +++ b/packages/core/src/utils/browserConsent.test.ts @@ -47,7 +47,7 @@ describe('browserConsent', () => { expect(emitSpy).not.toHaveBeenCalled(); }); - it('should auto-accept in non-interactive mode (no listeners)', async () => { + it('should auto-accept in non-interactive mode (no listeners) without persisting consent', async () => { // Consent file does not exist vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT')); // No listeners registered @@ -56,11 +56,9 @@ describe('browserConsent', () => { const result = await getBrowserConsentIfNeeded(); expect(result).toBe(true); - // Should persist the consent - expect(fs.writeFile).toHaveBeenCalledWith( - expect.stringContaining('browser-consent-acknowledged.txt'), - expect.stringContaining('consent acknowledged'), - ); + // Should NOT persist the consent — an interactive user on the same machine + // must still see the dialog the first time they use the browser agent. + expect(fs.writeFile).not.toHaveBeenCalled(); }); it('should request consent interactively and return true when accepted', async () => { diff --git a/packages/core/src/utils/browserConsent.ts b/packages/core/src/utils/browserConsent.ts index 097c3b683e..8a651e0694 100644 --- a/packages/core/src/utils/browserConsent.ts +++ b/packages/core/src/utils/browserConsent.ts @@ -42,9 +42,11 @@ export async function getBrowserConsentIfNeeded(): Promise { void 0; } - // Non-interactive mode (no UI listeners): auto-accept. + // Non-interactive mode (no UI listeners): skip the dialog for this session + // only. Do NOT persist the sentinel file — an interactive user on the same + // machine should still see the consent dialog the first time they use the + // browser agent. if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { - await markConsentAsAcknowledged(consentFilePath); return true; } From 5acaacad96fe76d2f019885d64fab5979bd4b566 Mon Sep 17 00:00:00 2001 From: Gaurav <39389231+gsquared94@users.noreply.github.com> Date: Thu, 19 Mar 2026 03:45:59 -0700 Subject: [PATCH 020/110] fix(core): narrow browser agent description to prevent stealing URL tasks from web_fetch (#23086) --- packages/core/src/agents/browser/browserAgentDefinition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts index 0d0f863834..51b874a07f 100644 --- a/packages/core/src/agents/browser/browserAgentDefinition.ts +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -131,7 +131,7 @@ export const BrowserAgentDefinition = ( kind: 'local', experimental: true, displayName: 'Browser Agent', - description: `Specialized autonomous agent for end-to-end web browser automation and objective-driven problem solving. Delegate complete, high-level tasks to this agent — it independently plans, executes multi-step interactions, interprets dynamic page feedback (e.g., game states, form validation errors, search results), and iterates until the goal is achieved. It perceives page structure through the Accessibility Tree, handles overlays and popups, and supports complex web apps.`, + description: `Specialized autonomous agent for interactive web browser automation requiring real browser rendering. Delegate tasks that require clicking, form-filling, navigating multi-step flows, or interacting with JavaScript-heavy web applications that cannot be accessed via simple HTTP fetching. Do NOT delegate to this agent for simply reading, summarizing, or extracting content from URLs — use the web_fetch tool or other available tools for that instead. This agent independently plans, executes multi-step interactions, interprets dynamic page feedback (e.g., game states, form validation errors, search results), and iterates until the goal is achieved. It perceives page structure through the Accessibility Tree, handles overlays and popups, and supports complex web apps.`, inputConfig: { inputSchema: { From 39d3b0e28c1a3cf26f92981ad6e9b73b6ff54450 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Thu, 19 Mar 2026 09:02:13 -0700 Subject: [PATCH 021/110] feat(cli): Partial threading of AgentLoopContext. (#22978) --- packages/cli/src/acp/acpClient.test.ts | 9 +++ packages/cli/src/acp/acpClient.ts | 77 +++++++++++-------- packages/cli/src/acp/acpResume.test.ts | 8 +- packages/cli/src/acp/commands/extensions.ts | 19 ++--- packages/cli/src/acp/commands/init.ts | 2 +- packages/cli/src/acp/commands/memory.ts | 12 +-- packages/cli/src/acp/commands/restore.ts | 5 +- packages/cli/src/acp/commands/types.ts | 4 +- packages/cli/src/nonInteractiveCliCommands.ts | 6 +- .../prompt-processors/atFileProcessor.test.ts | 7 +- .../prompt-processors/atFileProcessor.ts | 2 +- .../prompt-processors/shellProcessor.test.ts | 7 +- .../prompt-processors/shellProcessor.ts | 2 +- .../src/test-utils/mockCommandContext.test.ts | 12 ++- .../cli/src/test-utils/mockCommandContext.ts | 2 +- .../cli/src/ui/commands/aboutCommand.test.ts | 23 +++--- packages/cli/src/ui/commands/aboutCommand.ts | 7 +- .../cli/src/ui/commands/agentsCommand.test.ts | 14 ++-- packages/cli/src/ui/commands/agentsCommand.ts | 19 +++-- .../cli/src/ui/commands/authCommand.test.ts | 25 +++--- packages/cli/src/ui/commands/authCommand.ts | 2 +- .../cli/src/ui/commands/bugCommand.test.ts | 52 +++++++------ packages/cli/src/ui/commands/bugCommand.ts | 8 +- .../cli/src/ui/commands/chatCommand.test.ts | 29 ++++--- packages/cli/src/ui/commands/chatCommand.ts | 14 ++-- .../cli/src/ui/commands/clearCommand.test.ts | 37 ++++----- packages/cli/src/ui/commands/clearCommand.ts | 4 +- .../src/ui/commands/compressCommand.test.ts | 9 +-- .../cli/src/ui/commands/compressCommand.ts | 8 +- .../cli/src/ui/commands/copyCommand.test.ts | 8 +- packages/cli/src/ui/commands/copyCommand.ts | 2 +- .../src/ui/commands/directoryCommand.test.tsx | 5 +- .../cli/src/ui/commands/directoryCommand.tsx | 29 ++++--- .../src/ui/commands/extensionsCommand.test.ts | 24 +++--- .../cli/src/ui/commands/extensionsCommand.ts | 40 ++++++---- .../cli/src/ui/commands/hooksCommand.test.ts | 14 ++-- packages/cli/src/ui/commands/hooksCommand.ts | 21 +++-- .../cli/src/ui/commands/ideCommand.test.ts | 10 ++- packages/cli/src/ui/commands/ideCommand.ts | 20 +++-- .../cli/src/ui/commands/initCommand.test.ts | 8 +- packages/cli/src/ui/commands/initCommand.ts | 4 +- .../cli/src/ui/commands/mcpCommand.test.ts | 18 +++-- packages/cli/src/ui/commands/mcpCommand.ts | 29 ++++--- .../cli/src/ui/commands/memoryCommand.test.ts | 22 +++--- packages/cli/src/ui/commands/memoryCommand.ts | 6 +- .../cli/src/ui/commands/modelCommand.test.ts | 20 ++++- packages/cli/src/ui/commands/modelCommand.ts | 10 +-- .../cli/src/ui/commands/oncallCommand.tsx | 6 +- .../cli/src/ui/commands/planCommand.test.ts | 48 +++++++----- packages/cli/src/ui/commands/planCommand.ts | 4 +- .../src/ui/commands/policiesCommand.test.ts | 17 +++- .../cli/src/ui/commands/policiesCommand.ts | 3 +- .../src/ui/commands/restoreCommand.test.ts | 9 ++- .../cli/src/ui/commands/restoreCommand.ts | 12 +-- .../src/ui/commands/rewindCommand.test.tsx | 28 ++++--- .../cli/src/ui/commands/rewindCommand.tsx | 7 +- .../cli/src/ui/commands/setupGithubCommand.ts | 2 +- .../cli/src/ui/commands/skillsCommand.test.ts | 46 ++++++----- packages/cli/src/ui/commands/skillsCommand.ts | 20 ++--- .../cli/src/ui/commands/statsCommand.test.ts | 10 ++- packages/cli/src/ui/commands/statsCommand.ts | 32 ++++---- .../cli/src/ui/commands/toolsCommand.test.ts | 34 ++++---- packages/cli/src/ui/commands/toolsCommand.ts | 2 +- packages/cli/src/ui/commands/types.ts | 4 +- .../src/ui/commands/upgradeCommand.test.ts | 20 ++--- .../cli/src/ui/commands/upgradeCommand.ts | 6 +- .../ui/hooks/slashCommandProcessor.test.tsx | 2 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 +- 68 files changed, 608 insertions(+), 421 deletions(-) diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index abad9d374d..ca525182b5 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -177,6 +177,9 @@ describe('GeminiAgent', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked>>; mockSettings = { merged: { @@ -656,6 +659,12 @@ describe('Session', () => { getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + get config() { + return this; + }, + get toolRegistry() { + return mockToolRegistry; + }, } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 44c0373515..bd5a52f126 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -47,6 +47,7 @@ import { DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, + type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -104,7 +105,7 @@ export class GeminiAgent { private customHeaders: Record | undefined; constructor( - private config: Config, + private context: AgentLoopContext, private settings: LoadedSettings, private argv: CliArgs, private connection: acp.AgentSideConnection, @@ -148,7 +149,7 @@ export class GeminiAgent { }, ]; - await this.config.initialize(); + await this.context.config.initialize(); const version = await getVersion(); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -220,7 +221,7 @@ export class GeminiAgent { this.baseUrl = baseUrl; this.customHeaders = headers; - await this.config.refreshAuth( + await this.context.config.refreshAuth( method, apiKey ?? this.apiKey, baseUrl, @@ -537,7 +538,7 @@ export class Session { constructor( private readonly id: string, private readonly chat: GeminiChat, - private readonly config: Config, + private readonly context: AgentLoopContext, private readonly connection: acp.AgentSideConnection, private readonly settings: LoadedSettings, ) {} @@ -552,13 +553,15 @@ export class Session { } setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { - const availableModes = buildAvailableModes(this.config.isPlanEnabled()); + const availableModes = buildAvailableModes( + this.context.config.isPlanEnabled(), + ); const mode = availableModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`Invalid or unavailable mode: ${modeId}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - this.config.setApprovalMode(mode.id as ApprovalMode); + this.context.config.setApprovalMode(mode.id as ApprovalMode); return {}; } @@ -579,7 +582,7 @@ export class Session { } setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { - this.config.setModel(modelId); + this.context.config.setModel(modelId); return {}; } @@ -634,7 +637,7 @@ export class Session { } } - const tool = this.config.getToolRegistry().getTool(toolCall.name); + const tool = this.context.toolRegistry.getTool(toolCall.name); await this.sendUpdate({ sessionUpdate: 'tool_call', @@ -658,7 +661,7 @@ export class Session { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; - await this.config.waitForMcpInit(); + await this.context.config.waitForMcpInit(); const promptId = Math.random().toString(16).slice(2); const chat = this.chat; @@ -712,8 +715,8 @@ export class Session { try { const model = resolveModel( - this.config.getModel(), - (await this.config.getGemini31Launched?.()) ?? false, + this.context.config.getModel(), + (await this.context.config.getGemini31Launched?.()) ?? false, ); const responseStream = await chat.sendMessageStream( { model }, @@ -804,9 +807,9 @@ export class Session { // eslint-disable-next-line @typescript-eslint/no-unused-vars parts: Part[], ): Promise { - const gitService = await this.config.getGitService(); + const gitService = await this.context.config.getGitService(); const commandContext = { - config: this.config, + agentContext: this.context, settings: this.settings, git: gitService, sendMessage: async (text: string) => { @@ -842,7 +845,7 @@ export class Session { const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -872,7 +875,7 @@ export class Session { return errorResponse(new Error('Missing function name')); } - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const tool = toolRegistry.getTool(fc.name); if (!tool) { @@ -908,7 +911,10 @@ export class Session { const params: acp.RequestPermissionRequest = { sessionId: this.id, - options: toPermissionOptions(confirmationDetails, this.config), + options: toPermissionOptions( + confirmationDetails, + this.context.config, + ), toolCall: { toolCallId: callId, status: 'pending', @@ -974,7 +980,7 @@ export class Session { const durationMs = Date.now() - startTime; logToolCall( - this.config, + this.context.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -988,7 +994,7 @@ export class Session { ), ); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Success, request: { @@ -1006,8 +1012,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), - this.config, + this.context.config.getActiveModel(), + this.context.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, @@ -1020,8 +1026,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.config.getActiveModel(), - this.config, + this.context.config.getActiveModel(), + this.context.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -1036,7 +1042,7 @@ export class Session { kind: toAcpToolKind(tool.kind), }); - this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ { status: CoreToolCallStatus.Error, request: { @@ -1122,18 +1128,18 @@ export class Session { const atPathToResolvedSpecMap = new Map(); // Get centralized file discovery service - const fileDiscovery = this.config.getFileService(); + const fileDiscovery = this.context.config.getFileService(); const fileFilteringOptions: FilterFilesOptions = - this.config.getFileFilteringOptions(); + this.context.config.getFileFilteringOptions(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; - const toolRegistry = this.config.getToolRegistry(); + const toolRegistry = this.context.toolRegistry; const readManyFilesTool = new ReadManyFilesTool( - this.config, - this.config.getMessageBus(), + this.context.config, + this.context.messageBus, ); const globTool = toolRegistry.getTool('glob'); @@ -1152,8 +1158,11 @@ export class Session { let currentPathSpec = pathName; let resolvedSuccessfully = false; try { - const absolutePath = path.resolve(this.config.getTargetDir(), pathName); - if (isWithinRoot(absolutePath, this.config.getTargetDir())) { + const absolutePath = path.resolve( + this.context.config.getTargetDir(), + pathName, + ); + if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { currentPathSpec = pathName.endsWith('/') @@ -1173,7 +1182,7 @@ export class Session { } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - if (this.config.getEnableRecursiveFileSearch() && globTool) { + if (this.context.config.getEnableRecursiveFileSearch() && globTool) { this.debug( `Path ${pathName} not found directly, attempting glob search.`, ); @@ -1181,7 +1190,7 @@ export class Session { const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, - path: this.config.getTargetDir(), + path: this.context.config.getTargetDir(), }, abortSignal, ); @@ -1195,7 +1204,7 @@ export class Session { if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); currentPathSpec = path.relative( - this.config.getTargetDir(), + this.context.config.getTargetDir(), firstMatchAbsolute, ); this.debug( @@ -1410,7 +1419,7 @@ export class Session { } debug(msg: string) { - if (this.config.getDebugMode()) { + if (this.context.config.getDebugMode()) { debugLogger.warn(msg); } } diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts index 9668ef74f8..77021004ca 100644 --- a/packages/cli/src/acp/acpResume.test.ts +++ b/packages/cli/src/acp/acpResume.test.ts @@ -97,6 +97,9 @@ describe('GeminiAgent Session Resume', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), + get config() { + return this; + }, } as unknown as Mocked; mockSettings = { merged: { @@ -158,9 +161,10 @@ describe('GeminiAgent Session Resume', () => { ], }; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockConfig as any).toolRegistry = { getTool: vi.fn().mockReturnValue({ kind: 'read' }), - }); + }; (SessionSelector as unknown as Mock).mockImplementation(() => ({ resolveSession: vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index c2bd0e7190..a6e08f9bbc 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -53,7 +53,7 @@ export class ListExtensionsCommand implements Command { context: CommandContext, _: string[], ): Promise { - const extensions = listExtensions(context.config); + const extensions = listExtensions(context.agentContext.config); const data = extensions.length ? extensions : 'No extensions installed.'; return { name: this.name, data }; @@ -134,7 +134,7 @@ export class EnableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.config, + context.agentContext.config, args, 'enable', ); @@ -156,7 +156,8 @@ export class EnableExtensionCommand implements Command { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = context.config.getMcpClientManager(); + const mcpClientManager = + context.agentContext.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers), ); @@ -191,7 +192,7 @@ export class DisableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.config, + context.agentContext.config, args, 'disable', ); @@ -223,7 +224,7 @@ export class InstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -268,7 +269,7 @@ export class LinkExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -313,7 +314,7 @@ export class UninstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -369,7 +370,7 @@ export class RestartExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot restart extensions.' }; } @@ -424,7 +425,7 @@ export class UpdateExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.config.getExtensionLoader(); + const extensionLoader = context.agentContext.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot update extensions.' }; } diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts index 5c4197f84c..a9104aa84f 100644 --- a/packages/cli/src/acp/commands/init.ts +++ b/packages/cli/src/acp/commands/init.ts @@ -22,7 +22,7 @@ export class InitCommand implements Command { context: CommandContext, _args: string[] = [], ): Promise { - const targetDir = context.config.getTargetDir(); + const targetDir = context.agentContext.config.getTargetDir(); if (!targetDir) { throw new Error('Command requires a workspace.'); } diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index f88aaac4f2..ac919f2a9b 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -49,7 +49,7 @@ export class ShowMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = showMemory(context.config); + const result = showMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -63,7 +63,7 @@ export class RefreshMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = await refreshMemory(context.config); + const result = await refreshMemory(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -76,7 +76,7 @@ export class ListMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = listMemoryFiles(context.config); + const result = listMemoryFiles(context.agentContext.config); return { name: this.name, data: result.content }; } } @@ -95,7 +95,7 @@ export class AddMemoryCommand implements Command { return { name: this.name, data: result.content }; } - const toolRegistry = context.config.getToolRegistry(); + const toolRegistry = context.agentContext.toolRegistry; const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); @@ -106,10 +106,10 @@ export class AddMemoryCommand implements Command { await tool.buildAndExecute(result.toolArgs, signal, undefined, { shellExecutionConfig: { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, - sandboxManager: context.config.sandboxManager, + sandboxManager: context.agentContext.sandboxManager, }, }); - await refreshMemory(context.config); + await refreshMemory(context.agentContext.config); return { name: this.name, data: `Added memory: "${textToAdd}"`, diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts index ec9166ed84..6898cff2e1 100644 --- a/packages/cli/src/acp/commands/restore.ts +++ b/packages/cli/src/acp/commands/restore.ts @@ -29,7 +29,8 @@ export class RestoreCommand implements Command { context: CommandContext, args: string[], ): Promise { - const { config, git: gitService } = context; + const { agentContext: agentContext, git: gitService } = context; + const { config } = agentContext; const argsStr = args.join(' '); try { @@ -116,7 +117,7 @@ export class ListCheckpointsCommand implements Command { readonly description = 'Lists all available checkpoints.'; async execute(context: CommandContext): Promise { - const { config } = context; + const { config } = context.agentContext; try { if (!config.getCheckpointingEnabled()) { diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts index 099f0c923f..6f5656bd89 100644 --- a/packages/cli/src/acp/commands/types.ts +++ b/packages/cli/src/acp/commands/types.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, GitService } from '@google/gemini-cli-core'; +import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; export interface CommandContext { - config: Config; + agentContext: AgentLoopContext; settings: LoadedSettings; git?: GitService; sendMessage: (text: string) => Promise; diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index e09db71312..35cf5105ab 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -65,9 +65,9 @@ export const handleSlashCommand = async ( const logger = new Logger(config?.getSessionId() || '', config?.storage); - const context: CommandContext = { + const commandContext: CommandContext = { services: { - config, + agentContext: config, settings, git: undefined, logger, @@ -84,7 +84,7 @@ export const handleSlashCommand = async ( }, }; - const result = await commandToExecute.action(context, args); + const result = await commandToExecute.action(commandContext, args); if (result) { switch (result.type) { diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts index 3f49248169..3b84baae67 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts @@ -31,11 +31,14 @@ describe('AtFileProcessor', () => { mockConfig = { // The processor only passes the config through, so we don't need a full mock. + get config() { + return this; + }, } as unknown as Config; context = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, }, }); @@ -60,7 +63,7 @@ describe('AtFileProcessor', () => { const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); const result = await processor.process(prompt, contextWithoutConfig); diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts index 48e527ed5f..8c1b168584 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -25,7 +25,7 @@ export class AtFileProcessor implements IPromptProcessor { input: PromptPipelineContent, context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { return input; } diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 84010ab625..8ab4581228 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -89,6 +89,9 @@ describe('ShellProcessor', () => { getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), + get config() { + return this as unknown as Config; + }, }; context = createMockCommandContext({ @@ -98,7 +101,7 @@ describe('ShellProcessor', () => { args: 'default args', }, services: { - config: mockConfig as Config, + agentContext: mockConfig as Config, }, session: { sessionShellAllowlist: new Set(), @@ -120,7 +123,7 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 4c8369f664..0042dc4f49 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -74,7 +74,7 @@ export class ShellProcessor implements IPromptProcessor { ]; } - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { throw new Error( `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, diff --git a/packages/cli/src/test-utils/mockCommandContext.test.ts b/packages/cli/src/test-utils/mockCommandContext.test.ts index 310bf74864..605718e027 100644 --- a/packages/cli/src/test-utils/mockCommandContext.test.ts +++ b/packages/cli/src/test-utils/mockCommandContext.test.ts @@ -46,15 +46,19 @@ describe('createMockCommandContext', () => { const overrides = { services: { - config: mockConfig, + agentContext: { config: mockConfig }, }, }; const context = createMockCommandContext(overrides); - expect(context.services.config).toBeDefined(); - expect(context.services.config?.getModel()).toBe('gemini-pro'); - expect(context.services.config?.getProjectRoot()).toBe('/test/project'); + expect(context.services.agentContext).toBeDefined(); + expect(context.services.agentContext?.config?.getModel()).toBe( + 'gemini-pro', + ); + expect(context.services.agentContext?.config?.getProjectRoot()).toBe( + '/test/project', + ); // Verify a default property on the same nested object is still there expect(context.services.logger).toBeDefined(); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index b153aaf85e..15e6422e1a 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -36,7 +36,7 @@ export const createMockCommandContext = ( args: '', }, services: { - config: null, + agentContext: null, settings: { merged: defaultMergedSettings, diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index f1c010678e..0fa1f709ba 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -36,10 +36,12 @@ describe('aboutCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getModel: vi.fn(), - getIdeMode: vi.fn().mockReturnValue(true), - getUserTierName: vi.fn().mockReturnValue(undefined), + agentContext: { + config: { + getModel: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, }, settings: { merged: { @@ -57,9 +59,10 @@ describe('aboutCommand', () => { } as unknown as CommandContext); vi.mocked(getVersion).mockResolvedValue('test-version'); - vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( - 'test-model', - ); + vi.spyOn( + mockContext.services.agentContext!.config, + 'getModel', + ).mockReturnValue('test-model'); process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; Object.defineProperty(process, 'platform', { value: 'test-os', @@ -160,9 +163,9 @@ describe('aboutCommand', () => { }); it('should display the tier when getUserTierName returns a value', async () => { - vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( - 'Enterprise Tier', - ); + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Enterprise Tier'); if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index afd1ada9cd..8b436d69b8 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -34,7 +34,8 @@ export const aboutCommand: SlashCommand = { process.env['SEATBELT_PROFILE'] || 'unknown' })`; } - const modelVersion = context.services.config?.getModel() || 'Unknown'; + const modelVersion = + context.services.agentContext?.config.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; @@ -48,7 +49,7 @@ export const aboutCommand: SlashCommand = { }); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); + const tier = context.services.agentContext?.config.getUserTierName(); const aboutItem: Omit = { type: MessageType.ABOUT, @@ -68,7 +69,7 @@ export const aboutCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 5e6cc36efa..1a5de99122 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -26,6 +26,7 @@ describe('agentsCommand', () => { let mockContext: ReturnType; let mockConfig: { getAgentRegistry: ReturnType; + config: Config; }; beforeEach(() => { @@ -37,11 +38,14 @@ describe('agentsCommand', () => { getAllAgentNames: vi.fn().mockReturnValue([]), reload: vi.fn(), }), + get config() { + return this as unknown as Config; + }, }; mockContext = createMockCommandContext({ services: { - config: mockConfig as unknown as Config, + agentContext: mockConfig as unknown as Config, settings: { workspace: { path: '/mock/path' }, merged: { agents: { overrides: {} } }, @@ -53,7 +57,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -226,7 +230,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for enable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', @@ -332,7 +336,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for disable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', @@ -433,7 +437,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 3658c741ff..d1b582d673 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -21,7 +21,7 @@ const agentsListCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -61,7 +61,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -137,7 +138,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) { return { type: 'message', @@ -216,7 +218,7 @@ async function configAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) { return { type: 'message', @@ -266,7 +268,8 @@ async function configAction( } function completeAgentsToEnable(context: CommandContext, partialArg: string) { - const { config, settings } = context.services; + const config = context.services.agentContext?.config; + const { settings } = context.services; if (!config) return []; const overrides = settings.merged.agents.overrides; @@ -278,7 +281,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { } function completeAgentsToDisable(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -287,7 +290,7 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) { } function completeAllAgents(context: CommandContext, partialArg: string) { - const { config } = context.services; + const config = context.services.agentContext?.config; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -328,7 +331,7 @@ const agentsReloadCommand: SlashCommand = { description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { - const { config } = context.services; + const config = context.services.agentContext?.config; const agentRegistry = config?.getAgentRegistry(); if (!agentRegistry) { return { diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index 88e3273c8d..ff4f2ba614 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -9,6 +9,7 @@ import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; +import type { GeminiClient } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); @@ -24,8 +25,10 @@ describe('authCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: vi.fn(), + agentContext: { + geminiClient: { + stripThoughtsFromHistory: vi.fn(), + }, }, }, }); @@ -101,17 +104,19 @@ describe('authCommand', () => { const mockStripThoughts = vi.fn(); const mockClient = { stripThoughtsFromHistory: mockStripThoughts, - } as unknown as ReturnType< - NonNullable['getGeminiClient'] - >; - - if (mockContext.services.config) { - mockContext.services.config.getGeminiClient = vi.fn(() => mockClient); + } as unknown as GeminiClient; + if (mockContext.services.agentContext?.config) { + mockContext.services.agentContext.config.getGeminiClient = vi.fn( + () => mockClient, + ); } await logoutCommand!.action!(mockContext, ''); - expect(mockStripThoughts).toHaveBeenCalled(); + expect( + mockContext.services.agentContext?.geminiClient + .stripThoughtsFromHistory, + ).toHaveBeenCalled(); }); it('should return logout action to signal explicit state change', async () => { @@ -123,7 +128,7 @@ describe('authCommand', () => { it('should handle missing config gracefully', async () => { const logoutCommand = authCommand.subCommands?.[1]; - mockContext.services.config = null; + mockContext.services.agentContext = null; const result = await logoutCommand!.action!(mockContext, ''); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 80c432894c..084763058c 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -39,7 +39,7 @@ const authLogoutCommand: SlashCommand = { undefined, ); // Strip thoughts from history instead of clearing completely - context.services.config?.getGeminiClient()?.stripThoughtsFromHistory(); + context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); // Return logout action to signal explicit state change return { type: 'logout', diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index 88db905e77..c2c1a9a1d6 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -83,16 +83,18 @@ describe('bugCommand', () => { it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), + }, }, }, }); @@ -126,18 +128,20 @@ describe('bugCommand', () => { ]; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', + }, + }, + geminiClient: { getChat: () => ({ getHistory: () => history, }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), - storage: { - getProjectTempDir: () => '/tmp/gemini', }, }, }, @@ -172,16 +176,18 @@ describe('bugCommand', () => { 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; const mockContext = createMockCommandContext({ services: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getGeminiClient: () => ({ + agentContext: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => ({ urlTemplate: customTemplate }), + getIdeMode: () => true, + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, + geminiClient: { getChat: () => ({ getHistory: () => [], }), - }), - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + }, }, }, }); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 26ddb7e850..134bccc9f0 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -32,8 +32,8 @@ export const bugCommand: SlashCommand = { autoExecute: false, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { @@ -73,7 +73,7 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } - const chat = config?.getGeminiClient()?.getChat(); + const chat = agentContext?.geminiClient?.getChat(); const history = chat?.getHistory() || []; let historyFileMessage = ''; let problemValue = bugDescription; @@ -134,7 +134,7 @@ export const bugCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.config?.getIdeMode()) { + if (!context.services.agentContext?.config.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index c0288fbef2..04d0753ee8 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -70,18 +70,19 @@ describe('chatCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getProjectRoot: () => '/project/root', - getGeminiClient: () => - ({ - getChat: mockGetChat, - }) as unknown as GeminiClient, - storage: { - getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + agentContext: { + config: { + getProjectRoot: () => '/project/root', + getContentGeneratorConfig: () => ({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', + }, }, - getContentGeneratorConfig: () => ({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), + geminiClient: { + getChat: mockGetChat, + } as unknown as GeminiClient, }, logger: { saveCheckpoint: mockSaveCheckpoint, @@ -698,7 +699,11 @@ Hi there!`; beforeEach(() => { mockGetLatestApiRequest = vi.fn(); - mockContext.services.config!.getLatestApiRequest = + if (!mockContext.services.agentContext!.config) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext!.config as any) = {}; + } + mockContext.services.agentContext!.config.getLatestApiRequest = mockGetLatestApiRequest; vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); vi.spyOn(Date, 'now').mockReturnValue(1234567890); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 8b38204aa2..87aacb056b 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -35,7 +35,7 @@ const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { - const cfg = context.services.config; + const cfg = context.services.agentContext?.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; @@ -103,7 +103,8 @@ const saveCommand: SlashCommand = { }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); if (!context.overwriteConfirmed) { @@ -125,7 +126,7 @@ const saveCommand: SlashCommand = { } } - const chat = config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -172,7 +173,8 @@ const resumeCheckpointCommand: SlashCommand = { }; } - const { logger, config } = context.services; + const { logger } = context.services; + const config = context.services.agentContext?.config; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; @@ -298,7 +300,7 @@ const shareCommand: SlashCommand = { }; } - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); if (!chat) { return { type: 'message', @@ -344,7 +346,7 @@ export const debugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { - const req = context.services.config?.getLatestApiRequest(); + const req = context.services.agentContext?.config.getLatestApiRequest(); if (!req) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 0072bebf27..77f6e4854d 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -36,24 +36,25 @@ describe('clearCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - resetChat: mockResetChat, - getChat: () => ({ - getChatRecordingService: mockGetChatRecordingService, - }), - }) as unknown as GeminiClient, - setSessionId: vi.fn(), - getEnableHooks: vi.fn().mockReturnValue(false), - getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: vi.fn().mockReturnValue({ - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), - fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), - }), - injectionService: { - clear: mockHintClear, + agentContext: { + config: { + getEnableHooks: vi.fn().mockReturnValue(false), + setSessionId: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), + injectionService: { + clear: mockHintClear, + }, }, + geminiClient: { + resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), + } as unknown as GeminiClient, }, }, }); @@ -98,7 +99,7 @@ describe('clearCommand', () => { const nullConfigContext = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 05eb96193f..061c4f9085 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -20,8 +20,8 @@ export const clearCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args) => { - const geminiClient = context.services.config?.getGeminiClient(); - const config = context.services.config; + const geminiClient = context.services.agentContext?.geminiClient; + const config = context.services.agentContext?.config; // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index 5fd6f8dc6a..fd60b54354 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -22,11 +22,10 @@ describe('compressCommand', () => { mockTryCompressChat = vi.fn(); context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => - ({ - tryCompressChat: mockTryCompressChat, - }) as unknown as GeminiClient, + agentContext: { + geminiClient: { + tryCompressChat: mockTryCompressChat, + } as unknown as GeminiClient, }, }, }); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index a52e75ab32..6d53667010 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -39,9 +39,11 @@ export const compressCommand: SlashCommand = { try { ui.setPendingItem(pendingMessage); const promptId = `compress-${Date.now()}`; - const compressed = await context.services.config - ?.getGeminiClient() - ?.tryCompressChat(promptId, true); + const compressed = + await context.services.agentContext?.geminiClient?.tryCompressChat( + promptId, + true, + ); if (compressed) { ui.addItem( { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 611162fe20..6a1d36ca21 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -29,10 +29,10 @@ describe('copyCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChat: mockGetChat, - }), + }, }, }, }); @@ -301,7 +301,7 @@ describe('copyCommand', () => { if (!copyCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); const result = await copyCommand.action(nullConfigContext, ''); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 0c01b252ec..746d6899a6 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -18,7 +18,7 @@ export const copyCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args): Promise => { - const chat = context.services.config?.getGeminiClient()?.getChat(); + const chat = context.services.agentContext?.geminiClient?.getChat(); const history = chat?.getHistory(); // Get the last message from the AI (model role) diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index bdfa6ac3a0..837bc696b7 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -85,11 +85,14 @@ describe('directoryCommand', () => { getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), + get config() { + return this; + }, } as unknown as Config; mockContext = { services: { - config: mockConfig, + agentContext: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 70206410de..4106efa97b 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -60,7 +60,7 @@ async function finishAddingDirectories( } if (added.length > 0) { - const gemini = config.getGeminiClient(); + const gemini = config.geminiClient; if (gemini) { await gemini.addDirectoryContext(); @@ -110,9 +110,9 @@ export const directoryCommand: SlashCommand = { // Filter out existing directories let filteredSuggestions = suggestions; - if (context.services.config) { + if (context.services.agentContext?.config) { const workspaceContext = - context.services.config.getWorkspaceContext(); + context.services.agentContext.config.getWorkspaceContext(); const existingDirs = new Set( workspaceContext.getDirectories().map((dir) => path.resolve(dir)), ); @@ -144,11 +144,11 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext, args: string) => { const { ui: { addItem }, - services: { config, settings }, + services: { agentContext, settings }, } = context; const [...rest] = args.split(' '); - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', @@ -156,7 +156,7 @@ export const directoryCommand: SlashCommand = { return; } - if (config.isRestrictiveSandbox()) { + if (agentContext.config.isRestrictiveSandbox()) { return { type: 'message' as const, messageType: 'error' as const, @@ -181,7 +181,7 @@ export const directoryCommand: SlashCommand = { const errors: string[] = []; const alreadyAdded: string[] = []; - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const currentWorkspaceDirs = workspaceContext.getDirectories(); const pathsToProcess: string[] = []; @@ -252,7 +252,7 @@ export const directoryCommand: SlashCommand = { trustedDirs={added} errors={errors} finishAddingDirectories={finishAddingDirectories} - config={config} + config={agentContext.config} addItem={addItem} /> ), @@ -264,7 +264,12 @@ export const directoryCommand: SlashCommand = { errors.push(...result.errors); } - await finishAddingDirectories(config, addItem, added, errors); + await finishAddingDirectories( + agentContext.config, + addItem, + added, + errors, + ); return; }, }, @@ -275,16 +280,16 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext) => { const { ui: { addItem }, - services: { config }, + services: { agentContext }, } = context; - if (!config) { + if (!agentContext) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } - const workspaceContext = config.getWorkspaceContext(); + const workspaceContext = agentContext.config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); addItem({ diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index d1c2ede5e8..dc49390c7e 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -161,14 +161,16 @@ describe('extensionsCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getExtensions: mockGetExtensions, - getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), - getWorkingDir: () => '/test/dir', - reloadSkills: mockReloadSkills, - getAgentRegistry: vi.fn().mockReturnValue({ - reload: mockReloadAgents, - }), + agentContext: { + config: { + getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), + getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), + }, }, }, ui: { @@ -917,7 +919,7 @@ describe('extensionsCommand', () => { expect(restartAction).not.toBeNull(); mockRestartExtension = vi.fn(); - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: mockGetExtensions, @@ -927,7 +929,7 @@ describe('extensionsCommand', () => { }); it('should show a message if no extensions are installed', async () => { - mockContext.services.config!.getExtensionLoader = vi + mockContext.services.agentContext!.config.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: () => [], @@ -1017,7 +1019,7 @@ describe('extensionsCommand', () => { }); it('shows an error if no extension loader is available', async () => { - mockContext.services.config!.getExtensionLoader = vi.fn(); + mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); await restartAction!(mockContext, '--all'); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 8fe206bfc4..8e988917e5 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -54,8 +54,8 @@ function showMessageIfNoExtensions( } async function listAction(context: CommandContext) { - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -88,8 +88,8 @@ function updateAction(context: CommandContext, args: string): Promise { (resolve) => (resolveUpdateComplete = resolve), ); - const extensions = context.services.config - ? listExtensions(context.services.config) + const extensions = context.services.agentContext?.config + ? listExtensions(context.services.agentContext.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -128,7 +128,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = listExtensions(context.services.config!); + const extensions = listExtensions(context.services.agentContext!.config); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -156,7 +156,8 @@ async function restartAction( context: CommandContext, args: string, ): Promise { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!extensionLoader) { context.ui.addItem({ type: MessageType.ERROR, @@ -235,8 +236,8 @@ async function restartAction( if (failures.length < extensionsToRestart.length) { try { - await context.services.config?.reloadSkills(); - await context.services.config?.getAgentRegistry()?.reload(); + await context.services.agentContext?.config.reloadSkills(); + await context.services.agentContext?.config.getAgentRegistry()?.reload(); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, @@ -274,7 +275,8 @@ async function exploreAction( const useRegistryUI = settings.experimental?.extensionRegistry; if (useRegistryUI) { - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (extensionManager instanceof ExtensionManager) { return { type: 'custom_dialog' as const, @@ -331,7 +333,8 @@ function getEnableDisableContext( names: string[]; scope: SettingScope; } | null { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -431,7 +434,8 @@ async function enableAction(context: CommandContext, args: string) { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = context.services.config?.getMcpClientManager(); + const mcpClientManager = + context.services.agentContext?.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); @@ -463,7 +467,8 @@ async function installAction( args: string, requestConsentOverride?: (consent: string) => Promise, ) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -529,7 +534,8 @@ async function installAction( } async function linkAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -593,7 +599,8 @@ async function linkAction(context: CommandContext, args: string) { } async function uninstallAction(context: CommandContext, args: string) { - const extensionLoader = context.services.config?.getExtensionLoader(); + const extensionLoader = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -692,7 +699,8 @@ async function configAction(context: CommandContext, args: string) { } } - const extensionManager = context.services.config?.getExtensionLoader(); + const extensionManager = + context.services.agentContext?.config.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -729,7 +737,7 @@ export function completeExtensions( context: CommandContext, partialArg: string, ) { - let extensions = context.services.config?.getExtensions() ?? []; + let extensions = context.services.agentContext?.config.getExtensions() ?? []; if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 930658e1ab..0059f86105 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -93,7 +93,7 @@ describe('hooksCommand', () => { // Create mock context with config and settings mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: mockSettings, }, }); @@ -141,7 +141,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -225,7 +225,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -338,7 +338,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -470,7 +470,7 @@ describe('hooksCommand', () => { it('should return empty array when config is not available', () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -567,7 +567,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -691,7 +691,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index bc51f42037..4bdc9ead54 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -27,7 +27,8 @@ import { HooksDialog } from '../components/HooksDialog.js'; function panelAction( context: CommandContext, ): MessageActionReturn | OpenCustomDialogActionReturn { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -55,7 +56,8 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -108,7 +110,8 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -163,7 +166,8 @@ function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -183,7 +187,8 @@ function completeDisabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -209,7 +214,8 @@ function getHookDisplayName(hook: HookRegistryEntry): string { async function enableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -280,7 +286,8 @@ async function enableAllAction( async function disableAllAction( context: CommandContext, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 1ddb55dc89..2cb880feaa 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -60,10 +60,12 @@ describe('ideCommand', () => { settings: { setValue: vi.fn(), }, - config: { - getIdeMode: vi.fn(), - setIdeMode: vi.fn(), - getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + agentContext: { + config: { + getIdeMode: vi.fn(), + setIdeMode: vi.fn(), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + }, }, }, } as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 1f726f90e5..df26fdf471 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -217,9 +217,13 @@ export const ideCommand = async (): Promise => { ); // Poll for up to 5 seconds for the extension to activate. for (let i = 0; i < 10; i++) { - await setIdeModeAndSyncConnection(context.services.config!, true, { - logToConsole: false, - }); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + { + logToConsole: false, + }, + ); if ( ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected @@ -262,7 +266,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', true, ); - await setIdeModeAndSyncConnection(context.services.config!, true); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + true, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { @@ -285,7 +292,10 @@ export const ideCommand = async (): Promise => { 'ide.enabled', false, ); - await setIdeModeAndSyncConnection(context.services.config!, false); + await setIdeModeAndSyncConnection( + context.services.agentContext!.config, + false, + ); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 62991c7610..0e4f24a1fe 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -31,8 +31,10 @@ describe('initCommand', () => { // Create a fresh mock context for each test mockContext = createMockCommandContext({ services: { - config: { - getTargetDir: () => targetDir, + agentContext: { + config: { + getTargetDir: () => targetDir, + }, }, }, }); @@ -94,7 +96,7 @@ describe('initCommand', () => { // Arrange: Create a context without config const noConfigContext = createMockCommandContext(); if (noConfigContext.services) { - noConfigContext.services.config = null; + noConfigContext.services.agentContext = null; } // Act: Run the command's action diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index ea0d1ea0c6..d4d8040622 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,14 +23,14 @@ export const initCommand: SlashCommand = { context: CommandContext, _args: string, ): Promise => { - if (!context.services.config) { + if (!context.services.agentContext?.config) { return { type: 'message', messageType: 'error', content: 'Configuration not available.', }; } - const targetDir = context.services.config.getTargetDir(); + const targetDir = context.services.agentContext.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 3acace0774..9a3254fbae 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -119,7 +119,10 @@ describe('mcpCommand', () => { mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { + config: mockConfig, + toolRegistry: mockConfig.getToolRegistry(), + }, }, }); }); @@ -132,7 +135,7 @@ describe('mcpCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - config: null, + agentContext: null, }, }); @@ -146,7 +149,8 @@ describe('mcpCommand', () => { }); it('should show an error if tool registry is not available', async () => { - mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = undefined; const result = await mcpCommand.action!(mockContext, ''); @@ -196,9 +200,13 @@ describe('mcpCommand', () => { ...mockServer3Tools, ]; - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ + const mockToolRegistry = { getAllTools: vi.fn().mockReturnValue(allTools), - }); + }; + mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (mockContext.services.agentContext as any).toolRegistry = + mockToolRegistry; const resourcesByServer: Record< string, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 9ccaaf4273..0fb6b5a1dd 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -42,8 +42,8 @@ const authCommand: SlashCommand = { args: string, ): Promise => { const serverName = args.trim(); - const { config } = context.services; - + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -138,7 +138,7 @@ const authCommand: SlashCommand = { await mcpClientManager.restartServer(serverName); } // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = context.services.agentContext?.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -162,7 +162,8 @@ const authCommand: SlashCommand = { } }, completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; @@ -177,7 +178,8 @@ const listAction = async ( showDescriptions = false, showSchema = false, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -188,7 +190,7 @@ const listAction = async ( config.setUserInteractedWithMcp(); - const toolRegistry = config.getToolRegistry(); + const toolRegistry = agentContext.toolRegistry; if (!toolRegistry) { return { type: 'message', @@ -334,7 +336,8 @@ const reloadCommand: SlashCommand = { action: async ( context: CommandContext, ): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -360,7 +363,7 @@ const reloadCommand: SlashCommand = { await mcpClientManager.restart(); // Update the client with the new tools - const geminiClient = config.getGeminiClient(); + const geminiClient = agentContext.geminiClient; if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -377,7 +380,8 @@ async function handleEnableDisable( args: string, enable: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { return { type: 'message', @@ -465,8 +469,8 @@ async function handleEnableDisable( ); await mcpClientManager.restart(); } - if (config.getGeminiClient()?.isInitialized()) - await config.getGeminiClient().setTools(); + if (agentContext.geminiClient?.isInitialized()) + await agentContext.geminiClient.setTools(); context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: msg }; @@ -477,7 +481,8 @@ async function getEnablementCompletion( partialArg: string, showEnabled: boolean, ): Promise { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return []; const servers = Object.keys( config.getMcpClientManager()?.getMcpServers() || {}, diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 4e70054fac..f02393bef2 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -102,10 +102,12 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getUserMemory: mockGetUserMemory, - getGeminiMdFileCount: mockGetGeminiMdFileCount, - getExtensionLoader: () => new SimpleExtensionLoader([]), + agentContext: { + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), + }, }, }, }); @@ -250,7 +252,7 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: { config: mockConfig }, settings: { merged: { memoryDiscoveryMaxDirs: 1000, @@ -268,7 +270,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config - const config = mockContext.services.config; + const config = mockContext.services.agentContext?.config; if (!config) throw new Error('Config is undefined'); vi.mocked(config.isJitContextEnabled).mockReturnValue(true); @@ -370,7 +372,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { config: null }, + services: { agentContext: null }, }); await expect( @@ -413,8 +415,10 @@ describe('memoryCommand', () => { }); mockContext = createMockCommandContext({ services: { - config: { - getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + agentContext: { + config: { + getGeminiMdFilePaths: mockGetGeminiMdfilePaths, + }, }, }, }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 44c632c67a..145fbae9c3 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -29,7 +29,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = showMemory(config); @@ -81,7 +81,7 @@ export const memoryCommand: SlashCommand = { ); try { - const config = context.services.config; + const config = context.services.agentContext?.config; if (config) { const result = await refreshMemory(config); @@ -111,7 +111,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) return; const result = listMemoryFiles(config); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 89938eb037..aa2359d8fa 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -37,8 +37,11 @@ describe('modelCommand', () => { } const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await modelCommand.action(mockContext, ''); @@ -66,8 +69,11 @@ describe('modelCommand', () => { (c) => c.name === 'manage', ); const mockRefreshUserQuota = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, + get config() { + return this; + }, } as unknown as Config; await manageCommand!.action!(mockContext, ''); @@ -84,7 +90,7 @@ describe('modelCommand', () => { expect(setCommand).toBeDefined(); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -98,6 +104,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro'); @@ -116,7 +125,7 @@ describe('modelCommand', () => { (c) => c.name === 'set', ); const mockSetModel = vi.fn(); - mockContext.services.config = { + mockContext.services.agentContext = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -130,6 +139,9 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), + get config() { + return this; + }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro --persist'); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index ead7e521c5..facaba81ba 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -34,10 +34,10 @@ const setModelCommand: SlashCommand = { const modelName = parts[0]; const persist = parts.includes('--persist'); - if (context.services.config) { - context.services.config.setModel(modelName, !persist); + if (context.services.agentContext?.config) { + context.services.agentContext.config.setModel(modelName, !persist); const event = new ModelSlashCommandEvent(modelName); - logModelSlashCommand(context.services.config, event); + logModelSlashCommand(context.services.agentContext.config, event); context.ui.addItem({ type: MessageType.INFO, @@ -53,8 +53,8 @@ const manageModelCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - if (context.services.config) { - await context.services.config.refreshUserQuota(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.refreshUserQuota(); } return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx index ba4cbe4835..23236ea49c 100644 --- a/packages/cli/src/ui/commands/oncallCommand.tsx +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -24,7 +24,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } @@ -56,7 +57,8 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { throw new Error('Config not available'); } diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index fab1267b17..49c00ce8bd 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -52,14 +52,16 @@ describe('planCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - config: { - isPlanEnabled: vi.fn(), - setApprovalMode: vi.fn(), - getApprovedPlanPath: vi.fn(), - getApprovalMode: vi.fn(), - getFileSystemService: vi.fn(), - storage: { - getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + agentContext: { + config: { + isPlanEnabled: vi.fn(), + setApprovalMode: vi.fn(), + getApprovedPlanPath: vi.fn(), + getApprovalMode: vi.fn(), + getFileSystemService: vi.fn(), + storage: { + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + }, }, }, }, @@ -83,17 +85,19 @@ describe('planCommand', () => { }); it('should switch to plan mode if enabled', async () => { - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - undefined, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(undefined); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); - expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.PLAN, - ); + expect( + mockContext.services.agentContext!.config.setApprovalMode, + ).toHaveBeenCalledWith(ApprovalMode.PLAN); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Switched to Plan Mode.', @@ -102,10 +106,12 @@ describe('planCommand', () => { it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; - vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); - vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( - mockPlanPath, - ); + vi.mocked( + mockContext.services.agentContext!.config.isPlanEnabled, + ).mockReturnValue(true); + vi.mocked( + mockContext.services.agentContext!.config.getApprovedPlanPath, + ).mockReturnValue(mockPlanPath); vi.mocked(processSingleFileContent).mockResolvedValue({ llmContent: '# Approved Plan Content', returnDisplay: '# Approved Plan Content', @@ -128,7 +134,7 @@ describe('planCommand', () => { it('should copy the approved plan to clipboard', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( - mockContext.services.config!.getApprovedPlanPath, + mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(mockPlanPath); vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); @@ -149,7 +155,7 @@ describe('planCommand', () => { it('should warn if no approved plan is found', async () => { vi.mocked( - mockContext.services.config!.getApprovedPlanPath, + mockContext.services.agentContext!.config.getApprovedPlanPath, ).mockReturnValue(undefined); const copySubCommand = planCommand.subCommands?.find( diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index cfa3f9433e..c38d021d90 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -22,7 +22,7 @@ import * as path from 'node:path'; import { copyToClipboard } from '../utils/commandUtils.js'; async function copyAction(context: CommandContext) { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan copy command: config is not available in context'); return; @@ -53,7 +53,7 @@ export const planCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context) => { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { debugLogger.debug('Plan command: config is not available in context'); return; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 554d5cd53d..c5baa89d5d 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -32,7 +32,7 @@ describe('policiesCommand', () => { describe('list subcommand', () => { it('should show error if config is missing', async () => { - mockContext.services.config = null; + mockContext.services.agentContext = null; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); @@ -50,8 +50,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue([]), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -85,8 +88,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -142,8 +148,11 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.config = { + mockContext.services.agentContext = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + get config() { + return this; + }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index f4bd13de28..40ed56ae3b 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -51,7 +51,8 @@ const listPoliciesCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const { config } = context.services; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) { context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index 2a5def5c42..a2f29ca5b9 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -47,14 +47,17 @@ describe('restoreCommand', () => { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, - getGeminiClient: vi.fn().mockReturnValue({ + geminiClient: { setHistory: mockSetHistory, - }), + }, + get config() { + return this; + }, } as unknown as Config; mockContext = createMockCommandContext({ services: { - config: mockConfig, + agentContext: mockConfig, git: mockGitService, }, }); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 3051588e7c..cf18836c20 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -37,10 +37,11 @@ async function restoreAction( args: string, ): Promise { const { services, ui } = context; - const { config, git: gitService } = services; + const { agentContext, git: gitService } = services; const { addItem, loadHistory } = ui; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { @@ -116,7 +117,7 @@ async function restoreAction( } else if (action.type === 'load_history' && loadHistory) { loadHistory(action.history); if (action.clientHistory) { - config?.getGeminiClient()?.setHistory(action.clientHistory); + agentContext!.geminiClient?.setHistory(action.clientHistory); } } } @@ -140,8 +141,9 @@ async function completion( _partialArg: string, ): Promise { const { services } = context; - const { config } = services; - const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); + const { agentContext } = services; + const checkpointDir = + agentContext?.config.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index 529991b07f..d93d365a3e 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -97,15 +97,17 @@ describe('rewindCommand', () => { mockContext = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ + agentContext: { + geminiClient: { getChatRecordingService: mockGetChatRecordingService, setHistory: mockSetHistory, sendMessageStream: mockSendMessageStream, - }), - getSessionId: () => 'test-session-id', - getContextManager: () => ({ refresh: mockResetContext }), - getProjectRoot: mockGetProjectRoot, + }, + config: { + getSessionId: () => 'test-session-id', + getContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, + }, }, }, ui: { @@ -293,7 +295,12 @@ describe('rewindCommand', () => { it('should fail if client is not initialized', () => { const context = createMockCommandContext({ services: { - config: { getGeminiClient: () => undefined }, + agentContext: { + geminiClient: undefined, + get config() { + return this; + }, + }, }, }) as unknown as CommandContext; @@ -309,8 +316,11 @@ describe('rewindCommand', () => { it('should fail if recording service is unavailable', () => { const context = createMockCommandContext({ services: { - config: { - getGeminiClient: () => ({ getChatRecordingService: () => undefined }), + agentContext: { + geminiClient: { getChatRecordingService: () => undefined }, + get config() { + return this; + }, }, }, }) as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index c4af3e845d..c4e0284d0f 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -61,7 +61,7 @@ async function rewindConversation( client.setHistory(clientHistory as Content[]); // Reset context manager as we are rewinding history - await context.services.config?.getContextManager()?.refresh(); + await context.services.agentContext?.config.getContextManager()?.refresh(); // Update UI History // We generate IDs based on index for the rewind history @@ -94,7 +94,8 @@ export const rewindCommand: SlashCommand = { description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, action: (context) => { - const config = context.services.config; + const agentContext = context.services.agentContext; + const config = agentContext?.config; if (!config) return { type: 'message', @@ -102,7 +103,7 @@ export const rewindCommand: SlashCommand = { content: 'Config not found', }; - const client = config.getGeminiClient(); + const client = agentContext.geminiClient; if (!client) return { type: 'message', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index c68dd5cb88..afc9b7210e 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -230,7 +230,7 @@ export const setupGithubCommand: SlashCommand = { } // Get the latest release tag from GitHub - const proxy = context?.services?.config?.getProxy(); + const proxy = context?.services?.agentContext?.config.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 89f690e143..120ba01ed7 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -68,7 +68,7 @@ describe('skillsCommand', () => { ]; context = createMockCommandContext({ services: { - config: { + agentContext: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), getSkills: vi.fn().mockReturnValue(skills), @@ -80,6 +80,9 @@ describe('skillsCommand', () => { ), }), getContentGenerator: vi.fn(), + get config() { + return this; + }, } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -162,7 +165,8 @@ describe('skillsCommand', () => { }); it('should filter built-in skills by default and show them with "all"', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'regular', @@ -452,7 +456,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during disable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const disableCmd = skillsCommand.subCommands!.find( @@ -470,7 +475,8 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during enable', async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const enableCmd = skillsCommand.subCommands!.find( @@ -497,8 +503,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); @@ -537,15 +542,15 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, { name: 'skill2' }, { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -562,13 +567,13 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -585,14 +590,14 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill2' }, // skill1 removed, skill3 added { name: 'skill3' }, ] as SkillDefinition[]); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -608,7 +613,7 @@ describe('skillsCommand', () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; - context.services.config = null; + context.services.agentContext = null; await reloadCmd.action!(context, ''); @@ -628,8 +633,7 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((_, reject) => setTimeout(() => reject(error), 200)); }); - // @ts-expect-error Mocking reloadSkills - context.services.config.reloadSkills = reloadSkillsMock; + context.services.agentContext!.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); await vi.advanceTimersByTimeAsync(100); @@ -651,7 +655,8 @@ describe('skillsCommand', () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', @@ -681,7 +686,8 @@ describe('skillsCommand', () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; - const skillManager = context.services.config!.getSkillManager(); + const skillManager = + context.services.agentContext!.config.getSkillManager(); const mockSkills = [ { name: 'skill1', diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 6f1672208d..a1f9c82445 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -46,7 +46,7 @@ async function listAction( } } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { context.ui.addItem({ type: MessageType.ERROR, @@ -127,8 +127,8 @@ async function linkAction( text: `Successfully linked skills from "${sourcePath}" (${scope}).`, }); - if (context.services.config) { - await context.services.config.reloadSkills(); + if (context.services.agentContext?.config) { + await context.services.agentContext.config.reloadSkills(); } } catch (error) { context.ui.addItem({ @@ -150,14 +150,14 @@ async function disableAction( }); return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -211,14 +211,14 @@ async function enableAction( return; } - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.config ?? undefined, + context.services.agentContext?.config ?? undefined, ), }, Date.now(), @@ -246,7 +246,7 @@ async function enableAction( async function reloadAction( context: CommandContext, ): Promise { - const config = context.services.config; + const config = context.services.agentContext?.config; if (!config) { context.ui.addItem({ type: MessageType.ERROR, @@ -333,7 +333,7 @@ function disableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } @@ -347,7 +347,7 @@ function enableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.config?.getSkillManager(); + const skillManager = context.services.agentContext?.config.getSkillManager(); if (!skillManager) { return []; } diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 57fff84b6b..86ecf68654 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -43,12 +43,15 @@ describe('statsCommand', () => { it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: vi.fn(), refreshAvailableCredits: vi.fn(), getUserTierName: vi.fn(), getUserPaidTier: vi.fn(), getModel: vi.fn(), + get config() { + return this; + }, } as unknown as Config; await statsCommand.action(mockContext, ''); @@ -80,7 +83,7 @@ describe('statsCommand', () => { .fn() .mockReturnValue('2025-01-01T12:00:00Z'); - mockContext.services.config = { + mockContext.services.agentContext = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, getModel: mockGetModel, @@ -89,6 +92,9 @@ describe('statsCommand', () => { getQuotaResetTime: mockGetQuotaResetTime, getUserPaidTier: vi.fn(), refreshAvailableCredits: vi.fn(), + get config() { + return this; + }, } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index fe991e97ed..2ca4596337 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -29,8 +29,8 @@ function getUserIdentity(context: CommandContext) { const cachedAccount = userAccountManager.getCachedGoogleAccount(); const userEmail = cachedAccount ?? undefined; - const tier = context.services.config?.getUserTierName(); - const paidTier = context.services.config?.getUserPaidTier(); + const tier = context.services.agentContext?.config.getUserTierName(); + const paidTier = context.services.agentContext?.config.getUserPaidTier(); const creditBalance = getG1CreditBalance(paidTier) ?? undefined; return { selectedAuthType, userEmail, tier, creditBalance }; @@ -50,7 +50,7 @@ async function defaultSessionView(context: CommandContext) { const { selectedAuthType, userEmail, tier, creditBalance } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); + const currentModel = context.services.agentContext?.config.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, @@ -62,16 +62,19 @@ async function defaultSessionView(context: CommandContext) { creditBalance, }; - if (context.services.config) { + if (context.services.agentContext?.config) { const [quota] = await Promise.all([ - context.services.config.refreshUserQuota(), - context.services.config.refreshAvailableCredits(), + context.services.agentContext.config.refreshUserQuota(), + context.services.agentContext.config.refreshAvailableCredits(), ]); if (quota) { statsItem.quotas = quota; - statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); - statsItem.pooledLimit = context.services.config.getQuotaLimit(); - statsItem.pooledResetTime = context.services.config.getQuotaResetTime(); + statsItem.pooledRemaining = + context.services.agentContext.config.getQuotaRemaining(); + statsItem.pooledLimit = + context.services.agentContext.config.getQuotaLimit(); + statsItem.pooledResetTime = + context.services.agentContext.config.getQuotaResetTime(); } } @@ -107,10 +110,13 @@ export const statsCommand: SlashCommand = { isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); - const currentModel = context.services.config?.getModel(); - const pooledRemaining = context.services.config?.getQuotaRemaining(); - const pooledLimit = context.services.config?.getQuotaLimit(); - const pooledResetTime = context.services.config?.getQuotaResetTime(); + const currentModel = context.services.agentContext?.config.getModel(); + const pooledRemaining = + context.services.agentContext?.config.getQuotaRemaining(); + const pooledLimit = + context.services.agentContext?.config.getQuotaLimit(); + const pooledResetTime = + context.services.agentContext?.config.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index f5ff86f259..02d9ddb5bc 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -30,8 +30,8 @@ describe('toolsCommand', () => { it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => undefined, + agentContext: { + toolRegistry: undefined, }, }, }); @@ -48,10 +48,10 @@ describe('toolsCommand', () => { it('should display "No tools available" when none are found', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ + agentContext: { + toolRegistry: { getAllTools: () => [] as Array>, - }), + }, }, }, }); @@ -69,8 +69,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions by default (no args)', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -90,8 +90,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions when "list" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -111,8 +111,8 @@ describe('toolsCommand', () => { it('should list tools with descriptions when "desc" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -144,8 +144,8 @@ describe('toolsCommand', () => { it('subcommand "list" should display tools without descriptions', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -165,8 +165,8 @@ describe('toolsCommand', () => { it('subcommand "desc" should display tools with descriptions', async () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); @@ -196,8 +196,8 @@ describe('toolsCommand', () => { const mockContext = createMockCommandContext({ services: { - config: { - getToolRegistry: () => ({ getAllTools: () => mockTools }), + agentContext: { + toolRegistry: { getAllTools: () => mockTools }, }, }, }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 082da26fab..d3e5aef74b 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -15,7 +15,7 @@ async function listTools( context: CommandContext, showDescriptions: boolean, ): Promise { - const toolRegistry = context.services.config?.getToolRegistry(); + const toolRegistry = context.services.agentContext?.toolRegistry; if (!toolRegistry) { context.ui.addItem({ type: MessageType.ERROR, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 7bd640090f..4065e075bf 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,11 +11,11 @@ import type { ConfirmationRequest, } from '../types.js'; import type { - Config, GitService, Logger, CommandActionReturn, AgentDefinition, + AgentLoopContext, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -39,7 +39,7 @@ export interface CommandContext { // Core services and configuration services: { // TODO(abhipatel12): Ensure that config is never null. - config: Config | null; + agentContext: AgentLoopContext | null; settings: LoadedSettings; git: GitService | undefined; logger: Logger; diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts index 9c54eb0191..bb07c1bd44 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.test.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -33,11 +33,13 @@ describe('upgradeCommand', () => { vi.clearAllMocks(); mockContext = createMockCommandContext({ services: { - config: { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), - getUserTierName: vi.fn().mockReturnValue(undefined), + agentContext: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + getUserTierName: vi.fn().mockReturnValue(undefined), + }, }, }, } as unknown as CommandContext); @@ -62,7 +64,7 @@ describe('upgradeCommand', () => { it('should return an error message when NOT logged in with Google', async () => { vi.mocked( - mockContext.services.config!.getContentGeneratorConfig, + mockContext.services.agentContext!.config.getContentGeneratorConfig, ).mockReturnValue({ authType: AuthType.USE_GEMINI, }); @@ -118,9 +120,9 @@ describe('upgradeCommand', () => { }); it('should return info message for ultra tiers', async () => { - vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( - 'Advanced Ultra', - ); + vi.mocked( + mockContext.services.agentContext!.config.getUserTierName, + ).mockReturnValue('Advanced Ultra'); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts index 9bbea156ce..f7c09a42f0 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -23,8 +23,8 @@ export const upgradeCommand: SlashCommand = { description: 'Upgrade your Gemini Code Assist tier for higher limits', autoExecute: true, action: async (context) => { - const authType = - context.services.config?.getContentGeneratorConfig()?.authType; + const config = context.services.agentContext?.config; + const authType = config?.getContentGeneratorConfig()?.authType; if (authType !== AuthType.LOGIN_WITH_GOOGLE) { // This command should ideally be hidden if not logged in with Google, // but we add a safety check here just in case. @@ -36,7 +36,7 @@ export const upgradeCommand: SlashCommand = { }; } - const tierName = context.services.config?.getUserTierName(); + const tierName = config?.getUserTierName(); if (isUltraTier(tierName)) { return { type: 'message', diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 6de411ae64..04b521e6a6 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -423,7 +423,7 @@ describe('useSlashCommandProcessor', () => { expect(childAction).toHaveBeenCalledWith( expect.objectContaining({ services: expect.objectContaining({ - config: mockConfig, + agentContext: mockConfig, }), ui: expect.objectContaining({ addItem: mockAddItem, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index d070840f2d..1839670df7 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -209,7 +209,7 @@ export const useSlashCommandProcessor = ( const commandContext = useMemo( (): CommandContext => ({ services: { - config, + agentContext: config, settings, git: gitService, logger, From 7de06162292e0dbc0672eebf93e13f2dd01436a6 Mon Sep 17 00:00:00 2001 From: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:32:35 -0700 Subject: [PATCH 022/110] fix(browser-agent): enable "Allow all server tools" session policy (#22343) --- integration-tests/browser-policy.responses | 5 + integration-tests/browser-policy.test.ts | 178 ++++++++++++++++++ .../core/src/agents/browser/mcpToolWrapper.ts | 22 ++- .../mcpToolWrapperConfirmation.test.ts | 6 +- packages/core/src/policy/config.test.ts | 28 +++ packages/core/src/policy/config.ts | 2 + .../core/src/policy/policy-updater.test.ts | 62 ++++++ 7 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 integration-tests/browser-policy.responses create mode 100644 integration-tests/browser-policy.test.ts diff --git a/integration-tests/browser-policy.responses b/integration-tests/browser-policy.responses new file mode 100644 index 0000000000..23d14e0cb3 --- /dev/null +++ b/integration-tests/browser-policy.responses @@ -0,0 +1,5 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts new file mode 100644 index 0000000000..1bfdc27415 --- /dev/null +++ b/integration-tests/browser-policy.test.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig, poll } from './test-helper.js'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execSync } from 'node:child_process'; +import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import stripAnsi from 'strip-ansi'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const chromeAvailable = (() => { + try { + if (process.platform === 'darwin') { + execSync( + 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', + { + stdio: 'ignore', + }, + ); + } else if (process.platform === 'linux') { + execSync( + 'which google-chrome || which chromium-browser || which chromium', + { stdio: 'ignore' }, + ); + } else if (process.platform === 'win32') { + const chromePaths = [ + 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', + 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', + `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, + ]; + const found = chromePaths.some((p) => existsSync(p)); + if (!found) { + execSync('where chrome || where chromium', { stdio: 'ignore' }); + } + } else { + return false; + } + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!chromeAvailable)('browser-policy', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => { + rig.setup('browser-policy-skip-confirmation', { + fakeResponsesPath: join(__dirname, 'browser-policy.responses'), + settings: { + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: true, + sessionMode: 'isolated', + allowedDomains: ['example.com'], + }, + }, + }, + }); + + // Manually trust the folder to avoid the dialog and enable option 3 + const geminiDir = join(rig.homeDir!, '.gemini'); + mkdirSync(geminiDir, { recursive: true }); + + // Write to trustedFolders.json + const trustedFoldersPath = join(geminiDir, 'trustedFolders.json'); + const trustedFolders = { + [rig.testDir!]: 'TRUST_FOLDER', + }; + writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2)); + + // Force confirmation for browser agent. + // NOTE: We don't force confirm browser tools here because "Allow all server tools" + // adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by + // a rule in the user tier (4.x) like the one from this TOML. + // By removing the explicit mcp rule, the first MCP tool will still prompt + // due to default approvalMode = 'default', and then "Allow all" will correctly + // bypass subsequent tools. + const policyFile = join(rig.testDir!, 'force-confirm.toml'); + writeFileSync( + policyFile, + ` +[[rule]] +name = "Force confirm browser_agent" +toolName = "browser_agent" +decision = "ask_user" +priority = 200 +`, + ); + + // Update settings.json in both project and home directories to point to the policy file + for (const baseDir of [rig.testDir!, rig.homeDir!]) { + const settingsPath = join(baseDir, '.gemini', 'settings.json'); + if (existsSync(settingsPath)) { + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); + settings.policyPaths = [policyFile]; + // Ensure folder trust is enabled + settings.security = settings.security || {}; + settings.security.folderTrust = settings.security.folderTrust || {}; + settings.security.folderTrust.enabled = true; + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + } + + const run = await rig.runInteractive({ + approvalMode: 'default', + env: { + GEMINI_CLI_INTEGRATION_TEST: 'true', + }, + }); + + await run.sendKeys( + 'Open https://example.com and check if there is a heading\r', + ); + await run.sendKeys('\r'); + + // Handle confirmations. + // 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once) + await poll( + () => stripAnsi(run.output).toLowerCase().includes('action required'), + 60000, + 1000, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 2000)); + + // Handle privacy notice + await poll( + () => stripAnsi(run.output).toLowerCase().includes('privacy notice'), + 5000, + 100, + ); + await run.sendKeys('1\r'); + await new Promise((r) => setTimeout(r, 5000)); + + // new_page (MCP tool, should have 4 options, use option 3: Allow all server tools) + await poll( + () => { + const stripped = stripAnsi(run.output).toLowerCase(); + return ( + stripped.includes('new_page') && + stripped.includes('allow all server tools for this session') + ); + }, + 60000, + 1000, + ); + + // Select "Allow all server tools for this session" (option 3) + await run.sendKeys('3\r'); + await new Promise((r) => setTimeout(r, 30000)); + + const output = stripAnsi(run.output).toLowerCase(); + + expect(output).toContain('browser_agent'); + expect(output).toContain('completed successfully'); + }); +}); diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts index 3af3f307da..7a352e975c 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.ts @@ -31,6 +31,8 @@ import type { MessageBus } from '../../confirmation-bus/message-bus.js'; import type { BrowserManager, McpToolCallResult } from './browserManager.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { suspendInputBlocker, resumeInputBlocker } from './inputBlocker.js'; +import { MCP_TOOL_PREFIX } from '../../tools/mcp-tool.js'; +import { BROWSER_AGENT_NAME } from './browserAgentDefinition.js'; /** * Tools that interact with page elements and require the input blocker @@ -62,7 +64,13 @@ class McpToolInvocation extends BaseToolInvocation< messageBus: MessageBus, private readonly shouldDisableInput: boolean, ) { - super(params, messageBus, toolName, toolName); + super( + params, + messageBus, + `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`, + toolName, + BROWSER_AGENT_NAME, + ); } getDescription(): string { @@ -79,7 +87,7 @@ class McpToolInvocation extends BaseToolInvocation< return { type: 'mcp', title: `Confirm MCP Tool: ${this.toolName}`, - serverName: 'browser-agent', + serverName: BROWSER_AGENT_NAME, toolName: this.toolName, toolDisplayName: this.toolName, onConfirm: async (outcome: ToolConfirmationOutcome) => { @@ -92,7 +100,7 @@ class McpToolInvocation extends BaseToolInvocation< _outcome: ToolConfirmationOutcome, ): PolicyUpdateOptions | undefined { return { - mcpName: 'browser-agent', + mcpName: BROWSER_AGENT_NAME, }; } @@ -202,6 +210,14 @@ class McpDeclarativeTool extends DeclarativeTool< ); } + // Used for determining tool identity in the policy engine to check if a tool + // call is allowed based on policy. + override get toolAnnotations(): Record { + return { + _serverName: BROWSER_AGENT_NAME, + }; + } + build( params: Record, ): ToolInvocation, ToolResult> { diff --git a/packages/core/src/agents/browser/mcpToolWrapperConfirmation.test.ts b/packages/core/src/agents/browser/mcpToolWrapperConfirmation.test.ts index 25c65f612f..2dcbc21538 100644 --- a/packages/core/src/agents/browser/mcpToolWrapperConfirmation.test.ts +++ b/packages/core/src/agents/browser/mcpToolWrapperConfirmation.test.ts @@ -61,7 +61,7 @@ describe('mcpToolWrapper Confirmation', () => { expect(details).toEqual( expect.objectContaining({ type: 'mcp', - serverName: 'browser-agent', + serverName: 'browser_agent', toolName: 'test_tool', }), ); @@ -76,7 +76,7 @@ describe('mcpToolWrapper Confirmation', () => { expect(mockMessageBus.publish).toHaveBeenCalledWith( expect.objectContaining({ type: MessageBusType.UPDATE_POLICY, - mcpName: 'browser-agent', + mcpName: 'browser_agent', persist: false, }), ); @@ -94,7 +94,7 @@ describe('mcpToolWrapper Confirmation', () => { ); expect(options).toEqual({ - mcpName: 'browser-agent', + mcpName: 'browser_agent', }); }); }); diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 0e2301c1c8..c4204e3c6c 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -630,6 +630,34 @@ name = "invalid-name" ).toBeUndefined(); }); + it('should support mcpName in policy rules from TOML', async () => { + mockPolicyFile( + nodePath.join(MOCK_DEFAULT_DIR, 'mcp.toml'), + ` + [[rule]] + toolName = "my-tool" + mcpName = "my-server" + decision = "allow" + priority = 150 + `, + ); + + const config = await createPolicyEngineConfig( + {}, + ApprovalMode.DEFAULT, + MOCK_DEFAULT_DIR, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'mcp_my-server_my-tool' && + r.mcpName === 'my-server' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + expect(rule?.priority).toBeCloseTo(1.15, 5); + }); + it('should have default ASK_USER rule for discovered tools', async () => { const config = await createPolicyEngineConfig({}, ApprovalMode.DEFAULT); const discoveredRule = config.rules?.find( diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 392ab15c0c..eb53196c92 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -576,6 +576,7 @@ export function createPolicyUpdater( decision: PolicyDecision.ALLOW, priority, argsPattern: new RegExp(pattern), + mcpName: message.mcpName, source: 'Dynamic (Confirmed)', }); } @@ -611,6 +612,7 @@ export function createPolicyUpdater( decision: PolicyDecision.ALLOW, priority, argsPattern, + mcpName: message.mcpName, source: 'Dynamic (Confirmed)', }); } diff --git a/packages/core/src/policy/policy-updater.test.ts b/packages/core/src/policy/policy-updater.test.ts index 7aafcd5153..3bf3579bbc 100644 --- a/packages/core/src/policy/policy-updater.test.ts +++ b/packages/core/src/policy/policy-updater.test.ts @@ -30,6 +30,8 @@ vi.mock('../utils/shell-utils.js', () => ({ interface ParsedPolicy { rule?: Array<{ commandPrefix?: string | string[]; + mcpName?: string; + toolName?: string; }>; } @@ -67,6 +69,7 @@ describe('createPolicyUpdater', () => { type: MessageBusType.UPDATE_POLICY, toolName: 'run_shell_command', commandPrefix: ['echo', 'ls'], + mcpName: 'test-mcp', persist: false, }); @@ -76,6 +79,7 @@ describe('createPolicyUpdater', () => { expect.objectContaining({ toolName: 'run_shell_command', priority: ALWAYS_ALLOW_PRIORITY, + mcpName: 'test-mcp', argsPattern: new RegExp( escapeRegex('"command":"echo') + '(?:[\\s"]|\\\\")', ), @@ -86,6 +90,7 @@ describe('createPolicyUpdater', () => { expect.objectContaining({ toolName: 'run_shell_command', priority: ALWAYS_ALLOW_PRIORITY, + mcpName: 'test-mcp', argsPattern: new RegExp( escapeRegex('"command":"ls') + '(?:[\\s"]|\\\\")', ), @@ -93,6 +98,63 @@ describe('createPolicyUpdater', () => { ); }); + it('should pass mcpName to policyEngine.addRule for argsPattern updates', async () => { + createPolicyUpdater(policyEngine, messageBus, mockStorage); + + await messageBus.publish({ + type: MessageBusType.UPDATE_POLICY, + toolName: 'test_tool', + argsPattern: '"foo":"bar"', + mcpName: 'test-mcp', + persist: false, + }); + + expect(policyEngine.addRule).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'test_tool', + mcpName: 'test-mcp', + argsPattern: /"foo":"bar"/, + }), + ); + }); + + it('should persist mcpName to TOML', async () => { + createPolicyUpdater(policyEngine, messageBus, mockStorage); + vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); + vi.mocked(fs.mkdir).mockResolvedValue(undefined); + + const mockFileHandle = { + writeFile: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(fs.open).mockResolvedValue( + mockFileHandle as unknown as fs.FileHandle, + ); + vi.mocked(fs.rename).mockResolvedValue(undefined); + + await messageBus.publish({ + type: MessageBusType.UPDATE_POLICY, + toolName: 'mcp_test-mcp_tool', + mcpName: 'test-mcp', + commandPrefix: 'ls', + persist: true, + }); + + // Wait for the async listener to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fs.open).toHaveBeenCalled(); + const [content] = mockFileHandle.writeFile.mock.calls[0] as [ + string, + string, + ]; + const parsed = toml.parse(content) as unknown as ParsedPolicy; + + expect(parsed.rule).toHaveLength(1); + expect(parsed.rule![0].mcpName).toBe('test-mcp'); + expect(parsed.rule![0].toolName).toBe('tool'); // toolName should be stripped of MCP prefix + }); + it('should add a single rule when commandPrefix is a string', async () => { createPolicyUpdater(policyEngine, messageBus, mockStorage); From 23264ced9a3d6ccd26a216dfb4d925b00a7e7ac1 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Thu, 19 Mar 2026 17:05:33 +0000 Subject: [PATCH 023/110] refactor(cli): integrate real config loading into async test utils (#23040) --- .../integration-tests/modelSteering.test.tsx | 2 +- packages/cli/src/test-utils/AppRig.test.tsx | 10 +- packages/cli/src/test-utils/AppRig.tsx | 10 +- packages/cli/src/test-utils/render.tsx | 101 ++-- packages/cli/src/ui/App.test.tsx | 32 +- .../cli/src/ui/IdeIntegrationNudge.test.tsx | 17 +- packages/cli/src/ui/auth/AuthDialog.test.tsx | 34 +- .../src/ui/auth/BannedAccountDialog.test.tsx | 20 +- .../cli/src/ui/components/AboutBox.test.tsx | 8 +- .../AdminSettingsChangedDialog.test.tsx | 6 +- .../ui/components/AgentConfigDialog.test.tsx | 4 +- .../AlternateBufferQuittingDisplay.test.tsx | 12 +- .../cli/src/ui/components/AppHeader.test.tsx | 44 +- .../src/ui/components/AppHeaderIcon.test.tsx | 4 +- .../src/ui/components/AskUserDialog.test.tsx | 70 +-- .../cli/src/ui/components/Banner.test.tsx | 4 +- .../ui/components/BubblingRegression.test.tsx | 2 +- .../cli/src/ui/components/CliSpinner.test.tsx | 6 +- .../src/ui/components/ColorsDisplay.test.tsx | 2 +- .../ui/components/ConfigInitDisplay.test.tsx | 8 +- .../components/ContextUsageDisplay.test.tsx | 10 +- .../DetailedMessagesDisplay.test.tsx | 10 +- .../src/ui/components/DialogManager.test.tsx | 4 +- .../components/EditorSettingsDialog.test.tsx | 12 +- .../ui/components/EmptyWalletDialog.test.tsx | 22 +- .../ui/components/ExitPlanModeDialog.test.tsx | 56 +- .../ui/components/FolderTrustDialog.test.tsx | 43 +- .../cli/src/ui/components/Footer.test.tsx | 138 +++-- .../ui/components/FooterConfigDialog.test.tsx | 16 +- .../ui/components/GradientRegression.test.tsx | 10 +- .../ui/components/HistoryItemDisplay.test.tsx | 134 ++--- .../src/ui/components/HooksDialog.test.tsx | 44 +- .../components/IdeTrustChangeDialog.test.tsx | 12 +- .../src/ui/components/InputPrompt.test.tsx | 532 +++++++++++------- .../ui/components/LoadingIndicator.test.tsx | 38 +- .../LogoutConfirmationDialog.test.tsx | 10 +- .../LoopDetectionConfirmation.test.tsx | 4 +- .../src/ui/components/MainContent.test.tsx | 28 +- .../src/ui/components/ModelDialog.test.tsx | 11 +- .../components/NewAgentsNotification.test.tsx | 6 +- .../src/ui/components/Notifications.test.tsx | 72 +-- .../ui/components/OverageMenuDialog.test.tsx | 22 +- .../PermissionsModifyTrustDialog.test.tsx | 29 +- .../ui/components/PolicyUpdateDialog.test.tsx | 8 +- .../ui/components/RewindConfirmation.test.tsx | 8 +- .../src/ui/components/RewindViewer.test.tsx | 85 +-- .../components/SessionSummaryDisplay.test.tsx | 2 +- .../src/ui/components/SettingsDialog.test.tsx | 113 ++-- .../src/ui/components/ShortcutsHelp.test.tsx | 4 +- .../src/ui/components/StatsDisplay.test.tsx | 49 +- .../src/ui/components/StickyHeader.test.tsx | 2 +- .../src/ui/components/ThemeDialog.test.tsx | 25 +- .../src/ui/components/ToastDisplay.test.tsx | 22 +- .../components/ToolConfirmationQueue.test.tsx | 16 +- .../src/ui/components/UserIdentity.test.tsx | 18 +- .../ConfigInitDisplay.test.tsx.snap | 12 - .../messages/CompressionMessage.test.tsx | 32 +- .../components/messages/DiffRenderer.test.tsx | 34 +- .../messages/GeminiMessage.test.tsx | 6 +- .../messages/RedirectionConfirmation.test.tsx | 2 +- .../messages/ShellToolMessage.test.tsx | 53 +- .../messages/SubagentGroupDisplay.test.tsx | 11 +- .../messages/ThinkingMessage.test.tsx | 16 +- .../messages/ToolConfirmationMessage.test.tsx | 76 +-- .../messages/ToolGroupMessage.test.tsx | 78 +-- .../components/messages/ToolMessage.test.tsx | 54 +- .../messages/ToolMessageFocusHint.test.tsx | 6 +- .../messages/ToolMessageRawMarkdown.test.tsx | 2 +- .../ToolOverflowConsistencyChecks.test.tsx | 4 +- .../messages/ToolResultDisplay.test.tsx | 26 +- .../ToolResultDisplayOverflow.test.tsx | 6 +- .../ToolStickyHeaderRegression.test.tsx | 4 +- .../components/messages/UserMessage.test.tsx | 8 +- .../shared/BaseSelectionList.test.tsx | 6 +- .../shared/BaseSettingsDialog.test.tsx | 2 +- .../DescriptiveRadioButtonSelect.test.tsx | 2 +- .../components/shared/EnumSelector.test.tsx | 18 +- .../shared/HalfLinePaddedBox.test.tsx | 8 +- .../ui/components/shared/MaxSizedBox.test.tsx | 2 +- .../shared/RadioButtonSelect.test.tsx | 14 +- .../ui/components/shared/Scrollable.test.tsx | 12 +- .../components/shared/ScrollableList.test.tsx | 28 +- .../components/shared/SearchableList.test.tsx | 18 +- .../components/shared/SectionHeader.test.tsx | 2 +- .../ui/components/shared/TabHeader.test.tsx | 26 +- .../ui/components/shared/text-buffer.test.ts | 16 +- .../views/ExtensionDetails.test.tsx | 14 +- .../views/ExtensionRegistryView.test.tsx | 14 +- .../ui/components/views/ToolsList.test.tsx | 6 +- .../src/ui/contexts/KeypressContext.test.tsx | 185 ++++-- .../cli/src/ui/contexts/MouseContext.test.tsx | 44 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 - .../ui/hooks/useCommandCompletion.test.tsx | 85 +-- packages/cli/src/ui/hooks/useFocus.test.tsx | 32 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 140 ++--- .../cli/src/ui/hooks/useKeypress.test.tsx | 51 +- .../hooks/useReverseSearchCompletion.test.tsx | 40 +- .../cli/src/ui/hooks/useSnowfall.test.tsx | 61 +- packages/cli/src/ui/hooks/useTips.test.ts | 12 +- .../cli/src/ui/utils/CodeColorizer.test.tsx | 4 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 32 +- .../cli/src/ui/utils/TableRenderer.test.tsx | 24 +- .../cli/src/ui/utils/borderStyles.test.tsx | 6 +- 103 files changed, 1806 insertions(+), 1541 deletions(-) diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx index 27bcde0dc2..bada268329 100644 --- a/packages/cli/src/integration-tests/modelSteering.test.tsx +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -29,7 +29,7 @@ describe('Model Steering Integration', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx index 76c0ddc522..6d94342937 100644 --- a/packages/cli/src/test-utils/AppRig.test.tsx +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -5,7 +5,6 @@ */ import { describe, it, afterEach, expect } from 'vitest'; -import { act } from 'react'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,7 +30,7 @@ describe('AppRig', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - rig.render(); + await rig.render(); await rig.waitForIdle(); // Set breakpoints on the canonical tool names @@ -69,12 +68,7 @@ describe('AppRig', () => { ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); - await act(async () => { - rig!.render(); - // Allow async initializations (like banners) to settle within the act boundary - await new Promise((resolve) => setTimeout(resolve, 0)); - }); - + await rig.render(); // Wait for initial render await rig.waitForIdle(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 39a896a3f8..5ead5d615a 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -11,7 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; -import { renderWithProviders } from './render.js'; +import { renderWithProviders, type RenderInstance } from './render.js'; import { makeFakeConfig, type Config, @@ -155,7 +155,7 @@ export interface PendingConfirmation { } export class AppRig { - private renderResult: ReturnType | undefined; + private renderResult: RenderInstance | undefined; private config: Config | undefined; private settings: LoadedSettings | undefined; private testDir: string; @@ -393,12 +393,12 @@ export class AppRig { return isAnyToolActive || isAwaitingConfirmation; } - render() { + async render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); - act(() => { - this.renderResult = renderWithProviders( + await act(async () => { + this.renderResult = await renderWithProviders( ({ - persistentState: persistentStateMock, + get persistentState() { + return persistentStateMock; + }, })); vi.mock('../ui/utils/terminalUtils.js', () => ({ @@ -486,50 +487,6 @@ export const simulateClick = async ( }); }; -let mockConfigInternal: Config | undefined; - -const getMockConfigInternal = (): Config => { - if (!mockConfigInternal) { - mockConfigInternal = makeFakeConfig({ - targetDir: os.tmpdir(), - enableEventDrivenScheduler: true, - }); - } - return mockConfigInternal; -}; - -const configProxy = new Proxy({} as Config, { - get(_target, prop) { - if (prop === 'getTargetDir') { - return () => - path.join( - path.parse(process.cwd()).root, - 'Users', - 'test', - 'project', - 'foo', - 'bar', - 'and', - 'some', - 'more', - 'directories', - 'to', - 'make', - 'it', - 'long', - ); - } - if (prop === 'getUseBackgroundColor') { - return () => true; - } - const internal = getMockConfigInternal(); - if (prop in internal) { - return internal[prop as keyof typeof internal]; - } - throw new Error(`mockConfig does not have property ${String(prop)}`); - }, -}); - export const mockSettings = createMockSettings(); // A minimal mock UIState to satisfy the context provider. @@ -639,7 +596,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ return <>{children}; }; -export const renderWithProviders = ( +export const renderWithProviders = async ( component: React.ReactElement, { shellFocus = true, @@ -647,8 +604,7 @@ export const renderWithProviders = ( uiState: providedUiState, width, mouseEventsEnabled = false, - - config = configProxy as unknown as Config, + config, uiActions, persistentState, appState = mockAppState, @@ -666,13 +622,15 @@ export const renderWithProviders = ( }; appState?: AppState; } = {}, -): RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; -} => { +): Promise< + RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; + } +> => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { @@ -701,8 +659,15 @@ export const renderWithProviders = ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; - const finalSettings = settings; - const finalConfig = config; + + if (!config) { + config = await loadCliConfig( + settings.merged, + 'random-session-id', + {} as unknown as CliArgs, + { cwd: '/' }, + ); + } const mainAreaWidth = terminalWidth; @@ -732,8 +697,8 @@ export const renderWithProviders = ( const wrapWithProviders = (comp: React.ReactElement) => ( - - + + @@ -744,7 +709,7 @@ export const renderWithProviders = ( ( return { result, rerender, unmount, waitUntilReady, generateSvg }; } -export function renderHookWithProviders( +export async function renderHookWithProviders( renderCallback: (props: Props) => Result, options: { initialProps?: Props; @@ -876,13 +841,13 @@ export function renderHookWithProviders( mouseEventsEnabled?: boolean; config?: Config; } = {}, -): { +): Promise<{ result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -} { +}> { const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -901,8 +866,8 @@ export function renderHookWithProviders( let renderResult: ReturnType; - act(() => { - renderResult = renderWithProviders( + 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 4e59ab854e..7f5e55c022 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -94,11 +94,10 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - config: makeFakeConfig({ useAlternateBuffer: false }), settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); @@ -116,11 +115,10 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, - config: makeFakeConfig({ useAlternateBuffer: false }), settings: createMockSettings({ ui: { useAlternateBuffer: false } }), }, ); @@ -138,11 +136,10 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: quittingUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -159,11 +156,10 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -187,11 +183,10 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -205,11 +200,10 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -225,11 +219,10 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -281,7 +274,7 @@ describe('App', () => { vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: stateWithConfirmingTool, @@ -302,11 +295,10 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -317,11 +309,10 @@ describe('App', () => { it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: mockUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); @@ -335,11 +326,10 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: dialogUIState, - config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), }, ); diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index 1b30e0e0b2..5df3534f12 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -53,7 +53,7 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -68,7 +68,7 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); @@ -89,7 +89,7 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); @@ -115,7 +115,7 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); @@ -146,7 +146,7 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); @@ -173,9 +173,10 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); await waitUntilReady(); diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 7ab5fc0be2..878b2a8ee0 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -143,7 +143,7 @@ describe('AuthDialog', () => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -161,7 +161,7 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -173,7 +173,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 } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -213,7 +213,7 @@ describe('AuthDialog', () => { }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -226,7 +226,7 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -245,7 +245,7 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -261,7 +261,7 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -278,7 +278,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 } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -297,7 +297,7 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -316,7 +316,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 } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -337,7 +337,7 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -360,7 +360,7 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -383,7 +383,7 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -429,7 +429,7 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -442,7 +442,7 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -452,7 +452,7 @@ describe('AuthDialog', () => { it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -462,7 +462,7 @@ describe('AuthDialog', () => { it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx index 692b249415..0670c81bc9 100644 --- a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -73,7 +73,7 @@ describe('BannedAccountDialog', () => { }); it('renders the suspension message from accountSuspensionInfo', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders menu options with appeal link text from response', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('opens browser when appeal option is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('calls onExit when "Exit" is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('calls onChangeAuth when "Change authentication" is selected', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('exits on escape key', async () => { - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( { }); it('renders snapshot correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }; it('renders with required props', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -46,7 +46,7 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -58,7 +58,7 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -69,7 +69,7 @@ describe('AboutBox', () => { it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 0cfe00c764..19db058b87 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,7 +17,7 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( , ); await waitUntilReady(); @@ -25,7 +25,7 @@ describe('AdminSettingsChangedDialog', () => { }); it('restarts on "r" key press', async () => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { @@ -43,7 +43,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin, waitUntilReady } = renderWithProviders( + const { stdin, waitUntilReady } = await renderWithProviders( , { uiActions: { diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 2e5b6ecdb2..a2bfe052bb 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -115,7 +115,7 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = renderWithProviders( + const result = await renderWithProviders( { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -125,7 +125,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -142,7 +142,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -159,7 +159,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -195,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { @@ -220,7 +220,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index ebcd4de973..0d7e2b3a7b 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -10,7 +10,6 @@ import { } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; import { describe, it, expect, vi } from 'vitest'; -import { makeFakeConfig } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; vi.mock('../utils/terminalSetup.js', () => ({ @@ -19,7 +18,6 @@ vi.mock('../utils/terminalSetup.js', () => ({ describe('', () => { it('should render the banner with default text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -29,10 +27,9 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -44,7 +41,6 @@ describe('', () => { }); it('should render the banner with warning text', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -54,10 +50,9 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -69,7 +64,6 @@ describe('', () => { }); it('should not render the banner when no flags are set', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -78,10 +72,9 @@ describe('', () => { }, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -93,7 +86,6 @@ describe('', () => { }); it('should not render the default banner if shown count is 5 or more', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -111,10 +103,9 @@ describe('', () => { }, }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -126,7 +117,6 @@ describe('', () => { }); it('should increment the version count when default banner is displayed', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -139,10 +129,9 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -161,7 +150,6 @@ describe('', () => { }); it('should render banner text with unescaped newlines', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -171,10 +159,9 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -185,7 +172,6 @@ describe('', () => { }); it('should render Tips when tipsShown is less than 10', async () => { - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -197,10 +183,9 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -212,7 +197,6 @@ describe('', () => { }); it('should NOT render Tips when tipsShown is 10 or more', async () => { - const mockConfig = makeFakeConfig(); const uiState = { bannerData: { defaultText: '', @@ -222,10 +206,9 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { - config: mockConfig, uiState, }, ); @@ -238,7 +221,6 @@ describe('', () => { it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); - const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -249,8 +231,7 @@ describe('', () => { }; // First session - const session1 = renderWithProviders(, { - config: mockConfig, + const session1 = await renderWithProviders(, { uiState, }); await session1.waitUntilReady(); @@ -260,9 +241,10 @@ describe('', () => { session1.unmount(); // Second session - state is persisted in the fake - const session2 = renderWithProviders(, { - config: mockConfig, - }); + const session2 = await renderWithProviders( + , + {}, + ); await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx index c16febea66..6b6f0e6210 100644 --- a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -32,7 +32,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the default icon in standard terminals', async () => { vi.mocked(isAppleTerminal).mockReturnValue(false); - const result = renderWithProviders(); + const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); @@ -41,7 +41,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the symmetric icon in Apple Terminal', async () => { vi.mocked(isAppleTerminal).mockReturnValue(true); - const result = renderWithProviders(); + const result = await renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 67289769be..8ed240389c 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 } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }, ] as Question[]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }); it('hides progress header for single question', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }); it('shows keyboard hints', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { ]; const onCancel = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { availableTerminalHeight: 5, } as UIState; - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = renderWithProviders( + const { stdin } = await renderWithProviders( { ['info mode', false, 'Info Message'], ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const renderResult = renderWithProviders( + const renderResult = await renderWithProviders( , ); await renderResult.waitUntilReady(); @@ -24,7 +24,7 @@ describe('Banner', () => { it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const renderResult = renderWithProviders( + const renderResult = await renderWithProviders( , ); await renderResult.waitUntilReady(); diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx index b91943b019..5e83a6b9eb 100644 --- a/packages/cli/src/ui/components/BubblingRegression.test.tsx +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -30,7 +30,7 @@ describe('Key Bubbling Regression', () => { ]; it('does not navigate when pressing "j" or "k" in a focused text input', async () => { - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame } = await renderWithProviders( ', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { waitUntilReady, unmount } = renderWithProviders(); + const { waitUntilReady, unmount } = await renderWithProviders( + , + ); await waitUntilReady(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); @@ -26,7 +28,7 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { settings }, ); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx index ec44bd6406..fdd08fd653 100644 --- a/packages/cli/src/ui/components/ColorsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -96,7 +96,7 @@ describe('ColorsDisplay', () => { it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index 36ecbcbe5f..45ead4862e 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -43,7 +43,7 @@ describe('ConfigInitDisplay', () => { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = renderWithProviders( + const { lastFrame, waitUntilReady } = await renderWithProviders( , ); await waitUntilReady(); @@ -59,7 +59,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); // Wait for listener to be registered await waitFor(() => { @@ -97,7 +97,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); @@ -133,7 +133,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = await renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index dcb2a3eae7..904e06635c 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -19,7 +19,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { describe('ContextUsageDisplay', () => { it('renders correct percentage used', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders correctly when usage is 0%', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders abbreviated label when terminal width is small', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders 80% correctly', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }); it('renders 100% when full', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( ({ describe('DetailedMessagesDisplay', () => { it('renders nothing when messages are empty', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { { type: 'debug', content: 'Debug message', count: 1 }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { { type: 'error', content: 'Error message', count: 1 }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { { type: 'error', content: 'Error message', count: 1 }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { { type: 'log', content: 'Repeated message', count: 5 }, ]; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( { }; it('renders nothing by default', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); @@ -197,7 +197,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , { uiState: { diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index d3b285c3a4..bd995652b1 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -51,11 +51,11 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = (ui: React.ReactElement) => + const renderWithProvider = async (ui: React.ReactElement) => renderWithProviders(ui); it('renders correctly', async () => { - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { }); it('switches focus between editor and scope sections on Tab', async () => { - const { lastFrame, stdin, waitUntilReady } = renderWithProvider( + const { lastFrame, stdin, waitUntilReady } = await renderWithProvider( { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); - const { stdin, waitUntilReady } = renderWithProvider( + const { stdin, waitUntilReady } = await renderWithProvider( { }, } as unknown as LoadedSettings; - const { lastFrame, waitUntilReady } = renderWithProvider( + const { lastFrame, waitUntilReady } = await renderWithProvider( { describe('rendering', () => { it('should match snapshot with fallback available', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should match snapshot without fallback', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display the model name and usage limit message', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display purchase prompt and credits update notice', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display reset time when provided', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should not display reset time when not provided', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { }); it('should display slash command hints', async () => { - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { 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 } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { }); it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { - const { unmount, stdin, waitUntilReady } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { 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 } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { 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 } = renderWithProviders( + const { unmount, stdin, waitUntilReady } = await renderWithProviders( { + const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => { const useAlternateBuffer = options?.useAlternateBuffer ?? true; return renderWithProviders( { it('renders correctly with plan content', async () => { - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); // Advance timers to pass the debounce period await act(async () => { @@ -199,7 +201,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with AUTO_EDIT when first option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -217,7 +221,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with DEFAULT when second option is selected', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -236,7 +242,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onFeedback when feedback is typed and submitted', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -267,7 +275,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onCancel when Esc is pressed', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -293,7 +303,9 @@ Implement a comprehensive authentication system with multiple providers. error: 'File not found', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -309,7 +321,9 @@ Implement a comprehensive authentication system with multiple providers. it('displays error state when plan file is empty', async () => { vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.'); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -328,7 +342,9 @@ Implement a comprehensive authentication system with multiple providers. returnDisplay: 'Read file', }); - const { lastFrame } = renderDialog({ useAlternateBuffer }); + const { lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -344,7 +360,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows number key quick selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -363,7 +381,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('clears feedback text when Ctrl+C is pressed while editing', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -420,7 +440,7 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = renderWithProviders( + const { stdin, lastFrame } = await renderWithProviders( { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -518,7 +540,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows arrow navigation while typing feedback to change selection', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); @@ -550,7 +574,9 @@ Implement a comprehensive authentication system with multiple providers. }); it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { - const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + const { stdin, lastFrame } = await act(async () => + renderDialog({ useAlternateBuffer }), + ); await act(async () => { vi.runAllTimers(); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 9ad4fac02d..c1d04b3ff9 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -48,7 +48,7 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -72,7 +72,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { securityWarnings: [], }; - const { lastFrame, unmount } = renderWithProviders( + const { lastFrame, unmount } = await renderWithProviders( { // because it's handled in AppContainer. // But we can re-render with constrainHeight: false. const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = - renderWithProviders( + await renderWithProviders( { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderWithProviders( + , + ); await waitUntilReady(); await act(async () => { @@ -245,7 +246,7 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -259,7 +260,7 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -274,7 +275,7 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { waitUntilReady, unmount } = renderWithProviders( + const { waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -288,7 +289,7 @@ describe('FolderTrustDialog', () => { }); it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { - const { stdin, waitUntilReady, unmount } = renderWithProviders( + const { stdin, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -307,7 +308,7 @@ 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 } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -317,7 +318,7 @@ describe('FolderTrustDialog', () => { it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -327,7 +328,7 @@ describe('FolderTrustDialog', () => { it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( , ); await waitUntilReady(); @@ -347,7 +348,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = await renderWithProviders( { return frame.replace(/\\/g, '/'); }; -let mockIsDevelopment = false; +const { mocks } = vi.hoisted(() => ({ + mocks: { + isDevelopment: false, + }, +})); vi.mock('../../utils/installationInfo.js', async (importOriginal) => { const original = @@ -24,7 +29,7 @@ vi.mock('../../utils/installationInfo.js', async (importOriginal) => { return { ...original, get isDevelopment() { - return mockIsDevelopment; + return mocks.isDevelopment; }, }; }); @@ -45,11 +50,34 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const defaultProps = { model: 'gemini-pro', - targetDir: - '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', + targetDir: path.join( + path.parse(process.cwd()).root, + 'Users', + 'test', + 'project', + 'foo', + 'bar', + 'and', + 'some', + 'more', + 'directories', + 'to', + 'make', + 'it', + 'long', + ), branchName: 'main', }; +const mockConfig = { + getTargetDir: () => defaultProps.targetDir, + getDebugMode: () => false, + getModel: () => defaultProps.model, + getIdeMode: () => false, + isTrustedFolder: () => true, + getExtensionRegistryURI: () => undefined, +} as unknown as Config; + const mockSessionStats = { sessionId: 'test-session-id', sessionStartTime: new Date(), @@ -110,9 +138,10 @@ describe('