From ddc54584513643d6c8032073361c9c8a625c5e00 Mon Sep 17 00:00:00 2001 From: christine betts Date: Thu, 19 Feb 2026 16:06:37 -0500 Subject: [PATCH 01/52] =?UTF-8?q?Revert=20"Add=20generic=20searchable=20li?= =?UTF-8?q?st=20to=20back=20settings=20and=20extensions=20(=E2=80=A6=20(#1?= =?UTF-8?q?9434)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: jacob314 --- .../cli/src/ui/components/SettingsDialog.tsx | 122 ++++++++++- .../components/shared/BaseSettingsDialog.tsx | 42 ++-- .../components/shared/SearchableList.test.tsx | 157 --------------- .../ui/components/shared/SearchableList.tsx | 189 ------------------ packages/cli/src/ui/hooks/useFuzzyList.ts | 151 -------------- 5 files changed, 132 insertions(+), 529 deletions(-) delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx delete mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx delete mode 100644 packages/cli/src/ui/hooks/useFuzzyList.ts diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 2bfbe7a9fa..fe3acbd1f1 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react'; import { Text } from 'ink'; +import { AsyncFzf } from 'fzf'; import type { Key } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import type { @@ -31,17 +32,27 @@ import { getEffectiveValue, } from '../../utils/settingsUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; +import { getCachedStringWidth } from '../utils/textUtils.js'; import { type SettingsValue, TOGGLE_TYPES, } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useTextBuffer } from './shared/text-buffer.js'; import { - type SettingsDialogItem, BaseSettingsDialog, + type SettingsDialogItem, } from './shared/BaseSettingsDialog.js'; -import { useFuzzyList } from '../hooks/useFuzzyList.js'; + +interface FzfResult { + item: string; + start: number; + end: number; + score: number; + positions?: number[]; +} interface SettingsDialogProps { settings: LoadedSettings; @@ -70,6 +81,60 @@ export function SettingsDialog({ const [showRestartPrompt, setShowRestartPrompt] = useState(false); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + const [filteredKeys, setFilteredKeys] = useState(() => + getDialogSettingKeys(), + ); + const { fzfInstance, searchMap } = useMemo(() => { + const keys = getDialogSettingKeys(); + const map = new Map(); + const searchItems: string[] = []; + + keys.forEach((key) => { + const def = getSettingDefinition(key); + if (def?.label) { + searchItems.push(def.label); + map.set(def.label.toLowerCase(), key); + } + }); + + const fzf = new AsyncFzf(searchItems, { + fuzzy: 'v2', + casing: 'case-insensitive', + }); + return { fzfInstance: fzf, searchMap: map }; + }, []); + + // Perform search + useEffect(() => { + let active = true; + if (!searchQuery.trim() || !fzfInstance) { + setFilteredKeys(getDialogSettingKeys()); + return; + } + + const doSearch = async () => { + const results = await fzfInstance.find(searchQuery); + + if (!active) return; + + const matchedKeys = new Set(); + results.forEach((res: FzfResult) => { + const key = searchMap.get(res.item.toLowerCase()); + if (key) matchedKeys.add(key); + }); + setFilteredKeys(Array.from(matchedKeys)); + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + doSearch(); + + return () => { + active = false; + }; + }, [searchQuery, fzfInstance, searchMap]); + // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => // Deep clone to avoid mutation @@ -117,8 +182,49 @@ export function SettingsDialog({ setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); - // Generate items for SearchableList - const settingKeys = useMemo(() => getDialogSettingKeys(), []); + // Calculate max width for the left column (Label/Description) to keep values aligned or close + const maxLabelOrDescriptionWidth = useMemo(() => { + const allKeys = getDialogSettingKeys(); + let max = 0; + for (const key of allKeys) { + const def = getSettingDefinition(key); + if (!def) continue; + + const scopeMessage = getScopeMessageForSetting( + key, + selectedScope, + settings, + ); + const label = def.label || key; + const labelFull = label + (scopeMessage ? ` ${scopeMessage}` : ''); + const lWidth = getCachedStringWidth(labelFull); + const dWidth = def.description + ? getCachedStringWidth(def.description) + : 0; + + max = Math.max(max, lWidth, dWidth); + } + return max; + }, [selectedScope, settings]); + + // Get mainAreaWidth for search buffer viewport + const { mainAreaWidth } = useUIState(); + const viewportWidth = mainAreaWidth - 8; + + // Search input buffer + const searchBuffer = useTextBuffer({ + initialText: '', + initialCursorOffset: 0, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + // Generate items for BaseSettingsDialog + const settingKeys = searchQuery ? filteredKeys : getDialogSettingKeys(); const items: SettingsDialogItem[] = useMemo(() => { const scopeSettings = settings.forScope(selectedScope).settings; const mergedSettings = settings.merged; @@ -164,10 +270,6 @@ export function SettingsDialog({ }); }, [settingKeys, selectedScope, settings, modifiedSettings, pendingSettings]); - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - }); - // Scope selection handler const handleScopeChange = useCallback((scope: LoadableSettingScope) => { setSelectedScope(scope); @@ -594,12 +696,12 @@ export function SettingsDialog({ borderColor={showRestartPrompt ? theme.status.warning : undefined} searchEnabled={showSearch} searchBuffer={searchBuffer} - items={filteredItems} + items={items} showScopeSelector={showScopeSelection} selectedScope={selectedScope} onScopeChange={handleScopeChange} maxItemsToShow={effectiveMaxItemsToShow} - maxLabelWidth={maxLabelWidth} + maxLabelWidth={maxLabelOrDescriptionWidth} onItemToggle={handleItemToggle} onEditCommit={handleEditCommit} onItemClear={handleItemClear} diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx index e257600188..29592b479b 100644 --- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx +++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx @@ -144,30 +144,28 @@ export function BaseSettingsDialog({ useEffect(() => { const prevItems = prevItemsRef.current; if (prevItems !== items) { - if (items.length === 0) { + const prevActiveItem = prevItems[activeIndex]; + if (prevActiveItem) { + const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); + if (newIndex !== -1) { + // Item still exists in the filtered list, keep focus on it + setActiveIndex(newIndex); + // Adjust scroll offset to ensure the item is visible + let newScroll = scrollOffset; + if (newIndex < scrollOffset) newScroll = newIndex; + else if (newIndex >= scrollOffset + maxItemsToShow) + newScroll = newIndex - maxItemsToShow + 1; + + const maxScroll = Math.max(0, items.length - maxItemsToShow); + setScrollOffset(Math.min(newScroll, maxScroll)); + } else { + // Item was filtered out, reset to the top + setActiveIndex(0); + setScrollOffset(0); + } + } else { setActiveIndex(0); setScrollOffset(0); - } else { - const prevActiveItem = prevItems[activeIndex]; - if (prevActiveItem) { - const newIndex = items.findIndex((i) => i.key === prevActiveItem.key); - if (newIndex !== -1) { - // Item still exists in the filtered list, keep focus on it - setActiveIndex(newIndex); - // Adjust scroll offset to ensure the item is visible - let newScroll = scrollOffset; - if (newIndex < scrollOffset) newScroll = newIndex; - else if (newIndex >= scrollOffset + maxItemsToShow) - newScroll = newIndex - maxItemsToShow + 1; - - const maxScroll = Math.max(0, items.length - maxItemsToShow); - setScrollOffset(Math.min(newScroll, maxScroll)); - } else { - // Item was filtered out, reset to the top - setActiveIndex(0); - setScrollOffset(0); - } - } } prevItemsRef.current = items; } diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx deleted file mode 100644 index fa20352a8b..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { render } from '../../../test-utils/render.js'; -import { waitFor } from '../../../test-utils/async.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { SearchableList, type SearchableListProps } from './SearchableList.js'; -import { KeypressProvider } from '../../contexts/KeypressContext.js'; -import { type GenericListItem } from '../../hooks/useFuzzyList.js'; - -// Mock UI State -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => ({ - mainAreaWidth: 100, - }), -})); - -const mockItems: GenericListItem[] = [ - { - key: 'item-1', - label: 'Item One', - description: 'Description for item one', - }, - { - key: 'item-2', - label: 'Item Two', - description: 'Description for item two', - }, - { - key: 'item-3', - label: 'Item Three', - description: 'Description for item three', - }, -]; - -describe('SearchableList', () => { - let mockOnSelect: ReturnType; - let mockOnClose: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - mockOnSelect = vi.fn(); - mockOnClose = vi.fn(); - }); - - const renderList = ( - props: Partial> = {}, - ) => { - const defaultProps: SearchableListProps = { - title: 'Test List', - items: mockItems, - onSelect: mockOnSelect, - onClose: mockOnClose, - ...props, - }; - - return render( - - - , - ); - }; - - it('should render all items initially', async () => { - const { lastFrame, waitUntilReady } = renderList(); - await waitUntilReady(); - const frame = lastFrame(); - - // Check for title - expect(frame).toContain('Test List'); - - // Check for items - expect(frame).toContain('Item One'); - expect(frame).toContain('Item Two'); - expect(frame).toContain('Item Three'); - - // Check for descriptions - expect(frame).toContain('Description for item one'); - }); - - it('should filter items based on search query', async () => { - const { lastFrame, stdin } = renderList(); - - // Type "Two" into search - await React.act(async () => { - stdin.write('Two'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('Item Two'); - expect(frame).not.toContain('Item One'); - expect(frame).not.toContain('Item Three'); - }); - }); - - it('should show "No items found." when no items match', async () => { - const { lastFrame, stdin } = renderList(); - - // Type something that won't match - await React.act(async () => { - stdin.write('xyz123'); - }); - - await waitFor(() => { - const frame = lastFrame(); - expect(frame).toContain('No items found.'); - }); - }); - - it('should handle selection with Enter', async () => { - const { stdin } = renderList(); - - // Select first item (default active) - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); - }); - }); - - it('should handle navigation and selection', async () => { - const { stdin } = renderList(); - - // Navigate down to second item - await React.act(async () => { - stdin.write('\u001B[B'); // Down Arrow - }); - - // Select second item - await React.act(async () => { - stdin.write('\r'); // Enter - }); - - await waitFor(() => { - expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); - }); - }); - - it('should handle close with Esc', async () => { - const { stdin } = renderList(); - - await React.act(async () => { - stdin.write('\u001B'); // Esc - }); - - await waitFor(() => { - expect(mockOnClose).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx deleted file mode 100644 index 07720ce5d6..0000000000 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ /dev/null @@ -1,189 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { useState, useEffect } from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { TextInput } from './TextInput.js'; -import { useKeypress, type Key } from '../../hooks/useKeypress.js'; -import { keyMatchers, Command } from '../../keyMatchers.js'; -import { - useFuzzyList, - type GenericListItem, -} from '../../hooks/useFuzzyList.js'; - -export interface SearchableListProps { - /** List title */ - title?: string; - /** Available items */ - items: T[]; - /** Callback when an item is selected */ - onSelect: (item: T) => void; - /** Callback when the list is closed (e.g. via Esc) */ - onClose?: () => void; - /** Initial search query */ - initialSearchQuery?: string; - /** Placeholder for search input */ - searchPlaceholder?: string; - /** Max items to show at once */ - maxItemsToShow?: number; -} - -/** - * A generic searchable list component. - */ -export function SearchableList({ - title, - items, - onSelect, - onClose, - initialSearchQuery = '', - searchPlaceholder = 'Search...', - maxItemsToShow = 10, -}: SearchableListProps): React.JSX.Element { - const { filteredItems, searchBuffer, maxLabelWidth } = useFuzzyList({ - items, - initialQuery: initialSearchQuery, - }); - - const [activeIndex, setActiveIndex] = useState(0); - const [scrollOffset, setScrollOffset] = useState(0); - - // Reset selection when filtered items change - useEffect(() => { - setActiveIndex(0); - setScrollOffset(0); - }, [filteredItems]); - - // Calculate visible items - const visibleItems = filteredItems.slice( - scrollOffset, - scrollOffset + maxItemsToShow, - ); - const showScrollUp = scrollOffset > 0; - const showScrollDown = scrollOffset + maxItemsToShow < filteredItems.length; - - useKeypress( - (key: Key) => { - // Navigation - if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) { - const newIndex = - activeIndex > 0 ? activeIndex - 1 : filteredItems.length - 1; - setActiveIndex(newIndex); - if (newIndex === filteredItems.length - 1) { - setScrollOffset(Math.max(0, filteredItems.length - maxItemsToShow)); - } else if (newIndex < scrollOffset) { - setScrollOffset(newIndex); - } - return; - } - if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) { - const newIndex = - activeIndex < filteredItems.length - 1 ? activeIndex + 1 : 0; - setActiveIndex(newIndex); - if (newIndex === 0) { - setScrollOffset(0); - } else if (newIndex >= scrollOffset + maxItemsToShow) { - setScrollOffset(newIndex - maxItemsToShow + 1); - } - return; - } - - // Selection - if (keyMatchers[Command.RETURN](key)) { - const item = filteredItems[activeIndex]; - if (item) { - onSelect(item); - } - return; - } - - // Close - if (keyMatchers[Command.ESCAPE](key)) { - onClose?.(); - return; - } - }, - { isActive: true }, - ); - - return ( - - {/* Header */} - {title && ( - - {title} - - )} - - {/* Search Input */} - {searchBuffer && ( - - - - )} - - {/* List */} - - {visibleItems.length === 0 ? ( - No items found. - ) : ( - visibleItems.map((item, idx) => { - const index = scrollOffset + idx; - const isActive = index === activeIndex; - - return ( - - - {isActive ? '> ' : ' '} - - - - {item.label} - - - {item.description && ( - {item.description} - )} - - ); - }) - )} - - - {/* Footer/Scroll Indicators */} - {(showScrollUp || showScrollDown) && ( - - - {showScrollUp ? '▲ ' : ' '} - {filteredItems.length} items - {showScrollDown ? ' ▼' : ' '} - - - )} - - ); -} diff --git a/packages/cli/src/ui/hooks/useFuzzyList.ts b/packages/cli/src/ui/hooks/useFuzzyList.ts deleted file mode 100644 index 6d07b0ea75..0000000000 --- a/packages/cli/src/ui/hooks/useFuzzyList.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useMemo, useEffect } from 'react'; -import { AsyncFzf } from 'fzf'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { getCachedStringWidth } from '../utils/textUtils.js'; - -interface FzfResult { - item: string; - start: number; - end: number; - score: number; - positions?: number[]; -} - -export interface GenericListItem { - key: string; - label: string; - description?: string; - scopeMessage?: string; -} - -export interface UseFuzzyListProps { - items: T[]; - initialQuery?: string; - onSearch?: (query: string) => void; -} - -export interface UseFuzzyListResult { - filteredItems: T[]; - searchBuffer: TextBuffer | undefined; - searchQuery: string; - setSearchQuery: (query: string) => void; - maxLabelWidth: number; -} - -export function useFuzzyList({ - items, - initialQuery = '', - onSearch, -}: UseFuzzyListProps): UseFuzzyListResult { - // Search state - const [searchQuery, setSearchQuery] = useState(initialQuery); - const [filteredKeys, setFilteredKeys] = useState(() => - items.map((i) => i.key), - ); - - // FZF instance for fuzzy searching - const { fzfInstance, searchMap } = useMemo(() => { - const map = new Map(); - const searchItems: string[] = []; - - items.forEach((item) => { - searchItems.push(item.label); - map.set(item.label.toLowerCase(), item.key); - }); - - const fzf = new AsyncFzf(searchItems, { - fuzzy: 'v2', - casing: 'case-insensitive', - }); - return { fzfInstance: fzf, searchMap: map }; - }, [items]); - - // Perform search - useEffect(() => { - let active = true; - if (!searchQuery.trim() || !fzfInstance) { - setFilteredKeys(items.map((i) => i.key)); - return; - } - - const doSearch = async () => { - const results = await fzfInstance.find(searchQuery); - - if (!active) return; - - const matchedKeys = new Set(); - results.forEach((res: FzfResult) => { - const key = searchMap.get(res.item.toLowerCase()); - if (key) matchedKeys.add(key); - }); - setFilteredKeys(Array.from(matchedKeys)); - onSearch?.(searchQuery); - }; - - void doSearch().catch((error) => { - // eslint-disable-next-line no-console - console.error('Search failed:', error); - setFilteredKeys(items.map((i) => i.key)); // Reset to all items on error - }); - - return () => { - active = false; - }; - }, [searchQuery, fzfInstance, searchMap, items, onSearch]); - - // Get mainAreaWidth for search buffer viewport from UIState - const { mainAreaWidth } = useUIState(); - const viewportWidth = Math.max(20, mainAreaWidth - 8); - - // Search input buffer - const searchBuffer = useTextBuffer({ - initialText: searchQuery, - initialCursorOffset: searchQuery.length, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), - }); - - // Filtered items to display - const filteredItems = useMemo(() => { - if (!searchQuery) return items; - return items.filter((item) => filteredKeys.includes(item.key)); - }, [items, filteredKeys, searchQuery]); - - // Calculate max label width for alignment - const maxLabelWidth = useMemo(() => { - let max = 0; - // We use all items for consistent alignment even when filtered - items.forEach((item) => { - const labelFull = - item.label + (item.scopeMessage ? ` ${item.scopeMessage}` : ''); - const lWidth = getCachedStringWidth(labelFull); - const dWidth = item.description - ? getCachedStringWidth(item.description) - : 0; - max = Math.max(max, lWidth, dWidth); - }); - return max; - }, [items]); - - return { - filteredItems, - searchBuffer, - searchQuery, - setSearchQuery, - maxLabelWidth, - }; -} From 806497389900034e45cd6a160a9d8e71e6ae1ef2 Mon Sep 17 00:00:00 2001 From: Yuna Seol Date: Thu, 19 Feb 2026 16:19:19 -0500 Subject: [PATCH 02/52] fix(core): improve error type extraction for telemetry (#19565) Co-authored-by: Yuna Seol --- .../src/core/loggingContentGenerator.test.ts | 14 ++++++ .../core/src/core/loggingContentGenerator.ts | 3 +- packages/core/src/utils/errors.test.ts | 49 +++++++++++++++++++ packages/core/src/utils/errors.ts | 9 ++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/packages/core/src/core/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator.test.ts index dd354fa16f..45e5028553 100644 --- a/packages/core/src/core/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator.test.ts @@ -32,6 +32,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js'; import type { Config } from '../config/config.js'; import { UserTierId } from '../code_assist/types.js'; import { ApiRequestEvent, LlmRole } from '../telemetry/types.js'; +import { FatalAuthenticationError } from '../utils/errors.js'; describe('LoggingContentGenerator', () => { let wrapped: ContentGenerator; @@ -137,6 +138,19 @@ describe('LoggingContentGenerator', () => { const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; expect(errorEvent.duration_ms).toBe(1000); }); + + describe('error type extraction', () => { + it('should extract error type correctly', async () => { + const req = { contents: [], model: 'm' }; + const error = new FatalAuthenticationError('test'); + vi.mocked(wrapped.generateContent).mockRejectedValue(error); + await expect( + loggingContentGenerator.generateContent(req, 'id', LlmRole.MAIN), + ).rejects.toThrow(); + const errorEvent = vi.mocked(logApiError).mock.calls[0][1]; + expect(errorEvent.error_type).toBe('FatalAuthenticationError'); + }); + }); }); describe('generateContentStream', () => { diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts index 12a1722475..1544087ae0 100644 --- a/packages/core/src/core/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator.ts @@ -36,6 +36,7 @@ import { toContents } from '../code_assist/converter.js'; import { isStructuredError } from '../utils/quotaErrorDetection.js'; import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { getErrorType } from '../utils/errors.js'; interface StructuredError { status: number; @@ -167,7 +168,7 @@ export class LoggingContentGenerator implements ContentGenerator { serverDetails?: ServerDetails, ): void { const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = error instanceof Error ? error.name : 'unknown'; + const errorType = getErrorType(error); logApiError( this.config, diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index 58c7004190..b3df89ef93 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -12,6 +12,14 @@ import { BadRequestError, ForbiddenError, getErrorMessage, + getErrorType, + FatalAuthenticationError, + FatalCancellationError, + FatalInputError, + FatalSandboxError, + FatalConfigError, + FatalTurnLimitedError, + FatalToolExecutionError, } from './errors.js'; describe('getErrorMessage', () => { @@ -201,3 +209,44 @@ describe('toFriendlyError', () => { expect(toFriendlyError(error)).toBe(error); }); }); + +describe('getErrorType', () => { + it('should return error name for standard errors', () => { + expect(getErrorType(new Error('test'))).toBe('Error'); + expect(getErrorType(new TypeError('test'))).toBe('TypeError'); + expect(getErrorType(new SyntaxError('test'))).toBe('SyntaxError'); + }); + + it('should return constructor name for custom errors', () => { + expect(getErrorType(new FatalAuthenticationError('test'))).toBe( + 'FatalAuthenticationError', + ); + expect(getErrorType(new FatalInputError('test'))).toBe('FatalInputError'); + expect(getErrorType(new FatalSandboxError('test'))).toBe( + 'FatalSandboxError', + ); + expect(getErrorType(new FatalConfigError('test'))).toBe('FatalConfigError'); + expect(getErrorType(new FatalTurnLimitedError('test'))).toBe( + 'FatalTurnLimitedError', + ); + expect(getErrorType(new FatalToolExecutionError('test'))).toBe( + 'FatalToolExecutionError', + ); + expect(getErrorType(new FatalCancellationError('test'))).toBe( + 'FatalCancellationError', + ); + expect(getErrorType(new ForbiddenError('test'))).toBe('ForbiddenError'); + expect(getErrorType(new UnauthorizedError('test'))).toBe( + 'UnauthorizedError', + ); + expect(getErrorType(new BadRequestError('test'))).toBe('BadRequestError'); + }); + + it('should return "unknown" for non-Error objects', () => { + expect(getErrorType('string error')).toBe('unknown'); + expect(getErrorType(123)).toBe('unknown'); + expect(getErrorType({})).toBe('unknown'); + expect(getErrorType(null)).toBe('unknown'); + expect(getErrorType(undefined)).toBe('unknown'); + }); +}); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 2bba4f8abe..dc7d5b33c5 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -26,6 +26,15 @@ export function getErrorMessage(error: unknown): string { } } +export function getErrorType(error: unknown): string { + if (!(error instanceof Error)) return 'unknown'; + + // Return constructor name if the generic 'Error' name is used (for custom errors) + return error.name === 'Error' + ? (error.constructor?.name ?? 'Error') + : error.name; +} + export class FatalError extends Error { constructor( message: string, From 2cba2ab37ac4ba2a462d013a2aa5c71408200e81 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Thu, 19 Feb 2026 16:31:09 -0500 Subject: [PATCH 03/52] fix: remove extra padding in Composer (#19529) --- packages/cli/src/ui/components/Composer.tsx | 2 +- .../components/__snapshots__/Composer.test.tsx.snap | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index fe2adba9ab..2adc370ed5 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -191,7 +191,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { {showUiDetails && } - + Snapshots > matches snapshot in idle state 1`] = ` -" - ShortcutsHint +" ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator StatusDisplay InputPrompt: Type your message or @path/to/file @@ -11,22 +10,19 @@ Footer `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode 1`] = ` -" - ShortcutsHint +" ShortcutsHint InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in minimal UI mode while loading 1`] = ` -" - LoadingIndicator +" LoadingIndicator InputPrompt: Type your message or @path/to/file " `; exports[`Composer > Snapshots > matches snapshot in narrow view 1`] = ` " - ShortcutsHint ──────────────────────────────────────── ApprovalModeIndicator @@ -39,8 +35,7 @@ Footer `; exports[`Composer > Snapshots > matches snapshot while streaming 1`] = ` -" - LoadingIndicator: Thinking ShortcutsHint +" LoadingIndicator: Thinking ShortcutsHint ──────────────────────────────────────────────────────────────────────────────────────────────────── ApprovalModeIndicator InputPrompt: Type your message or @path/to/file From 537e56ffae2df937f5763f01663df67b8277fa4b Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Thu, 19 Feb 2026 17:47:08 -0500 Subject: [PATCH 04/52] feat(plan): support configuring custom plans storage directory (#19577) --- docs/cli/plan-mode.md | 41 +++++++ docs/cli/settings.md | 1 + docs/get-started/configuration.md | 6 + packages/cli/src/config/config.test.ts | 21 +++- packages/cli/src/config/config.ts | 1 + .../cli/src/config/settingsSchema.test.ts | 10 ++ packages/cli/src/config/settingsSchema.ts | 22 ++++ .../cli/src/ui/commands/planCommand.test.ts | 2 +- packages/cli/src/ui/commands/planCommand.ts | 2 +- .../ui/components/ExitPlanModeDialog.test.tsx | 4 +- .../src/ui/components/ExitPlanModeDialog.tsx | 4 +- .../components/ToolConfirmationQueue.test.tsx | 2 +- .../SettingsDialog.test.tsx.snap | 54 ++++----- packages/core/src/config/config.test.ts | 40 ++++++- packages/core/src/config/config.ts | 8 +- packages/core/src/config/storage.test.ts | 106 +++++++++++++++++- packages/core/src/config/storage.ts | 27 +++++ packages/core/src/core/prompts.test.ts | 8 +- .../core/src/prompts/promptProvider.test.ts | 4 +- packages/core/src/prompts/promptProvider.ts | 2 +- .../core/src/tools/enter-plan-mode.test.ts | 2 +- .../core/src/tools/exit-plan-mode.test.ts | 2 +- packages/core/src/tools/exit-plan-mode.ts | 10 +- schemas/settings.schema.json | 16 +++ 24 files changed, 337 insertions(+), 58 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 1f283a63aa..995e693bb2 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -127,6 +127,47 @@ To use a skill in Plan Mode, you can explicitly ask the agent to "use the [skill-name] skill to plan..." or the agent may autonomously activate it based on the task description. +### Custom Plan Directory and Policies + +By default, planning artifacts are stored in a managed temporary directory +outside your project: `~/.gemini/tmp///plans/`. + +You can configure a custom directory for plans in your `settings.json`. For +example, to store plans in a `.gemini/plans` directory within your project: + +```json +{ + "general": { + "plan": { + "directory": ".gemini/plans" + } + } +} +``` + +To maintain the safety of Plan Mode, user-configured paths for the plans +directory are restricted to the project root. This ensures that custom planning +locations defined within a project's workspace cannot be used to escape and +overwrite sensitive files elsewhere. Any user-configured directory must reside +within the project boundary. + +Because Plan Mode is read-only by default, using a custom directory requires +updating your [Policy Engine] configurations to allow `write_file` and `replace` +in that specific location. For example, to allow writing to the `.gemini/plans` +directory within your project, create a policy file at +`~/.gemini/policies/plan-custom-directory.toml`: + +```toml +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 100 +modes = ["plan"] +# Adjust the pattern to match your custom directory. +# This example matches any .md file in a .gemini/plans directory within the project. +argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\"" +``` + ### Customizing Policies Plan Mode is designed to be read-only by default to ensure safety during the diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 318fdbea75..a7689fbcea 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -28,6 +28,7 @@ they appear in the UI. | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | | Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | +| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 8e2164080f..ba86442a4d 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -131,6 +131,12 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`general.plan.directory`** (string): + - **Description:** The directory where planning artifacts are stored. If not + specified, defaults to the system temporary directory. + - **Default:** `undefined` + - **Requires restart:** Yes + - **`general.enablePromptCompletion`** (boolean): - **Description:** Enable AI-powered prompt completion suggestions while typing. diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 0c52c9bc4b..809b31cd82 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,7 +21,11 @@ import { type MCPServerConfig, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; -import { type Settings, createTestMergedSettings } from './settings.js'; +import { + type Settings, + type MergedSettings, + createTestMergedSettings, +} from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -2599,6 +2603,21 @@ describe('loadCliConfig approval mode', () => { expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); + it('should pass planSettings.directory from settings to config', async () => { + process.argv = ['node', 'script.js']; + const settings = createTestMergedSettings({ + general: { + plan: { + directory: '.custom-plans', + }, + }, + } as unknown as MergedSettings); + const argv = await parseArguments(settings); + const config = await loadCliConfig(settings, 'test-session', argv); + const plansDir = config.storage.getPlansDir(); + expect(plansDir).toContain('.custom-plans'); + }); + // --- Untrusted Folder Scenarios --- describe('when folder is NOT trusted', () => { beforeEach(() => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4a17ae8ecc..6b7f3460af 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -814,6 +814,7 @@ export async function loadCliConfig( enableExtensionReloading: settings.experimental?.extensionReloading, enableAgents: settings.experimental?.enableAgents, plan: settings.experimental?.plan, + planSettings: settings.general.plan, enableEventDrivenScheduler: true, skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2638ac0347..ffe1dd2ac5 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -107,6 +107,16 @@ describe('SettingsSchema', () => { ).toBe('boolean'); }); + it('should have plan nested properties', () => { + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory, + ).toBeDefined(); + expect( + getSettingsSchema().general?.properties?.plan?.properties?.directory + .type, + ).toBe('string'); + }); + it('should have fileFiltering nested properties', () => { expect( getSettingsSchema().context.properties.fileFiltering.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 70c5363659..f0e092b45b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -266,6 +266,27 @@ const SETTINGS_SCHEMA = { }, }, }, + plan: { + type: 'object', + label: 'Plan', + category: 'General', + requiresRestart: true, + default: {}, + description: 'Planning features configuration.', + showInDialog: false, + properties: { + directory: { + type: 'string', + label: 'Plan Directory', + category: 'General', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', + showInDialog: true, + }, + }, + }, enablePromptCompletion: { type: 'boolean', label: 'Enable Prompt Completion', @@ -1313,6 +1334,7 @@ const SETTINGS_SCHEMA = { }, }, }, + useWriteTodos: { type: 'boolean', label: 'Use WriteTodos', diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index af556ae255..2608b44ca9 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -51,7 +51,7 @@ describe('planCommand', () => { getApprovalMode: vi.fn(), getFileSystemService: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c64b0048f4..d9cc6739da 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -43,7 +43,7 @@ export const planCommand: SlashCommand = { try { const content = await processSingleFileContent( approvedPlanPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); const fileName = path.basename(approvedPlanPath); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 36c7bb3437..26b61829a0 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -154,7 +154,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), @@ -429,7 +429,7 @@ Implement a comprehensive authentication system with multiple providers. getIdeMode: () => false, isTrustedFolder: () => true, storage: { - getProjectTempPlansDir: () => mockPlansDir, + getPlansDir: () => mockPlansDir, }, getFileSystemService: (): FileSystemService => ({ readTextFile: vi.fn(), diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 9fc1adfc23..8777136d86 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -65,7 +65,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { try { const pathError = await validatePlanPath( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getTargetDir(), ); if (ignore) return; @@ -83,7 +83,7 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { const result = await processSingleFileContent( planPath, - config.storage.getProjectTempPlansDir(), + config.storage.getPlansDir(), config.getFileSystemService(), ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 4ea2f22796..345d00a263 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -49,7 +49,7 @@ describe('ToolConfirmationQueue', () => { readFile: vi.fn().mockResolvedValue('Plan content'), }), storage: { - getProjectTempPlansDir: () => '/mock/temp/plans', + getPlansDir: () => '/mock/temp/plans', }, } as unknown as Config; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 0dba43a791..3fec2244d7 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -69,6 +69,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -78,9 +81,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -116,6 +116,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -125,9 +128,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -163,6 +163,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -172,9 +175,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -210,6 +210,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -219,9 +222,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -257,6 +257,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -266,9 +269,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -304,6 +304,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -313,9 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -351,6 +351,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -360,9 +363,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -398,6 +398,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Notifications false │ │ Enable run-event notifications for action-required prompts and session completion. … │ │ │ +│ Plan Directory undefined │ +│ The directory where planning artifacts are stored. If not specified, defaults t… │ +│ │ │ Enable Prompt Completion true* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -407,9 +410,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Session Cleanup false │ │ Enable automatic session cleanup │ │ │ -│ Keep chat history undefined │ -│ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c297a20ef6..1c8820f273 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -737,6 +737,42 @@ describe('Server Config (config.ts)', () => { ); }); + describe('Plan Settings', () => { + const testCases = [ + { + name: 'should pass custom plan directory to storage', + planSettings: { directory: 'custom-plans' }, + expected: 'custom-plans', + }, + { + name: 'should call setCustomPlansDir with undefined if directory is not provided', + planSettings: {}, + expected: undefined, + }, + { + name: 'should call setCustomPlansDir with undefined if planSettings is not provided', + planSettings: undefined, + expected: undefined, + }, + ]; + + testCases.forEach(({ name, planSettings, expected }) => { + it(`${name}`, () => { + const setCustomPlansDirSpy = vi.spyOn( + Storage.prototype, + 'setCustomPlansDir', + ); + new Config({ + ...baseParams, + planSettings, + }); + + expect(setCustomPlansDirSpy).toHaveBeenCalledWith(expected); + setCustomPlansDirSpy.mockRestore(); + }); + }); + }); + describe('Telemetry Settings', () => { it('should return default telemetry target if not provided', () => { const params: ConfigParameters = { @@ -2501,7 +2537,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).toHaveBeenCalledWith(plansDir, { recursive: true, }); @@ -2518,7 +2554,7 @@ describe('Plans Directory Initialization', () => { await config.initialize(); - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); expect(fs.promises.mkdir).not.toHaveBeenCalledWith(plansDir, { recursive: true, }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 33e02abf89..5b57a81acf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -141,6 +141,10 @@ export interface SummarizeToolOutputSettings { tokenBudget?: number; } +export interface PlanSettings { + directory?: string; +} + export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; @@ -483,6 +487,7 @@ export interface ConfigParameters { toolOutputMasking?: Partial; disableLLMCorrection?: boolean; plan?: boolean; + planSettings?: PlanSettings; modelSteering?: boolean; onModelChange?: (model: string) => void; mcpEnabled?: boolean; @@ -836,6 +841,7 @@ export class Config { this.extensionManagement = params.extensionManagement ?? true; this.enableExtensionReloading = params.enableExtensionReloading ?? false; this.storage = new Storage(this.targetDir, this.sessionId); + this.storage.setCustomPlansDir(params.planSettings?.directory); this.fakeResponses = params.fakeResponses; this.recordResponses = params.recordResponses; @@ -949,7 +955,7 @@ export class Config { // Add plans directory to workspace context for plan file storage if (this.planEnabled) { - const plansDir = this.storage.getProjectTempPlansDir(); + const plansDir = this.storage.getPlansDir(); await fs.promises.mkdir(plansDir, { recursive: true }); this.workspaceContext.addDirectory(plansDir); } diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index 8d91ca1a3e..afb3eaeeeb 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -12,12 +12,14 @@ vi.unmock('./storageMigration.js'); import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; vi.mock('fs', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, mkdirSync: vi.fn(), + realpathSync: vi.fn(actual.realpathSync), }; }); @@ -61,12 +63,11 @@ describe('Storage – initialize', () => { ).toHaveBeenCalledWith(projectRoot); // Verify migration calls - const shortId = 'project-slug'; // We can't easily get the hash here without repeating logic, but we can verify it's called twice expect(StorageMigration.migrateDirectory).toHaveBeenCalledTimes(2); // Verify identifier is set by checking a path - expect(storage.getProjectTempDir()).toContain(shortId); + expect(storage.getProjectTempDir()).toContain(PROJECT_SLUG); }); }); @@ -105,6 +106,12 @@ describe('Storage – additional helpers', () => { const projectRoot = '/tmp/project'; const storage = new Storage(projectRoot); + beforeEach(() => { + ProjectRegistry.prototype.getShortId = vi + .fn() + .mockReturnValue(PROJECT_SLUG); + }); + it('getWorkspaceSettingsPath returns project/.gemini/settings.json', () => { const expected = path.join(projectRoot, GEMINI_DIR, 'settings.json'); expect(storage.getWorkspaceSettingsPath()).toBe(expected); @@ -172,6 +179,101 @@ describe('Storage – additional helpers', () => { const expected = path.join(tempDir, sessionId, 'plans'); expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + + describe('getPlansDir', () => { + interface TestCase { + name: string; + customDir: string | undefined; + expected: string | (() => string); + expectedError?: string; + setup?: () => () => void; + } + + const testCases: TestCase[] = [ + { + name: 'custom relative path', + customDir: '.my-plans', + expected: path.resolve(projectRoot, '.my-plans'), + }, + { + name: 'custom absolute path outside throws', + customDir: '/absolute/path/to/plans', + expected: '', + expectedError: + "Custom plans directory '/absolute/path/to/plans' resolves to '/absolute/path/to/plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'absolute path that happens to be inside project root', + customDir: path.join(projectRoot, 'internal-plans'), + expected: path.join(projectRoot, 'internal-plans'), + }, + { + name: 'relative path that stays within project root', + customDir: 'subdir/../plans', + expected: path.resolve(projectRoot, 'plans'), + }, + { + name: 'dot path', + customDir: '.', + expected: projectRoot, + }, + { + name: 'default behavior when customDir is undefined', + customDir: undefined, + expected: () => storage.getProjectTempPlansDir(), + }, + { + name: 'escaping relative path throws', + customDir: '../escaped-plans', + expected: '', + expectedError: + "Custom plans directory '../escaped-plans' resolves to '/tmp/escaped-plans', which is outside the project root '/tmp/project'.", + }, + { + name: 'hidden directory starting with ..', + customDir: '..plans', + expected: path.resolve(projectRoot, '..plans'), + }, + { + name: 'security escape via symbolic link throws', + customDir: 'symlink-to-outside', + setup: () => { + vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => { + if (p.toString().includes('symlink-to-outside')) { + return '/outside/project/root'; + } + return p.toString(); + }); + return () => vi.mocked(fs.realpathSync).mockRestore(); + }, + expected: '', + expectedError: + "Custom plans directory 'symlink-to-outside' resolves to '/outside/project/root', which is outside the project root '/tmp/project'.", + }, + ]; + + testCases.forEach(({ name, customDir, expected, expectedError, setup }) => { + it(`should handle ${name}`, async () => { + const cleanup = setup?.(); + try { + if (name.includes('default behavior')) { + await storage.initialize(); + } + + storage.setCustomPlansDir(customDir); + if (expectedError) { + expect(() => storage.getPlansDir()).toThrow(expectedError); + } else { + const expectedValue = + typeof expected === 'function' ? expected() : expected; + expect(storage.getPlansDir()).toBe(expectedValue); + } + } finally { + cleanup?.(); + } + }); + }); + }); }); describe('Storage - System Paths', () => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 2d7f5d8c2a..bce91f7991 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,6 +12,8 @@ import { GEMINI_DIR, homedir, GOOGLE_ACCOUNTS_FILENAME, + isSubpath, + resolveToRealPath, } from '../utils/paths.js'; import { ProjectRegistry } from './projectRegistry.js'; import { StorageMigration } from './storageMigration.js'; @@ -26,12 +28,17 @@ export class Storage { private readonly sessionId: string | undefined; private projectIdentifier: string | undefined; private initPromise: Promise | undefined; + private customPlansDir: string | undefined; constructor(targetDir: string, sessionId?: string) { this.targetDir = targetDir; this.sessionId = sessionId; } + setCustomPlansDir(dir: string | undefined): void { + this.customPlansDir = dir; + } + static getGlobalGeminiDir(): string { const homeDir = homedir(); if (!homeDir) { @@ -253,6 +260,26 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getPlansDir(): string { + if (this.customPlansDir) { + const resolvedPath = path.resolve( + this.getProjectRoot(), + this.customPlansDir, + ); + const realProjectRoot = resolveToRealPath(this.getProjectRoot()); + const realResolvedPath = resolveToRealPath(resolvedPath); + + if (!isSubpath(realProjectRoot, realResolvedPath)) { + throw new Error( + `Custom plans directory '${this.customPlansDir}' resolves to '${realResolvedPath}', which is outside the project root '${realProjectRoot}'.`, + ); + } + + return resolvedPath; + } + return this.getProjectTempPlansDir(); + } + getProjectTempTasksDir(): string { if (this.sessionId) { return path.join(this.getProjectTempDir(), this.sessionId, 'tasks'); diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index ce6f383009..0cee2f8ae4 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -89,9 +89,7 @@ describe('Core System Prompt (prompts.ts)', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), @@ -509,9 +507,7 @@ describe('Core System Prompt (prompts.ts)', () => { vi.mocked(mockConfig.getApprovalMode).mockReturnValue( ApprovalMode.PLAN, ); - vi.mocked(mockConfig.storage.getProjectTempPlansDir).mockReturnValue( - '/tmp/plans', - ); + vi.mocked(mockConfig.storage.getPlansDir).mockReturnValue('/tmp/plans'); }); it('should include approved plan path when set in config', () => { diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index bdc8d553f3..d112b2f06f 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -38,9 +38,7 @@ describe('PromptProvider', () => { getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), storage: { getProjectTempDir: vi.fn().mockReturnValue('/tmp/project-temp'), - getProjectTempPlansDir: vi - .fn() - .mockReturnValue('/tmp/project-temp/plans'), + getPlansDir: vi.fn().mockReturnValue('/tmp/project-temp/plans'), }, isInteractive: vi.fn().mockReturnValue(true), isInteractiveShellEnabled: vi.fn().mockReturnValue(true), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 2b7b7854eb..36ffddf71c 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -172,7 +172,7 @@ export class PromptProvider { 'planningWorkflow', () => ({ planModeToolsList, - plansDir: config.storage.getProjectTempPlansDir(), + plansDir: config.storage.getPlansDir(), approvedPlanPath: config.getApprovedPlanPath(), }), isPlanMode, diff --git a/packages/core/src/tools/enter-plan-mode.test.ts b/packages/core/src/tools/enter-plan-mode.test.ts index 0b1d0a37f0..48bc5b494e 100644 --- a/packages/core/src/tools/enter-plan-mode.test.ts +++ b/packages/core/src/tools/enter-plan-mode.test.ts @@ -24,7 +24,7 @@ describe('EnterPlanModeTool', () => { mockConfig = { setApprovalMode: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), } as unknown as Config['storage'], }; tool = new EnterPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.test.ts b/packages/core/src/tools/exit-plan-mode.test.ts index 3e226c5142..22de81fc7f 100644 --- a/packages/core/src/tools/exit-plan-mode.test.ts +++ b/packages/core/src/tools/exit-plan-mode.test.ts @@ -45,7 +45,7 @@ describe('ExitPlanModeTool', () => { setApprovalMode: vi.fn(), setApprovedPlanPath: vi.fn(), storage: { - getProjectTempPlansDir: vi.fn().mockReturnValue(mockPlansDir), + getPlansDir: vi.fn().mockReturnValue(mockPlansDir), } as unknown as Config['storage'], }; tool = new ExitPlanModeTool( diff --git a/packages/core/src/tools/exit-plan-mode.ts b/packages/core/src/tools/exit-plan-mode.ts index a0540b11e3..c11eaa119e 100644 --- a/packages/core/src/tools/exit-plan-mode.ts +++ b/packages/core/src/tools/exit-plan-mode.ts @@ -57,7 +57,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< private config: Config, messageBus: MessageBus, ) { - const plansDir = config.storage.getProjectTempPlansDir(); + const plansDir = config.storage.getPlansDir(); const definition = getExitPlanModeDefinition(plansDir); super( EXIT_PLAN_MODE_TOOL_NAME, @@ -78,9 +78,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< // Since validateToolParamValues is synchronous, we use a basic synchronous check // for path traversal safety. High-level async validation is deferred to shouldConfirmExecute. - const plansDir = resolveToRealPath( - this.config.storage.getProjectTempPlansDir(), - ); + const plansDir = resolveToRealPath(this.config.storage.getPlansDir()); const resolvedPath = path.resolve( this.config.getTargetDir(), params.plan_path, @@ -111,7 +109,7 @@ export class ExitPlanModeTool extends BaseDeclarativeTool< } override getSchema(modelId?: string) { - const plansDir = this.config.storage.getProjectTempPlansDir(); + const plansDir = this.config.storage.getPlansDir(); return resolveToolDeclaration(getExitPlanModeDefinition(plansDir), modelId); } } @@ -141,7 +139,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation< const pathError = await validatePlanPath( this.params.plan_path, - this.config.storage.getProjectTempPlansDir(), + this.config.storage.getPlansDir(), this.config.getTargetDir(), ); if (pathError) { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f15af605a3..4c51c13e1b 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -105,6 +105,22 @@ }, "additionalProperties": false }, + "plan": { + "title": "Plan", + "description": "Planning features configuration.", + "markdownDescription": "Planning features configuration.\n\n- Category: `General`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "directory": { + "title": "Plan Directory", + "description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.", + "markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`", + "type": "string" + } + }, + "additionalProperties": false + }, "enablePromptCompletion": { "title": "Enable Prompt Completion", "description": "Enable AI-powered prompt completion suggestions while typing.", From d25c469f77237f55c50fb531f6bed06c9a8d4cc9 Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:47:39 -0800 Subject: [PATCH 05/52] Migrate files to resource or references folder. (#19503) --- CONTRIBUTING.md | 2 +- docs/changelogs/index.md | 3 +- docs/cli/cli-reference.md | 46 ++++++------- docs/cli/enterprise.md | 14 ++-- docs/cli/gemini-md.md | 2 +- docs/cli/model.md | 2 +- docs/cli/plan-mode.md | 2 +- docs/cli/sandbox.md | 6 +- docs/cli/telemetry.md | 2 +- docs/cli/themes.md | 6 +- docs/cli/tutorials/memory-management.md | 4 +- docs/cli/tutorials/session-management.md | 2 +- docs/core/index.md | 14 ++-- docs/core/subagents.md | 2 +- docs/extensions/reference.md | 2 +- docs/get-started/authentication.md | 8 +-- docs/get-started/index.md | 2 +- docs/hooks/best-practices.md | 2 +- docs/index.md | 65 ++++++------------- docs/redirects.json | 19 ++++++ docs/{cli => reference}/commands.md | 10 +-- .../configuration.md | 13 ++-- docs/{cli => reference}/keyboard-shortcuts.md | 0 docs/{core => reference}/memport.md | 0 docs/{core => reference}/policy-engine.md | 0 docs/{core => reference}/tools-api.md | 0 docs/{ => resources}/faq.md | 2 +- docs/{ => resources}/quota-and-pricing.md | 0 docs/{ => resources}/tos-privacy.md | 6 +- docs/{ => resources}/troubleshooting.md | 2 +- docs/{cli => resources}/uninstall.md | 0 docs/sidebar.json | 38 ++++++----- docs/tools/index.md | 4 +- docs/tools/internal-docs.md | 10 +-- docs/tools/shell.md | 6 +- scripts/generate-keybindings-doc.ts | 2 +- scripts/generate-settings-doc.ts | 2 +- 37 files changed, 151 insertions(+), 149 deletions(-) create mode 100644 docs/redirects.json rename docs/{cli => reference}/commands.md (98%) rename docs/{get-started => reference}/configuration.md (99%) rename docs/{cli => reference}/keyboard-shortcuts.md (100%) rename docs/{core => reference}/memport.md (100%) rename docs/{core => reference}/policy-engine.md (100%) rename docs/{core => reference}/tools-api.md (100%) rename docs/{ => resources}/faq.md (98%) rename docs/{ => resources}/quota-and-pricing.md (100%) rename docs/{ => resources}/tos-privacy.md (96%) rename docs/{ => resources}/troubleshooting.md (99%) rename docs/{cli => resources}/uninstall.md (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d8252f86c..7dfe898f14 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -546,7 +546,7 @@ Before submitting your documentation pull request, please: If you have questions about contributing documentation: -- Check our [FAQ](/docs/faq.md). +- Check our [FAQ](/docs/resources/faq.md). - Review existing documentation for examples. - Open [an issue](https://github.com/google-gemini/gemini-cli/issues) to discuss your proposed changes. diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 990d116769..3cff4c123b 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -295,7 +295,8 @@ on GitHub. - **Experimental permission improvements:** We are now experimenting with a new policy engine in Gemini CLI. This allows users and administrators to create fine-grained policy for tool calls. Currently behind a flag. See - [policy engine documentation](../core/policy-engine.md) for more information. + [policy engine documentation](../reference/policy-engine.md) for more + information. - Blog: [https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/](https://allen.hutchison.org/2025/11/26/the-guardrails-of-autonomy/) - **Gemini 3 support for paid:** Gemini 3 support has been rolled out to all API diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index b44733916f..f8ff24bed6 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -26,29 +26,29 @@ and parameters. ## CLI Options -| Option | Alias | Type | Default | Description | -| -------------------------------- | ----- | ------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | -| `--version` | `-v` | - | - | Show CLI version number and exit | -| `--help` | `-h` | - | - | Show help information | -| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | -| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | -| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | -| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | -| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | -| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | -| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | -| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | -| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | -| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../core/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | -| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | -| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | -| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | -| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | -| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | -| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | -| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | -| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | +| Option | Alias | Type | Default | Description | +| -------------------------------- | ----- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--debug` | `-d` | boolean | `false` | Run in debug mode with verbose logging | +| `--version` | `-v` | - | - | Show CLI version number and exit | +| `--help` | `-h` | - | - | Show help information | +| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | +| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. | +| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | +| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | +| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | +| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | +| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** | +| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** | +| `--allowed-mcp-server-names` | - | array | - | Allowed MCP server names (comma-separated or multiple flags) | +| `--allowed-tools` | - | array | - | **Deprecated.** Use the [Policy Engine](../reference/policy-engine.md) instead. Tools that are allowed to run without confirmation (comma-separated or multiple flags) | +| `--extensions` | `-e` | array | - | List of extensions to use. If not provided, all extensions are enabled (comma-separated or multiple flags) | +| `--list-extensions` | `-l` | boolean | - | List all available extensions and exit | +| `--resume` | `-r` | string | - | Resume a previous session. Use `"latest"` for most recent or index number (e.g. `--resume 5`) | +| `--list-sessions` | - | boolean | - | List available sessions for the current project and exit | +| `--delete-session` | - | string | - | Delete a session by index number (use `--list-sessions` to see available sessions) | +| `--include-directories` | - | array | - | Additional directories to include in the workspace (comma-separated or multiple flags) | +| `--screen-reader` | - | boolean | - | Enable screen reader mode for accessibility | +| `--output-format` | `-o` | string | `text` | The format of the CLI output. Choices: `text`, `json`, `stream-json` | ## Model selection diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 31f7f89c83..b6d469755b 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -20,7 +20,7 @@ The most powerful tools for enterprise administration are the system-wide settings files. These files allow you to define a baseline configuration (`system-defaults.json`) and a set of overrides (`settings.json`) that apply to all users on a machine. For a complete overview of configuration options, see -the [Configuration documentation](../get-started/configuration.md). +the [Configuration documentation](../reference/configuration.md). Settings are merged from four files. The precedence order for single-value settings (like `theme`) is: @@ -224,8 +224,8 @@ gemini You can significantly enhance security by controlling which tools the Gemini model can use. This is achieved through the `tools.core` setting and the -[Policy Engine](../core/policy-engine.md). For a list of available tools, see -the [Tools documentation](../tools/index.md). +[Policy Engine](../reference/policy-engine.md). For a list of available tools, +see the [Tools documentation](../tools/index.md). ### Allowlisting with `coreTools` @@ -245,8 +245,8 @@ on the approved list. ### Blocklisting with `excludeTools` (Deprecated) -> **Deprecated:** Use the [Policy Engine](../core/policy-engine.md) for more -> robust control. +> **Deprecated:** Use the [Policy Engine](../reference/policy-engine.md) for +> more robust control. Alternatively, you can add specific tools that are considered dangerous in your environment to a blocklist. @@ -289,8 +289,8 @@ unintended tool execution. ## Managing custom tools (MCP servers) If your organization uses custom tools via -[Model-Context Protocol (MCP) servers](../core/tools-api.md), it is crucial to -understand how server configurations are managed to apply security policies +[Model-Context Protocol (MCP) servers](../reference/tools-api.md), it is crucial +to understand how server configurations are managed to apply security policies effectively. ### How MCP server configurations are merged diff --git a/docs/cli/gemini-md.md b/docs/cli/gemini-md.md index 01c99972d9..95f46ae095 100644 --- a/docs/cli/gemini-md.md +++ b/docs/cli/gemini-md.md @@ -88,7 +88,7 @@ More content here. @../shared/style-guide.md ``` -For more details, see the [Memory Import Processor](../core/memport.md) +For more details, see the [Memory Import Processor](../reference/memport.md) documentation. ## Customize the context file name diff --git a/docs/cli/model.md b/docs/cli/model.md index fd0e950bbb..62bfcf5b0b 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -39,7 +39,7 @@ To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable You can also use the `--model` flag to specify a particular Gemini model on startup. For more details, refer to the -[configuration documentation](../get-started/configuration.md). +[configuration documentation](../reference/configuration.md). Changes to these settings will be applied to all subsequent interactions with Gemini CLI. diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 995e693bb2..b59b0c3198 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -225,7 +225,7 @@ Guide]. [MCP tools]: /docs/tools/mcp-server.md [`activate_skill`]: /docs/cli/skills.md [experimental research sub-agents]: /docs/core/subagents.md -[Policy Engine Guide]: /docs/core/policy-engine.md +[Policy Engine Guide]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode [`ask_user`]: /docs/tools/ask-user.md diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index 9f632693c7..392c71a176 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -167,6 +167,6 @@ gemini -s -p "run shell command: mount | grep workspace" ## Related documentation -- [Configuration](../get-started/configuration.md): Full configuration options. -- [Commands](./commands.md): Available commands. -- [Troubleshooting](../troubleshooting.md): General troubleshooting. +- [Configuration](../reference/configuration.md): Full configuration options. +- [Commands](../reference/commands.md): Available commands. +- [Troubleshooting](../resources/troubleshooting.md): General troubleshooting. diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index ca44bccaf0..0cda8b4528 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -93,7 +93,7 @@ Environment variables can be used to override the settings in the file. `true` or `1` will enable the feature. Any other value will disable it. For detailed information about all configuration options, see the -[Configuration guide](../get-started/configuration.md). +[Configuration guide](../reference/configuration.md). ## Google Cloud telemetry diff --git a/docs/cli/themes.md b/docs/cli/themes.md index dc3423080f..08564a249a 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -41,8 +41,8 @@ can change the theme using the `/theme` command. ### Theme persistence Selected themes are saved in Gemini CLI's -[configuration](../get-started/configuration.md) so your preference is -remembered across sessions. +[configuration](../reference/configuration.md) so your preference is remembered +across sessions. --- @@ -194,7 +194,7 @@ untrusted sources. - Or, set it as the default by adding `"theme": "MyCustomTheme"` to the `ui` object in your `settings.json`. - Custom themes can be set at the user, project, or system level, and follow the - same [configuration precedence](../get-started/configuration.md) as other + same [configuration precedence](../reference/configuration.md) as other settings. ### Themes from extensions diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index f679719238..829fbecbd4 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -121,6 +121,6 @@ immediately. Force a reload with: - Learn about [Session management](session-management.md) to see how short-term history works. -- Explore the [Command reference](../../cli/commands.md) for more `/memory` - options. +- Explore the [Command reference](../../reference/commands.md) for more + `/memory` options. - Read the technical spec for [Project context](../../cli/gemini-md.md). diff --git a/docs/cli/tutorials/session-management.md b/docs/cli/tutorials/session-management.md index 775a1e9b5f..7815aa94d6 100644 --- a/docs/cli/tutorials/session-management.md +++ b/docs/cli/tutorials/session-management.md @@ -101,5 +101,5 @@ This creates a new branch of history without losing your original work. - Learn about [Checkpointing](../../cli/checkpointing.md) to understand the underlying safety mechanism. - Explore [Task planning](task-planning.md) to keep complex sessions organized. -- See the [Command reference](../../cli/commands.md) for all `/chat` and +- See the [Command reference](../../reference/commands.md) for all `/chat` and `/resume` options. diff --git a/docs/core/index.md b/docs/core/index.md index 0cd49ad43e..53aa647dc2 100644 --- a/docs/core/index.md +++ b/docs/core/index.md @@ -9,11 +9,11 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the - **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use specialized sub-agents for complex tasks. -- **[Core tools API](./tools-api.md):** Information on how tools are defined, - registered, and used by the core. -- **[Memory Import Processor](./memport.md):** Documentation for the modular - GEMINI.md import feature using @file.md syntax. -- **[Policy Engine](./policy-engine.md):** Use the Policy Engine for +- **[Core tools API](../reference/tools-api.md):** Information on how tools are + defined, registered, and used by the core. +- **[Memory Import Processor](../reference/memport.md):** Documentation for the + modular GEMINI.md import feature using @file.md syntax. +- **[Policy Engine](../reference/policy-engine.md):** Use the Policy Engine for fine-grained control over tool execution. ## Role of the core @@ -92,8 +92,8 @@ This allows you to have global, project-level, and component-level context files, which are all combined to provide the model with the most relevant information. -You can use the [`/memory` command](../cli/commands.md) to `show`, `add`, and -`refresh` the content of loaded `GEMINI.md` files. +You can use the [`/memory` command](../reference/commands.md) to `show`, `add`, +and `refresh` the content of loaded `GEMINI.md` files. ## Citations diff --git a/docs/core/subagents.md b/docs/core/subagents.md index 7e17da94ae..3619609e95 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -17,7 +17,7 @@ the main agent's context or toolset. > ``` > > **Warning:** Subagents currently operate in -> ["YOLO mode"](../get-started/configuration.md#command-line-arguments), meaning +> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning > they may execute tools without individual user confirmation for each step. > Proceed with caution when defining agents with powerful tools like > `run_shell_command` or `write_file`. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index eec5b82025..b4a0df7336 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -130,7 +130,7 @@ The manifest file defines the extension's behavior and configuration. - `description`: A short summary shown in the extension gallery. - `mcpServers`: A map of Model Context Protocol (MCP) servers. Extension servers follow the same format as standard - [CLI configuration](../get-started/configuration.md). + [CLI configuration](../reference/configuration.md). - `contextFileName`: The name of the context file (defaults to `GEMINI.md`). Can also be an array of strings to load multiple context files. - `excludeTools`: An array of tools to block from the model. You can restrict diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 6e2ce5ca05..e8696137cf 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -22,8 +22,8 @@ Select the authentication method that matches your situation in the table below: ### What is my Google account type? - **Individual Google accounts:** Includes all - [free tier accounts](../quota-and-pricing/#free-usage) such as Gemini Code - Assist for individuals, as well as paid subscriptions for + [free tier accounts](../resources/quota-and-pricing.md#free-usage) such as + Gemini Code Assist for individuals, as well as paid subscriptions for [Google AI Pro and Ultra](https://gemini.google/subscriptions/). - **Organization accounts:** Accounts using paid licenses through an @@ -317,5 +317,5 @@ configure authentication using environment variables: Your authentication method affects your quotas, pricing, Terms of Service, and privacy notices. Review the following pages to learn more: -- [Gemini CLI: Quotas and Pricing](../quota-and-pricing.md). -- [Gemini CLI: Terms of Service and Privacy Notice](../tos-privacy.md). +- [Gemini CLI: Quotas and Pricing](../resources/quota-and-pricing.md). +- [Gemini CLI: Terms of Service and Privacy Notice](../resources/tos-privacy.md). diff --git a/docs/get-started/index.md b/docs/get-started/index.md index d5cf891381..4d0158b71f 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -54,7 +54,7 @@ Gemini CLI offers several ways to configure its behavior, including environment variables, command-line arguments, and settings files. To explore your configuration options, see -[Gemini CLI Configuration](./configuration.md). +[Gemini CLI Configuration](../reference/configuration.md). ## Use diff --git a/docs/hooks/best-practices.md b/docs/hooks/best-practices.md index 316aacbc29..fd80fc0b40 100644 --- a/docs/hooks/best-practices.md +++ b/docs/hooks/best-practices.md @@ -420,7 +420,7 @@ When you open a project with hooks defined in `.gemini/settings.json`: Hooks inherit the environment of the Gemini CLI process, which may include sensitive API keys. Gemini CLI provides a -[redaction system](/docs/get-started/configuration#environment-variable-redaction) +[redaction system](/docs/reference/configuration.md#environment-variable-redaction) that automatically filters variables matching sensitive patterns (e.g., `KEY`, `TOKEN`). diff --git a/docs/index.md b/docs/index.md index 813cd4047e..81e760fadd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,41 +48,8 @@ User-focused guides and tutorials for daily development workflows. ## Features -Technical reference documentation for each capability of Gemini CLI. +Technical documentation for each capability of Gemini CLI. -- **[/about](./cli/commands.md#about):** About Gemini CLI. -- **[/auth](./get-started/authentication.md):** Authentication. -- **[/bug](./cli/commands.md#bug):** Report a bug. -- **[/chat](./cli/commands.md#chat):** Chat history. -- **[/clear](./cli/commands.md#clear):** Clear screen. -- **[/compress](./cli/commands.md#compress):** Compress context. -- **[/copy](./cli/commands.md#copy):** Copy output. -- **[/directory](./cli/commands.md#directory-or-dir):** Manage workspace. -- **[/docs](./cli/commands.md#docs):** Open documentation. -- **[/editor](./cli/commands.md#editor):** Select editor. -- **[/extensions](./extensions/index.md):** Manage extensions. -- **[/help](./cli/commands.md#help-or):** Show help. -- **[/hooks](./hooks/index.md):** Hooks. -- **[/ide](./ide-integration/index.md):** IDE integration. -- **[/init](./cli/commands.md#init):** Initialize context. -- **[/mcp](./tools/mcp-server.md):** MCP servers. -- **[/memory](./cli/commands.md#memory):** Manage memory. -- **[/model](./cli/model.md):** Model selection. -- **[/policies](./cli/commands.md#policies):** Manage policies. -- **[/privacy](./cli/commands.md#privacy):** Privacy notice. -- **[/quit](./cli/commands.md#quit-or-exit):** Exit CLI. -- **[/restore](./cli/checkpointing.md):** Restore files. -- **[/resume](./cli/commands.md#resume):** Resume session. -- **[/rewind](./cli/rewind.md):** Rewind. -- **[/settings](./cli/settings.md):** Settings. -- **[/setup-github](./cli/commands.md#setup-github):** GitHub setup. -- **[/shells](./cli/commands.md#shells-or-bashes):** Manage processes. -- **[/skills](./cli/skills.md):** Agent skills. -- **[/stats](./cli/commands.md#stats):** Session statistics. -- **[/terminal-setup](./cli/commands.md#terminal-setup):** Terminal keybindings. -- **[/theme](./cli/themes.md):** Themes. -- **[/tools](./cli/commands.md#tools):** List tools. -- **[/vim](./cli/commands.md#vim):** Vim mode. - **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for loading expert procedures. - **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for @@ -97,12 +64,12 @@ Technical reference documentation for each capability of Gemini CLI. - **[Model routing](./cli/model-routing.md):** Automatic fallback resilience. - **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for planning complex changes. +- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific + tasks. - **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using remote agents. - **[Sandboxing](./cli/sandbox.md):** Isolate tool execution. - **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters. -- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific - tasks. - **[Telemetry](./cli/telemetry.md):** Usage and performance metric details. - **[Todo (tool)](./tools/todos.md):** Progress tracking specification. - **[Token caching](./cli/token-caching.md):** Performance optimization. @@ -134,23 +101,29 @@ Settings and customization options for Gemini CLI. Deep technical documentation and API specifications. -- **[Command reference](./cli/commands.md):** Detailed slash command guide. -- **[Configuration reference](./get-started/configuration.md):** Settings and +- **[Command reference](./reference/commands.md):** Detailed slash command + guide. +- **[Configuration reference](./reference/configuration.md):** Settings and environment variables. -- **[Keyboard shortcuts](./cli/keyboard-shortcuts.md):** Productivity tips. -- **[Memory import processor](./core/memport.md):** How Gemini CLI processes - memory from various sources. -- **[Policy engine](./core/policy-engine.md):** Fine-grained execution control. -- **[Tools API](./core/tools-api.md):** The API for defining and using tools. +- **[Keyboard shortcuts](./reference/keyboard-shortcuts.md):** Productivity + tips. +- **[Memory import processor](./reference/memport.md):** How Gemini CLI + processes memory from various sources. +- **[Policy engine](./reference/policy-engine.md):** Fine-grained execution + control. +- **[Tools API](./reference/tools-api.md):** The API for defining and using + tools. ## Resources Support, release history, and legal information. -- **[FAQ](./faq.md):** Answers to frequently asked questions. +- **[FAQ](./resources/faq.md):** Answers to frequently asked questions. - **[Changelogs](./changelogs/index.md):** Highlights and notable changes. -- **[Quota and pricing](./quota-and-pricing.md):** Limits and billing details. -- **[Terms and privacy](./tos-privacy.md):** Official notices and terms. +- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing + details. +- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and + terms. ## Development diff --git a/docs/redirects.json b/docs/redirects.json new file mode 100644 index 0000000000..5183d0d476 --- /dev/null +++ b/docs/redirects.json @@ -0,0 +1,19 @@ +{ + "/docs/architecture": "/docs/cli/index", + "/docs/cli/commands": "/docs/reference/commands", + "/docs/cli": "/docs", + "/docs/cli/index": "/docs", + "/docs/cli/keyboard-shortcuts": "/docs/reference/keyboard-shortcuts", + "/docs/cli/uninstall": "/docs/resources/uninstall", + "/docs/core/concepts": "/docs", + "/docs/core/memport": "/docs/reference/memport", + "/docs/core/policy-engine": "/docs/reference/policy-engine", + "/docs/core/tools-api": "/docs/reference/tools-api", + "/docs/faq": "/docs/resources/faq", + "/docs/get-started/configuration": "/docs/reference/configuration", + "/docs/get-started/configuration-v1": "/docs/reference/configuration", + "/docs/index": "/docs", + "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", + "/docs/tos-privacy": "/docs/resources/tos-privacy", + "/docs/troubleshooting": "/docs/resources/troubleshooting" +} diff --git a/docs/cli/commands.md b/docs/reference/commands.md similarity index 98% rename from docs/cli/commands.md rename to docs/reference/commands.md index 6d44659404..ee7ac6d581 100644 --- a/docs/cli/commands.md +++ b/docs/reference/commands.md @@ -217,7 +217,7 @@ Slash commands provide meta-level control over the CLI itself. model. - **Note:** For more details on how `GEMINI.md` files contribute to hierarchical memory, see the - [CLI Configuration documentation](../get-started/configuration.md). + [CLI Configuration documentation](./configuration.md). ### `/model` @@ -254,7 +254,7 @@ Slash commands provide meta-level control over the CLI itself. checkpoints to restore from. - **Usage:** `/restore [tool_call_id]` - **Note:** Only available if checkpointing is configured via - [settings](../get-started/configuration.md). See + [settings](./configuration.md). See [Checkpointing documentation](../cli/checkpointing.md) for more details. ### `/rewind` @@ -293,7 +293,8 @@ Slash commands provide meta-level control over the CLI itself. settings that control the behavior and appearance of Gemini CLI. It is equivalent to manually editing the `.gemini/settings.json` file, but with validation and guidance to prevent errors. See the - [settings documentation](./settings.md) for a full list of available settings. + [settings documentation](../cli/settings.md) for a full list of available + settings. - **Usage:** Simply run `/settings` and the editor will open. You can then browse or search for specific settings, view their current values, and modify them as desired. Changes to some settings are applied immediately, while @@ -380,7 +381,8 @@ Slash commands provide meta-level control over the CLI itself. Custom commands allow you to create personalized shortcuts for your most-used prompts. For detailed instructions on how to create, manage, and use them, -please see the dedicated [Custom Commands documentation](./custom-commands.md). +please see the dedicated +[Custom Commands documentation](../cli/custom-commands.md). ## Input prompt shortcuts diff --git a/docs/get-started/configuration.md b/docs/reference/configuration.md similarity index 99% rename from docs/get-started/configuration.md rename to docs/reference/configuration.md index ba86442a4d..de639f95cf 100644 --- a/docs/get-started/configuration.md +++ b/docs/reference/configuration.md @@ -1234,8 +1234,8 @@ within your user's home folder. Environment variables are a common way to configure applications, especially for sensitive information like API keys or for settings that might change between environments. For authentication setup, see the -[Authentication documentation](./authentication.md) which covers all available -authentication methods. +[Authentication documentation](../get-started/authentication.md) which covers +all available authentication methods. The CLI automatically loads environment variables from an `.env` file. The loading order is: @@ -1254,7 +1254,8 @@ the `advanced.excludedEnvVars` setting in your `settings.json` file. - **`GEMINI_API_KEY`**: - Your API key for the Gemini API. - - One of several available [authentication methods](./authentication.md). + - One of several available + [authentication methods](../get-started/authentication.md). - Set this in your shell profile (e.g., `~/.bashrc`, `~/.zshrc`) or an `.env` file. - **`GEMINI_MODEL`**: @@ -1600,15 +1601,15 @@ conventions and context. about the active instructional context. - **Importing content:** You can modularize your context files by importing other Markdown files using the `@path/to/file.md` syntax. For more details, - see the [Memory Import Processor documentation](../core/memport.md). + see the [Memory Import Processor documentation](./memport.md). - **Commands for memory management:** - Use `/memory refresh` to force a re-scan and reload of all context files from all configured locations. This updates the AI's instructional context. - Use `/memory show` to display the combined instructional context currently loaded, allowing you to verify the hierarchy and content being used by the AI. - - See the [Commands documentation](../cli/commands.md#memory) for full details - on the `/memory` command and its sub-commands (`show` and `refresh`). + - See the [Commands documentation](./commands.md#memory) for full details on + the `/memory` command and its sub-commands (`show` and `refresh`). By understanding and utilizing these configuration layers and the hierarchical nature of context files, you can effectively manage the AI's memory and tailor diff --git a/docs/cli/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md similarity index 100% rename from docs/cli/keyboard-shortcuts.md rename to docs/reference/keyboard-shortcuts.md diff --git a/docs/core/memport.md b/docs/reference/memport.md similarity index 100% rename from docs/core/memport.md rename to docs/reference/memport.md diff --git a/docs/core/policy-engine.md b/docs/reference/policy-engine.md similarity index 100% rename from docs/core/policy-engine.md rename to docs/reference/policy-engine.md diff --git a/docs/core/tools-api.md b/docs/reference/tools-api.md similarity index 100% rename from docs/core/tools-api.md rename to docs/reference/tools-api.md diff --git a/docs/faq.md b/docs/resources/faq.md similarity index 98% rename from docs/faq.md rename to docs/resources/faq.md index 2e78f3aa34..eeb0396495 100644 --- a/docs/faq.md +++ b/docs/resources/faq.md @@ -104,7 +104,7 @@ The Gemini CLI configuration is stored in two `settings.json` files: 1. In your home directory: `~/.gemini/settings.json`. 2. In your project's root directory: `./.gemini/settings.json`. -Refer to [Gemini CLI Configuration](./get-started/configuration.md) for more +Refer to [Gemini CLI Configuration](../reference/configuration.md) for more details. ## Google AI Pro/Ultra and subscription FAQs diff --git a/docs/quota-and-pricing.md b/docs/resources/quota-and-pricing.md similarity index 100% rename from docs/quota-and-pricing.md rename to docs/resources/quota-and-pricing.md diff --git a/docs/tos-privacy.md b/docs/resources/tos-privacy.md similarity index 96% rename from docs/tos-privacy.md rename to docs/resources/tos-privacy.md index 0c7073e0fb..e653e59d1d 100644 --- a/docs/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -10,8 +10,8 @@ and Privacy Notices applicable to those services apply to such access and use. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. -**Note:** See [quotas and pricing](/docs/quota-and-pricing.md) for the quota and -pricing details that apply to your usage of the Gemini CLI. +**Note:** See [quotas and pricing](/docs/resources/quota-and-pricing.md) for the +quota and pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods @@ -93,4 +93,4 @@ backend, these Terms of Service and Privacy Notice documents apply: You may opt-out from sending Gemini CLI Usage Statistics to Google by following the instructions available here: -[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md#usage-statistics). +[Usage Statistics Configuration](https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/configuration.md#usage-statistics). diff --git a/docs/troubleshooting.md b/docs/resources/troubleshooting.md similarity index 99% rename from docs/troubleshooting.md rename to docs/resources/troubleshooting.md index f700d0b74f..9e567652d9 100644 --- a/docs/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -93,7 +93,7 @@ topics on: - **Cause:** When sandboxing is enabled, Gemini CLI may attempt operations that are restricted by your sandbox configuration, such as writing outside the project directory or system temp directory. - - **Solution:** Refer to the [Configuration: Sandboxing](./cli/sandbox.md) + - **Solution:** Refer to the [Configuration: Sandboxing](../cli/sandbox.md) documentation for more information, including how to customize your sandbox configuration. diff --git a/docs/cli/uninstall.md b/docs/resources/uninstall.md similarity index 100% rename from docs/cli/uninstall.md rename to docs/resources/uninstall.md diff --git a/docs/sidebar.json b/docs/sidebar.json index 4e95111a13..ef9989884f 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -63,13 +63,13 @@ "slug": "docs/extensions/index" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Help", "link": "/docs/cli/commands/#help-or" }, + { "label": "Help", "link": "/docs/reference/commands/#help-or" }, { "label": "Hooks", "slug": "docs/hooks" }, { "label": "IDE integration", "slug": "docs/ide-integration" }, { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, { "label": "Memory management", - "link": "/docs/cli/commands/#memory" + "link": "/docs/reference/commands/#memory" }, { "label": "Model routing", "slug": "docs/cli/model-routing" }, { "label": "Model selection", "slug": "docs/cli/model" }, @@ -85,15 +85,15 @@ { "label": "Settings", "slug": "docs/cli/settings" }, { "label": "Shell", - "link": "/docs/cli/commands/#shells-or-bashes" + "link": "/docs/reference/commands/#shells-or-bashes" }, { "label": "Stats", - "link": "/docs/cli/commands/#stats" + "link": "/docs/reference/commands/#stats" }, { "label": "Telemetry", "slug": "docs/cli/telemetry" }, { "label": "Token caching", "slug": "docs/cli/token-caching" }, - { "label": "Tools", "link": "/docs/cli/commands/#tools" } + { "label": "Tools", "link": "/docs/reference/commands/#tools" } ] }, { @@ -148,25 +148,31 @@ { "label": "Reference", "items": [ - { "label": "Command reference", "slug": "docs/cli/commands" }, + { "label": "Command reference", "slug": "docs/reference/commands" }, { "label": "Configuration reference", - "slug": "docs/get-started/configuration" + "slug": "docs/reference/configuration" }, - { "label": "Keyboard shortcuts", "slug": "docs/cli/keyboard-shortcuts" }, - { "label": "Memory import processor", "slug": "docs/core/memport" }, - { "label": "Policy engine", "slug": "docs/core/policy-engine" }, - { "label": "Tools API", "slug": "docs/core/tools-api" } + { + "label": "Keyboard shortcuts", + "slug": "docs/reference/keyboard-shortcuts" + }, + { "label": "Memory import processor", "slug": "docs/reference/memport" }, + { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, + { "label": "Tools API", "slug": "docs/reference/tools-api" } ] }, { "label": "Resources", "items": [ - { "label": "FAQ", "slug": "docs/faq" }, - { "label": "Quota and pricing", "slug": "docs/quota-and-pricing" }, - { "label": "Terms and privacy", "slug": "docs/tos-privacy" }, - { "label": "Troubleshooting", "slug": "docs/troubleshooting" }, - { "label": "Uninstall", "slug": "docs/cli/uninstall" } + { "label": "FAQ", "slug": "docs/resources/faq" }, + { + "label": "Quota and pricing", + "slug": "docs/resources/quota-and-pricing" + }, + { "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" }, + { "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" }, + { "label": "Uninstall", "slug": "docs/resources/uninstall" } ] }, { diff --git a/docs/tools/index.md b/docs/tools/index.md index 42304ef008..f496ad591a 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -98,5 +98,5 @@ Always review confirmation prompts carefully before allowing a tool to execute. ## Next steps - Learn how to [Provide context](../cli/gemini-md.md) to guide tool use. -- Explore the [Command reference](../cli/commands.md) for tool-related slash - commands. +- Explore the [Command reference](../reference/commands.md) for tool-related + slash commands. diff --git a/docs/tools/internal-docs.md b/docs/tools/internal-docs.md index f87099b2f3..0d30655fcd 100644 --- a/docs/tools/internal-docs.md +++ b/docs/tools/internal-docs.md @@ -14,8 +14,8 @@ provides direct access to the Markdown files in the `docs/` directory. `get_internal_docs` takes one optional argument: - `path` (string, optional): The relative path to a specific documentation file - (for example, `cli/commands.md`). If omitted, the tool returns a list of all - available documentation paths. + (for example, `reference/commands.md`). If omitted, the tool returns a list of + all available documentation paths. ## Usage @@ -40,7 +40,7 @@ Gemini CLI uses this tool to ensure technical accuracy: ## Next steps -- Explore the [Command reference](../cli/commands.md) for a detailed guide to - slash commands. -- See the [Configuration guide](../get-started/configuration.md) for settings +- Explore the [Command reference](../reference/commands.md) for a detailed guide + to slash commands. +- See the [Configuration guide](../reference/configuration.md) for settings reference. diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 7d8e407434..34fd7c8490 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -131,9 +131,9 @@ configuration file. commands. Including the generic `run_shell_command` acts as a wildcard, allowing any command not explicitly blocked. - `tools.exclude` [DEPRECATED]: To block specific commands, use the - [Policy Engine](../core/policy-engine.md). Historically, this setting allowed - adding entries to the `exclude` list under the `tools` category in the format - `run_shell_command()`. For example, + [Policy Engine](../reference/policy-engine.md). Historically, this setting + allowed adding entries to the `exclude` list under the `tools` category in the + format `run_shell_command()`. For example, `"tools": {"exclude": ["run_shell_command(rm)"]}` will block `rm` commands. The validation logic is designed to be secure and flexible: diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts index 600a989936..eea7ef9af3 100644 --- a/scripts/generate-keybindings-doc.ts +++ b/scripts/generate-keybindings-doc.ts @@ -22,7 +22,7 @@ import { const START_MARKER = ''; const END_MARKER = ''; -const OUTPUT_RELATIVE_PATH = ['docs', 'cli', 'keyboard-shortcuts.md']; +const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md']; const KEY_NAME_OVERRIDES: Record = { return: 'Enter', diff --git a/scripts/generate-settings-doc.ts b/scripts/generate-settings-doc.ts index 32d9d47c0b..1d27eb962a 100644 --- a/scripts/generate-settings-doc.ts +++ b/scripts/generate-settings-doc.ts @@ -47,7 +47,7 @@ export async function main(argv = process.argv.slice(2)) { path.dirname(fileURLToPath(import.meta.url)), '..', ); - const docPath = path.join(repoRoot, 'docs/get-started/configuration.md'); + const docPath = path.join(repoRoot, 'docs/reference/configuration.md'); const cliSettingsDocPath = path.join(repoRoot, 'docs/cli/settings.md'); const { getSettingsSchema } = await loadSettingsSchemaModule(); From d8b24e698367b50704377960d6d5c915ed1ffcf9 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Thu, 19 Feb 2026 16:16:03 -0800 Subject: [PATCH 06/52] feat(policy): implement project-level policy support (#18682) --- docs/reference/policy-engine.md | 30 +- packages/cli/src/config/config.ts | 14 +- .../config/policy-engine.integration.test.ts | 24 +- packages/cli/src/config/policy.test.ts | 145 +++++++++ packages/cli/src/config/policy.ts | 72 +++++ .../src/config/workspace-policy-cli.test.ts | 239 +++++++++++++++ packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 14 + .../cli/src/ui/components/DialogManager.tsx | 10 + .../ui/components/PolicyUpdateDialog.test.tsx | 141 +++++++++ .../src/ui/components/PolicyUpdateDialog.tsx | 116 +++++++ .../PolicyUpdateDialog.test.tsx.snap | 15 + .../cli/src/ui/contexts/UIActionsContext.tsx | 3 +- .../cli/src/ui/contexts/UIStateContext.tsx | 3 + packages/core/src/config/config.ts | 50 +++ packages/core/src/config/storage.ts | 8 + packages/core/src/index.ts | 1 + packages/core/src/policy/config.test.ts | 52 ++-- packages/core/src/policy/config.ts | 120 +++++--- packages/core/src/policy/integrity.test.ts | 249 +++++++++++++++ packages/core/src/policy/integrity.ts | 154 ++++++++++ packages/core/src/policy/persistence.test.ts | 2 +- packages/core/src/policy/policies/plan.toml | 21 +- .../core/src/policy/policies/read-only.toml | 21 +- packages/core/src/policy/policies/write.toml | 21 +- packages/core/src/policy/policies/yolo.toml | 21 +- .../core/src/policy/policy-engine.test.ts | 85 +++++ packages/core/src/policy/policy-engine.ts | 20 +- packages/core/src/policy/toml-loader.test.ts | 33 +- packages/core/src/policy/toml-loader.ts | 98 +++--- packages/core/src/policy/types.ts | 8 + .../core/src/policy/workspace-policy.test.ts | 290 ++++++++++++++++++ 32 files changed, 1895 insertions(+), 186 deletions(-) create mode 100644 packages/cli/src/config/policy.test.ts create mode 100644 packages/cli/src/config/workspace-policy-cli.test.ts create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.test.tsx create mode 100644 packages/cli/src/ui/components/PolicyUpdateDialog.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap create mode 100644 packages/core/src/policy/integrity.test.ts create mode 100644 packages/core/src/policy/integrity.ts create mode 100644 packages/core/src/policy/workspace-policy.test.ts diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 23e672e4b9..2106b751c9 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -92,11 +92,12 @@ rule with the highest priority wins**. To provide a clear hierarchy, policies are organized into three tiers. Each tier has a designated number that forms the base of the final priority calculation. -| Tier | Base | Description | -| :------ | :--- | :------------------------------------------------------------------------- | -| Default | 1 | Built-in policies that ship with the Gemini CLI. | -| User | 2 | Custom policies defined by the user. | -| Admin | 3 | Policies managed by an administrator (e.g., in an enterprise environment). | +| Tier | Base | Description | +| :-------- | :--- | :------------------------------------------------------------------------- | +| Default | 1 | Built-in policies that ship with the Gemini CLI. | +| Workspace | 2 | Policies defined in the current workspace's configuration directory. | +| User | 3 | Custom policies defined by the user. | +| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). | Within a TOML policy file, you assign a priority value from **0 to 999**. The engine transforms this into a final priority using the following formula: @@ -105,15 +106,17 @@ engine transforms this into a final priority using the following formula: This system guarantees that: -- Admin policies always override User and Default policies. -- User policies always override Default policies. +- Admin policies always override User, Workspace, and Default policies. +- User policies override Workspace and Default policies. +- Workspace policies override Default policies. - You can still order rules within a single tier with fine-grained control. For example: - A `priority: 50` rule in a Default policy file becomes `1.050`. -- A `priority: 100` rule in a User policy file becomes `2.100`. -- A `priority: 20` rule in an Admin policy file becomes `3.020`. +- A `priority: 10` rule in a Workspace policy policy file becomes `2.010`. +- A `priority: 100` rule in a User policy file becomes `3.100`. +- A `priority: 20` rule in an Admin policy file becomes `4.020`. ### Approval modes @@ -156,10 +159,11 @@ User, and (if configured) Admin directories. ### Policy locations -| Tier | Type | Location | -| :-------- | :----- | :-------------------------- | -| **User** | Custom | `~/.gemini/policies/*.toml` | -| **Admin** | System | _See below (OS specific)_ | +| Tier | Type | Location | +| :------------ | :----- | :---------------------------------------- | +| **User** | Custom | `~/.gemini/policies/*.toml` | +| **Workspace** | Custom | `$WORKSPACE_ROOT/.gemini/policies/*.toml` | +| **Admin** | System | _See below (OS specific)_ | #### System-wide policies (Admin) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6b7f3460af..27b251139c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -56,7 +56,10 @@ import { resolvePath } from '../utils/resolvePath.js'; import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; -import { createPolicyEngineConfig } from './policy.js'; +import { + createPolicyEngineConfig, + resolveWorkspacePolicyState, +} from './policy.js'; import { ExtensionManager } from './extension-manager.js'; import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; @@ -692,9 +695,17 @@ export async function loadCliConfig( policyPaths: argv.policy, }; + const { workspacePoliciesDir, policyUpdateConfirmationRequest } = + await resolveWorkspacePolicyState({ + cwd, + trustedFolder, + interactive, + }); + const policyEngineConfig = await createPolicyEngineConfig( effectiveSettings, approvalMode, + workspacePoliciesDir, ); policyEngineConfig.nonInteractive = !interactive; @@ -758,6 +769,7 @@ export async function loadCliConfig( coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, policyEngineConfig, + policyUpdateConfirmationRequest, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 2c7ce599da..dbc7f6a415 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -148,13 +148,13 @@ describe('Policy Engine Integration Tests', () => { ); const engine = new PolicyEngine(config); - // MCP server allowed (priority 2.1) provides general allow for server - // MCP server allowed (priority 2.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server + // MCP server allowed (priority 3.1) provides general allow for server expect( (await engine.check({ name: 'my-server__safe-tool' }, undefined)) .decision, ).toBe(PolicyDecision.ALLOW); - // But specific tool exclude (priority 2.4) wins over server allow + // But specific tool exclude (priority 3.4) wins over server allow expect( (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) .decision, @@ -412,25 +412,25 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude + expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude + expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(2.3); // Command line allow + expect(specificToolRule?.priority).toBe(3.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server + expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) @@ -577,16 +577,16 @@ describe('Policy Engine Integration Tests', () => { // Verify each rule has the expected priority const tool3Rule = rules.find((r) => r.toolName === 'tool3'); - expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) + expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) + expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) + expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) + expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 0000000000..a0e687388d --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { resolveWorkspacePolicyState } from './policy.js'; +import { writeToStderr } from '@google/gemini-cli-core'; + +// Mock debugLogger to avoid noise in test output +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStderr: vi.fn(), + }; +}); + +describe('resolveWorkspacePolicyState', () => { + let tempDir: string; + let workspaceDir: string; + let policiesDir: string; + + beforeEach(() => { + // Create a temporary directory for the test + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); + // Redirect GEMINI_CLI_HOME to the temp directory to isolate integrity storage + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + + workspaceDir = path.join(tempDir, 'workspace'); + fs.mkdirSync(workspaceDir); + policiesDir = path.join(workspaceDir, '.gemini', 'policies'); + + vi.clearAllMocks(); + }); + + afterEach(() => { + // Clean up temporary directory + fs.rmSync(tempDir, { recursive: true, force: true }); + vi.unstubAllEnvs(); + }); + + it('should return empty state if folder is not trusted', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: false, + interactive: true, + }); + + expect(result).toEqual({ + workspacePoliciesDir: undefined, + policyUpdateConfirmationRequest: undefined, + }); + }); + + it('should return policy directory if integrity matches', async () => { + // Set up policies directory with a file + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + // First call to establish integrity (interactive accept) + const firstResult = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); + + // Establish integrity manually as if accepted + const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + 'workspace', + workspaceDir, + firstResult.policyUpdateConfirmationRequest!.newHash, + ); + + // Second call should match + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return undefined if integrity is NEW but fileCount is 0', async () => { + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + }); + + it('should return confirmation request if changed in interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + }); + + it('should warn and auto-accept if changed in non-interactive mode', async () => { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 70536070eb..e689094f94 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -12,12 +12,18 @@ import { type PolicySettings, createPolicyEngineConfig as createCorePolicyEngineConfig, createPolicyUpdater as createCorePolicyUpdater, + PolicyIntegrityManager, + IntegrityStatus, + Storage, + type PolicyUpdateConfirmationRequest, + writeToStderr, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, + workspacePoliciesDir?: string, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. @@ -26,6 +32,7 @@ export async function createPolicyEngineConfig( tools: settings.tools, mcpServers: settings.mcpServers, policyPaths: settings.policyPaths, + workspacePoliciesDir, }; return createCorePolicyEngineConfig(policySettings, approvalMode); @@ -37,3 +44,68 @@ export function createPolicyUpdater( ) { return createCorePolicyUpdater(policyEngine, messageBus); } + +export interface WorkspacePolicyState { + workspacePoliciesDir?: string; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; +} + +/** + * Resolves the workspace policy state by checking folder trust and policy integrity. + */ +export async function resolveWorkspacePolicyState(options: { + cwd: string; + trustedFolder: boolean; + interactive: boolean; +}): Promise { + const { cwd, trustedFolder, interactive } = options; + + let workspacePoliciesDir: string | undefined; + let policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; + + if (trustedFolder) { + const potentialWorkspacePoliciesDir = new Storage( + cwd, + ).getWorkspacePoliciesDir(); + const integrityManager = new PolicyIntegrityManager(); + const integrityResult = await integrityManager.checkIntegrity( + 'workspace', + cwd, + potentialWorkspacePoliciesDir, + ); + + if (integrityResult.status === IntegrityStatus.MATCH) { + workspacePoliciesDir = potentialWorkspacePoliciesDir; + } else if ( + integrityResult.status === IntegrityStatus.NEW && + integrityResult.fileCount === 0 + ) { + // No workspace policies found + workspacePoliciesDir = undefined; + } else if (interactive) { + // Policies changed or are new, and we are in interactive mode + policyUpdateConfirmationRequest = { + scope: 'workspace', + identifier: cwd, + policyDir: potentialWorkspacePoliciesDir, + newHash: integrityResult.hash, + }; + } else { + // Non-interactive mode: warn and automatically accept/load + await integrityManager.acceptIntegrity( + 'workspace', + cwd, + integrityResult.hash, + ); + workspacePoliciesDir = potentialWorkspacePoliciesDir; + // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', + ); + } + } + + return { workspacePoliciesDir, policyUpdateConfirmationRequest }; +} diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts new file mode 100644 index 0000000000..98cbe05bce --- /dev/null +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -0,0 +1,239 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as path from 'node:path'; +import { loadCliConfig, type CliArgs } from './config.js'; +import { createTestMergedSettings } from './settings.js'; +import * as ServerConfig from '@google/gemini-cli-core'; +import { isWorkspaceTrusted } from './trustedFolders.js'; + +// Mock dependencies +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(), +})); + +const mockCheckIntegrity = vi.fn(); +const mockAcceptIntegrity = vi.fn(); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual( + '@google/gemini-cli-core', + ); + return { + ...actual, + loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ + memoryContent: '', + fileCount: 0, + filePaths: [], + }), + createPolicyEngineConfig: vi.fn().mockResolvedValue({ + rules: [], + checkers: [], + }), + getVersion: vi.fn().mockResolvedValue('test-version'), + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + checkIntegrity: mockCheckIntegrity, + acceptIntegrity: mockAcceptIntegrity, + })), + IntegrityStatus: { MATCH: 'match', NEW: 'new', MISMATCH: 'mismatch' }, + debugLogger: { + warn: vi.fn(), + error: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), // Default to interactive + }; +}); + +describe('Workspace-Level Policy CLI Integration', () => { + const MOCK_CWD = process.cwd(); + + beforeEach(() => { + vi.clearAllMocks(); + // Default to MATCH for existing tests + mockCheckIntegrity.mockResolvedValue({ + status: 'match', + hash: 'test-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); + }); + + it('should have getWorkspacePoliciesDir on Storage class', () => { + const storage = new ServerConfig.Storage(MOCK_CWD); + expect(storage.getWorkspacePoliciesDir).toBeDefined(); + expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); + }); + + it('should pass workspacePoliciesDir to createPolicyEngineConfig when folder is trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir to createPolicyEngineConfig when folder is NOT trusted', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: false, + source: 'file', + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should NOT pass workspacePoliciesDir if integrity is NEW but fileCount is 0', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'hash', + fileCount: 0, + }); + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in non-interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + + const settings = createTestMergedSettings(); + const argv = { prompt: 'do something' } as unknown as CliArgs; + + await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); + + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + // In interactive mode without accept flag, it waits for user confirmation (handled by UI), + // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. + // The UI will handle the confirmation and reload/update. + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); + + it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'new', + hash: 'new-hash', + fileCount: 5, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { query: 'test' } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + }); +}); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 2375a0fba1..d84c04d01e 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,6 +506,7 @@ const mockUIActions: UIActions = { vimHandleInput: vi.fn(), handleIdePromptComplete: vi.fn(), handleFolderTrustSelect: vi.fn(), + setIsPolicyUpdateDialogOpen: vi.fn(), setConstrainHeight: vi.fn(), onEscapePromptChange: vi.fn(), refreshStatic: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 08bae44959..b7945b0e10 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1438,6 +1438,13 @@ Logging in with Google... Restarting Gemini CLI to continue. const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + + const policyUpdateConfirmationRequest = + config.getPolicyUpdateConfirmationRequest(); + const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( + !!policyUpdateConfirmationRequest, + ); + const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -1910,6 +1917,7 @@ Logging in with Google... Restarting Gemini CLI to continue. (shouldShowRetentionWarning && retentionCheckComplete) || shouldShowIdePrompt || isFolderTrustDialogOpen || + isPolicyUpdateDialogOpen || adminSettingsChanged || !!commandConfirmationRequest || !!authConsentRequest || @@ -2137,6 +2145,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2259,6 +2269,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + isPolicyUpdateDialogOpen, + policyUpdateConfirmationRequest, isTrustedFolder, constrainHeight, showErrorDetails, @@ -2356,6 +2368,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, onEscapePromptChange: handleEscapePromptChange, refreshStatic, @@ -2440,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue. vimHandleInput, handleIdePromptComplete, handleFolderTrustSelect, + setIsPolicyUpdateDialogOpen, setConstrainHeight, handleEscapePromptChange, refreshStatic, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index b28f5de218..9fdd4718a6 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -37,6 +37,7 @@ import { AgentConfigDialog } from './AgentConfigDialog.js'; import { SessionRetentionWarningDialog } from './SessionRetentionWarningDialog.js'; import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; +import { PolicyUpdateDialog } from './PolicyUpdateDialog.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -166,6 +167,15 @@ export const DialogManager = ({ /> ); } + if (uiState.isPolicyUpdateDialogOpen) { + return ( + uiActions.setIsPolicyUpdateDialogOpen(false)} + /> + ); + } if (uiState.loopDetectionConfirmationRequest) { return ( ({ + mockAcceptIntegrity: vi.fn(), +})); + +// Mock PolicyIntegrityManager +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + PolicyIntegrityManager: vi.fn().mockImplementation(() => ({ + acceptIntegrity: mockAcceptIntegrity, + checkIntegrity: vi.fn(), + })), + }; +}); + +describe('PolicyUpdateDialog', () => { + let mockConfig: Config; + let mockRequest: PolicyUpdateConfirmationRequest; + let onClose: () => void; + + beforeEach(() => { + mockConfig = { + loadWorkspacePolicies: vi.fn().mockResolvedValue(undefined), + } as unknown as Config; + + mockRequest = { + scope: 'workspace', + identifier: '/test/workspace/.gemini/policies', + policyDir: '/test/workspace/.gemini/policies', + newHash: 'test-hash', + } as PolicyUpdateConfirmationRequest; + + onClose = vi.fn(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('renders correctly and matches snapshot', async () => { + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + const output = lastFrame(); + expect(output).toMatchSnapshot(); + expect(output).toContain('New or changed workspace policies detected'); + expect(output).toContain('Location: /test/workspace/.gemini/policies'); + expect(output).toContain('Accept and Load'); + expect(output).toContain('Ignore'); + }); + + it('handles ACCEPT correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Accept is the first option, so pressing enter should select it + await act(async () => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).toHaveBeenCalledWith( + mockRequest.policyDir, + ); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('handles IGNORE correctly', async () => { + const { stdin } = renderWithProviders( + , + ); + + // Move down to Ignore option + await act(async () => { + stdin.write('\x1B[B'); // Down arrow + }); + await act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(PolicyIntegrityManager).not.toHaveBeenCalled(); + expect(mockConfig.loadWorkspacePolicies).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('calls onClose when Escape key is pressed', async () => { + const { stdin } = renderWithProviders( + , + ); + + await act(async () => { + stdin.write('\x1B'); // Escape key (matches Command.ESCAPE default) + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx new file mode 100644 index 0000000000..e6ed75c4db --- /dev/null +++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useCallback, useRef } from 'react'; +import type React from 'react'; +import { + type Config, + type PolicyUpdateConfirmationRequest, + PolicyIntegrityManager, +} from '@google/gemini-cli-core'; +import { theme } from '../semantic-colors.js'; +import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; +import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; + +export enum PolicyUpdateChoice { + ACCEPT = 'accept', + IGNORE = 'ignore', +} + +interface PolicyUpdateDialogProps { + config: Config; + request: PolicyUpdateConfirmationRequest; + onClose: () => void; +} + +export const PolicyUpdateDialog: React.FC = ({ + config, + request, + onClose, +}) => { + const isProcessing = useRef(false); + + const handleSelect = useCallback( + async (choice: PolicyUpdateChoice) => { + if (isProcessing.current) { + return; + } + + isProcessing.current = true; + try { + if (choice === PolicyUpdateChoice.ACCEPT) { + const integrityManager = new PolicyIntegrityManager(); + await integrityManager.acceptIntegrity( + request.scope, + request.identifier, + request.newHash, + ); + await config.loadWorkspacePolicies(request.policyDir); + } + onClose(); + } finally { + isProcessing.current = false; + } + }, + [config, request, onClose], + ); + + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const options: Array> = [ + { + label: 'Accept and Load', + value: PolicyUpdateChoice.ACCEPT, + key: 'accept', + }, + { + label: 'Ignore (Use Default Policies)', + value: PolicyUpdateChoice.IGNORE, + key: 'ignore', + }, + ]; + + return ( + + + + + New or changed {request.scope} policies detected + + Location: {request.identifier} + + Do you want to accept and load these policies? + + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap new file mode 100644 index 0000000000..5f5b3c9c27 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/PolicyUpdateDialog.test.tsx.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PolicyUpdateDialog > renders correctly and matches snapshot 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New or changed workspace policies detected │ + │ Location: /test/workspace/.gemini/policies │ + │ Do you want to accept and load these policies? │ + │ │ + │ ● 1. Accept and Load │ + │ 2. Ignore (Use Default Policies) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ +" +`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af8706cfb1..03780c5068 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -52,6 +52,7 @@ export interface UIActions { vimHandleInput: (key: Key) => boolean; handleIdePromptComplete: (result: IdeIntegrationNudgeResult) => void; handleFolderTrustSelect: (choice: FolderTrustChoice) => void; + setIsPolicyUpdateDialogOpen: (value: boolean) => void; setConstrainHeight: (value: boolean) => void; onEscapePromptChange: (show: boolean) => void; refreshStatic: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 2df7473b0c..56d4b83c09 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; import type { DOMElement } from 'ink'; @@ -112,6 +113,8 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + isPolicyUpdateDialogOpen: boolean; + policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; isTrustedFolder: boolean | undefined; constrainHeight: boolean; showErrorDetails: boolean; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5b57a81acf..fa32fd4d5f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -126,6 +126,8 @@ import { import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; +import { WORKSPACE_POLICY_TIER } from '../policy/config.js'; +import { loadPoliciesFromToml } from '../policy/toml-loader.js'; export interface AccessibilitySettings { /** @deprecated Use ui.loadingPhrases instead. */ @@ -379,6 +381,13 @@ export interface McpEnablementCallbacks { isFileEnabled: (serverId: string) => Promise; } +export interface PolicyUpdateConfirmationRequest { + scope: string; + identifier: string; + policyDir: string; + newHash: string; +} + export interface ConfigParameters { sessionId: string; clientVersion?: string; @@ -459,6 +468,7 @@ export interface ConfigParameters { eventEmitter?: EventEmitter; useWriteTodos?: boolean; policyEngineConfig?: PolicyEngineConfig; + policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; output?: OutputSettings; disableModelRouterForAuth?: AuthType[]; continueOnFailedApiCall?: boolean; @@ -637,6 +647,9 @@ export class Config { private readonly useWriteTodos: boolean; private readonly messageBus: MessageBus; private readonly policyEngine: PolicyEngine; + private policyUpdateConfirmationRequest: + | PolicyUpdateConfirmationRequest + | undefined; private readonly outputSettings: OutputSettings; private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; @@ -853,6 +866,8 @@ export class Config { approvalMode: params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); + this.policyUpdateConfirmationRequest = + params.policyUpdateConfirmationRequest; this.messageBus = new MessageBus(this.policyEngine, this.debugMode); this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); @@ -1721,6 +1736,41 @@ export class Config { return this.policyEngine.getApprovalMode(); } + getPolicyUpdateConfirmationRequest(): + | PolicyUpdateConfirmationRequest + | undefined { + return this.policyUpdateConfirmationRequest; + } + + /** + * Hot-loads workspace policies from the specified directory into the active policy engine. + * This allows applying newly accepted policies without requiring an application restart. + * + * @param policyDir The directory containing the workspace policy TOML files. + */ + async loadWorkspacePolicies(policyDir: string): Promise { + const { rules, checkers } = await loadPoliciesFromToml( + [policyDir], + () => WORKSPACE_POLICY_TIER, + ); + + // Clear existing workspace policies to prevent duplicates/stale rules + this.policyEngine.removeRulesByTier(WORKSPACE_POLICY_TIER); + this.policyEngine.removeCheckersByTier(WORKSPACE_POLICY_TIER); + + for (const rule of rules) { + this.policyEngine.addRule(rule); + } + + for (const checker of checkers) { + this.policyEngine.addChecker(checker); + } + + this.policyUpdateConfirmationRequest = undefined; + + debugLogger.debug(`Workspace policies loaded from: ${policyDir}`); + } + setApprovalMode(mode: ApprovalMode): void { if (!this.isTrustedFolder() && mode !== ApprovalMode.DEFAULT) { throw new Error( diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bce91f7991..3a079f3b7e 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -103,6 +103,10 @@ export class Storage { ); } + static getPolicyIntegrityStoragePath(): string { + return path.join(Storage.getGlobalGeminiDir(), 'policy_integrity.json'); + } + private static getSystemConfigDir(): string { if (os.platform() === 'darwin') { return '/Library/Application Support/GeminiCli'; @@ -146,6 +150,10 @@ export class Storage { return path.join(tempDir, identifier); } + getWorkspacePoliciesDir(): string { + return path.join(this.getGeminiDir(), 'policies'); + } + ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8f82486173..36d10d3832 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,7 @@ export * from './policy/types.js'; export * from './policy/policy-engine.js'; export * from './policy/toml-loader.js'; export * from './policy/config.js'; +export * from './policy/integrity.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; diff --git a/packages/core/src/policy/config.test.ts b/packages/core/src/policy/config.test.ts index 32a5287113..a9fae7a1fa 100644 --- a/packages/core/src/policy/config.test.ts +++ b/packages/core/src/policy/config.test.ts @@ -169,7 +169,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow }); it('should deny tools in tools.exclude', async () => { @@ -188,7 +188,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(rule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should allow tools from allowed MCP servers', async () => { @@ -206,7 +206,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.1); // MCP allowed server + expect(rule?.priority).toBe(3.1); // MCP allowed server }); it('should deny tools from excluded MCP servers', async () => { @@ -224,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBe(2.9); // MCP excluded server + expect(rule?.priority).toBe(3.9); // MCP excluded server }); it('should allow tools from trusted MCP servers', async () => { @@ -251,7 +251,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -288,7 +288,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(2.1); // MCP allowed server + expect(allowedRule?.priority).toBe(3.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -297,7 +297,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(2.2); // MCP trusted server + expect(trustedRule?.priority).toBe(3.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -306,7 +306,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(2.9); // MCP excluded server + expect(excludedRule?.priority).toBe(3.9); // MCP excluded server }); it('should allow all tools in YOLO mode', async () => { @@ -387,11 +387,11 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server + expect(serverDenyRule?.priority).toBe(3.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(toolAllowRule?.priority).toBeCloseTo(3.3, 5); // Command line allow - // Server deny (2.9) has higher priority than tool allow (2.3), + // Server deny (3.9) has higher priority than tool allow (3.3), // so server deny wins (this is expected behavior - server-level blocks are security critical) }); @@ -424,7 +424,7 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); - // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // Command line exclude (3.4) has higher priority than MCP server trust (3.2) // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); @@ -432,16 +432,16 @@ describe('createPolicyEngineConfig', () => { it('should handle complex priority scenarios correctly', async () => { const settings: PolicySettings = { tools: { - allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 - exclude: ['my-server__tool2', 'glob'], // Priority 2.4 + allowed: ['my-server__tool1', 'other-tool'], // Priority 3.3 + exclude: ['my-server__tool2', 'glob'], // Priority 3.4 }, mcp: { - allowed: ['allowed-server'], // Priority 2.1 - excluded: ['excluded-server'], // Priority 2.9 + allowed: ['allowed-server'], // Priority 3.1 + excluded: ['excluded-server'], // Priority 3.9 }, mcpServers: { 'trusted-server': { - trust: true, // Priority 90 -> 2.2 + trust: true, // Priority 90 -> 3.2 }, }, }; @@ -517,7 +517,7 @@ describe('createPolicyEngineConfig', () => { expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); // Deny from settings (user tier) - expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(globDenyRule!.priority).toBeCloseTo(3.4, 5); // Command line exclude // Allow from default TOML: 1 + 50/1000 = 1.05 expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); @@ -530,11 +530,11 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes (user tier: 2.4 and 2.9) + // Check that the highest priority items are the excludes (user tier: 3.4 and 3.9) const highestPriorityExcludes = priorities?.filter( (p) => - Math.abs(p.priority! - 2.4) < 0.01 || - Math.abs(p.priority! - 2.9) < 0.01, + Math.abs(p.priority! - 3.4) < 0.01 || + Math.abs(p.priority! - 3.9) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), @@ -626,7 +626,7 @@ describe('createPolicyEngineConfig', () => { r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude + expect(excludeRule?.priority).toBeCloseTo(3.4, 5); // Command line exclude }); it('should support argsPattern in policy rules', async () => { @@ -733,8 +733,8 @@ priority = 150 r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - // Priority 150 in user tier → 2.150 - expect(rule?.priority).toBeCloseTo(2.15, 5); + // Priority 150 in user tier → 3.150 + expect(rule?.priority).toBeCloseTo(3.15, 5); expect(rule?.argsPattern).toBeInstanceOf(RegExp); expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); @@ -1046,7 +1046,7 @@ name = "invalid-name" r.decision === PolicyDecision.ALLOW, ); expect(rule).toBeDefined(); - expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow + expect(rule?.priority).toBeCloseTo(3.3, 5); // Command line allow vi.doUnmock('node:fs/promises'); }); @@ -1188,7 +1188,7 @@ modes = ["plan"] r.modes?.includes(ApprovalMode.PLAN), ); expect(subagentRule).toBeDefined(); - expect(subagentRule?.priority).toBeCloseTo(2.1, 5); + expect(subagentRule?.priority).toBeCloseTo(3.1, 5); vi.doUnmock('node:fs/promises'); }); diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index efa5083504..50fbc0ef2a 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -29,6 +29,7 @@ import { coreEvents } from '../utils/events.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { isNodeError } from '../utils/errors.js'; import { isDirectorySecure } from '../utils/security.js'; @@ -38,47 +39,55 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies'); // Policy tier constants for priority calculation export const DEFAULT_POLICY_TIER = 1; -export const USER_POLICY_TIER = 2; -export const ADMIN_POLICY_TIER = 3; +export const WORKSPACE_POLICY_TIER = 2; +export const USER_POLICY_TIER = 3; +export const ADMIN_POLICY_TIER = 4; /** - * Gets the list of directories to search for policy files, in order of decreasing priority - * (Admin -> User -> Default). + * Gets the list of directories to search for policy files, in order of increasing priority + * (Default -> User -> Project -> Admin). * * @param defaultPoliciesDir Optional path to a directory containing default policies. * @param policyPaths Optional user-provided policy paths (from --policy flag). * When provided, these replace the default user policies directory. + * @param workspacePoliciesDir Optional path to a directory containing workspace policies. */ export function getPolicyDirectories( defaultPoliciesDir?: string, policyPaths?: string[], + workspacePoliciesDir?: string, ): string[] { - const dirs: string[] = []; + const dirs = []; - // Default tier (lowest priority) - dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + // Admin tier (highest priority) + dirs.push(Storage.getSystemPoliciesDir()); - // User tier (middle priority) + // User tier (second highest priority) if (policyPaths && policyPaths.length > 0) { dirs.push(...policyPaths); } else { dirs.push(Storage.getUserPoliciesDir()); } - // Admin tier (highest priority) - dirs.push(Storage.getSystemPoliciesDir()); + // Workspace Tier (third highest) + if (workspacePoliciesDir) { + dirs.push(workspacePoliciesDir); + } - // Reverse so highest priority (Admin) is first - return dirs.reverse(); + // Default tier (lowest priority) + dirs.push(defaultPoliciesDir ?? DEFAULT_CORE_POLICIES_DIR); + + return dirs; } /** - * Determines the policy tier (1=default, 2=user, 3=admin) for a given directory. + * Determines the policy tier (1=default, 2=user, 3=workspace, 4=admin) for a given directory. * This is used by the TOML loader to assign priority bands. */ export function getPolicyTier( dir: string, defaultPoliciesDir?: string, + workspacePoliciesDir?: string, ): number { const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); const ADMIN_POLICIES_DIR = Storage.getSystemPoliciesDir(); @@ -99,6 +108,12 @@ export function getPolicyTier( if (normalizedDir === normalizedUser) { return USER_POLICY_TIER; } + if ( + workspacePoliciesDir && + normalizedDir === path.resolve(workspacePoliciesDir) + ) { + return WORKSPACE_POLICY_TIER; + } if (normalizedDir === normalizedAdmin) { return ADMIN_POLICY_TIER; } @@ -157,8 +172,8 @@ export async function createPolicyEngineConfig( const policyDirs = getPolicyDirectories( defaultPoliciesDir, settings.policyPaths, + settings.workspacePoliciesDir, ); - const securePolicyDirs = await filterSecurePolicyDirectories(policyDirs); const normalizedAdminPoliciesDir = path.resolve( @@ -171,7 +186,11 @@ export async function createPolicyEngineConfig( checkers: tomlCheckers, errors, } = await loadPoliciesFromToml(securePolicyDirs, (p) => { - const tier = getPolicyTier(p, defaultPoliciesDir); + const tier = getPolicyTier( + p, + defaultPoliciesDir, + settings.workspacePoliciesDir, + ); // If it's a user-provided path that isn't already categorized as ADMIN, // treat it as USER tier. @@ -207,19 +226,20 @@ export async function createPolicyEngineConfig( // // Priority bands (tiers): // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) - // - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) - // - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) // - // This ensures Admin > User > Default hierarchy is always preserved, + // This ensures Admin > User > Workspace > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // - // Settings-based and dynamic rules (all in user tier 2.x): - // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI - // 2.9: MCP servers excluded list (security: persistent server blocks) - // 2.4: Command line flag --exclude-tools (explicit temporary blocks) - // 2.3: Command line flag --allowed-tools (explicit temporary allows) - // 2.2: MCP servers with trust=true (persistent trusted servers) - // 2.1: MCP servers allowed list (persistent general server allows) + // Settings-based and dynamic rules (all in user tier 3.x): + // 3.95: Tools that the user has selected as "Always Allow" in the interactive UI + // 3.9: MCP servers excluded list (security: persistent server blocks) + // 3.4: Command line flag --exclude-tools (explicit temporary blocks) + // 3.3: Command line flag --allowed-tools (explicit temporary allows) + // 3.2: MCP servers with trust=true (persistent trusted servers) + // 3.1: MCP servers allowed list (persistent general server allows) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -230,33 +250,33 @@ export async function createPolicyEngineConfig( // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 2.9 (highest in user tier for security - persistent server blocks) + // Priority: 3.9 (highest in user tier for security - persistent server blocks) if (settings.mcp?.excluded) { for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.DENY, - priority: 2.9, + priority: 3.9, source: 'Settings (MCP Excluded)', }); } } // Tools that are explicitly excluded in the settings. - // Priority: 2.4 (user tier - explicit temporary blocks) + // Priority: 3.4 (user tier - explicit temporary blocks) if (settings.tools?.exclude) { for (const tool of settings.tools.exclude) { rules.push({ toolName: tool, decision: PolicyDecision.DENY, - priority: 2.4, + priority: 3.4, source: 'Settings (Tools Excluded)', }); } } // Tools that are explicitly allowed in the settings. - // Priority: 2.3 (user tier - explicit temporary allows) + // Priority: 3.3 (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { // Check for legacy format: toolName(args) @@ -276,7 +296,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, argsPattern: new RegExp(pattern), source: 'Settings (Tools Allowed)', }); @@ -288,7 +308,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -300,7 +320,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 2.3, + priority: 3.3, source: 'Settings (Tools Allowed)', }); } @@ -308,7 +328,7 @@ export async function createPolicyEngineConfig( } // MCP servers that are trusted in the settings. - // Priority: 2.2 (user tier - persistent trusted servers) + // Priority: 3.2 (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -319,7 +339,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.2, + priority: 3.2, source: 'Settings (MCP Trusted)', }); } @@ -327,13 +347,13 @@ export async function createPolicyEngineConfig( } // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 2.1 (user tier - persistent general server allows) + // Priority: 3.1 (user tier - persistent general server allows) if (settings.mcp?.allowed) { for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 2.1, + priority: 3.1, source: 'Settings (MCP Allowed)', }); } @@ -380,10 +400,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern: new RegExp(pattern), source: 'Dynamic (Confirmed)', }); @@ -405,10 +425,10 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (2) + high priority (950/1000) = 2.95 + // User tier (3) + high priority (950/1000) = 3.95 // This ensures user "always allow" selections are high priority - // but still lose to admin policies (3.xxx) and settings excludes (200) - priority: 2.95, + // but still lose to admin policies (4.xxx) and settings excludes (300) + priority: 3.95, argsPattern, source: 'Dynamic (Confirmed)', }); @@ -425,10 +445,16 @@ export function createPolicyUpdater( let existingData: { rule?: TomlRule[] } = {}; try { const fileContent = await fs.readFile(policyFile, 'utf-8'); - existingData = toml.parse(fileContent) as { rule?: TomlRule[] }; + const parsed = toml.parse(fileContent); + if ( + typeof parsed === 'object' && + parsed !== null && + (!('rule' in parsed) || Array.isArray(parsed['rule'])) + ) { + existingData = parsed as { rule?: TomlRule[] }; + } } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + if (!isNodeError(error) || error.code !== 'ENOENT') { debugLogger.warn( `Failed to parse ${policyFile}, overwriting with new policy.`, error, diff --git a/packages/core/src/policy/integrity.test.ts b/packages/core/src/policy/integrity.test.ts new file mode 100644 index 0000000000..32ebf56058 --- /dev/null +++ b/packages/core/src/policy/integrity.test.ts @@ -0,0 +1,249 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { PolicyIntegrityManager, IntegrityStatus } from './integrity.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { Storage } from '../config/storage.js'; + +describe('PolicyIntegrityManager', () => { + let integrityManager: PolicyIntegrityManager; + let tempDir: string; + let integrityStoragePath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + integrityStoragePath = path.join(tempDir, 'policy_integrity.json'); + + vi.spyOn(Storage, 'getPolicyIntegrityStoragePath').mockReturnValue( + integrityStoragePath, + ); + + integrityManager = new PolicyIntegrityManager(); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('checkIntegrity', () => { + it('should return NEW if no stored hash', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.NEW); + expect(result.hash).toBeDefined(); + expect(result.hash).toHaveLength(64); + expect(result.fileCount).toBe(1); + }); + + it('should return MATCH if stored hash matches', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + // First run to get the hash + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save the hash to mock storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': currentHash }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should return MISMATCH if stored hash differs', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + + const resultNew = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + const currentHash = resultNew.hash; + + // Save a different hash + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'workspace:id': 'different_hash' }), + ); + + const result = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + expect(result.status).toBe(IntegrityStatus.MISMATCH); + expect(result.hash).toBe(currentHash); + }); + + it('should result in different hash if filename changes', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + const policyDir2 = path.join(tempDir, 'policies2'); + await fs.mkdir(policyDir2); + await fs.writeFile(path.join(policyDir2, 'b.toml'), 'contentA'); + + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir2, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should result in different hash if content changes', async () => { + const policyDir = path.join(tempDir, 'policies'); + await fs.mkdir(policyDir); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentA'); + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + await fs.writeFile(path.join(policyDir, 'a.toml'), 'contentB'); + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir, + ); + + expect(result1.hash).not.toBe(result2.hash); + }); + + it('should be deterministic (sort order)', async () => { + const policyDir1 = path.join(tempDir, 'policies1'); + await fs.mkdir(policyDir1); + await fs.writeFile(path.join(policyDir1, 'a.toml'), 'contentA'); + await fs.writeFile(path.join(policyDir1, 'b.toml'), 'contentB'); + + const result1 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + // Re-read with same files but they might be in different order in readdir + // PolicyIntegrityManager should sort them. + const result2 = await integrityManager.checkIntegrity( + 'workspace', + 'id', + policyDir1, + ); + + expect(result1.hash).toBe(result2.hash); + }); + + it('should handle multiple projects correctly', async () => { + const dirA = path.join(tempDir, 'dirA'); + await fs.mkdir(dirA); + await fs.writeFile(path.join(dirA, 'p.toml'), 'contentA'); + + const dirB = path.join(tempDir, 'dirB'); + await fs.mkdir(dirB); + await fs.writeFile(path.join(dirB, 'p.toml'), 'contentB'); + + const { hash: hashA } = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + const { hash: hashB } = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + + // Save to storage + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ + 'workspace:idA': hashA, + 'workspace:idB': 'oldHashB', + }), + ); + + // Project A should match + const resultA = await integrityManager.checkIntegrity( + 'workspace', + 'idA', + dirA, + ); + expect(resultA.status).toBe(IntegrityStatus.MATCH); + expect(resultA.hash).toBe(hashA); + + // Project B should mismatch + const resultB = await integrityManager.checkIntegrity( + 'workspace', + 'idB', + dirB, + ); + expect(resultB.status).toBe(IntegrityStatus.MISMATCH); + expect(resultB.hash).toBe(hashB); + }); + }); + + describe('acceptIntegrity', () => { + it('should save the hash to storage', async () => { + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['workspace:id']).toBe('hash123'); + }); + + it('should update existing hash', async () => { + await fs.writeFile( + integrityStoragePath, + JSON.stringify({ 'other:id': 'otherhash' }), + ); + + await integrityManager.acceptIntegrity('workspace', 'id', 'hash123'); + + const stored = JSON.parse( + await fs.readFile(integrityStoragePath, 'utf-8'), + ); + expect(stored['other:id']).toBe('otherhash'); + expect(stored['workspace:id']).toBe('hash123'); + }); + }); +}); diff --git a/packages/core/src/policy/integrity.ts b/packages/core/src/policy/integrity.ts new file mode 100644 index 0000000000..e8716ed438 --- /dev/null +++ b/packages/core/src/policy/integrity.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { readPolicyFiles } from './toml-loader.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export enum IntegrityStatus { + MATCH = 'MATCH', + MISMATCH = 'MISMATCH', + NEW = 'NEW', +} + +export interface IntegrityResult { + status: IntegrityStatus; + hash: string; + fileCount: number; +} + +interface StoredIntegrityData { + [key: string]: string; // key = scope:identifier, value = hash +} + +export class PolicyIntegrityManager { + /** + * Checks the integrity of policies in a given directory against the stored hash. + * + * @param scope The scope of the policy (e.g., 'project', 'user'). + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param policyDir The directory containing the policy files. + * @returns IntegrityResult indicating if the current policies match the stored hash. + */ + async checkIntegrity( + scope: string, + identifier: string, + policyDir: string, + ): Promise { + const { hash: currentHash, fileCount } = + await PolicyIntegrityManager.calculateIntegrityHash(policyDir); + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + const storedHash = storedData[key]; + + if (!storedHash) { + return { status: IntegrityStatus.NEW, hash: currentHash, fileCount }; + } + + if (storedHash === currentHash) { + return { status: IntegrityStatus.MATCH, hash: currentHash, fileCount }; + } + + return { status: IntegrityStatus.MISMATCH, hash: currentHash, fileCount }; + } + + /** + * Accepts and persists the current integrity hash for a given policy scope. + * + * @param scope The scope of the policy. + * @param identifier A unique identifier for the policy scope (e.g., project path). + * @param hash The hash to persist. + */ + async acceptIntegrity( + scope: string, + identifier: string, + hash: string, + ): Promise { + const storedData = await this.loadIntegrityData(); + const key = this.getIntegrityKey(scope, identifier); + storedData[key] = hash; + await this.saveIntegrityData(storedData); + } + + /** + * Calculates a SHA-256 hash of all policy files in the directory. + * The hash includes the relative file path and content to detect renames and modifications. + * + * @param policyDir The directory containing the policy files. + * @returns The calculated hash and file count + */ + private static async calculateIntegrityHash( + policyDir: string, + ): Promise<{ hash: string; fileCount: number }> { + try { + const files = await readPolicyFiles(policyDir); + + // Sort files by path to ensure deterministic hashing + files.sort((a, b) => a.path.localeCompare(b.path)); + + const hash = crypto.createHash('sha256'); + + for (const file of files) { + const relativePath = path.relative(policyDir, file.path); + // Include relative path and content in the hash + hash.update(relativePath); + hash.update('\0'); // Separator + hash.update(file.content); + hash.update('\0'); // Separator + } + + return { hash: hash.digest('hex'), fileCount: files.length }; + } catch (error) { + debugLogger.error('Failed to calculate policy integrity hash', error); + // Return a unique hash (random) to force a mismatch if calculation fails? + // Or throw? Throwing is better so we don't accidentally accept/deny corrupted state. + throw error; + } + } + + private getIntegrityKey(scope: string, identifier: string): string { + return `${scope}:${identifier}`; + } + + private async loadIntegrityData(): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + const content = await fs.readFile(storagePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + if ( + typeof parsed === 'object' && + parsed !== null && + Object.values(parsed).every((v) => typeof v === 'string') + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return parsed as StoredIntegrityData; + } + debugLogger.warn('Invalid policy integrity data format'); + return {}; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return {}; + } + debugLogger.error('Failed to load policy integrity data', error); + return {}; + } + } + + private async saveIntegrityData(data: StoredIntegrityData): Promise { + const storagePath = Storage.getPolicyIntegrityStoragePath(); + try { + await fs.mkdir(path.dirname(storagePath), { recursive: true }); + await fs.writeFile(storagePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (error) { + debugLogger.error('Failed to save policy integrity data', error); + throw error; + } + } +} diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 7d80b41893..3acf7c714d 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -136,7 +136,7 @@ describe('createPolicyUpdater', () => { const rules = policyEngine.getRules(); const addedRule = rules.find((r) => r.toolName === toolName); expect(addedRule).toBeDefined(); - expect(addedRule?.priority).toBe(2.95); + expect(addedRule?.priority).toBe(3.95); expect(addedRule?.argsPattern).toEqual( new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`), ); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 12648fec5f..e7129208c8 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index b608a87904..1688d5108c 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 991424cebc..47cd9c98ae 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 95c3b411f1..332334db7c 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -5,19 +5,20 @@ # # Priority bands (tiers): # - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Workspace policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - User policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# - Admin policies (TOML): 4 + priority/1000 (e.g., priority 100 → 4.100) # -# This ensures Admin > User > Default hierarchy is always preserved, +# This ensures Admin > User > Workspace > Default hierarchy is always preserved, # while allowing user-specified priorities to work within each tier. # -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) +# Settings-based and dynamic rules (all in user tier 3.x): +# 3.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 3.9: MCP servers excluded list (security: persistent server blocks) +# 3.4: Command line flag --exclude-tools (explicit temporary blocks) +# 3.3: Command line flag --allowed-tools (explicit temporary allows) +# 3.2: MCP servers with trust=true (persistent trusted servers) +# 3.1: MCP servers allowed list (persistent general server allows) # # TOML policy priorities (before transformation): # 10: Write tools default to ASK_USER (becomes 1.010 in default tier) diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 693ae3a4b2..11e8333f47 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2373,4 +2373,89 @@ describe('PolicyEngine', () => { ); }); }); + + describe('removeRulesByTier', () => { + it('should remove rules matching a specific tier', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 1.1, + }); + engine.addRule({ + toolName: 'rule2', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 2.1, + }); + engine.addRule({ + toolName: 'rule4', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule5', decision: PolicyDecision.ALLOW }); // priority undefined -> 0 + + expect(engine.getRules()).toHaveLength(5); + + engine.removeRulesByTier(1); + + const rules = engine.getRules(); + expect(rules).toHaveLength(3); + expect(rules.some((r) => r.toolName === 'rule1')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule2')).toBe(false); + expect(rules.some((r) => r.toolName === 'rule3')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule4')).toBe(true); + expect(rules.some((r) => r.toolName === 'rule5')).toBe(true); + }); + + it('should handle removing tier 0 rules (including undefined priority)', () => { + engine.addRule({ + toolName: 'rule1', + decision: PolicyDecision.ALLOW, + priority: 0.5, + }); + engine.addRule({ toolName: 'rule2', decision: PolicyDecision.ALLOW }); // defaults to 0 + engine.addRule({ + toolName: 'rule3', + decision: PolicyDecision.ALLOW, + priority: 1.5, + }); + + expect(engine.getRules()).toHaveLength(3); + + engine.removeRulesByTier(0); + + const rules = engine.getRules(); + expect(rules).toHaveLength(1); + expect(rules[0].toolName).toBe('rule3'); + }); + }); + + describe('removeCheckersByTier', () => { + it('should remove checkers matching a specific tier', () => { + engine.addChecker({ + checker: { type: 'external', name: 'c1' }, + priority: 1.1, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c2' }, + priority: 1.9, + }); + engine.addChecker({ + checker: { type: 'external', name: 'c3' }, + priority: 2.5, + }); + + expect(engine.getCheckers()).toHaveLength(3); + + engine.removeCheckersByTier(1); + + const checkers = engine.getCheckers(); + expect(checkers).toHaveLength(1); + expect(checkers[0].priority).toBe(2.5); + }); + }); }); diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 3f386edd8f..353cdae9c1 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ @@ -475,6 +475,24 @@ export class PolicyEngine { this.checkers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); } + /** + * Remove rules matching a specific tier (priority band). + */ + removeRulesByTier(tier: number): void { + this.rules = this.rules.filter( + (rule) => Math.floor(rule.priority ?? 0) !== tier, + ); + } + + /** + * Remove checkers matching a specific tier (priority band). + */ + removeCheckersByTier(tier: number): void { + this.checkers = this.checkers.filter( + (checker) => Math.floor(checker.priority ?? 0) !== tier, + ); + } + /** * Remove rules for a specific tool. * If source is provided, only rules matching that source are removed. diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index c627f6d049..e706b16bf7 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -228,14 +228,18 @@ modes = ["autoEdit"] `, ); - const getPolicyTier = (_dir: string) => 2; // Tier 2 - const result = await loadPoliciesFromToml([tempDir], getPolicyTier); + const getPolicyTier2 = (_dir: string) => 2; // Tier 2 + const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2); - expect(result.rules).toHaveLength(1); - expect(result.rules[0].toolName).toBe('tier2-tool'); - expect(result.rules[0].modes).toEqual(['autoEdit']); - expect(result.rules[0].source).toBe('User: tier2.toml'); - expect(result.errors).toHaveLength(0); + expect(result2.rules).toHaveLength(1); + expect(result2.rules[0].toolName).toBe('tier2-tool'); + expect(result2.rules[0].modes).toEqual(['autoEdit']); + expect(result2.rules[0].source).toBe('Workspace: tier2.toml'); + + const getPolicyTier3 = (_dir: string) => 3; // Tier 3 + const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3); + expect(result3.rules[0].source).toBe('User: tier2.toml'); + expect(result3.errors).toHaveLength(0); }); it('should handle TOML parse errors', async () => { @@ -359,6 +363,21 @@ priority = -1 expect(result.errors[0].fileName).toBe('invalid.toml'); expect(result.errors[0].errorType).toBe('schema_validation'); }); + + it('should transform safety checker priorities based on tier', async () => { + const result = await runLoadPoliciesFromToml(` +[[safety_checker]] +toolName = "write_file" +priority = 100 +[safety_checker.checker] +type = "in-process" +name = "allowed-path" +`); + + expect(result.checkers).toHaveLength(1); + expect(result.checkers[0].priority).toBe(1.1); // tier 1 + 100/1000 + expect(result.checkers[0].source).toBe('Default: test.toml'); + }); }); describe('Negative Tests', () => { diff --git a/packages/core/src/policy/toml-loader.ts b/packages/core/src/policy/toml-loader.ts index a627064d41..7be3fe27dc 100644 --- a/packages/core/src/policy/toml-loader.ts +++ b/packages/core/src/policy/toml-loader.ts @@ -17,6 +17,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import toml from '@iarna/toml'; import { z, type ZodError } from 'zod'; +import { isNodeError } from '../utils/errors.js'; /** * Schema for a single policy rule in the TOML file (before transformation). @@ -105,7 +106,7 @@ export type PolicyFileErrorType = export interface PolicyFileError { filePath: string; fileName: string; - tier: 'default' | 'user' | 'admin'; + tier: 'default' | 'user' | 'workspace' | 'admin'; ruleIndex?: number; errorType: PolicyFileErrorType; message: string; @@ -122,13 +123,59 @@ export interface PolicyLoadResult { errors: PolicyFileError[]; } +export interface PolicyFile { + path: string; + content: string; +} + +/** + * Reads policy files from a directory or a single file. + * + * @param policyPath Path to a directory or a .toml file. + * @returns Array of PolicyFile objects. + */ +export async function readPolicyFiles( + policyPath: string, +): Promise { + let filesToLoad: string[] = []; + let baseDir = ''; + + try { + const stats = await fs.stat(policyPath); + if (stats.isDirectory()) { + baseDir = policyPath; + const dirEntries = await fs.readdir(policyPath, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } else if (stats.isFile() && policyPath.endsWith('.toml')) { + baseDir = path.dirname(policyPath); + filesToLoad = [path.basename(policyPath)]; + } + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return []; + } + throw e; + } + + const results: PolicyFile[] = []; + for (const file of filesToLoad) { + const filePath = path.join(baseDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + results.push({ path: filePath, content }); + } + return results; +} + /** * Converts a tier number to a human-readable tier name. */ -function getTierName(tier: number): 'default' | 'user' | 'admin' { +function getTierName(tier: number): 'default' | 'user' | 'workspace' | 'admin' { if (tier === 1) return 'default'; - if (tier === 2) return 'user'; - if (tier === 3) return 'admin'; + if (tier === 2) return 'workspace'; + if (tier === 3) return 'user'; + if (tier === 4) return 'admin'; return 'default'; } @@ -211,7 +258,7 @@ function transformPriority(priority: number, tier: number): number { * 4. Collects detailed error information for any failures * * @param policyPaths Array of paths (directories or files) to scan for policy files - * @param getPolicyTier Function to determine tier (1-3) for a path + * @param getPolicyTier Function to determine tier (1-4) for a path * @returns Object containing successfully parsed rules and any errors encountered */ export async function loadPoliciesFromToml( @@ -226,48 +273,26 @@ export async function loadPoliciesFromToml( const tier = getPolicyTier(p); const tierName = getTierName(tier); - let filesToLoad: string[] = []; - let baseDir = ''; + let policyFiles: PolicyFile[] = []; try { - const stats = await fs.stat(p); - if (stats.isDirectory()) { - baseDir = p; - const dirEntries = await fs.readdir(p, { withFileTypes: true }); - filesToLoad = dirEntries - .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) - .map((entry) => entry.name); - } else if (stats.isFile() && p.endsWith('.toml')) { - baseDir = path.dirname(p); - filesToLoad = [path.basename(p)]; - } - // Other file types or non-.toml files are silently ignored - // for consistency with directory scanning behavior. + policyFiles = await readPolicyFiles(p); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; - if (error.code === 'ENOENT') { - // Path doesn't exist, skip it (not an error) - continue; - } errors.push({ filePath: p, fileName: path.basename(p), tier: tierName, errorType: 'file_read', message: `Failed to read policy path`, - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); continue; } - for (const file of filesToLoad) { - const filePath = path.join(baseDir, file); + for (const { path: filePath, content: fileContent } of policyFiles) { + const file = path.basename(filePath); try { - // Read file - const fileContent = await fs.readFile(filePath, 'utf-8'); - // Parse TOML let parsed: unknown; try { @@ -438,10 +463,11 @@ export async function loadPoliciesFromToml( const safetyCheckerRule: SafetyCheckerRule = { toolName: effectiveToolName, - priority: checker.priority, + priority: transformPriority(checker.priority, tier), // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion checker: checker.checker as SafetyCheckerConfig, modes: checker.modes, + source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`, }; if (argsPattern) { @@ -485,17 +511,15 @@ export async function loadPoliciesFromToml( checkers.push(...parsedCheckers); } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const error = e as NodeJS.ErrnoException; // Catch-all for unexpected errors - if (error.code !== 'ENOENT') { + if (!isNodeError(e) || e.code !== 'ENOENT') { errors.push({ filePath, fileName: file, tier: tierName, errorType: 'file_read', message: 'Failed to read policy file', - details: error.message, + details: isNodeError(e) ? e.message : String(e), }); } } diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 2e672fff26..e8aa0e6dd1 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -182,6 +182,12 @@ export interface SafetyCheckerRule { * If undefined or empty, it applies to all modes. */ modes?: ApprovalMode[]; + + /** + * Source of the rule. + * e.g. "my-policies.toml", "Workspace: project.toml", etc. + */ + source?: string; } export interface HookExecutionContext { @@ -272,7 +278,9 @@ export interface PolicySettings { allowed?: string[]; }; mcpServers?: Record; + // User provided policies that will replace the USER level policies in ~/.gemini/policies policyPaths?: string[]; + workspacePoliciesDir?: string; } export interface CheckResult { diff --git a/packages/core/src/policy/workspace-policy.test.ts b/packages/core/src/policy/workspace-policy.test.ts new file mode 100644 index 0000000000..999dae6f0d --- /dev/null +++ b/packages/core/src/policy/workspace-policy.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import nodePath from 'node:path'; +import { ApprovalMode } from './types.js'; +import { isDirectorySecure } from '../utils/security.js'; + +// Mock dependencies +vi.mock('../utils/security.js', () => ({ + isDirectorySecure: vi.fn().mockResolvedValue({ secure: true }), +})); + +describe('Workspace-Level Policies', () => { + beforeEach(async () => { + vi.resetModules(); + const { Storage } = await import('../config/storage.js'); + vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue( + '/mock/user/policies', + ); + vi.spyOn(Storage, 'getSystemPoliciesDir').mockReturnValue( + '/mock/system/policies', + ); + // Ensure security check always returns secure + vi.mocked(isDirectorySecure).mockResolvedValue({ secure: true }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.doUnmock('node:fs/promises'); + }); + + it('should load workspace policies with correct priority (Tier 2)', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + // Mock readdir to return a policy file for each tier + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('user/policies')) + return [ + { name: 'user.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + if (normalizedPath.endsWith('system/policies')) + return [ + { name: 'admin.toml', isFile: () => true, isDirectory: () => false }, + ] as unknown as Awaited>; + return []; + }); + + // Mock readFile to return content with distinct priorities/decisions + const mockReadFile = vi.fn(async (path: string) => { + if (path.includes('default.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 1 -> 1.010 + } + if (path.includes('user.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 3 -> 3.010 + } + if (path.includes('workspace.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "allow" +priority = 10 +`; // Tier 2 -> 2.010 + } + if (path.includes('admin.toml')) { + return `[[rule]] +toolName = "test_tool" +decision = "deny" +priority = 10 +`; // Tier 4 -> 4.010 + } + return ''; + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + // Test 1: Workspace vs User (User should win) + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + const rules = config.rules?.filter((r) => r.toolName === 'test_tool'); + expect(rules).toBeDefined(); + + // Check for all 4 rules + const defaultRule = rules?.find((r) => r.priority === 1.01); + const workspaceRule = rules?.find((r) => r.priority === 2.01); + const userRule = rules?.find((r) => r.priority === 3.01); + const adminRule = rules?.find((r) => r.priority === 4.01); + + expect(defaultRule).toBeDefined(); + expect(userRule).toBeDefined(); + expect(workspaceRule).toBeDefined(); + expect(adminRule).toBeDefined(); + + // Verify Hierarchy: Admin > User > Workspace > Default + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + expect(userRule!.priority).toBeGreaterThan(workspaceRule!.priority!); + expect(workspaceRule!.priority).toBeGreaterThan(defaultRule!.priority!); + }); + + it('should ignore workspace policies if workspacePoliciesDir is undefined', async () => { + const defaultPoliciesDir = '/mock/default/policies'; + + // Mock FS (simplified) + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('default/policies')) + return [ + { + name: 'default.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="t" +decision="allow" +priority=10`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir: undefined }, + ApprovalMode.DEFAULT, + defaultPoliciesDir, + ); + + // Should only have default tier rule (1.01) + const rules = config.rules; + expect(rules).toHaveLength(1); + expect(rules![0].priority).toBe(1.01); + }); + + it('should load workspace policies and correctly transform to Tier 2', async () => { + const workspacePoliciesDir = '/mock/workspace/policies'; + + // Mock FS + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockStat = vi.fn(async (path: string) => { + if (typeof path === 'string' && path.startsWith('/mock/')) { + return { + isDirectory: () => true, + isFile: () => false, + } as unknown as Awaited>; + } + return actualFs.stat(path); + }); + + const mockReaddir = vi.fn(async (path: string) => { + const normalizedPath = nodePath.normalize(path); + if (normalizedPath.endsWith('workspace/policies')) + return [ + { + name: 'workspace.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + return []; + }); + const mockReadFile = vi.fn( + async () => `[[rule]] +toolName="p_tool" +decision="allow" +priority=500`, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { + ...actualFs, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + }, + readdir: mockReaddir, + readFile: mockReadFile, + stat: mockStat, + })); + + const { createPolicyEngineConfig } = await import('./config.js'); + + const config = await createPolicyEngineConfig( + { workspacePoliciesDir }, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find((r) => r.toolName === 'p_tool'); + expect(rule).toBeDefined(); + // Workspace Tier (2) + 500/1000 = 2.5 + expect(rule?.priority).toBe(2.5); + }); +}); From 2ac39b6acc33354ab30ca828902fabeea4adb30e Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 19 Feb 2026 16:38:07 -0800 Subject: [PATCH 07/52] chore(skills): adds pr-address-comments skill to work on PR feedback (#19576) --- .gemini/skills/pr-address-comments/SKILL.md | 13 ++ .../scripts/fetch-pr-info.js | 160 ++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 .gemini/skills/pr-address-comments/SKILL.md create mode 100755 .gemini/skills/pr-address-comments/scripts/fetch-pr-info.js diff --git a/.gemini/skills/pr-address-comments/SKILL.md b/.gemini/skills/pr-address-comments/SKILL.md new file mode 100644 index 0000000000..bd190feffa --- /dev/null +++ b/.gemini/skills/pr-address-comments/SKILL.md @@ -0,0 +1,13 @@ +--- +name: pr-address-comments +description: Use this skill if the user asks you to help them address GitHub PR comments for their current branch of the Gemini CLI. Requires `gh` CLI tool. +--- +You are helping the user address comments on their Pull Request. These comments may have come from an automated review agent or a team member. + +OBJECTIVE: Help the user review and address comments on their PR. + +# Comment Review Procedure + +1. Run the `scripts/fetch-pr-info.js` script to get PR info and state. MAKE SURE you read the entire output of the command, even if it gets truncated. +2. Summarize the review status by analyzing the diff, commit log, and comments to see which still need to be addressed. Pay attention to the current user's comments. For resolved threads, summarize as a single line with a ✅. For open threads, provide a reference number e.g. [1] and the comment content. +3. Present your summary of the feedback and current state and allow the user to guide you as to what to fix/address/skip. DO NOT begin fixing issues automatically. diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js new file mode 100755 index 0000000000..772f8d18a4 --- /dev/null +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-env node */ +/* global console, process */ + +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execAsync = promisify(exec); + +async function run(cmd) { + try { + const { stdout } = await execAsync(cmd, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'ignore'], + }); + return stdout.trim(); + } catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars + return null; + } +} + +const IGNORE_MESSAGES = [ + 'thank you so much for your contribution to Gemini CLI!', + "I'm currently reviewing this pull request and will post my feedback shortly.", + 'This pull request is being closed because it is not currently linked to an issue.', +]; + +const shouldIgnore = (body) => { + if (!body) return false; + return IGNORE_MESSAGES.some((msg) => body.includes(msg)); +}; + +async function main() { + const branch = await run('git branch --show-current'); + if (!branch) { + console.error('❌ Could not determine current git branch.'); + process.exit(1); + } + + const gqlQuery = `query($branch:String!){repository(name:"gemini-cli",owner:"google-gemini"){pullRequests(headRefName:$branch,first:100){nodes{id,number,state,comments(first:100){nodes{createdAt,isMinimized,minimizedReason,author{login},body,url,authorAssociation}},reviews(first:100){nodes{id,author{login},createdAt,isMinimized,minimizedReason,body,state,comments(first:30){nodes{id,replyTo{id},author{login},createdAt,body,isMinimized,minimizedReason,path,line,startLine,originalLine,originalStartLine}}}}}}}}`; + + const [authInfo, diff, commits, rawJson] = await Promise.all([ + run('gh auth status -a'), + run('gh pr diff'), + run( + 'git fetch && git log origin/main..origin/$(git branch --show-current)', + ), + run(`gh api graphql -F branch="${branch}" -f query='${gqlQuery}'`), + ]); + + if (!diff) { + console.error(`⚠️ No active PR found for branch: ${branch}`); + process.exit(1); + } + + console.log(`\n# Current GitHub user info:\n\n${authInfo}\n`); + console.log(`\n# PR diff for current branch: ${branch}\n\n\`\`\``); + console.log(diff); + console.log('```'); + console.log( + `\n# Commit history (origin/main..origin/${branch})\n\n${commits}`, + ); + + const data = JSON.parse(rawJson || '{}'); + const prs = data?.data?.repository?.pullRequests?.nodes || []; + + // Sort PRs by number descending so we check the newest one first + prs.sort((a, b) => b.number - a.number); + + const pr = prs.find((p) => p.state === 'OPEN') || prs[0]; + + if (!pr) { + console.error('❌ No PR data found.'); + process.exit(1); + } + + console.log('\n# PR Feedback\n'); + + // 1. General PR Comments + const general = pr.comments.nodes.filter((c) => !shouldIgnore(c.body)); + if (general.length > 0) { + console.log('\n💬 GENERAL COMMENTS:'); + general.forEach((c) => { + const minimized = c.isMinimized + ? ` (Minimized: ${c.minimizedReason})` + : ''; + console.log( + `[${c.createdAt}] [${c.author.login}]${minimized}: ${c.body}\n`, + ); + }); + } + + // 2. Process ALL Review Comments into a single Thread Map + const allInlineComments = pr.reviews.nodes.flatMap((r) => r.comments.nodes); + const filteredInlines = allInlineComments.filter( + (c) => !shouldIgnore(c.body), + ); + + console.log('🔍 CODE REVIEWS & INLINE THREADS:'); + + // Print Review Summaries First + pr.reviews.nodes.forEach((review) => { + if (review.body && !shouldIgnore(review.body)) { + const icon = review.state === 'APPROVED' ? '✅' : '💬'; + const minimized = review.isMinimized + ? ` (Minimized: ${review.minimizedReason})` + : ''; + console.log( + `\n${icon} ${review.state} by ${review.author.login} at ${review.createdAt}${minimized}: "${review.body}"`, + ); + } + }); + + // Build and Print Threads + const topLevelThreads = filteredInlines.filter((c) => !c.replyTo); + + const printThread = (parentId, depth = 1) => { + const indent = ' '.repeat(depth); + filteredInlines + .filter((c) => c.replyTo?.id === parentId) + .forEach((reply) => { + const minimized = reply.isMinimized + ? ` (Minimized: ${reply.minimizedReason})` + : ''; + console.log( + `${indent}↳ [${reply.createdAt}] ${reply.author.login}${minimized}: ${reply.body}`, + ); + printThread(reply.id, depth + 1); + }); + }; + + topLevelThreads.forEach((c) => { + const start = c.startLine || c.originalStartLine; + const end = c.line || c.originalLine; + const range = start && end && start !== end ? `${start}-${end}` : end || ''; + const fileInfo = c.path + ? `(${c.path}${range ? `:${range}` : ''}) ` + : range + ? `(Line ${range}) ` + : ''; + const minimized = c.isMinimized ? ` (Minimized: ${c.minimizedReason})` : ''; + console.log( + `\n💬 ${minimized}${c.author.login} | ${c.createdAt} ${fileInfo}\n${c.body}`, + ); + printThread(c.id); + }); + + console.log('\n'); +} + +main().catch((err) => { + console.error('❌ Unexpected error:', err); + process.exit(1); +}); From 6351352e54a61b8d9ceac7f6eab4998e57140770 Mon Sep 17 00:00:00 2001 From: joshualitt Date: Thu, 19 Feb 2026 16:38:22 -0800 Subject: [PATCH 08/52] feat(core): Implement parallel FC for read only tools. (#18791) --- integration-tests/parallel-tools.responses | 1 + integration-tests/parallel-tools.test.ts | 77 ++++ .../core/src/agents/subagent-tool.test.ts | 84 ++++ packages/core/src/agents/subagent-tool.ts | 48 +++ packages/core/src/scheduler/scheduler.test.ts | 358 ++++------------ packages/core/src/scheduler/scheduler.ts | 133 +++++- .../src/scheduler/scheduler_parallel.test.ts | 397 ++++++++++++++++++ packages/core/src/scheduler/state-manager.ts | 8 + packages/core/src/tools/mcp-tool.ts | 12 +- packages/core/src/tools/tools.test.ts | 29 ++ packages/core/src/tools/tools.ts | 16 + 11 files changed, 862 insertions(+), 301 deletions(-) create mode 100644 integration-tests/parallel-tools.responses create mode 100644 integration-tests/parallel-tools.test.ts create mode 100644 packages/core/src/scheduler/scheduler_parallel.test.ts diff --git a/integration-tests/parallel-tools.responses b/integration-tests/parallel-tools.responses new file mode 100644 index 0000000000..d7beedc8b2 --- /dev/null +++ b/integration-tests/parallel-tools.responses @@ -0,0 +1 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file2.txt"}}},{"functionCall":{"name":"write_file","args":{"file_path":"output.txt","content":"wave2"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file3.txt"}}},{"functionCall":{"name":"read_file","args":{"file_path":"file4.txt"}}}, {"text":"All waves completed successfully."}]},"finishReason":"STOP","index":0}]}]} diff --git a/integration-tests/parallel-tools.test.ts b/integration-tests/parallel-tools.test.ts new file mode 100644 index 0000000000..760f98cd7a --- /dev/null +++ b/integration-tests/parallel-tools.test.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; +import fs from 'node:fs'; + +describe('Parallel Tool Execution Integration', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + await rig.cleanup(); + }); + + it('should execute [read, read, write, read, read] in correct waves with user approval', async () => { + rig.setup('parallel-wave-execution', { + fakeResponsesPath: join(import.meta.dirname, 'parallel-tools.responses'), + settings: { + tools: { + core: ['read_file', 'write_file'], + approval: 'ASK', // Disable YOLO mode to show permission prompts + confirmationRequired: ['write_file'], + }, + }, + }); + + rig.createFile('file1.txt', 'c1'); + rig.createFile('file2.txt', 'c2'); + rig.createFile('file3.txt', 'c3'); + rig.createFile('file4.txt', 'c4'); + rig.sync(); + + const run = await rig.runInteractive({ approvalMode: 'default' }); + + // 1. Trigger the wave + await run.type('ok'); + await run.type('\r'); + + // 3. Wait for the write_file prompt. + await run.expectText('Allow', 5000); + + // 4. Press Enter to approve the write_file. + await run.type('y'); + await run.type('\r'); + + // 5. Wait for the final model response + await run.expectText('All waves completed successfully.', 5000); + + // Verify all tool calls were made and succeeded in the logs + await rig.expectToolCallSuccess(['write_file']); + const toolLogs = rig.readToolLogs(); + + const readFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'read_file', + ); + const writeFiles = toolLogs.filter( + (l) => l.toolRequest.name === 'write_file', + ); + + expect(readFiles.length).toBe(4); + expect(writeFiles.length).toBe(1); + expect(toolLogs.every((l) => l.toolRequest.success)).toBe(true); + + // Check that output.txt was actually written + expect(fs.readFileSync(join(rig.testDir!, 'output.txt'), 'utf8')).toBe( + 'wave2', + ); + }); +}); diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index 135365712d..d6d6bdfd89 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -17,10 +17,12 @@ import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import type { Config } from '../config/config.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { + DeclarativeTool, ToolCallConfirmationDetails, ToolInvocation, ToolResult, } from '../tools/tools.js'; +import type { ToolRegistry } from 'src/tools/tool-registry.js'; vi.mock('./subagent-tool-wrapper.js'); @@ -274,3 +276,85 @@ describe('SubAgentInvocation', () => { }); }); }); + +describe('SubagentTool Read-Only logic', () => { + let mockConfig: Config; + let mockMessageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = makeFakeConfig(); + mockMessageBus = createMockMessageBus(); + }); + + it('should be false for remote agents', () => { + const tool = new SubagentTool( + testRemoteDefinition, + mockConfig, + mockMessageBus, + ); + expect(tool.isReadOnly).toBe(false); + }); + + it('should be true for local agent with only read-only tools', () => { + const readOnlyTool = { + name: 'read', + isReadOnly: true, + } as unknown as DeclarativeTool; + const registry = { + getTool: (name: string) => (name === 'read' ? readOnlyTool : undefined), + }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defWithTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: ['read'] }, + }; + const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(true); + }); + + it('should be false for local agent with at least one non-read-only tool', () => { + const readOnlyTool = { + name: 'read', + isReadOnly: true, + } as unknown as DeclarativeTool; + const mutatorTool = { + name: 'write', + isReadOnly: false, + } as unknown as DeclarativeTool; + const registry = { + getTool: (name: string) => { + if (name === 'read') return readOnlyTool; + if (name === 'write') return mutatorTool; + return undefined; + }, + }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defWithTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: ['read', 'write'] }, + }; + const tool = new SubagentTool(defWithTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(false); + }); + + it('should be true for local agent with no tools', () => { + const registry = { getTool: () => undefined }; + vi.spyOn(mockConfig, 'getToolRegistry').mockReturnValue( + registry as unknown as ToolRegistry, + ); + + const defNoTools: LocalAgentDefinition = { + ...testDefinition, + toolConfig: { tools: [] }, + }; + const tool = new SubagentTool(defNoTools, mockConfig, mockMessageBus); + expect(tool.isReadOnly).toBe(true); + }); +}); diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index 3a92452c3d..f47b506634 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -11,6 +11,7 @@ import { type ToolResult, BaseToolInvocation, type ToolCallConfirmationDetails, + isTool, } from '../tools/tools.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import type { Config } from '../config/config.js'; @@ -48,6 +49,53 @@ export class SubagentTool extends BaseDeclarativeTool { ); } + private _memoizedIsReadOnly: boolean | undefined; + + override get isReadOnly(): boolean { + if (this._memoizedIsReadOnly !== undefined) { + return this._memoizedIsReadOnly; + } + // No try-catch here. If getToolRegistry() throws, we let it throw. + // This is an invariant: you can't check read-only status if the system isn't initialized. + this._memoizedIsReadOnly = SubagentTool.checkIsReadOnly( + this.definition, + this.config, + ); + return this._memoizedIsReadOnly; + } + + private static checkIsReadOnly( + definition: AgentDefinition, + config: Config, + ): boolean { + if (definition.kind === 'remote') { + return false; + } + const tools = definition.toolConfig?.tools ?? []; + const registry = config.getToolRegistry(); + + if (!registry) { + return false; + } + + for (const tool of tools) { + if (typeof tool === 'string') { + const resolvedTool = registry.getTool(tool); + if (!resolvedTool || !resolvedTool.isReadOnly) { + return false; + } + } else if (isTool(tool)) { + if (!tool.isReadOnly) { + return false; + } + } else { + // FunctionDeclaration - we don't know, so assume NOT read-only + return false; + } + } + return true; + } + protected createInvocation( params: AgentInputs, messageBus: MessageBus, diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index dd26ba4c03..61699d07a6 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -27,7 +27,6 @@ vi.mock('../telemetry/trace.js', () => ({ })); import { logToolCall } from '../telemetry/loggers.js'; -import { ToolCallEvent } from '../telemetry/types.js'; vi.mock('../telemetry/loggers.js', () => ({ logToolCall: vi.fn(), })); @@ -76,6 +75,8 @@ import type { CancelledToolCall, CompletedToolCall, ToolCallResponseInfo, + Status, + ToolCall, } from './types.js'; import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; @@ -168,29 +169,55 @@ describe('Scheduler (Orchestrator)', () => { getPreferredEditor = vi.fn().mockReturnValue('vim'); // --- Setup Sub-component Mocks --- + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + mockStateManager = { - enqueue: vi.fn(), - dequeue: vi.fn(), - getToolCall: vi.fn(), - updateStatus: vi.fn(), - finalizeCall: vi.fn(), + enqueue: vi.fn((calls: ToolCall[]) => { + // Clone to preserve initial state for Phase 1 tests + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), updateArgs: vi.fn(), setOutcome: vi.fn(), - cancelAllQueued: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), clearBatch: vi.fn(), } as unknown as Mocked; // Define getters for accessors idiomatically Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), configurable: true, }); Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValue(0), + get: vi.fn(() => mockQueue.length), configurable: true, }); Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(undefined), + get: vi.fn(() => mockActiveCallsMap.values().next().value), configurable: true, }); Object.defineProperty(mockStateManager, 'completedBatch', { @@ -227,8 +254,9 @@ describe('Scheduler (Orchestrator)', () => { ); mockStateManager.finalizeCall.mockImplementation((callId: string) => { - const call = mockStateManager.getToolCall(callId); + const call = mockActiveCallsMap.get(callId); if (call) { + mockActiveCallsMap.delete(callId); capturedTerminalHandler?.(call as CompletedToolCall); } }); @@ -242,6 +270,13 @@ describe('Scheduler (Orchestrator)', () => { vi.mocked(ToolExecutor).mockReturnValue( mockExecutor as unknown as Mocked, ); + mockExecutor.execute.mockResolvedValue({ + status: 'success', + response: { + callId: 'default', + responseParts: [], + } as unknown as ToolCallResponseInfo, + } as unknown as SuccessfulToolCall); vi.mocked(ToolModificationHandler).mockReturnValue( mockModifier as unknown as Mocked, ); @@ -339,35 +374,6 @@ describe('Scheduler (Orchestrator)', () => { describe('Phase 2: Queue Management', () => { it('should drain the queue if multiple calls are scheduled', async () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - // Execute is the end of the loop, stub it mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -375,56 +381,12 @@ describe('Scheduler (Orchestrator)', () => { await scheduler.schedule(req1, signal); - // Verify loop ran twice - expect(mockStateManager.dequeue).toHaveBeenCalledTimes(2); - expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(2); + // Verify loop ran once for this schedule call (which had 1 request) + // schedule(req1) enqueues 1 request. + expect(mockExecutor.execute).toHaveBeenCalledTimes(1); }); it('should execute tool calls sequentially (first completes before second starts)', async () => { - // Setup queue simulation: two items - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) // Used in loop check for call 1 - .mockReturnValueOnce(validatingCall1) // Used in _execute for call 1 - .mockReturnValueOnce(validatingCall2) // Used in loop check for call 2 - .mockReturnValueOnce(validatingCall2), // Used in _execute for call 2 - configurable: true, - }); - const executionLog: string[] = []; // Mock executor to push to log with a deterministic microtask delay @@ -452,52 +414,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue and process multiple schedule() calls made synchronously', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock state responses dynamically - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - // Queue state responses for the two batches: - // Batch 1: length 1 -> 0 - // Batch 2: length 1 -> 0 - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // Executor succeeds instantly mockExecutor.execute.mockResolvedValue({ status: CoreToolCallStatus.Success, @@ -516,50 +432,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should queue requests when scheduler is busy (overlapping batches)', async () => { - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, // Second request - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // 1. Setup State Manager for 2 sequential batches - Object.defineProperty(mockStateManager, 'isActive', { - get: vi.fn().mockReturnValue(false), - configurable: true, - }); - - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi - .fn() - .mockReturnValueOnce(1) // Batch 1 - .mockReturnValueOnce(0) - .mockReturnValueOnce(1) // Batch 2 - .mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2); - - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi - .fn() - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValueOnce(validatingCall2), - configurable: true, - }); - // 2. Setup Executor with a controllable lock for the first batch const executionLog: string[] = []; let finishFirstBatch: (value: unknown) => void; @@ -635,10 +507,8 @@ describe('Scheduler (Orchestrator)', () => { invocation: mockInvocation as unknown as AnyToolInvocation, }; - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(activeCall), - configurable: true, - }); + mockStateManager.enqueue([activeCall]); + mockStateManager.dequeue(); scheduler.cancelAll(); @@ -676,24 +546,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 3: Policy & Confirmation Loop', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - beforeEach(() => { - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - }); + beforeEach(() => {}); it('should update state to error with POLICY_VIOLATION if Policy returns DENY', async () => { vi.mocked(checkPolicy).mockResolvedValue({ @@ -854,30 +707,6 @@ describe('Scheduler (Orchestrator)', () => { }); it('should auto-approve remaining identical tools in batch after ProceedAlways', async () => { - // Setup: two identical tools - const validatingCall1: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - const validatingCall2: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req2, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - vi.mocked(mockStateManager.dequeue) - .mockReturnValueOnce(validatingCall1) - .mockReturnValueOnce(validatingCall2) - .mockReturnValue(undefined); - - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(2) - .mockReturnValueOnce(1) - .mockReturnValue(0); - // First call requires confirmation, second is auto-approved (simulating policy update) vi.mocked(checkPolicy) .mockResolvedValueOnce({ @@ -1045,21 +874,7 @@ describe('Scheduler (Orchestrator)', () => { }); describe('Phase 4: Execution Outcomes', () => { - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - beforeEach(() => { - vi.spyOn(mockStateManager, 'queueLength', 'get') - .mockReturnValueOnce(1) - .mockReturnValue(0); - mockStateManager.dequeue.mockReturnValue(validatingCall); - vi.spyOn(mockStateManager, 'firstActiveCall', 'get').mockReturnValue( - validatingCall, - ); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, }); // Bypass confirmation @@ -1132,30 +947,12 @@ describe('Scheduler (Orchestrator)', () => { response: mockResponse, } as unknown as SuccessfulToolCall); - // Mock the state manager to return a SUCCESS state when getToolCall is - // called - const successfulCall: SuccessfulToolCall = { - status: CoreToolCallStatus.Success, - request: req1, - response: mockResponse, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - mockStateManager.getToolCall.mockReturnValue(successfulCall); - Object.defineProperty(mockStateManager, 'completedBatch', { - get: vi.fn().mockReturnValue([successfulCall]), - configurable: true, - }); - await scheduler.schedule(req1, signal); // Verify the finalizer and logger were called expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); - expect(ToolCallEvent).toHaveBeenCalledWith(successfulCall); - expect(logToolCall).toHaveBeenCalledWith( - mockConfig, - expect.objectContaining(successfulCall), - ); + // We check that logToolCall was called (it's called via the state manager's terminal handler) + expect(logToolCall).toHaveBeenCalled(); }); it('should not double-report completed tools when concurrent completions occur', async () => { @@ -1182,6 +979,33 @@ describe('Scheduler (Orchestrator)', () => { expect(mockStateManager.finalizeCall).toHaveBeenCalledTimes(1); expect(mockStateManager.finalizeCall).toHaveBeenCalledWith('call-1'); }); + + it('should break the loop if no progress is made (safeguard against stuck states)', async () => { + // Setup: A tool that is 'validating' but stays 'validating' even after processing + // This simulates a bug in state management or a weird edge case. + const stuckCall: ValidatingToolCall = { + status: CoreToolCallStatus.Validating, + request: req1, + tool: mockTool, + invocation: mockInvocation as unknown as AnyToolInvocation, + }; + + // Mock dequeue to keep returning the same stuck call + mockStateManager.dequeue.mockReturnValue(stuckCall); + // Mock isActive to be true + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn().mockReturnValue(true), + configurable: true, + }); + + // Mock updateStatus to do NOTHING (simulating no progress) + mockStateManager.updateStatus.mockImplementation(() => {}); + + // This should return false (break loop) instead of hanging indefinitely + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await (scheduler as any)._processNextItem(signal); + expect(result).toBe(false); + }); }); describe('Tool Call Context Propagation', () => { @@ -1196,26 +1020,6 @@ describe('Scheduler (Orchestrator)', () => { parentCallId, }); - const validatingCall: ValidatingToolCall = { - status: CoreToolCallStatus.Validating, - request: req1, - tool: mockTool, - invocation: mockInvocation as unknown as AnyToolInvocation, - }; - - // Mock queueLength to run the loop once - Object.defineProperty(mockStateManager, 'queueLength', { - get: vi.fn().mockReturnValueOnce(1).mockReturnValue(0), - configurable: true, - }); - - vi.mocked(mockStateManager.dequeue).mockReturnValue(validatingCall); - Object.defineProperty(mockStateManager, 'firstActiveCall', { - get: vi.fn().mockReturnValue(validatingCall), - configurable: true, - }); - vi.mocked(mockStateManager.getToolCall).mockReturnValue(validatingCall); - mockToolRegistry.getTool.mockReturnValue(mockTool); mockPolicyEngine.check.mockResolvedValue({ decision: PolicyDecision.ALLOW, diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index deb28d33a5..3ee55975f1 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -20,6 +20,7 @@ import { type ValidatingToolCall, type ErroredToolCall, CoreToolCallStatus, + type ScheduledToolCall, } from './types.js'; import { ToolErrorType } from '../tools/tool-error.js'; import type { ApprovalMode } from '../policy/types.js'; @@ -231,14 +232,16 @@ export class Scheduler { next?.reject(new Error('Operation cancelled by user')); } - // Cancel active call - const activeCall = this.state.firstActiveCall; - if (activeCall && !this.isTerminal(activeCall.status)) { - this.state.updateStatus( - activeCall.request.callId, - CoreToolCallStatus.Cancelled, - 'Operation cancelled by user', - ); + // Cancel active calls + const activeCalls = this.state.allActiveCalls; + for (const activeCall of activeCalls) { + if (!this.isTerminal(activeCall.status)) { + this.state.updateStatus( + activeCall.request.callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled by user', + ); + } } // Clear queue @@ -384,6 +387,10 @@ export class Scheduler { return false; } + const initialStatuses = new Map( + this.state.allActiveCalls.map((c) => [c.request.callId, c.status]), + ); + if (!this.state.isActive) { const next = this.state.dequeue(); if (!next) return false; @@ -397,16 +404,91 @@ export class Scheduler { this.state.finalizeCall(next.request.callId); return true; } + + // If the first tool is read-only, batch all contiguous read-only tools. + if (next.tool?.isReadOnly) { + while (this.state.queueLength > 0) { + const peeked = this.state.peekQueue(); + if (peeked && peeked.tool?.isReadOnly) { + this.state.dequeue(); + } else { + break; + } + } + } } - const active = this.state.firstActiveCall; - if (!active) return false; + // Now we have one or more active calls. Move them through the lifecycle + // as much as possible in this iteration. - if (active.status === CoreToolCallStatus.Validating) { - await this._processValidatingCall(active, signal); + // 1. Process all 'validating' calls (Policy & Confirmation) + let activeCalls = this.state.allActiveCalls; + const validatingCalls = activeCalls.filter( + (c): c is ValidatingToolCall => + c.status === CoreToolCallStatus.Validating, + ); + if (validatingCalls.length > 0) { + await Promise.all( + validatingCalls.map((c) => this._processValidatingCall(c, signal)), + ); } - return true; + // 2. Execute scheduled calls + // Refresh activeCalls as status might have changed to 'scheduled' + activeCalls = this.state.allActiveCalls; + const scheduledCalls = activeCalls.filter( + (c): c is ScheduledToolCall => c.status === CoreToolCallStatus.Scheduled, + ); + + // We only execute if ALL active calls are in a ready state (scheduled or terminal) + const allReady = activeCalls.every( + (c) => + c.status === CoreToolCallStatus.Scheduled || this.isTerminal(c.status), + ); + + if (allReady && scheduledCalls.length > 0) { + await Promise.all(scheduledCalls.map((c) => this._execute(c, signal))); + } + + // 3. Finalize terminal calls + activeCalls = this.state.allActiveCalls; + let madeProgress = false; + for (const call of activeCalls) { + if (this.isTerminal(call.status)) { + this.state.finalizeCall(call.request.callId); + madeProgress = true; + } + } + + // Check if any calls changed status during this iteration (excluding terminal finalization) + const currentStatuses = new Map( + activeCalls.map((c) => [c.request.callId, c.status]), + ); + const anyStatusChanged = Array.from(initialStatuses.entries()).some( + ([id, status]) => currentStatuses.get(id) !== status, + ); + + if (madeProgress || anyStatusChanged) { + return true; + } + + // If we have active calls but NONE of them progressed, check if we are waiting for external events. + // States that are 'waiting' from the loop's perspective: awaiting_approval, executing. + const isWaitingForExternal = activeCalls.some( + (c) => + c.status === CoreToolCallStatus.AwaitingApproval || + c.status === CoreToolCallStatus.Executing, + ); + + if (isWaitingForExternal && this.state.isActive) { + // Yield to the event loop to allow external events (tool completion, user input) to progress. + await new Promise((resolve) => queueMicrotask(() => resolve(true))); + return true; + } + + // If we are here, we have active calls (likely Validating or Scheduled) but none progressed. + // This is a stuck state. + return false; } private async _processValidatingCall( @@ -437,8 +519,6 @@ export class Scheduler { ); } } - - this.state.finalizeCall(active.request.callId); } // --- Phase 3: Single Call Orchestration --- @@ -467,7 +547,6 @@ export class Scheduler { errorType, ), ); - this.state.finalizeCall(callId); return; } @@ -506,13 +585,11 @@ export class Scheduler { CoreToolCallStatus.Cancelled, 'User denied execution.', ); - this.state.finalizeCall(callId); this.state.cancelAllQueued('User cancelled operation'); return; // Skip execution } - // Execution - await this._execute(callId, signal); + this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); } // --- Sub-phase Handlers --- @@ -520,13 +597,23 @@ export class Scheduler { /** * Executes the tool and records the result. */ - private async _execute(callId: string, signal: AbortSignal): Promise { - this.state.updateStatus(callId, CoreToolCallStatus.Scheduled); - if (signal.aborted) throw new Error('Operation cancelled'); + private async _execute( + toolCall: ScheduledToolCall, + signal: AbortSignal, + ): Promise { + const callId = toolCall.request.callId; + if (signal.aborted) { + this.state.updateStatus( + callId, + CoreToolCallStatus.Cancelled, + 'Operation cancelled', + ); + return; + } this.state.updateStatus(callId, CoreToolCallStatus.Executing); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const activeCall = this.state.firstActiveCall as ExecutingToolCall; + const activeCall = this.state.getToolCall(callId) as ExecutingToolCall; const result = await runWithToolCallContext( { diff --git a/packages/core/src/scheduler/scheduler_parallel.test.ts b/packages/core/src/scheduler/scheduler_parallel.test.ts new file mode 100644 index 0000000000..824cdc4a16 --- /dev/null +++ b/packages/core/src/scheduler/scheduler_parallel.test.ts @@ -0,0 +1,397 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, + type Mocked, +} from 'vitest'; +import { randomUUID } from 'node:crypto'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), +})); + +vi.mock('../telemetry/trace.js', () => ({ + runInDevTraceSpan: vi.fn(async (_opts, fn) => + fn({ metadata: { input: {}, output: {} } }), + ), +})); +vi.mock('../telemetry/loggers.js', () => ({ + logToolCall: vi.fn(), +})); +vi.mock('../telemetry/types.js', () => ({ + ToolCallEvent: vi.fn().mockImplementation((call) => ({ ...call })), +})); + +import { + SchedulerStateManager, + type TerminalCallHandler, +} from './state-manager.js'; +import { checkPolicy, updatePolicy } from './policy.js'; +import { ToolExecutor } from './tool-executor.js'; +import { ToolModificationHandler } from './tool-modifier.js'; + +vi.mock('./state-manager.js'); +vi.mock('./confirmation.js'); +vi.mock('./policy.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + checkPolicy: vi.fn(), + updatePolicy: vi.fn(), + }; +}); +vi.mock('./tool-executor.js'); +vi.mock('./tool-modifier.js'); + +import { Scheduler } from './scheduler.js'; +import type { Config } from '../config/config.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import type { PolicyEngine } from '../policy/policy-engine.js'; +import type { ToolRegistry } from '../tools/tool-registry.js'; +import { ApprovalMode, PolicyDecision } from '../policy/types.js'; +import { + type AnyDeclarativeTool, + type AnyToolInvocation, +} from '../tools/tools.js'; +import type { + ToolCallRequestInfo, + CompletedToolCall, + SuccessfulToolCall, + Status, + ToolCall, +} from './types.js'; +import { ROOT_SCHEDULER_ID } from './types.js'; +import type { EditorType } from '../utils/editor.js'; + +describe('Scheduler Parallel Execution', () => { + let scheduler: Scheduler; + let signal: AbortSignal; + let abortController: AbortController; + + let mockConfig: Mocked; + let mockMessageBus: Mocked; + let mockPolicyEngine: Mocked; + let mockToolRegistry: Mocked; + let getPreferredEditor: Mock<() => EditorType | undefined>; + + let mockStateManager: Mocked; + let mockExecutor: Mocked; + let mockModifier: Mocked; + + const req1: ToolCallRequestInfo = { + callId: 'call-1', + name: 'read-tool-1', + args: { path: 'a.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req2: ToolCallRequestInfo = { + callId: 'call-2', + name: 'read-tool-2', + args: { path: 'b.txt' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const req3: ToolCallRequestInfo = { + callId: 'call-3', + name: 'write-tool', + args: { path: 'c.txt', content: 'hi' }, + isClientInitiated: false, + prompt_id: 'p1', + schedulerId: ROOT_SCHEDULER_ID, + }; + + const readTool1 = { + name: 'read-tool-1', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const readTool2 = { + name: 'read-tool-2', + isReadOnly: true, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + const writeTool = { + name: 'write-tool', + isReadOnly: false, + build: vi.fn(), + } as unknown as AnyDeclarativeTool; + + const mockInvocation = { + shouldConfirmExecute: vi.fn().mockResolvedValue(false), + }; + + beforeEach(() => { + vi.mocked(randomUUID).mockReturnValue( + 'uuid' as unknown as `${string}-${string}-${string}-${string}-${string}`, + ); + abortController = new AbortController(); + signal = abortController.signal; + + mockPolicyEngine = { + check: vi.fn().mockResolvedValue({ decision: PolicyDecision.ALLOW }), + } as unknown as Mocked; + + mockToolRegistry = { + getTool: vi.fn((name) => { + if (name === 'read-tool-1') return readTool1; + if (name === 'read-tool-2') return readTool2; + if (name === 'write-tool') return writeTool; + return undefined; + }), + getAllToolNames: vi + .fn() + .mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']), + } as unknown as Mocked; + + mockConfig = { + getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + isInteractive: vi.fn().mockReturnValue(true), + getEnableHooks: vi.fn().mockReturnValue(true), + setApprovalMode: vi.fn(), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + } as unknown as Mocked; + + mockMessageBus = { + publish: vi.fn(), + subscribe: vi.fn(), + } as unknown as Mocked; + getPreferredEditor = vi.fn().mockReturnValue('vim'); + + vi.mocked(checkPolicy).mockReset(); + vi.mocked(checkPolicy).mockResolvedValue({ + decision: PolicyDecision.ALLOW, + rule: undefined, + }); + vi.mocked(updatePolicy).mockReset(); + + const mockActiveCallsMap = new Map(); + const mockQueue: ToolCall[] = []; + let capturedTerminalHandler: TerminalCallHandler | undefined; + + mockStateManager = { + enqueue: vi.fn((calls: ToolCall[]) => { + mockQueue.push(...calls.map((c) => ({ ...c }) as ToolCall)); + }), + dequeue: vi.fn(() => { + const next = mockQueue.shift(); + if (next) mockActiveCallsMap.set(next.request.callId, next); + return next; + }), + peekQueue: vi.fn(() => mockQueue[0]), + getToolCall: vi.fn((id: string) => mockActiveCallsMap.get(id)), + updateStatus: vi.fn((id: string, status: Status) => { + const call = mockActiveCallsMap.get(id); + if (call) (call as unknown as { status: Status }).status = status; + }), + finalizeCall: vi.fn((id: string) => { + const call = mockActiveCallsMap.get(id); + if (call) { + mockActiveCallsMap.delete(id); + capturedTerminalHandler?.(call as CompletedToolCall); + } + }), + updateArgs: vi.fn(), + setOutcome: vi.fn(), + cancelAllQueued: vi.fn(() => { + mockQueue.length = 0; + }), + clearBatch: vi.fn(), + } as unknown as Mocked; + + Object.defineProperty(mockStateManager, 'isActive', { + get: vi.fn(() => mockActiveCallsMap.size > 0), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'allActiveCalls', { + get: vi.fn(() => Array.from(mockActiveCallsMap.values())), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'queueLength', { + get: vi.fn(() => mockQueue.length), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'firstActiveCall', { + get: vi.fn(() => mockActiveCallsMap.values().next().value), + configurable: true, + }); + Object.defineProperty(mockStateManager, 'completedBatch', { + get: vi.fn().mockReturnValue([]), + configurable: true, + }); + + vi.mocked(SchedulerStateManager).mockImplementation( + (_bus, _id, onTerminal) => { + capturedTerminalHandler = onTerminal; + return mockStateManager as unknown as SchedulerStateManager; + }, + ); + + mockExecutor = { execute: vi.fn() } as unknown as Mocked; + vi.mocked(ToolExecutor).mockReturnValue( + mockExecutor as unknown as Mocked, + ); + mockModifier = { + handleModifyWithEditor: vi.fn(), + applyInlineModify: vi.fn(), + } as unknown as Mocked; + vi.mocked(ToolModificationHandler).mockReturnValue( + mockModifier as unknown as Mocked, + ); + + scheduler = new Scheduler({ + config: mockConfig, + messageBus: mockMessageBus, + getPreferredEditor, + schedulerId: 'root', + }); + + vi.mocked(readTool1.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(readTool2.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + vi.mocked(writeTool.build).mockReturnValue( + mockInvocation as unknown as AnyToolInvocation, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should execute contiguous read-only tools in parallel', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // Schedule 2 read tools and 1 write tool + await scheduler.schedule([req1, req2, req3], signal); + + // Parallel read tools should start together + expect(executionLog[0]).toBe('start-call-1'); + expect(executionLog[1]).toBe('start-call-2'); + + // They can finish in any order, but both must finish before call-3 starts + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-1'), + ); + expect(executionLog.indexOf('start-call-3')).toBeGreaterThan( + executionLog.indexOf('end-call-2'), + ); + + expect(executionLog).toContain('end-call-3'); + }); + + it('should execute non-read-only tools sequentially', async () => { + const executionLog: string[] = []; + + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 is NOT read-only + await scheduler.schedule([req3, req1], signal); + + // Should be strictly sequential + expect(executionLog).toEqual([ + 'start-call-3', + 'end-call-3', + 'start-call-1', + 'end-call-1', + ]); + }); + + it('should execute [WRITE, READ, READ] as [sequential, parallel]', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + // req3 (WRITE), req1 (READ), req2 (READ) + await scheduler.schedule([req3, req1, req2], signal); + + // Order should be: + // 1. write starts and ends + // 2. read1 and read2 start together (parallel) + expect(executionLog[0]).toBe('start-call-3'); + expect(executionLog[1]).toBe('end-call-3'); + expect(executionLog.slice(2, 4)).toContain('start-call-1'); + expect(executionLog.slice(2, 4)).toContain('start-call-2'); + }); + + it('should execute [READ, READ, WRITE, READ, READ] in three waves', async () => { + const executionLog: string[] = []; + mockExecutor.execute.mockImplementation(async ({ call }) => { + const id = call.request.callId; + executionLog.push(`start-${id}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + executionLog.push(`end-${id}`); + return { + status: 'success', + response: { callId: id, responseParts: [] }, + } as unknown as SuccessfulToolCall; + }); + + const req4: ToolCallRequestInfo = { ...req1, callId: 'call-4' }; + const req5: ToolCallRequestInfo = { ...req2, callId: 'call-5' }; + + await scheduler.schedule([req1, req2, req3, req4, req5], signal); + + // Wave 1: call-1, call-2 (parallel) + expect(executionLog.slice(0, 2)).toContain('start-call-1'); + expect(executionLog.slice(0, 2)).toContain('start-call-2'); + + // Wave 2: call-3 (sequential) + // Must start after both call-1 and call-2 end + const start3 = executionLog.indexOf('start-call-3'); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-1')); + expect(start3).toBeGreaterThan(executionLog.indexOf('end-call-2')); + const end3 = executionLog.indexOf('end-call-3'); + expect(end3).toBeGreaterThan(start3); + + // Wave 3: call-4, call-5 (parallel) + // Must start after call-3 ends + expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3); + expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3); + }); +}); diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index b282c3eb78..42da1d78ac 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -78,10 +78,18 @@ export class SchedulerStateManager { return next; } + peekQueue(): ToolCall | undefined { + return this.queue[0]; + } + get isActive(): boolean { return this.activeCalls.size > 0; } + get allActiveCalls(): ToolCall[] { + return Array.from(this.activeCalls.values()); + } + get activeCallCount(): number { return this.activeCalls.size; } diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 280af4589a..1279d0f705 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -247,7 +247,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< override readonly parameterSchema: unknown, messageBus: MessageBus, readonly trust?: boolean, - readonly isReadOnly?: boolean, + isReadOnly?: boolean, nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionName?: string, @@ -265,6 +265,16 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< extensionName, extensionId, ); + this._isReadOnly = isReadOnly; + } + + private readonly _isReadOnly?: boolean; + + override get isReadOnly(): boolean { + if (this._isReadOnly !== undefined) { + return this._isReadOnly; + } + return super.isReadOnly; } getFullyQualifiedPrefix(): string { diff --git a/packages/core/src/tools/tools.test.ts b/packages/core/src/tools/tools.test.ts index 514f4f3455..41edf9f21d 100644 --- a/packages/core/src/tools/tools.test.ts +++ b/packages/core/src/tools/tools.test.ts @@ -9,6 +9,8 @@ import type { ToolInvocation, ToolResult } from './tools.js'; import { DeclarativeTool, hasCycleInSchema, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; +import { ReadFileTool } from './read-file.js'; +import { makeFakeConfig } from '../test-utils/config.js'; class TestToolInvocation implements ToolInvocation { constructor( @@ -238,3 +240,30 @@ describe('hasCycleInSchema', () => { expect(hasCycleInSchema({})).toBe(false); }); }); + +describe('Tools Read-Only property', () => { + it('should have isReadOnly true for ReadFileTool', () => { + const config = makeFakeConfig(); + const bus = createMockMessageBus(); + const tool = new ReadFileTool(config, bus); + expect(tool.isReadOnly).toBe(true); + }); + + it('should derive isReadOnly from Kind', () => { + const bus = createMockMessageBus(); + class MyTool extends DeclarativeTool { + build(_params: object): ToolInvocation { + throw new Error('Not implemented'); + } + } + + const mutator = new MyTool('m', 'M', 'd', Kind.Edit, {}, bus); + expect(mutator.isReadOnly).toBe(false); + + const reader = new MyTool('r', 'R', 'd', Kind.Read, {}, bus); + expect(reader.isReadOnly).toBe(true); + + const searcher = new MyTool('s', 'S', 'd', Kind.Search, {}, bus); + expect(searcher.isReadOnly).toBe(true); + }); +}); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 4e9972e37c..acbbd7bfff 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -333,6 +333,11 @@ export interface ToolBuilder< */ canUpdateOutput: boolean; + /** + * Whether the tool is read-only (has no side effects). + */ + isReadOnly: boolean; + /** * Validates raw parameters and builds a ready-to-execute invocation. * @param params The raw, untrusted parameters from the model. @@ -363,6 +368,10 @@ export abstract class DeclarativeTool< readonly extensionId?: string, ) {} + get isReadOnly(): boolean { + return READ_ONLY_KINDS.includes(this.kind); + } + getSchema(_modelId?: string): FunctionDeclaration { return { name: this.name, @@ -819,6 +828,13 @@ export const MUTATOR_KINDS: Kind[] = [ Kind.Execute, ] as const; +// Function kinds that are safe to run in parallel +export const READ_ONLY_KINDS: Kind[] = [ + Kind.Read, + Kind.Search, + Kind.Fetch, +] as const; + export interface ToolLocation { // Absolute path to the file path: string; From f1c0a695f8175dd36544356a81be88601f75b673 Mon Sep 17 00:00:00 2001 From: Michael Bleigh Date: Thu, 19 Feb 2026 16:47:35 -0800 Subject: [PATCH 09/52] refactor(sdk): introduce session-based architecture (#19180) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- eslint.config.js | 1 + packages/core/src/config/storage.test.ts | 80 +++++ packages/core/src/config/storage.ts | 57 ++++ packages/core/src/index.ts | 2 +- packages/core/src/utils/session.ts | 4 + packages/sdk/SDK_DESIGN.md | 18 +- packages/sdk/src/agent.integration.test.ts | 77 ++--- packages/sdk/src/agent.ts | 276 ++++-------------- packages/sdk/src/index.ts | 1 + packages/sdk/src/session.ts | 270 +++++++++++++++++ packages/sdk/src/skills.integration.test.ts | 6 +- packages/sdk/src/tool.integration.test.ts | 9 +- packages/sdk/src/types.ts | 20 ++ .../test-data/agent-dynamic-instructions.json | 24 +- .../sdk/test-data/agent-resume-session.json | 2 + .../test-data/agent-static-instructions.json | 3 + packages/sdk/test-data/skill-dir-success.json | 6 + .../sdk/test-data/skill-root-success.json | 6 + .../sdk/test-data/tool-catchall-error.json | 6 + .../sdk/test-data/tool-error-recovery.json | 6 + packages/sdk/test-data/tool-success.json | 6 + 21 files changed, 612 insertions(+), 268 deletions(-) create mode 100644 packages/sdk/src/session.ts create mode 100644 packages/sdk/test-data/agent-resume-session.json diff --git a/eslint.config.js b/eslint.config.js index 48af3775f2..b05072cfc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', + '.gemini/skills/**', ], }, eslint.configs.recommended, diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index afb3eaeeeb..15b49d12f1 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -180,6 +180,86 @@ describe('Storage – additional helpers', () => { expect(storageWithSession.getProjectTempPlansDir()).toBe(expected); }); + describe('Session and JSON Loading', () => { + beforeEach(async () => { + await storage.initialize(); + }); + + it('listProjectChatFiles returns sorted sessions from chats directory', async () => { + const readdirSpy = vi + .spyOn(fs.promises, 'readdir') + /* eslint-disable @typescript-eslint/no-explicit-any */ + .mockResolvedValue([ + 'session-1.json', + 'session-2.json', + 'not-a-session.txt', + ] as any); + + const statSpy = vi + .spyOn(fs.promises, 'stat') + .mockImplementation(async (p: any) => { + if (p.toString().endsWith('session-1.json')) { + return { + mtime: new Date('2026-02-01'), + mtimeMs: 1000, + } as any; + } + return { + mtime: new Date('2026-02-02'), + mtimeMs: 2000, + } as any; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + const sessions = await storage.listProjectChatFiles(); + + expect(readdirSpy).toHaveBeenCalledWith(expect.stringContaining('chats')); + expect(sessions).toHaveLength(2); + // Sorted by mtime desc + expect(sessions[0].filePath).toBe(path.join('chats', 'session-2.json')); + expect(sessions[1].filePath).toBe(path.join('chats', 'session-1.json')); + expect(sessions[0].lastUpdated).toBe( + new Date('2026-02-02').toISOString(), + ); + + readdirSpy.mockRestore(); + statSpy.mockRestore(); + }); + + it('loadProjectTempFile loads and parses JSON from relative path', async () => { + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockResolvedValue(JSON.stringify({ hello: 'world' })); + + const result = await storage.loadProjectTempFile<{ hello: string }>( + 'some/file.json', + ); + + expect(readFileSpy).toHaveBeenCalledWith( + expect.stringContaining(path.join(PROJECT_SLUG, 'some/file.json')), + 'utf8', + ); + expect(result).toEqual({ hello: 'world' }); + + readFileSpy.mockRestore(); + }); + + it('loadProjectTempFile returns null if file does not exist', async () => { + const error = new Error('File not found'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (error as any).code = 'ENOENT'; + const readFileSpy = vi + .spyOn(fs.promises, 'readFile') + .mockRejectedValue(error); + + const result = await storage.loadProjectTempFile('missing.json'); + + expect(result).toBeNull(); + + readFileSpy.mockRestore(); + }); + }); + describe('getPlansDir', () => { interface TestCase { name: string; diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 3a079f3b7e..bd04123c34 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -295,6 +295,63 @@ export class Storage { return path.join(this.getProjectTempDir(), 'tasks'); } + async listProjectChatFiles(): Promise< + Array<{ filePath: string; lastUpdated: string }> + > { + const chatsDir = path.join(this.getProjectTempDir(), 'chats'); + try { + const files = await fs.promises.readdir(chatsDir); + const jsonFiles = files.filter((f) => f.endsWith('.json')); + + const sessions = await Promise.all( + jsonFiles.map(async (file) => { + const absolutePath = path.join(chatsDir, file); + const stats = await fs.promises.stat(absolutePath); + return { + filePath: path.join('chats', file), + lastUpdated: stats.mtime.toISOString(), + mtimeMs: stats.mtimeMs, + }; + }), + ); + + return sessions + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .map(({ filePath, lastUpdated }) => ({ filePath, lastUpdated })); + } catch (e) { + // If directory doesn't exist, return empty + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return []; + } + throw e; + } + } + + async loadProjectTempFile(filePath: string): Promise { + const absolutePath = path.join(this.getProjectTempDir(), filePath); + try { + const content = await fs.promises.readFile(absolutePath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return JSON.parse(content) as T; + } catch (e) { + // If file doesn't exist, return null + if ( + e instanceof Error && + 'code' in e && + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (e as NodeJS.ErrnoException).code === 'ENOENT' + ) { + return null; + } + throw e; + } + } + getExtensionsDir(): string { return path.join(this.getGeminiDir(), 'extensions'); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 36d10d3832..8ecd8cef7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -180,7 +180,7 @@ export { OAuthUtils } from './mcp/oauth-utils.js'; // Export telemetry functions export * from './telemetry/index.js'; -export { sessionId } from './utils/session.js'; +export { sessionId, createSessionId } from './utils/session.js'; export * from './utils/compatibility.js'; export * from './utils/browser.js'; export { Storage } from './config/storage.js'; diff --git a/packages/core/src/utils/session.ts b/packages/core/src/utils/session.ts index 96cdbbf48c..2a0ec52115 100644 --- a/packages/core/src/utils/session.ts +++ b/packages/core/src/utils/session.ts @@ -7,3 +7,7 @@ import { randomUUID } from 'node:crypto'; export const sessionId = randomUUID(); + +export function createSessionId(): string { + return randomUUID(); +} diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md index d8c8512991..d3031f32b8 100644 --- a/packages/sdk/SDK_DESIGN.md +++ b/packages/sdk/SDK_DESIGN.md @@ -8,7 +8,8 @@ ## `Simple Example` -> **Status:** Implemented. `GeminiCliAgent` supports `cwd` and `sendStream`. +> **Status:** Implemented. `GeminiCliAgent` supports `session()` and +> `resumeSession()`. Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and user settings. @@ -20,10 +21,14 @@ const simpleAgent = new GeminiCliAgent({ cwd: '/path/to/some/dir', }); -for await (const chunk of simpleAgent.sendStream( - 'what does this project do?', -)) { - console.log(chunk); // equivalent to JSON streaming chunks (probably?) for now +// Create a new empty session +const session = simpleAgent.session(); + +// Resume a specific session by ID +// const session = await simpleAgent.resumeSession('some-session-id'); + +for await (const chunk of session.sendStream('what does this project do?')) { + console.log(chunk); // equivalent to JSON streaming chunks } ``` @@ -268,8 +273,9 @@ export interface SessionContext { // helpers to access files and run shell commands while adhering to policies/validation fs: AgentFilesystem; shell: AgentShell; - // the agent itself is passed as context + // the agent and session are passed as context agent: GeminiCliAgent; + session: GeminiCliSession; } export interface AgentFilesystem { diff --git a/packages/sdk/src/agent.integration.test.ts b/packages/sdk/src/agent.integration.test.ts index 5226e30e06..064cd9fad7 100644 --- a/packages/sdk/src/agent.integration.test.ts +++ b/packages/sdk/src/agent.integration.test.ts @@ -30,8 +30,12 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + expect(session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); const events = []; - const stream = agent.sendStream('Say hello.'); + const stream = session.sendStream('Say hello.'); for await (const event of stream) { events.push(event); @@ -60,9 +64,11 @@ describe('GeminiCliAgent Integration', () => { fakeResponses: RECORD_MODE ? undefined : goldenFile, }); + const session = agent.session(); + // First turn - const stream1 = agent.sendStream('What is the secret number?'); const events1 = []; + const stream1 = session.sendStream('What is the secret number?'); for await (const event of stream1) { events1.push(event); } @@ -72,11 +78,10 @@ describe('GeminiCliAgent Integration', () => { .join(''); expect(responseText1).toContain('1'); - expect(callCount).toBe(1); // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session.sendStream('What is the secret number now?'); for await (const event of stream2) { events2.push(event); } @@ -85,57 +90,60 @@ describe('GeminiCliAgent Integration', () => { .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText2).toContain('2'); }, 30000); - it('handles async dynamic instructions', async () => { - const goldenFile = getGoldenPath('agent-async-instructions'); + it('resumes a session', async () => { + const goldenFile = getGoldenPath('agent-resume-session'); - let callCount = 0; + // Create initial session const agent = new GeminiCliAgent({ - instructions: async (_ctx) => { - await new Promise((resolve) => setTimeout(resolve, 10)); // Simulate async work - callCount++; - return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`; - }, + instructions: 'You are a memory test. Remember the word "BANANA".', model: 'gemini-2.0-flash', recordResponses: RECORD_MODE ? goldenFile : undefined, fakeResponses: RECORD_MODE ? undefined : goldenFile, }); - // First turn - const stream1 = agent.sendStream('What is the secret number?'); - const events1 = []; - for await (const event of stream1) { - events1.push(event); + const session1 = agent.session({ sessionId: 'resume-test-fixed-id' }); + const sessionId = session1.id; + const stream1 = session1.sendStream('What is the word?'); + for await (const _ of stream1) { + // consume stream } - const responseText1 = events1 - .filter((e) => e.type === 'content') - .map((e) => (typeof e.value === 'string' ? e.value : '')) - .join(''); - expect(responseText1).toContain('1'); - expect(callCount).toBe(1); + // Resume session + // Allow some time for async writes if any + await new Promise((resolve) => setTimeout(resolve, 500)); + + const session2 = await agent.resumeSession(sessionId); + expect(session2.id).toBe(sessionId); - // Second turn - const stream2 = agent.sendStream('What is the secret number now?'); const events2 = []; + const stream2 = session2.sendStream('What is the word again?'); for await (const event of stream2) { events2.push(event); } - const responseText2 = events2 + + const responseText = events2 .filter((e) => e.type === 'content') .map((e) => (typeof e.value === 'string' ? e.value : '')) .join(''); - // Should still be 1 because instructions are only loaded once per session - expect(responseText2).toContain('1'); - expect(callCount).toBe(1); + expect(responseText).toContain('BANANA'); }, 30000); - it('throws when dynamic instructions fail', async () => { + it('throws on invalid instructions', () => { + // Missing instructions should be fine + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => new GeminiCliAgent({} as any).session()).not.toThrow(); + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new GeminiCliAgent({ instructions: 123 as any }).session(), + ).toThrow('Instructions must be a string or a function.'); + }); + + it('propagates errors from dynamic instructions', async () => { const agent = new GeminiCliAgent({ instructions: () => { throw new Error('Dynamic instruction failure'); @@ -143,7 +151,8 @@ describe('GeminiCliAgent Integration', () => { model: 'gemini-2.0-flash', }); - const stream = agent.sendStream('Say hello.'); + const session = agent.session(); + const stream = session.sendStream('Say hello.'); await expect(async () => { for await (const _event of stream) { diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index 7db03a98f5..6e713c0fe1 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -4,245 +4,81 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as path from 'node:path'; import { - Config, - type ConfigParameters, - AuthType, - PREVIEW_GEMINI_MODEL_AUTO, - GeminiEventType, - type ToolCallRequestInfo, - type ServerGeminiStreamEvent, - type GeminiClient, - type Content, - scheduleAgentTools, - getAuthTypeFromEnv, - type ToolRegistry, - loadSkillsFromDir, - ActivateSkillTool, + Storage, + createSessionId, + type ResumedSessionData, + type ConversationRecord, } from '@google/gemini-cli-core'; -import { type Tool, SdkTool } from './tool.js'; -import { SdkAgentFilesystem } from './fs.js'; -import { SdkAgentShell } from './shell.js'; -import type { SessionContext } from './types.js'; -import type { SkillReference } from './skills.js'; - -export type SystemInstructions = - | string - | ((context: SessionContext) => string | Promise); - -export interface GeminiCliAgentOptions { - instructions: SystemInstructions; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - tools?: Array>; - skills?: SkillReference[]; - model?: string; - cwd?: string; - debug?: boolean; - recordResponses?: string; - fakeResponses?: string; -} +import { GeminiCliSession } from './session.js'; +import type { GeminiCliAgentOptions } from './types.js'; export class GeminiCliAgent { - private readonly config: Config; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private readonly tools: Array>; - private readonly skillRefs: SkillReference[]; - private readonly instructions: SystemInstructions; - private instructionsLoaded = false; + private options: GeminiCliAgentOptions; constructor(options: GeminiCliAgentOptions) { - this.instructions = options.instructions; - const cwd = options.cwd || process.cwd(); - this.tools = options.tools || []; - this.skillRefs = options.skills || []; - - const initialMemory = - typeof this.instructions === 'string' ? this.instructions : ''; - - const configParams: ConfigParameters = { - sessionId: `sdk-${Date.now()}`, - targetDir: cwd, - cwd, - debugMode: options.debug ?? false, - model: options.model || PREVIEW_GEMINI_MODEL_AUTO, - userMemory: initialMemory, - // Minimal config - enableHooks: false, - mcpEnabled: false, - extensionsEnabled: false, - recordResponses: options.recordResponses, - fakeResponses: options.fakeResponses, - skillsSupport: true, - adminSkillsEnabled: true, - }; - - this.config = new Config(configParams); + this.options = options; } - async *sendStream( - prompt: string, - signal?: AbortSignal, - ): AsyncGenerator { - // Lazy initialization of auth and client - if (!this.config.getContentGenerator()) { - const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + session(options?: { sessionId?: string }): GeminiCliSession { + const sessionId = options?.sessionId || createSessionId(); + return new GeminiCliSession(this.options, sessionId, this); + } - await this.config.refreshAuth(authType); - await this.config.initialize(); + async resumeSession(sessionId: string): Promise { + const cwd = this.options.cwd || process.cwd(); + const storage = new Storage(cwd); + await storage.initialize(); - // Load additional skills from options - if (this.skillRefs.length > 0) { - const skillManager = this.config.getSkillManager(); + let conversation: ConversationRecord | undefined; + let filePath: string | undefined; - const loadPromises = this.skillRefs.map(async (ref) => { - try { - if (ref.type === 'dir') { - return await loadSkillsFromDir(ref.path); - } - } catch (e) { - // eslint-disable-next-line no-console - console.error(`Failed to load skills from ${ref.path}:`, e); - } - return []; - }); + const sessions = await storage.listProjectChatFiles(); - const loadedSkills = (await Promise.all(loadPromises)).flat(); - - if (loadedSkills.length > 0) { - skillManager.addSkills(loadedSkills); - } - } - - // Re-register ActivateSkillTool if we have skills (either built-in/workspace or manually loaded) - // This is required because ActivateSkillTool captures the set of available skills at construction time. - const skillManager = this.config.getSkillManager(); - if (skillManager.getSkills().length > 0) { - const registry = this.config.getToolRegistry(); - const toolName = ActivateSkillTool.Name; - // Config.initialize already registers it, but we might have added more skills. - // Re-registering updates the schema with new skills. - if (registry.getTool(toolName)) { - registry.unregisterTool(toolName); - } - registry.registerTool( - new ActivateSkillTool(this.config, this.config.getMessageBus()), - ); - } - - // Register tools now that registry exists - const registry = this.config.getToolRegistry(); - const messageBus = this.config.getMessageBus(); - - for (const toolDef of this.tools) { - const sdkTool = new SdkTool(toolDef, messageBus, this); - registry.registerTool(sdkTool); - } + if (sessions.length === 0) { + throw new Error( + `No sessions found in ${path.join(storage.getProjectTempDir(), 'chats')}`, + ); } - const client = this.config.getGeminiClient(); - const abortSignal = signal ?? new AbortController().signal; - const sessionId = this.config.getSessionId(); + const truncatedId = sessionId.slice(0, 8); + // Optimization: filenames include first 8 chars of sessionId. + // Filter sessions that might match. + const candidates = sessions.filter((s) => s.filePath.includes(truncatedId)); - const fs = new SdkAgentFilesystem(this.config); - const shell = new SdkAgentShell(this.config); + // If optimization fails (e.g. old files), check all? + // Assuming filenames always follow convention if created by this tool. + // But we can fallback to checking all if needed, but let's stick to candidates first. + // If candidates is empty, maybe fallback to all. + const filesToCheck = candidates.length > 0 ? candidates : sessions; - let request: Parameters[0] = [ - { text: prompt }, - ]; - - if (!this.instructionsLoaded && typeof this.instructions === 'function') { - const context: SessionContext = { - sessionId, - transcript: client.getHistory(), - cwd: this.config.getWorkingDir(), - timestamp: new Date().toISOString(), - fs, - shell, - agent: this, - }; - try { - const newInstructions = await this.instructions(context); - this.config.setUserMemory(newInstructions); - client.updateSystemInstruction(); - this.instructionsLoaded = true; - } catch (e) { - const error = - e instanceof Error - ? e - : new Error(`Error resolving dynamic instructions: ${String(e)}`); - throw error; - } - } - - while (true) { - // sendMessageStream returns AsyncGenerator - const stream = client.sendMessageStream(request, abortSignal, sessionId); - - const toolCallsToSchedule: ToolCallRequestInfo[] = []; - - for await (const event of stream) { - yield event; - if (event.type === GeminiEventType.ToolCallRequest) { - const toolCall = event.value; - let args = toolCall.args; - if (typeof args === 'string') { - args = JSON.parse(args); - } - toolCallsToSchedule.push({ - ...toolCall, - args, - isClientInitiated: false, - prompt_id: sessionId, - }); - } - } - - if (toolCallsToSchedule.length === 0) { + for (const sessionFile of filesToCheck) { + const loaded = await storage.loadProjectTempFile( + sessionFile.filePath, + ); + if (loaded && loaded.sessionId === sessionId) { + conversation = loaded; + filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath); break; } - - // Prepare SessionContext - const transcript: Content[] = client.getHistory(); - const context: SessionContext = { - sessionId, - transcript, - cwd: this.config.getWorkingDir(), - timestamp: new Date().toISOString(), - fs, - shell, - agent: this, - }; - - // Create a scoped registry for this turn to bind context safely - const originalRegistry = this.config.getToolRegistry(); - const scopedRegistry: ToolRegistry = Object.create(originalRegistry); - scopedRegistry.getTool = (name: string) => { - const tool = originalRegistry.getTool(name); - if (tool instanceof SdkTool) { - return tool.bindContext(context); - } - return tool; - }; - - const completedCalls = await scheduleAgentTools( - this.config, - toolCallsToSchedule, - { - schedulerId: sessionId, - toolRegistry: scopedRegistry, - signal: abortSignal, - }, - ); - - const functionResponses = completedCalls.flatMap( - (call) => call.response.responseParts, - ); - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - request = functionResponses as unknown as Parameters< - GeminiClient['sendMessageStream'] - >[0]; } + + if (!conversation || !filePath) { + throw new Error(`Session with ID ${sessionId} not found`); + } + + const resumedData: ResumedSessionData = { + conversation, + filePath, + }; + + return new GeminiCliSession( + this.options, + conversation.sessionId, + this, + resumedData, + ); } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f1b9e020f5..91e7a080f0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -5,6 +5,7 @@ */ export * from './agent.js'; +export * from './session.js'; export * from './tool.js'; export * from './skills.js'; export * from './types.js'; diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts new file mode 100644 index 0000000000..d5e6b9ee8a --- /dev/null +++ b/packages/sdk/src/session.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Config, + type ConfigParameters, + AuthType, + PREVIEW_GEMINI_MODEL_AUTO, + GeminiEventType, + type ToolCallRequestInfo, + type ServerGeminiStreamEvent, + type GeminiClient, + type Content, + scheduleAgentTools, + getAuthTypeFromEnv, + type ToolRegistry, + loadSkillsFromDir, + ActivateSkillTool, + type ResumedSessionData, + PolicyDecision, +} from '@google/gemini-cli-core'; + +import { type Tool, SdkTool } from './tool.js'; +import { SdkAgentFilesystem } from './fs.js'; +import { SdkAgentShell } from './shell.js'; +import type { + SessionContext, + GeminiCliAgentOptions, + SystemInstructions, +} from './types.js'; +import type { SkillReference } from './skills.js'; +import type { GeminiCliAgent } from './agent.js'; + +export class GeminiCliSession { + private readonly config: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly tools: Array>; + private readonly skillRefs: SkillReference[]; + private readonly instructions: SystemInstructions | undefined; + private client: GeminiClient | undefined; + private initialized = false; + + constructor( + options: GeminiCliAgentOptions, + private readonly sessionId: string, + private readonly agent: GeminiCliAgent, + private readonly resumedData?: ResumedSessionData, + ) { + this.instructions = options.instructions; + const cwd = options.cwd || process.cwd(); + this.tools = options.tools || []; + this.skillRefs = options.skills || []; + + let initialMemory = ''; + if (typeof this.instructions === 'string') { + initialMemory = this.instructions; + } else if (this.instructions && typeof this.instructions !== 'function') { + throw new Error('Instructions must be a string or a function.'); + } + + const configParams: ConfigParameters = { + sessionId: this.sessionId, + targetDir: cwd, + cwd, + debugMode: options.debug ?? false, + model: options.model || PREVIEW_GEMINI_MODEL_AUTO, + userMemory: initialMemory, + // Minimal config + enableHooks: false, + mcpEnabled: false, + extensionsEnabled: false, + recordResponses: options.recordResponses, + fakeResponses: options.fakeResponses, + skillsSupport: true, + adminSkillsEnabled: true, + policyEngineConfig: { + // TODO: Revisit this default when we have a mechanism for wiring up approvals + defaultDecision: PolicyDecision.ALLOW, + }, + }; + + this.config = new Config(configParams); + } + + get id(): string { + return this.sessionId; + } + + async initialize(): Promise { + if (this.initialized) return; + + const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + + await this.config.refreshAuth(authType); + await this.config.initialize(); + + // Load additional skills from options + if (this.skillRefs.length > 0) { + const skillManager = this.config.getSkillManager(); + + const loadPromises = this.skillRefs.map(async (ref) => { + try { + if (ref.type === 'dir') { + return await loadSkillsFromDir(ref.path); + } + } catch (e) { + // TODO: refactor this to use a proper logger interface + // eslint-disable-next-line no-console + console.error(`Failed to load skills from ${ref.path}:`, e); + } + return []; + }); + + const loadedSkills = (await Promise.all(loadPromises)).flat(); + + if (loadedSkills.length > 0) { + skillManager.addSkills(loadedSkills); + } + } + + // Re-register ActivateSkillTool if we have skills + const skillManager = this.config.getSkillManager(); + if (skillManager.getSkills().length > 0) { + const registry = this.config.getToolRegistry(); + const toolName = ActivateSkillTool.Name; + if (registry.getTool(toolName)) { + registry.unregisterTool(toolName); + } + registry.registerTool( + new ActivateSkillTool(this.config, this.config.getMessageBus()), + ); + } + + // Register tools + const registry = this.config.getToolRegistry(); + const messageBus = this.config.getMessageBus(); + + for (const toolDef of this.tools) { + const sdkTool = new SdkTool(toolDef, messageBus, this.agent, undefined); + registry.registerTool(sdkTool); + } + + this.client = this.config.getGeminiClient(); + + if (this.resumedData) { + const history: Content[] = this.resumedData.conversation.messages.map( + (m) => { + const role = m.type === 'gemini' ? 'model' : 'user'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let parts: any[] = []; + if (Array.isArray(m.content)) { + parts = m.content; + } else if (m.content) { + parts = [{ text: String(m.content) }]; + } + return { role, parts }; + }, + ); + await this.client.resumeChat(history, this.resumedData); + } + + this.initialized = true; + } + + async *sendStream( + prompt: string, + signal?: AbortSignal, + ): AsyncGenerator { + if (!this.initialized || !this.client) { + await this.initialize(); + } + const client = this.client!; + const abortSignal = signal ?? new AbortController().signal; + const sessionId = this.config.getSessionId(); + + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + + let request: Parameters[0] = [ + { text: prompt }, + ]; + + while (true) { + if (typeof this.instructions === 'function') { + const context: SessionContext = { + sessionId, + transcript: client.getHistory(), + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + const newInstructions = await this.instructions(context); + this.config.setUserMemory(newInstructions); + client.updateSystemInstruction(); + } + + const stream = client.sendMessageStream(request, abortSignal, sessionId); + + const toolCallsToSchedule: ToolCallRequestInfo[] = []; + + for await (const event of stream) { + yield event; + if (event.type === GeminiEventType.ToolCallRequest) { + const toolCall = event.value; + let args = toolCall.args; + if (typeof args === 'string') { + args = JSON.parse(args); + } + toolCallsToSchedule.push({ + ...toolCall, + args, + isClientInitiated: false, + prompt_id: sessionId, + }); + } + } + + if (toolCallsToSchedule.length === 0) { + break; + } + + const transcript: Content[] = client.getHistory(); + const context: SessionContext = { + sessionId, + transcript, + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + + const originalRegistry = this.config.getToolRegistry(); + const scopedRegistry: ToolRegistry = Object.create(originalRegistry); + scopedRegistry.getTool = (name: string) => { + const tool = originalRegistry.getTool(name); + if (tool instanceof SdkTool) { + return tool.bindContext(context); + } + return tool; + }; + + const completedCalls = await scheduleAgentTools( + this.config, + toolCallsToSchedule, + { + schedulerId: sessionId, + toolRegistry: scopedRegistry, + signal: abortSignal, + }, + ); + + const functionResponses = completedCalls.flatMap( + (call) => call.response.responseParts, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + request = functionResponses as unknown as Parameters< + GeminiClient['sendMessageStream'] + >[0]; + } + } +} diff --git a/packages/sdk/src/skills.integration.test.ts b/packages/sdk/src/skills.integration.test.ts index a91480ff30..48f0e0fb06 100644 --- a/packages/sdk/src/skills.integration.test.ts +++ b/packages/sdk/src/skills.integration.test.ts @@ -39,8 +39,9 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; + const session = agent.session(); // The prompt explicitly asks to activate the skill by name - const stream = agent.sendStream( + const stream = session.sendStream( 'Activate the pirate-skill and then tell me a joke.', ); @@ -72,7 +73,8 @@ describe('GeminiCliAgent Skills Integration', () => { // 1. Ask to activate the skill const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Activate the pirate-skill and confirm it is active.', ); diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts index 1ec9d73abd..cc0a7c2454 100644 --- a/packages/sdk/src/tool.integration.test.ts +++ b/packages/sdk/src/tool.integration.test.ts @@ -45,7 +45,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream('What is 5 + 3?'); + const session = agent.session(); + const stream = session.sendStream('What is 5 + 3?'); for await (const event of stream) { events.push(event); @@ -85,9 +86,10 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; + const session = agent.session(); // Force the model to trigger the error first, then hopefully recover or at least acknowledge it. // The prompt is crafted to make the model try 'fail' first. - const stream = agent.sendStream( + const stream = session.sendStream( 'Call the tool with "fail". If it fails, tell me the error message.', ); @@ -128,7 +130,8 @@ describe('GeminiCliAgent Tool Integration', () => { }); const events = []; - const stream = agent.sendStream( + const session = agent.session(); + const stream = session.sendStream( 'Check the system status and report any errors.', ); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d7e013d66c..9b6bf7093a 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -5,7 +5,26 @@ */ import type { Content } from '@google/gemini-cli-core'; +import type { Tool } from './tool.js'; +import type { SkillReference } from './skills.js'; import type { GeminiCliAgent } from './agent.js'; +import type { GeminiCliSession } from './session.js'; + +export type SystemInstructions = + | string + | ((context: SessionContext) => string | Promise); + +export interface GeminiCliAgentOptions { + instructions: SystemInstructions; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Array>; + skills?: SkillReference[]; + model?: string; + cwd?: string; + debug?: boolean; + recordResponses?: string; + fakeResponses?: string; +} export interface AgentFilesystem { readFile(path: string): Promise; @@ -38,4 +57,5 @@ export interface SessionContext { fs: AgentFilesystem; shell: AgentShell; agent: GeminiCliAgent; + session: GeminiCliSession; } diff --git a/packages/sdk/test-data/agent-dynamic-instructions.json b/packages/sdk/test-data/agent-dynamic-instructions.json index 833467ad84..cbafd27206 100644 --- a/packages/sdk/test-data/agent-dynamic-instructions.json +++ b/packages/sdk/test-data/agent-dynamic-instructions.json @@ -1,4 +1,24 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10028,"totalTokenCount":10028,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10028}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7193,"candidatesTokenCount":7,"totalTokenCount":7200,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7193}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10044,"totalTokenCount":10044,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10044}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7207,"candidatesTokenCount":7,"totalTokenCount":7214,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7207}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":8,"totalTokenCount":7226,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10112,"totalTokenCount":10112,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10112}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7177,"candidatesTokenCount":7,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7177}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10128,"totalTokenCount":10128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10128}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7191,"candidatesTokenCount":7,"totalTokenCount":7198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7191}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} diff --git a/packages/sdk/test-data/agent-resume-session.json b/packages/sdk/test-data/agent-resume-session.json new file mode 100644 index 0000000000..f9d521898e --- /dev/null +++ b/packages/sdk/test-data/agent-resume-session.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BAN"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10103,"totalTokenCount":10103,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10103}]}},{"candidates":[{"content":{"parts":[{"text":"ANA\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":3,"totalTokenCount":7171,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BANANA\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10124,"totalTokenCount":10124,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10124}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":3,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} diff --git a/packages/sdk/test-data/agent-static-instructions.json b/packages/sdk/test-data/agent-static-instructions.json index 733c1915e7..216960da38 100644 --- a/packages/sdk/test-data/agent-static-instructions.json +++ b/packages/sdk/test-data/agent-static-instructions.json @@ -1 +1,4 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9828,"totalTokenCount":9828,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9828}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through the code?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7095,"candidatesTokenCount":15,"totalTokenCount":7110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7095}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to plunder the codebase, are ye?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":16,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to shiver some timbers and get to work?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":18,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through these here files, are we?\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7163,"candidatesTokenCount":20,"totalTokenCount":7183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7163}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":20}]}}]} diff --git a/packages/sdk/test-data/skill-dir-success.json b/packages/sdk/test-data/skill-dir-success.json index f14658426f..63a7267093 100644 --- a/packages/sdk/test-data/skill-dir-success.json +++ b/packages/sdk/test-data/skill-dir-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" sorry, I cannot activate the skill at this time because it requires user confirmation."}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" Would you like me to proceed with telling you a joke without activating the skill?"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":35,"totalTokenCount":7253,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":35}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play cards? Because someone's always standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7309,"candidatesTokenCount":24,"totalTokenCount":7333,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7309}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":24}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":8,"totalTokenCount":7182,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":"rr, why don't pirates play cards? Because someone always be standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7298,"candidatesTokenCount":23,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7298}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} diff --git a/packages/sdk/test-data/skill-root-success.json b/packages/sdk/test-data/skill-root-success.json index 1048a2c627..4fe1e3fe6e 100644 --- a/packages/sdk/test-data/skill-root-success.json +++ b/packages/sdk/test-data/skill-root-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to activate the skill without user confirmation. I will await further instructions.\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7217,"candidatesTokenCount":18,"totalTokenCount":7235,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7217}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10274,"totalTokenCount":10274,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10274}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate-skill be active, aye!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7308,"candidatesTokenCount":13,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7308}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":13}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7173,"candidatesTokenCount":8,"totalTokenCount":7181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7173}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10263,"totalTokenCount":10263,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10263}]}},{"candidates":[{"content":{"parts":[{"text":"rr, pirate skill activated, aye!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7297,"candidatesTokenCount":9,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7297}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":9}]}}]} diff --git a/packages/sdk/test-data/tool-catchall-error.json b/packages/sdk/test-data/tool-catchall-error.json index 43c3b44d8b..8572c8be42 100644 --- a/packages/sdk/test-data/tool-catchall-error.json +++ b/packages/sdk/test-data/tool-catchall-error.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am unable to"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" check the system status because it requires user confirmation in interactive mode. Is there anything else I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" can help with?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7213,"candidatesTokenCount":26,"totalTokenCount":7239,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7213}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":26}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" reported an error: \"Standard error caught\".\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":14,"totalTokenCount":7208,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"There"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" is an error reported in the system status.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":11,"totalTokenCount":7205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":11}]}}]} diff --git a/packages/sdk/test-data/tool-error-recovery.json b/packages/sdk/test-data/tool-error-recovery.json index 4e36d24aa7..9b17bf52d3 100644 --- a/packages/sdk/test-data/tool-error-recovery.json +++ b/packages/sdk/test-data/tool-error-recovery.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":" tool execution for \"failVisible\" requires user confirmation, which is not supported in non-"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":"interactive mode."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7226,"candidatesTokenCount":22,"totalTokenCount":7248,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7226}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":22}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10192,"totalTokenCount":10192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10192}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly. The error message is \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7208,"candidatesTokenCount":14,"totalTokenCount":7222,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7208}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":4,"totalTokenCount":7191,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The tool failed with"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10181,"totalTokenCount":10181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10181}]}},{"candidates":[{"content":{"parts":[{"text":" the error message \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7197,"candidatesTokenCount":12,"totalTokenCount":7209,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7197}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} diff --git a/packages/sdk/test-data/tool-success.json b/packages/sdk/test-data/tool-success.json index 1b17993fe4..d90d09cd0d 100644 --- a/packages/sdk/test-data/tool-success.json +++ b/packages/sdk/test-data/tool-success.json @@ -1,2 +1,8 @@ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"b":3,"a":5}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10204,"totalTokenCount":10204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10204}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to execute the add tool in non-interactive mode.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7206,"candidatesTokenCount":15,"totalTokenCount":7221,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7206}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":1,"totalTokenCount":7188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":5,"totalTokenCount":7173,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10174,"totalTokenCount":10174,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10174}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7176,"candidatesTokenCount":2,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7176}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":2}]}}]} From 99fa700231b0f003f3019cf9b8a3d2e0c28a8b46 Mon Sep 17 00:00:00 2001 From: Bryan Morgan Date: Thu, 19 Feb 2026 20:19:01 -0500 Subject: [PATCH 10/52] fix(ci): add fallback JSON extraction to issue triage workflow (#19593) --- .../workflows/gemini-automated-issue-triage.yml | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 08b97db0a2..64609b5c3b 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -284,8 +284,21 @@ jobs: return; } } else { - core.setFailed(`Output is not valid JSON and does not contain a JSON markdown block.\nRaw output: ${rawOutput}`); - return; + // If no markdown block, try to find a raw JSON object in the output. + // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode) + // before the actual JSON response. + const jsonObjectMatch = rawOutput.match(/(\{[\s\S]*"labels_to_set"[\s\S]*\})/); + if (jsonObjectMatch) { + try { + parsedLabels = JSON.parse(jsonObjectMatch[0]); + } catch (extractError) { + core.setFailed(`Found JSON-like content but failed to parse: ${extractError.message}\nRaw output: ${rawOutput}`); + return; + } + } else { + core.setFailed(`Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${rawOutput}`); + return; + } } } From fb1b1b451d5165e256976c7fc2074c52681aafc7 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 19 Feb 2026 17:03:10 -0800 Subject: [PATCH 11/52] feat(core): refine Edit and WriteFile tool schemas for Gemini 3 (#19476) --- .../coreToolsModelSnapshots.test.ts.snap | 37 +++---------------- .../definitions/model-family-sets/gemini-3.ts | 37 +++---------------- 2 files changed, 12 insertions(+), 62 deletions(-) diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 8aa86f60a7..9767829f0e 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -1293,18 +1293,8 @@ Use this tool when the user's query implies needing the content of several files exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: replace 1`] = ` { - "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the read_file tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.", + "description": "Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.", "name": "replace", "parametersJsonSchema": { "properties": { @@ -1318,29 +1308,15 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "type": "string", }, "instruction": { - "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -", + "description": "A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.", "type": "string", }, "new_string": { - "description": "The exact literal text to replace \`old_string\` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", + "description": "The exact literal text to replace \`old_string\` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.", "type": "string", }, "old_string": { - "description": "The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", + "description": "The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.", "type": "string", }, }, @@ -1448,8 +1424,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: write_file 1`] = ` { "description": "Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.", +The user has the ability to modify \`content\`. If modified, this will be stated in the response.", "name": "write_file", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index a532cac8ba..71e8aaec1c 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -64,8 +64,7 @@ export const GEMINI_3_SET: CoreToolSet = { write_file: { name: WRITE_FILE_TOOL_NAME, description: `Writes content to a specified file in the local filesystem. - - The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, +The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -291,18 +290,8 @@ export const GEMINI_3_SET: CoreToolSet = { replace: { name: EDIT_TOOL_NAME, - description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. Always use the ${READ_FILE_TOOL_NAME} tool to examine the file's current content before attempting a text replacement. - - The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response. - - Expectation for required parameters: - 1. \`old_string\` MUST be the exact literal text to replace (including all whitespace, indentation, newlines, and surrounding code etc.). - 2. \`new_string\` MUST be the exact literal text to replace \`old_string\` with (also including all whitespace, indentation, newlines, and surrounding code etc.). Ensure the resulting code is correct and idiomatic and that \`old_string\` and \`new_string\` are different. - 3. \`instruction\` is the detailed instruction of what needs to be changed. It is important to Make it specific and detailed so developers or large language models can understand what needs to be changed and perform the changes on their own if necessary. - 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. - **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. - 5. Prefer to break down complex and long changes into multiple smaller atomic calls to this tool. Always check the content of the file after changes or not finding a string to match. - **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, + description: `Replaces text within a file. By default, replaces a single occurrence, but can replace multiple occurrences ONLY when \`expected_replacements\` is specified. This tool requires providing significant context around the change to ensure precise targeting. +The user has the ability to modify the \`new_string\` content. If modified, this will be stated in the response.`, parametersJsonSchema: { type: 'object', properties: { @@ -311,31 +300,17 @@ export const GEMINI_3_SET: CoreToolSet = { type: 'string', }, instruction: { - description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change. - -A good instruction should concisely answer: -1. WHY is the change needed? (e.g., "To fix a bug where users can be null...") -2. WHERE should the change happen? (e.g., "...in the 'renderUserProfile' function...") -3. WHAT is the high-level change? (e.g., "...add a null check for the 'user' object...") -4. WHAT is the desired outcome? (e.g., "...so that it displays a loading spinner instead of crashing.") - -**GOOD Example:** "In the 'calculateTotal' function, correct the sales tax calculation by updating the 'taxRate' constant from 0.05 to 0.075 to reflect the new regional tax laws." - -**BAD Examples:** -- "Change the text." (Too vague) -- "Fix the bug." (Doesn't explain the bug or the fix) -- "Replace the line with this new line." (Brittle, just repeats the other parameters) -`, + description: `A clear, semantic instruction for the code change, acting as a high-quality prompt for an expert LLM assistant. It must be self-contained and explain the goal of the change.`, type: 'string', }, old_string: { description: - 'The exact literal text to replace, preferably unescaped. For single replacements (default), include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', + 'The exact literal text to replace, unescaped. If this string is not the exact literal text (i.e. you escaped it) or does not match exactly, the tool will fail.', type: 'string', }, new_string: { description: - 'The exact literal text to replace `old_string` with, preferably unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', + 'The exact literal text to replace `old_string` with, unescaped. Provide the EXACT text. Ensure the resulting code is correct and idiomatic.', type: 'string', }, expected_replacements: { From cbfb2a4e2646989a5fed30ed3e6c5a7ffba7f0df Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 19 Feb 2026 20:10:05 -0500 Subject: [PATCH 12/52] Changelog for v0.30.0-preview.3 (#19585) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> --- docs/changelogs/preview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index e8ac4a2dc9..4cb6a3824b 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,4 +1,4 @@ -# Preview release: v0.30.0-preview.1 +# Preview release: v0.30.0-preview.3 Released: February 19, 2026 @@ -311,4 +311,4 @@ npm install -g @google/gemini-cli@preview [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) **Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.1 +https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.3 From 5fd557347edf557230baf483a5a8f35e1c3f5e89 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:53:12 -0500 Subject: [PATCH 13/52] fix(plan): exclude EnterPlanMode tool from YOLO mode (#19570) --- docs/cli/plan-mode.md | 1 + docs/tools/planning.md | 2 ++ packages/core/src/config/config.test.ts | 38 ++++++++++++++++++++++++- packages/core/src/config/config.ts | 15 ++++++++-- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index b59b0c3198..03da2a6ac9 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -69,6 +69,7 @@ You can enter Plan Mode in three ways: 2. **Command:** Type `/plan` in the input box. 3. **Natural Language:** Ask the agent to "start a plan for...". The agent will then call the [`enter_plan_mode`] tool to switch modes. + - **Note:** This tool is not available when the CLI is in YOLO mode. ### The Planning Workflow diff --git a/docs/tools/planning.md b/docs/tools/planning.md index 686b27f058..458b172510 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,6 +11,8 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. +> **Note:** This tool is not available when the CLI is in YOLO mode. + - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode - **File:** `enter-plan-mode.ts` diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 1c8820f273..a899ee045f 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1392,7 +1392,22 @@ describe('setApprovalMode with folder trust', () => { expect(updateSpy).toHaveBeenCalled(); }); - it('should not update system instruction when switching between non-Plan modes', () => { + it('should update system instruction when entering YOLO mode', () => { + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(config, 'getToolRegistry').mockReturnValue({ + getTool: vi.fn().mockReturnValue(undefined), + unregisterTool: vi.fn(), + registerTool: vi.fn(), + } as Partial as ToolRegistry); + const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); + + config.setApprovalMode(ApprovalMode.YOLO); + + expect(updateSpy).toHaveBeenCalled(); + }); + + it('should not update system instruction when switching between non-Plan/non-YOLO modes', () => { const config = new Config(baseParams); vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); const updateSpy = vi.spyOn(config, 'updateSystemInstructionIfInitialized'); @@ -2649,6 +2664,27 @@ describe('syncPlanModeTools', () => { expect(registeredTool).toBeUndefined(); }); + it('should NOT register EnterPlanModeTool when in YOLO mode, even if plan is enabled', async () => { + const config = new Config({ + ...baseParams, + approvalMode: ApprovalMode.YOLO, + plan: true, + }); + const registry = new ToolRegistry(config, config.getMessageBus()); + vi.spyOn(config, 'getToolRegistry').mockReturnValue(registry); + + const registerSpy = vi.spyOn(registry, 'registerTool'); + vi.spyOn(registry, 'getTool').mockReturnValue(undefined); + + config.syncPlanModeTools(); + + const { EnterPlanModeTool } = await import('../tools/enter-plan-mode.js'); + const registeredTool = registerSpy.mock.calls.find( + (call) => call[0] instanceof EnterPlanModeTool, + ); + expect(registeredTool).toBeUndefined(); + }); + it('should call geminiClient.setTools if initialized', async () => { const config = new Config(baseParams); const registry = new ToolRegistry(config, config.getMessageBus()); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fa32fd4d5f..406835310a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1793,7 +1793,11 @@ export class Config { const isPlanModeTransition = currentMode !== mode && (currentMode === ApprovalMode.PLAN || mode === ApprovalMode.PLAN); - if (isPlanModeTransition) { + const isYoloModeTransition = + currentMode !== mode && + (currentMode === ApprovalMode.YOLO || mode === ApprovalMode.YOLO); + + if (isPlanModeTransition || isYoloModeTransition) { this.syncPlanModeTools(); this.updateSystemInstructionIfInitialized(); } @@ -1803,8 +1807,13 @@ export class Config { * Synchronizes enter/exit plan mode tools based on current mode. */ syncPlanModeTools(): void { - const isPlanMode = this.getApprovalMode() === ApprovalMode.PLAN; const registry = this.getToolRegistry(); + if (!registry) { + return; + } + const approvalMode = this.getApprovalMode(); + const isPlanMode = approvalMode === ApprovalMode.PLAN; + const isYoloMode = approvalMode === ApprovalMode.YOLO; if (isPlanMode) { if (registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { @@ -1817,7 +1826,7 @@ export class Config { if (registry.getTool(EXIT_PLAN_MODE_TOOL_NAME)) { registry.unregisterTool(EXIT_PLAN_MODE_TOOL_NAME); } - if (this.planEnabled) { + if (this.planEnabled && !isYoloMode) { if (!registry.getTool(ENTER_PLAN_MODE_TOOL_NAME)) { registry.registerTool(new EnterPlanModeTool(this, this.messageBus)); } From c3b52b820697cb71ae4945fdae25d8d5bde306e0 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Thu, 19 Feb 2026 21:25:56 -0600 Subject: [PATCH 14/52] chore: resolve build warnings and update dependencies (#18880) --- eslint.config.js | 2 +- integration-tests/acp-env-auth.test.ts | 2 +- package-lock.json | 504 +++++++++++-------- package.json | 4 +- packages/cli/package.json | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 4 + packages/core/package.json | 4 +- 7 files changed, 300 insertions(+), 222 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index b05072cfc0..7dc3b5ace7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -56,7 +56,7 @@ export default tseslint.config( }, { // Import specific config - files: ['packages/cli/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages plugins: { import: importPlugin, }, diff --git a/integration-tests/acp-env-auth.test.ts b/integration-tests/acp-env-auth.test.ts index 78eec9cd56..c83dbafce5 100644 --- a/integration-tests/acp-env-auth.test.ts +++ b/integration-tests/acp-env-auth.test.ts @@ -26,7 +26,7 @@ class MockClient implements acp.Client { }; } -describe('ACP Environment and Auth', () => { +describe.skip('ACP Environment and Auth', () => { let rig: TestRig; let child: ChildProcess | undefined; diff --git a/package-lock.json b/package-lock.json index e8417cc3e5..0bfce7daa0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1402,19 +1402,22 @@ "link": true }, "node_modules/@google/genai": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz", - "integrity": "sha512-3MRcgczBFbUat1wIlZoLJ0vCCfXgm7Qxjh59cZi2X08RgWLtm9hKOspzp7TOg1TV2e26/MLxR2GR5yD5GmBV2w==", + "version": "1.42.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.42.0.tgz", + "integrity": "sha512-+3nlMTcrQufbQ8IumGkOphxD5Pd5kKyJOzLcnY0/1IuE8upJk5aLmoexZ2BJhBp1zAjRJMEB4a2CJwKI9e2EYw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "engines": { "node": ">=20.0.0" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.20.1" + "@modelcontextprotocol/sdk": "^1.25.2" }, "peerDependenciesMeta": { "@modelcontextprotocol/sdk": { @@ -1422,10 +1425,18 @@ } } }, + "node_modules/@google/genai/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, "node_modules/@google/genai/node_modules/gaxios": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -1441,6 +1452,7 @@ "version": "8.1.2", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "gaxios": "^7.0.0", @@ -1455,6 +1467,7 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -1473,6 +1486,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1482,6 +1496,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, "license": "MIT", "dependencies": { "gaxios": "^7.0.0", @@ -1495,6 +1510,7 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -1509,6 +1525,20 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/@google/genai/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -2979,16 +3009,6 @@ "@noble/hashes": "^1.1.5" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -5133,46 +5153,6 @@ "win32" ] }, - "node_modules/@vscode/vsce/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@vscode/vsce/node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -5212,33 +5192,6 @@ "node": ">=4" } }, - "node_modules/@vscode/vsce/node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@vscode/vsce/node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", - "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@vscode/vsce/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -5816,6 +5769,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -10478,6 +10432,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-network-error": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", + "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -12908,6 +12874,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-retry": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz", + "integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==", + "license": "MIT", + "dependencies": { + "is-network-error": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/package-json/-/package-json-10.0.1.tgz", @@ -13447,9 +13428,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -14164,81 +14145,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/rollup": { "version": "4.53.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", @@ -15706,43 +15612,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/test-exclude/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -15759,23 +15628,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -17310,7 +17162,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", @@ -17370,6 +17222,29 @@ "node": ">=20" } }, + "packages/cli/node_modules/@google/genai": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz", + "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^7.1.1", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/cli/node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -17385,6 +17260,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/cli/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/cli/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "packages/cli/node_modules/string-width": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", @@ -17410,7 +17372,7 @@ "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -17442,7 +17404,7 @@ "fdir": "^6.4.6", "fzf": "^0.5.2", "glob": "^12.0.0", - "google-auth-library": "^9.11.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", @@ -17488,6 +17450,29 @@ "node-pty": "^1.0.0" } }, + "packages/core/node_modules/@google/genai": { + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.41.0.tgz", + "integrity": "sha512-S4WGil+PG0NBQRAx+0yrQuM/TWOLn2gGEy5wn4IsoOI6ouHad0P61p3OWdhJ3aqr9kfj8o904i/jevfaGoGuIQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^7.1.1", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "packages/core/node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -17518,6 +17503,75 @@ } } }, + "packages/core/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "packages/core/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "packages/core/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "packages/core/node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -17542,6 +17596,24 @@ "node": ">=16" } }, + "packages/core/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "packages/core/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/package.json b/package.json index ef1791523d..7f5bf66348 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,9 @@ "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" - } + }, + "glob": "^12.0.0", + "node-domexception": "^1.0.0" }, "bin": { "gemini": "bundle/gemini.js" diff --git a/packages/cli/package.json b/packages/cli/package.json index 4d38b87a92..5ad1e37d9d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,7 +31,7 @@ "dependencies": { "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.23.0", "ansi-escapes": "^7.3.0", diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index a4733699ce..f90dfff690 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -967,6 +967,10 @@ export const useGeminiStream = ( 'Response stopped due to prohibited image content.', [FinishReason.NO_IMAGE]: 'Response stopped because no image was generated.', + [FinishReason.IMAGE_RECITATION]: + 'Response stopped due to image recitation policy.', + [FinishReason.IMAGE_OTHER]: + 'Response stopped due to other image-related reasons.', }; const message = finishReasonMessages[finishReason]; diff --git a/packages/core/package.json b/packages/core/package.json index 529a788b44..278100611f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,7 @@ "@google-cloud/logging": "^11.2.1", "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", - "@google/genai": "1.30.0", + "@google/genai": "1.41.0", "@iarna/toml": "^2.2.5", "@joshua.litt/get-ripgrep": "^0.0.3", "@modelcontextprotocol/sdk": "^1.23.0", @@ -57,7 +57,7 @@ "fdir": "^6.4.6", "fzf": "^0.5.2", "glob": "^12.0.0", - "google-auth-library": "^9.11.0", + "google-auth-library": "^10.5.0", "html-to-text": "^9.0.5", "https-proxy-agent": "^7.0.6", "ignore": "^7.0.0", From d08b1efc72a8e08f89058a7854eaa27829cce79a Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 10:54:59 -0500 Subject: [PATCH 15/52] feat(ui): add source indicators to slash commands (#18839) --- .../cli/src/services/CommandService.test.ts | 141 ++++++++++-------- packages/cli/src/services/CommandService.ts | 121 ++++++++++----- .../src/services/FileCommandLoader.test.ts | 116 +++++++++++++- .../cli/src/services/FileCommandLoader.ts | 25 ++-- packages/cli/src/ui/commands/types.ts | 6 + 5 files changed, 293 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index ea906a3da6..6d888d4b2d 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,8 +10,13 @@ import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; -const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ +const createMockCommand = ( + name: string, + kind: CommandKind, + namespace?: string, +): SlashCommand => ({ name, + namespace, description: `Description for ${name}`, kind, action: vi.fn(), @@ -179,18 +184,18 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); - it('should rename extension commands when they conflict', async () => { + it('should apply namespaces to commands from user and extensions', async () => { const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE); + const userCommand = createMockCommand('sync', CommandKind.FILE, 'user'); const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', + description: 'Deploy to Firebase', }; const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE), + ...createMockCommand('sync', CommandKind.FILE, 'git-helper'), extensionName: 'git-helper', - description: '[git-helper] Sync with remote', + description: 'Sync with remote', }; const mockLoader1 = new MockCommandLoader([builtinCommand]); @@ -208,30 +213,28 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(4); - // Built-in command keeps original name + // Built-in command keeps original name because it has no namespace const deployBuiltin = commands.find( (cmd) => cmd.name === 'deploy' && !cmd.extensionName, ); expect(deployBuiltin).toBeDefined(); expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - // Extension command conflicting with built-in gets renamed + // Extension command gets namespaced, preventing conflict with built-in const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase.deploy', + (cmd) => cmd.name === 'firebase:deploy', ); expect(deployExtension).toBeDefined(); expect(deployExtension?.extensionName).toBe('firebase'); - // User command keeps original name - const syncUser = commands.find( - (cmd) => cmd.name === 'sync' && !cmd.extensionName, - ); + // User command gets namespaced + const syncUser = commands.find((cmd) => cmd.name === 'user:sync'); expect(syncUser).toBeDefined(); expect(syncUser?.kind).toBe(CommandKind.FILE); - // Extension command conflicting with user command gets renamed + // Extension command gets namespaced const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper.sync', + (cmd) => cmd.name === 'git-helper:sync', ); expect(syncExtension).toBeDefined(); expect(syncExtension?.extensionName).toBe('git-helper'); @@ -269,16 +272,16 @@ describe('CommandService', () => { expect(deployCommand?.kind).toBe(CommandKind.FILE); }); - it('should handle secondary conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp.deploy commands + it('should handle namespaced name conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp:deploy commands const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); - // Extension also has a deploy command that will conflict with user's /deploy + // Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2 const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -301,31 +304,31 @@ describe('CommandService', () => { ); expect(deployUser).toBeDefined(); - // User's dot notation command keeps its name + // User's command keeps its name const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, + (cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName, ); expect(gcpDeployUser).toBeDefined(); - // Extension command gets renamed with suffix due to secondary conflict + // Extension command gets renamed with suffix due to namespaced name conflict const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp.deploy, and /gcp.deploy1 + it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp:deploy, and /gcp:deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE); - // Extension has a deploy command + // Extension has a deploy command which resolves to /gcp:deploy const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), extensionName: 'gcp', - description: '[gcp] Deploy to Google Cloud', + description: 'Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -345,16 +348,19 @@ describe('CommandService', () => { // Extension command gets renamed with suffix 2 due to multiple conflicts const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('Deploy to Google Cloud'); }); - it('should report conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report extension namespaced name conflicts via getConflicts', async () => { + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; @@ -372,29 +378,29 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: builtinCommand, losers: [ { - renamedTo: 'firebase.deploy', + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'firebase', + namespace: 'firebase', }), }, ], }); }); - it('should report extension vs extension conflicts correctly', async () => { - // Both extensions try to register 'deploy' + it('should report extension vs extension namespaced name conflicts correctly', async () => { + // Both extensions try to register 'firebase:deploy' const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE), + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), extensionName: 'firebase', }; const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'aws', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([ @@ -411,32 +417,37 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'deploy', + name: 'firebase:deploy', winner: expect.objectContaining({ - name: 'deploy', + name: 'firebase:deploy', extensionName: 'firebase', }), losers: [ { - renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list + renamedTo: 'firebase:deploy1', command: expect.objectContaining({ name: 'deploy', - extensionName: 'aws', + extensionName: 'firebase', }), }, ], }); }); - it('should report multiple conflicts for the same command name', async () => { - const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); + it('should report multiple extension namespaced name conflicts for the same name', async () => { + // Built-in command is 'firebase:deploy' + const builtinCommand = createMockCommand( + 'firebase:deploy', + CommandKind.BUILT_IN, + ); + // Two extension commands from extension 'firebase' also try to be 'firebase:deploy' const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext1', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'ext2', + ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + extensionName: 'firebase', }; const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); @@ -448,17 +459,23 @@ describe('CommandService', () => { const conflicts = service.getConflicts(); expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('deploy'); + expect(conflicts[0].name).toBe('firebase:deploy'); expect(conflicts[0].losers).toHaveLength(2); expect(conflicts[0].losers).toEqual( expect.arrayContaining([ expect.objectContaining({ - renamedTo: 'ext1.deploy', - command: expect.objectContaining({ extensionName: 'ext1' }), + renamedTo: 'firebase:deploy1', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), expect.objectContaining({ - renamedTo: 'ext2.deploy', - command: expect.objectContaining({ extensionName: 'ext2' }), + renamedTo: 'firebase:deploy2', + command: expect.objectContaining({ + name: 'deploy', + namespace: 'firebase', + }), }), ]), ); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index bd42226a32..570bfee36f 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -79,61 +79,100 @@ export class CommandService { const conflictsMap = new Map(); for (const cmd of allCommands) { - let finalName = cmd.name; - + let fullName = this.resolveFullName(cmd); // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(cmd.name)) { - const winner = commandMap.get(cmd.name)!; - let renamedName = `${cmd.extensionName}.${cmd.name}`; - let suffix = 1; - - // Keep trying until we find a name that doesn't conflict - while (commandMap.has(renamedName)) { - renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; - suffix++; - } - - finalName = renamedName; - - if (!conflictsMap.has(cmd.name)) { - conflictsMap.set(cmd.name, { - name: cmd.name, - winner, - losers: [], - }); - } - - conflictsMap.get(cmd.name)!.losers.push({ - command: cmd, - renamedTo: finalName, - }); + if (cmd.extensionName && commandMap.has(fullName)) { + fullName = this.resolveConflict( + fullName, + cmd, + commandMap, + conflictsMap, + ); } - commandMap.set(finalName, { + commandMap.set(fullName, { ...cmd, - name: finalName, + name: fullName, }); } const conflicts = Array.from(conflictsMap.values()); - if (conflicts.length > 0) { - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } + this.emitConflicts(conflicts); const finalCommands = Object.freeze(Array.from(commandMap.values())); const finalConflicts = Object.freeze(conflicts); return new CommandService(finalCommands, finalConflicts); } + /** + * Prepends the namespace to the command name if provided and not already present. + */ + private static resolveFullName(cmd: SlashCommand): string { + if (!cmd.namespace) { + return cmd.name; + } + + const prefix = `${cmd.namespace}:`; + return cmd.name.startsWith(prefix) ? cmd.name : `${prefix}${cmd.name}`; + } + + /** + * Resolves a naming conflict by generating a unique name for an extension command. + * Also records the conflict for reporting. + */ + private static resolveConflict( + fullName: string, + cmd: SlashCommand, + commandMap: Map, + conflictsMap: Map, + ): string { + const winner = commandMap.get(fullName)!; + let renamedName = fullName; + let suffix = 1; + + // Generate a unique name by appending an incrementing numeric suffix. + while (commandMap.has(renamedName)) { + renamedName = `${fullName}${suffix}`; + suffix++; + } + + // Record the conflict details for downstream reporting. + if (!conflictsMap.has(fullName)) { + conflictsMap.set(fullName, { + name: fullName, + winner, + losers: [], + }); + } + + conflictsMap.get(fullName)!.losers.push({ + command: cmd, + renamedTo: renamedName, + }); + + return renamedName; + } + + /** + * Emits conflict events for all detected collisions. + */ + private static emitConflicts(conflicts: CommandConflict[]): void { + if (conflicts.length === 0) { + return; + } + + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: c.winner.extensionName, + })), + ), + ); + } + /** * Retrieves the currently loaded and de-duplicated list of slash commands. * diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 077b8c45fe..4a92543add 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -32,6 +32,9 @@ vi.mock('./prompt-processors/atFileProcessor.js', () => ({ process: mockAtFileProcess, })), })); +vi.mock('../utils/osUtils.js', () => ({ + getUsername: vi.fn().mockReturnValue('mock-user'), +})); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, @@ -582,7 +585,7 @@ describe('FileCommandLoader', () => { const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); - expect(extCommand?.description).toMatch(/^\[test-ext\]/); + expect(extCommand?.description).toBe('Custom command from ext.toml'); }); it('extension commands have extensionName metadata for conflict resolution', async () => { @@ -670,7 +673,7 @@ describe('FileCommandLoader', () => { expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); - expect(commands[2].description).toMatch(/^\[test-ext\]/); + expect(commands[2].description).toBe('Custom command from deploy.toml'); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { @@ -747,7 +750,7 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); - expect(commands[0].description).toMatch(/^\[active-ext\]/); + expect(commands[0].description).toBe('Custom command from active.toml'); }); it('handles missing extension commands directory gracefully', async () => { @@ -830,7 +833,7 @@ describe('FileCommandLoader', () => { const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); - expect(nestedCmd?.description).toMatch(/^\[a\]/); + expect(nestedCmd?.description).toBe('Custom command from c.toml'); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ @@ -1402,4 +1405,109 @@ describe('FileCommandLoader', () => { expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); + + describe('command namespace', () => { + it('is "user" for user commands', async () => { + const userCommandsDir = Storage.getUserCommandsDir(); + mock({ + [userCommandsDir]: { + 'test.toml': 'prompt = "User prompt"', + }, + }); + + const loader = new FileCommandLoader(null); + const commands = await loader.loadCommands(signal); + + expect(commands[0].name).toBe('test'); + expect(commands[0].namespace).toBe('user'); + expect(commands[0].description).toBe('Custom command from test.toml'); + }); + + it.each([ + { + name: 'standard path', + projectRoot: '/path/to/my-awesome-project', + }, + { + name: 'Windows-style path', + projectRoot: 'C:\\Users\\test\\projects\\win-project', + }, + ])( + 'is "workspace" for project commands ($name)', + async ({ projectRoot }) => { + const projectCommandsDir = path.join( + projectRoot, + GEMINI_DIR, + 'commands', + ); + + mock({ + [projectCommandsDir]: { + 'project.toml': 'prompt = "Project prompt"', + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => projectRoot), + getExtensions: vi.fn(() => []), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + storage: new Storage(projectRoot), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const projectCmd = commands.find((c) => c.name === 'project'); + expect(projectCmd).toBeDefined(); + expect(projectCmd?.namespace).toBe('workspace'); + expect(projectCmd?.description).toBe( + `Custom command from project.toml`, + ); + }, + ); + + it('is the extension name for extension commands', async () => { + const extensionDir = path.join( + process.cwd(), + GEMINI_DIR, + 'extensions', + 'my-ext', + ); + + mock({ + [extensionDir]: { + 'gemini-extension.json': JSON.stringify({ + name: 'my-ext', + version: '1.0.0', + }), + commands: { + 'ext.toml': 'prompt = "Extension prompt"', + }, + }, + }); + + const mockConfig = { + getProjectRoot: vi.fn(() => process.cwd()), + getExtensions: vi.fn(() => [ + { + name: 'my-ext', + version: '1.0.0', + isActive: true, + path: extensionDir, + }, + ]), + getFolderTrust: vi.fn(() => false), + isTrustedFolder: vi.fn(() => false), + } as unknown as Config; + + const loader = new FileCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + const extCmd = commands.find((c) => c.name === 'ext'); + expect(extCmd).toBeDefined(); + expect(extCmd?.namespace).toBe('my-ext'); + expect(extCmd?.description).toBe('Custom command from ext.toml'); + }); + }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index fb27327ead..ea46efbfec 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -37,6 +37,7 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; + namespace: string; extensionName?: string; extensionId?: string; } @@ -111,6 +112,7 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, + dirInfo.namespace, dirInfo.extensionName, dirInfo.extensionId, ), @@ -151,10 +153,16 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ path: Storage.getUserCommandsDir() }); + dirs.push({ + path: Storage.getUserCommandsDir(), + namespace: 'user', + }); // 2. Project commands (override user commands) - dirs.push({ path: storage.getProjectCommandsDir() }); + dirs.push({ + path: storage.getProjectCommandsDir(), + namespace: 'workspace', + }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -165,6 +173,7 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), + namespace: ext.name, extensionName: ext.name, extensionId: ext.id, })); @@ -179,14 +188,16 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. + * @param namespace The namespace of the command. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, - extensionName?: string, - extensionId?: string, + namespace: string, + extensionName: string | undefined, + extensionId: string | undefined, ): Promise { let fileContent: string; try { @@ -245,16 +256,11 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); - // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; description = sanitizeForDisplay(description, 100); - if (extensionName) { - description = `[${extensionName}] ${description}`; - } - const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); const usesShellInjection = validDef.prompt.includes( @@ -285,6 +291,7 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, + namespace, description, kind: CommandKind.FILE, extensionName, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 2cbb9da9a7..11029cd2f4 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -191,6 +191,12 @@ export interface SlashCommand { kind: CommandKind; + /** + * Optional namespace for the command (e.g., 'user', 'workspace', 'extensionName'). + * If provided, the command will be registered as 'namespace:name'. + */ + namespace?: string; + /** * Controls whether the command auto-executes when selected with Enter. * From 429932c663a134f3b792ae203c9f0d1744975170 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Fri, 20 Feb 2026 10:56:49 -0500 Subject: [PATCH 16/52] docs: refine Plan Mode documentation structure and workflow (#19644) --- docs/cli/plan-mode.md | 255 ++++++++++++++++++++++-------------------- 1 file changed, 134 insertions(+), 121 deletions(-) diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 03da2a6ac9..d5e78f6fb5 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -1,50 +1,61 @@ # Plan Mode (experimental) -Plan Mode is a safe, read-only mode for researching and designing complex -changes. It prevents modifications while you research, design and plan an -implementation strategy. +Plan Mode is a read-only environment for architecting robust solutions before +implementation. It allows you to: -> **Note: Plan Mode is currently an experimental feature.** -> -> Experimental features are subject to change. To use Plan Mode, enable it via -> `/settings` (search for `Plan`) or add the following to your `settings.json`: -> -> ```json -> { -> "experimental": { -> "plan": true -> } -> } -> ``` -> -> Your feedback is invaluable as we refine this feature. If you have ideas, +- **Research:** Explore the project in a read-only state to prevent accidental + changes. +- **Design:** Understand problems, evaluate trade-offs, and choose a solution. +- **Plan:** Align on an execution strategy before any code is modified. + +> **Note:** This is a preview feature currently under active development. Your +> feedback is invaluable as we refine this feature. If you have ideas, > suggestions, or encounter issues: > -> - Use the `/bug` command within the CLI to file an issue. > - [Open an issue](https://github.com/google-gemini/gemini-cli/issues) on > GitHub. +> - Use the **/bug** command within Gemini CLI to file an issue. -- [Starting in Plan Mode](#starting-in-plan-mode) +- [Enabling Plan Mode](#enabling-plan-mode) - [How to use Plan Mode](#how-to-use-plan-mode) - [Entering Plan Mode](#entering-plan-mode) - - [The Planning Workflow](#the-planning-workflow) + - [Planning Workflow](#planning-workflow) - [Exiting Plan Mode](#exiting-plan-mode) - [Tool Restrictions](#tool-restrictions) - [Customizing Planning with Skills](#customizing-planning-with-skills) - [Customizing Policies](#customizing-policies) + - [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode) + - [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode) + - [Custom Plan Directory and Policies](#custom-plan-directory-and-policies) -## Starting in Plan Mode +## Enabling Plan Mode -You can configure Gemini CLI to start directly in Plan Mode by default: +To use Plan Mode, enable it via **/settings** (search for **Plan**) or add the +following to your `settings.json`: -1. Type `/settings` in the CLI. -2. Search for `Default Approval Mode`. -3. Set the value to `Plan`. +```json +{ + "experimental": { + "plan": true + } +} +``` -Other ways to start in Plan Mode: +## How to use Plan Mode -- **CLI Flag:** `gemini --approval-mode=plan` -- **Manual Settings:** Manually update your `settings.json`: +### Entering Plan Mode + +You can configure Gemini CLI to start in Plan Mode by default or enter it +manually during a session. + +- **Configuration:** Configure Gemini CLI to start directly in Plan Mode by + default: + 1. Type `/settings` in the CLI. + 2. Search for **Default Approval Mode**. + 3. Set the value to **Plan**. + + Alternatively, use the `gemini --approval-mode=plan` CLI flag or manually + update: ```json { @@ -54,43 +65,44 @@ Other ways to start in Plan Mode: } ``` -## How to use Plan Mode +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes + (`Default` -> `Auto-Edit` -> `Plan`). -### Entering Plan Mode + > **Note:** Plan Mode is automatically removed from the rotation when Gemini + > CLI is actively processing or showing confirmation dialogs. -You can enter Plan Mode in three ways: +- **Command:** Type `/plan` in the input box. -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). +- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI then + calls the [`enter_plan_mode`] tool to switch modes. + > **Note:** This tool is not available when Gemini CLI is in [YOLO mode]. - > **Note:** Plan Mode is automatically removed from the rotation when the - > agent is actively processing or showing confirmation dialogs. +### Planning Workflow -2. **Command:** Type `/plan` in the input box. -3. **Natural Language:** Ask the agent to "start a plan for...". The agent will - then call the [`enter_plan_mode`] tool to switch modes. - - **Note:** This tool is not available when the CLI is in YOLO mode. - -### The Planning Workflow - -1. **Requirements:** The agent clarifies goals using [`ask_user`]. -2. **Exploration:** The agent uses read-only tools (like [`read_file`]) to map - the codebase and validate assumptions. -3. **Design:** The agent proposes alternative approaches with a recommended - solution for you to choose from. -4. **Planning:** A detailed plan is written to a temporary Markdown file. -5. **Review:** You review the plan. - - **Approve:** Exit Plan Mode and start implementation (switching to - Auto-Edit or Default approval mode). +1. **Explore & Analyze:** Analyze requirements and use read-only tools to map + the codebase and validate assumptions. For complex tasks, identify at least + two viable implementation approaches. +2. **Consult:** Present a summary of the identified approaches via [`ask_user`] + to obtain a selection. For simple or canonical tasks, this step may be + skipped. +3. **Draft:** Once an approach is selected, write a detailed implementation + plan to the plans directory. +4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan + and formally request approval. + - **Approve:** Exit Plan Mode and start implementation. - **Iterate:** Provide feedback to refine the plan. +For more complex or specialized planning tasks, you can +[customize the planning workflow with skills](#customizing-planning-with-skills). + ### Exiting Plan Mode -To exit Plan Mode: +To exit Plan Mode, you can: -1. **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. -2. **Tool:** The agent calls the [`exit_plan_mode`] tool to present the - finalized plan for your approval. +- **Keyboard Shortcut:** Press `Shift+Tab` to cycle to the desired mode. + +- **Tool:** Gemini CLI calls the [`exit_plan_mode`] tool to present the + finalized plan for your approval. ## Tool Restrictions @@ -103,30 +115,78 @@ These are the only allowed tools: - **Interaction:** [`ask_user`] - **MCP Tools (Read):** Read-only [MCP tools] (e.g., `github_read_issue`, `postgres_read_schema`) are allowed. -- **Planning (Write):** [`write_file`] and [`replace`] ONLY allowed for `.md` - files in the `~/.gemini/tmp///plans/` directory. +- **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` + files in the `~/.gemini/tmp///plans/` directory or your + [custom plans directory](#custom-plan-directory-and-policies). - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) ### Customizing Planning with Skills -You can leverage [Agent Skills](./skills.md) to customize how Gemini CLI -approaches planning for specific types of tasks. When a skill is activated -during Plan Mode, its specialized instructions and procedural workflows will -guide the research and design phases. +You can use [Agent Skills](./skills.md) to customize how Gemini CLI approaches +planning for specific types of tasks. When a skill is activated during Plan +Mode, its specialized instructions and procedural workflows will guide the +research, design and planning phases. For example: - A **"Database Migration"** skill could ensure the plan includes data safety checks and rollback strategies. -- A **"Security Audit"** skill could prompt the agent to look for specific +- A **"Security Audit"** skill could prompt Gemini CLI to look for specific vulnerabilities during codebase exploration. -- A **"Frontend Design"** skill could guide the agent to use specific UI +- A **"Frontend Design"** skill could guide Gemini CLI to use specific UI components and accessibility standards in its proposal. -To use a skill in Plan Mode, you can explicitly ask the agent to "use the -[skill-name] skill to plan..." or the agent may autonomously activate it based -on the task description. +To use a skill in Plan Mode, you can explicitly ask Gemini CLI to "use the +`` skill to plan..." or Gemini CLI may autonomously activate it +based on the task description. + +### Customizing Policies + +Plan Mode is designed to be read-only by default to ensure safety during the +research phase. However, you may occasionally need to allow specific tools to +assist in your planning. + +Because user policies (Tier 2) have a higher base priority than built-in +policies (Tier 1), you can override Plan Mode's default restrictions by creating +a rule in your `~/.gemini/policies/` directory. + +#### Example: Allow git commands in Plan Mode + +This rule allows you to check the repository status and see changes while in +Plan Mode. + +`~/.gemini/policies/git-research.toml` + +```toml +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git diff"] +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +#### Example: Enable research subagents in Plan Mode + +You can enable experimental research [subagents] like `codebase_investigator` to +help gather architecture details during the planning phase. + +`~/.gemini/policies/research-subagents.toml` + +```toml +[[rule]] +toolName = "codebase_investigator" +decision = "allow" +priority = 100 +modes = ["plan"] +``` + +Tell Gemini CLI it can use these tools in your prompt, for example: _"You can +check ongoing changes in git."_ + +For more information on how the policy engine works, see the [policy engine] +docs. ### Custom Plan Directory and Policies @@ -152,11 +212,10 @@ locations defined within a project's workspace cannot be used to escape and overwrite sensitive files elsewhere. Any user-configured directory must reside within the project boundary. -Because Plan Mode is read-only by default, using a custom directory requires -updating your [Policy Engine] configurations to allow `write_file` and `replace` -in that specific location. For example, to allow writing to the `.gemini/plans` -directory within your project, create a policy file at -`~/.gemini/policies/plan-custom-directory.toml`: +Using a custom directory requires updating your [policy engine] configurations +to allow `write_file` and `replace` in that specific location. For example, to +allow writing to the `.gemini/plans` directory within your project, create a +policy file at `~/.gemini/policies/plan-custom-directory.toml`: ```toml [[rule]] @@ -166,56 +225,9 @@ priority = 100 modes = ["plan"] # Adjust the pattern to match your custom directory. # This example matches any .md file in a .gemini/plans directory within the project. -argsPattern = "\"file_path\":\".*\\\\.gemini/plans/.*\\\\.md\"" +argsPattern = "\"file_path\":\"[^\"]*/\\.gemini/plans/[a-zA-Z0-9_-]+\\.md\"" ``` -### Customizing Policies - -Plan Mode is designed to be read-only by default to ensure safety during the -research phase. However, you may occasionally need to allow specific tools to -assist in your planning. - -Because user policies (Tier 2) have a higher base priority than built-in -policies (Tier 1), you can override Plan Mode's default restrictions by creating -a rule in your `~/.gemini/policies/` directory. - -#### Example: Allow `git status` and `git diff` in Plan Mode - -This rule allows you to check the repository status and see changes while in -Plan Mode. - -`~/.gemini/policies/git-research.toml` - -```toml -[[rule]] -toolName = "run_shell_command" -commandPrefix = ["git status", "git diff"] -decision = "allow" -priority = 100 -modes = ["plan"] -``` - -#### Example: Enable research sub-agents in Plan Mode - -You can enable [experimental research sub-agents] like `codebase_investigator` -to help gather architecture details during the planning phase. - -`~/.gemini/policies/research-subagents.toml` - -```toml -[[rule]] -toolName = "codebase_investigator" -decision = "allow" -priority = 100 -modes = ["plan"] -``` - -Tell the agent it can use these tools in your prompt, for example: _"You can -check ongoing changes in git."_ - -For more information on how the policy engine works, see the [Policy Engine -Guide]. - [`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder [`read_file`]: /docs/tools/file-system.md#2-read_file-readfile [`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext @@ -225,8 +237,9 @@ Guide]. [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md [`activate_skill`]: /docs/cli/skills.md -[experimental research sub-agents]: /docs/core/subagents.md -[Policy Engine Guide]: /docs/reference/policy-engine.md +[subagents]: /docs/core/subagents.md +[policy engine]: /docs/reference/policy-engine.md [`enter_plan_mode`]: /docs/tools/planning.md#1-enter_plan_mode-enterplanmode [`exit_plan_mode`]: /docs/tools/planning.md#2-exit_plan_mode-exitplanmode [`ask_user`]: /docs/tools/ask-user.md +[YOLO mode]: /docs/reference/configuration.md#command-line-arguments From c7e309efc97fe7e0229a7339592a4b22182316e4 Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Fri, 20 Feb 2026 08:37:41 -0800 Subject: [PATCH 17/52] Docs: Update release information regarding Gemini 3.1 (#19568) --- docs/get-started/gemini-3.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 333dbdb68d..a5eed9ab1d 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,6 +2,21 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! +> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have +> access to Gemini 3.1, use the `/model` command and select **Manual**. If you +> have access, you will see `gemini-3.1-pro-preview`. +> +> If you have access to Gemini 3.1, it will be included in model routing when +> you select **Auto (Gemini 3)**. You can also launch the Gemini 3.1 model +> directly using the `-m` flag: +> +> ``` +> gemini -m gemini-3.1-pro-preview +> ``` +> +> Learn more about [models](../cli/model.md) and +> [model routing](../cli/model-routing.md). + ## How to get started with Gemini 3 on Gemini CLI Get started by upgrading Gemini CLI to the latest version: @@ -12,9 +27,8 @@ npm install -g @google/gemini-cli@latest After you’ve confirmed your version is 0.21.1 or later: -1. Use the `/settings` command in Gemini CLI. -2. Toggle **Preview Features** to `true`. -3. Run `/model` and select **Auto (Gemini 3)**. +1. Run `/model`. +2. Select **Auto (Gemini 3)**. For more information, see [Gemini CLI model selection](../cli/model.md). From 0f855fc0c4d8e18df36bc76a22dcaaf7fa5e812d Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 20 Feb 2026 11:18:07 -0600 Subject: [PATCH 18/52] fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection (#19567) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- integration-tests/concurrency-limit.responses | 12 +++++ integration-tests/concurrency-limit.test.ts | 48 +++++++++++++++++ packages/core/src/scheduler/policy.ts | 5 +- packages/core/src/scheduler/tool-executor.ts | 6 +++ packages/core/src/scheduler/types.ts | 6 +++ packages/core/src/telemetry/types.ts | 12 +++++ packages/core/src/tools/web-fetch.test.ts | 20 +++++++ packages/core/src/tools/web-fetch.ts | 53 +++++++++++++++++++ packages/test-utils/src/test-rig.ts | 5 ++ 9 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 integration-tests/concurrency-limit.responses create mode 100644 integration-tests/concurrency-limit.test.ts diff --git a/integration-tests/concurrency-limit.responses b/integration-tests/concurrency-limit.responses new file mode 100644 index 0000000000..e2bd5efe2a --- /dev/null +++ b/integration-tests/concurrency-limit.responses @@ -0,0 +1,12 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/1"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/2"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/3"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/4"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/5"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/6"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/7"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/8"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/9"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/10"}}},{"functionCall":{"name":"web_fetch","args":{"prompt":"fetch https://example.com/11"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":500,"totalTokenCount":600}}]} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 1 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 2 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 3 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 4 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 5 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 6 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 7 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 8 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 9 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"Page 10 content"}],"role":"model"},"finishReason":"STOP","index":0}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Some requests were rate limited: Rate limit exceeded for host. Please wait 60 seconds before trying again."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":50,"totalTokenCount":1050}}]} diff --git a/integration-tests/concurrency-limit.test.ts b/integration-tests/concurrency-limit.test.ts new file mode 100644 index 0000000000..ba165b3393 --- /dev/null +++ b/integration-tests/concurrency-limit.test.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { TestRig } from './test-helper.js'; +import { join } from 'node:path'; + +describe('web-fetch rate limiting', () => { + let rig: TestRig; + + beforeEach(() => { + rig = new TestRig(); + }); + + afterEach(async () => { + if (rig) { + await rig.cleanup(); + } + }); + + it('should rate limit multiple requests to the same host', async () => { + rig.setup('web-fetch rate limit', { + settings: { tools: { core: ['web_fetch'] } }, + fakeResponsesPath: join( + import.meta.dirname, + 'concurrency-limit.responses', + ), + }); + + const result = await rig.run({ + args: `Fetch 11 pages from example.com`, + }); + + // We expect to find at least one tool call that failed with a rate limit error. + const toolLogs = rig.readToolLogs(); + const rateLimitedCalls = toolLogs.filter( + (log) => + log.toolRequest.name === 'web_fetch' && + log.toolRequest.error?.includes('Rate limit exceeded'), + ); + + expect(rateLimitedCalls.length).toBeGreaterThan(0); + expect(result).toContain('Rate limit exceeded'); + }); +}); diff --git a/packages/core/src/scheduler/policy.ts b/packages/core/src/scheduler/policy.ts index 247b696f22..579f081d2c 100644 --- a/packages/core/src/scheduler/policy.ts +++ b/packages/core/src/scheduler/policy.ts @@ -77,7 +77,10 @@ export async function checkPolicy( } } - return { decision, rule: result.rule }; + return { + decision, + rule: result.rule, + }; } /** diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 116598a2b9..b94b0e5184 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -192,6 +192,8 @@ export class ToolExecutor { tool: call.tool, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -263,6 +265,8 @@ export class ToolExecutor { response: successResponse, invocation: call.invocation, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } @@ -287,6 +291,8 @@ export class ToolExecutor { response, tool: call.tool, durationMs: startTime ? Date.now() - startTime : undefined, + startTime, + endTime: Date.now(), outcome: call.outcome, }; } diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7da611f23a..5fe6028bac 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -86,6 +86,8 @@ export type ErroredToolCall = { response: ToolCallResponseInfo; tool?: AnyDeclarativeTool; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -98,6 +100,8 @@ export type SuccessfulToolCall = { response: ToolCallResponseInfo; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; @@ -125,6 +129,8 @@ export type CancelledToolCall = { tool: AnyDeclarativeTool; invocation: AnyToolInvocation; durationMs?: number; + startTime?: number; + endTime?: number; outcome?: ToolConfirmationOutcome; schedulerId?: string; approvalMode?: ApprovalMode; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 497ff97469..e1a4079f3d 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -243,6 +243,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name?: string; extension_name?: string; extension_id?: string; + start_time?: number; + end_time?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any metadata?: { [key: string]: any }; @@ -256,6 +258,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id: string, tool_type: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ); constructor( call?: CompletedToolCall, @@ -266,6 +270,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { prompt_id?: string, tool_type?: 'native' | 'mcp', error?: string, + start_time?: number, + end_time?: number, ) { this['event.name'] = 'tool_call'; this['event.timestamp'] = new Date().toISOString(); @@ -282,6 +288,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.error_type = call.response.errorType; this.prompt_id = call.request.prompt_id; this.content_length = call.response.contentLength; + this.start_time = call.startTime; + this.end_time = call.endTime; if ( typeof call.tool !== 'undefined' && call.tool instanceof DiscoveredMCPTool @@ -332,6 +340,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { this.prompt_id = prompt_id!; this.tool_type = tool_type!; this.error = error; + this.start_time = start_time; + this.end_time = end_time; } } @@ -351,6 +361,8 @@ export class ToolCallEvent implements BaseTelemetryEvent { mcp_server_name: this.mcp_server_name, extension_name: this.extension_name, extension_id: this.extension_id, + start_time: this.start_time, + end_time: this.end_time, metadata: this.metadata, }; diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index f0c6ff2c7e..2e06a46ee5 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -183,6 +183,26 @@ describe('WebFetchTool', () => { }); describe('execute', () => { + it('should return WEB_FETCH_PROCESSING_ERROR on rate limit exceeded', async () => { + vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'response' }] } }], + }); + const tool = new WebFetchTool(mockConfig, bus); + const params = { prompt: 'fetch https://ratelimit.example.com' }; + const invocation = tool.build(params); + + // Execute 10 times to hit the limit + for (let i = 0; i < 10; i++) { + await invocation.execute(new AbortController().signal); + } + + // The 11th time should fail due to rate limit + const result = await invocation.execute(new AbortController().signal); + expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); + expect(result.error?.message).toContain('Rate limit exceeded for host'); + }); + it('should return WEB_FETCH_FALLBACK_FAILED on fallback fetch failure', async () => { vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue( diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 214cf4916b..9b6f832971 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -33,10 +33,46 @@ import { debugLogger } from '../utils/debugLogger.js'; import { retryWithBackoff } from '../utils/retry.js'; import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { LRUCache } from 'mnemonist'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; +// Rate limiting configuration +const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute +const MAX_REQUESTS_PER_WINDOW = 10; +const hostRequestHistory = new LRUCache(1000); + +function checkRateLimit(url: string): { + allowed: boolean; + waitTimeMs?: number; +} { + try { + const hostname = new URL(url).hostname; + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_MS; + + let history = hostRequestHistory.get(hostname) || []; + // Clean up old timestamps + history = history.filter((timestamp) => timestamp > windowStart); + + if (history.length >= MAX_REQUESTS_PER_WINDOW) { + // Calculate wait time based on the oldest timestamp in the current window + const oldestTimestamp = history[0]; + const waitTimeMs = oldestTimestamp + RATE_LIMIT_WINDOW_MS - now; + hostRequestHistory.set(hostname, history); // Update cleaned history + return { allowed: false, waitTimeMs: Math.max(0, waitTimeMs) }; + } + + history.push(now); + hostRequestHistory.set(hostname, history); + return { allowed: true }; + } catch (_e) { + // If URL parsing fails, we fallback to allowed (should be caught by parsePrompt anyway) + return { allowed: true }; + } +} + /** * Parses a prompt to extract valid URLs and identify malformed ones. */ @@ -258,6 +294,23 @@ ${textContent} const userPrompt = this.params.prompt; const { validUrls: urls } = parsePrompt(userPrompt); const url = urls[0]; + + // Enforce rate limiting + const rateLimitResult = checkRateLimit(url); + if (!rateLimitResult.allowed) { + const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000); + const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`; + debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR, + }, + }; + } + const isPrivate = isPrivateIp(url); if (isPrivate) { diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 6e32ec7790..1cd55b84f7 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -208,6 +208,7 @@ export interface ParsedLog { stdout?: string; stderr?: string; error?: string; + error_type?: string; prompt_id?: string; }; scopeMetrics?: { @@ -1255,6 +1256,8 @@ export class TestRig { success: boolean; duration_ms: number; prompt_id?: string; + error?: string; + error_type?: string; }; }[] = []; @@ -1272,6 +1275,8 @@ export class TestRig { success: logData.attributes.success ?? false, duration_ms: logData.attributes.duration_ms ?? 0, prompt_id: logData.attributes.prompt_id, + error: logData.attributes.error, + error_type: logData.attributes.error_type, }, }); } From 2bb7aaecd08a17aa9df14b72840c6b418080795d Mon Sep 17 00:00:00 2001 From: christine betts Date: Fri, 20 Feb 2026 12:30:49 -0500 Subject: [PATCH 19/52] Add initial implementation of /extensions explore command (#19029) --- .../config/extensionRegistryClient.test.ts | 55 +++++ .../cli/src/config/extensionRegistryClient.ts | 3 +- .../cli/src/ui/commands/extensionsCommand.ts | 25 +- .../components/shared/SearchableList.test.tsx | 233 ++++++++++++++++++ .../ui/components/shared/SearchableList.tsx | 231 +++++++++++++++++ .../SearchableList.test.tsx.snap | 19 ++ .../views/ExtensionRegistryView.test.tsx | 204 +++++++++++++++ .../views/ExtensionRegistryView.tsx | 200 +++++++++++++++ .../cli/src/ui/hooks/useExtensionRegistry.ts | 101 ++++++++ .../cli/src/ui/hooks/useRegistrySearch.ts | 67 +++++ 10 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/SearchableList.test.tsx create mode 100644 packages/cli/src/ui/components/shared/SearchableList.tsx create mode 100644 packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap create mode 100644 packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx create mode 100644 packages/cli/src/ui/components/views/ExtensionRegistryView.tsx create mode 100644 packages/cli/src/ui/hooks/useExtensionRegistry.ts create mode 100644 packages/cli/src/ui/hooks/useRegistrySearch.ts diff --git a/packages/cli/src/config/extensionRegistryClient.test.ts b/packages/cli/src/config/extensionRegistryClient.test.ts index 187390ceb0..4b9699d5e3 100644 --- a/packages/cli/src/config/extensionRegistryClient.test.ts +++ b/packages/cli/src/config/extensionRegistryClient.test.ts @@ -224,4 +224,59 @@ describe('ExtensionRegistryClient', () => { 'Failed to fetch extensions: Not Found', ); }); + + it('should not return irrelevant results', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: async () => [ + ...mockExtensions, + { + id: 'dataplex', + extensionName: 'dataplex', + extensionDescription: 'Connect to Dataplex Universal Catalog...', + fullName: 'google-cloud/dataplex', + rank: 6, + stars: 6, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: true, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + { + id: 'conductor', + extensionName: 'conductor', + extensionDescription: 'A conductor extension that actually matches.', + fullName: 'someone/conductor', + rank: 100, + stars: 100, + url: '', + repoDescription: '', + lastUpdated: '', + extensionVersion: '1.0.0', + avatarUrl: '', + hasMCP: false, + hasContext: false, + isGoogleOwned: false, + licenseKey: '', + hasHooks: false, + hasCustomCommands: false, + hasSkills: false, + }, + ], + }); + + const results = await client.searchExtensions('conductor'); + const ids = results.map((r) => r.id); + + expect(ids).not.toContain('dataplex'); + expect(ids).toContain('conductor'); + }); }); diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index aeda50dc48..3735f0a798 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -79,7 +79,7 @@ export class ExtensionRegistryClient { const fzf = new AsyncFzf(allExtensions, { selector: (ext: RegistryExtension) => `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, - fuzzy: 'v2', + fuzzy: true, }); const results = await fzf.find(query); return results.map((r: { item: RegistryExtension }) => r.item); @@ -108,7 +108,6 @@ export class ExtensionRegistryClient { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return (await response.json()) as RegistryExtension[]; } catch (error) { - // Clear the promise on failure so that subsequent calls can try again ExtensionRegistryClient.fetchPromise = null; throw error; } diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c7359a2a46..0a8a8d74e3 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { import { type CommandContext, type SlashCommand, + type SlashCommandActionReturn, CommandKind, } from './types.js'; import open from 'open'; @@ -35,6 +36,7 @@ import { stat } from 'node:fs/promises'; import { ExtensionSettingScope } from '../../config/extensions/extensionSettings.js'; import { type ConfigLogger } from '../../commands/extensions/utils.js'; import { ConfigExtensionDialog } from '../components/ConfigExtensionDialog.js'; +import { ExtensionRegistryView } from '../components/views/ExtensionRegistryView.js'; import React from 'react'; function showMessageIfNoExtensions( @@ -265,7 +267,28 @@ async function restartAction( } } -async function exploreAction(context: CommandContext) { +async function exploreAction( + context: CommandContext, +): Promise { + const settings = context.services.settings.merged; + const useRegistryUI = settings.experimental?.extensionRegistry; + + if (useRegistryUI) { + const extensionManager = context.services.config?.getExtensionLoader(); + if (extensionManager instanceof ExtensionManager) { + return { + type: 'custom_dialog' as const, + component: React.createElement(ExtensionRegistryView, { + onSelect: (extension) => { + debugLogger.debug(`Selected extension: ${extension.extensionName}`); + }, + onClose: () => context.ui.removeComponent(), + extensionManager, + }), + }; + } + } + const extensionsUrl = 'https://geminicli.com/extensions/'; // Only check for NODE_ENV for explicit test mode, not for unit test framework diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx new file mode 100644 index 0000000000..42b118e251 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SearchableList, + type SearchableListProps, + type SearchListState, + type GenericListItem, +} from './SearchableList.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { useTextBuffer } from './text-buffer.js'; + +const useMockSearch = (props: { + items: GenericListItem[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): SearchListState => { + const { onSearch, items, initialQuery = '' } = props; + const [text, setText] = React.useState(initialQuery); + const filteredItems = React.useMemo( + () => + items.filter((item: GenericListItem) => + item.label.toLowerCase().includes(text.toLowerCase()), + ), + [items, text], + ); + + React.useEffect(() => { + onSearch?.(text); + }, [text, onSearch]); + + const searchBuffer = useTextBuffer({ + initialText: text, + onChange: setText, + viewport: { width: 100, height: 1 }, + singleLine: true, + }); + + return { + filteredItems, + searchBuffer, + searchQuery: text, + setSearchQuery: setText, + maxLabelWidth: 10, + }; +}; + +vi.mock('../../contexts/UIStateContext.js', () => ({ + useUIState: () => ({ + mainAreaWidth: 100, + }), +})); + +const mockItems: GenericListItem[] = [ + { + key: 'item-1', + label: 'Item One', + description: 'Description for item one', + }, + { + key: 'item-2', + label: 'Item Two', + description: 'Description for item two', + }, + { + key: 'item-3', + label: 'Item Three', + description: 'Description for item three', + }, +]; + +describe('SearchableList', () => { + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + }); + + const renderList = ( + props: Partial> = {}, + ) => { + const defaultProps: SearchableListProps = { + title: 'Test List', + items: mockItems, + onSelect: mockOnSelect, + onClose: mockOnClose, + useSearch: useMockSearch, + ...props, + }; + + return render( + + + , + ); + }; + + it('should render all items initially', async () => { + const { lastFrame, waitUntilReady } = renderList(); + await waitUntilReady(); + const frame = lastFrame(); + + expect(frame).toContain('Test List'); + + expect(frame).toContain('Item One'); + expect(frame).toContain('Item Two'); + expect(frame).toContain('Item Three'); + + expect(frame).toContain('Description for item one'); + }); + + it('should reset selection to top when items change if resetSelectionOnItemsChange is true', async () => { + const { lastFrame, stdin, waitUntilReady } = renderList({ + resetSelectionOnItemsChange: true, + }); + await waitUntilReady(); + + await React.act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('> Item Two'); + }); + + await React.act(async () => { + stdin.write('One'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item One'); + expect(frame).not.toContain('Item Two'); + }); + + await React.act(async () => { + // Backspace "One" (3 chars) + stdin.write('\u007F\u007F\u007F'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).toContain('> Item One'); + expect(frame).not.toContain('> Item Two'); + }); + }); + + it('should filter items based on search query', async () => { + const { lastFrame, stdin } = renderList(); + + await React.act(async () => { + stdin.write('Two'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Item Two'); + expect(frame).not.toContain('Item One'); + expect(frame).not.toContain('Item Three'); + }); + }); + + it('should show "No items found." when no items match', async () => { + const { lastFrame, stdin } = renderList(); + + await React.act(async () => { + stdin.write('xyz123'); + }); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('No items found.'); + }); + }); + + it('should handle selection with Enter', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0]); + }); + }); + + it('should handle navigation and selection', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B[B'); // Down arrow + }); + + await React.act(async () => { + stdin.write('\r'); // Enter + }); + + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith(mockItems[1]); + }); + }); + + it('should handle close with Esc', async () => { + const { stdin } = renderList(); + + await React.act(async () => { + stdin.write('\u001B'); // Esc + }); + + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it('should match snapshot', async () => { + const { lastFrame, waitUntilReady } = renderList(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx new file mode 100644 index 0000000000..a20a44be42 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -0,0 +1,231 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { useSelectionList } from '../../hooks/useSelectionList.js'; +import { TextInput } from './TextInput.js'; +import type { TextBuffer } from './text-buffer.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; + +/** + * Generic interface for items in a searchable list. + */ +export interface GenericListItem { + key: string; + label: string; + description?: string; + [key: string]: unknown; +} + +/** + * State returned by the search hook. + */ +export interface SearchListState { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +/** + * Props for the SearchableList component. + */ +export interface SearchableListProps { + title?: string; + items: T[]; + onSelect: (item: T) => void; + onClose: () => void; + searchPlaceholder?: string; + /** Custom item renderer */ + renderItem?: ( + item: T, + isActive: boolean, + labelWidth: number, + ) => React.ReactNode; + /** Optional header content */ + header?: React.ReactNode; + /** Optional footer content */ + footer?: (info: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => React.ReactNode; + maxItemsToShow?: number; + /** Hook to handle search logic */ + useSearch: (props: { + items: T[]; + onSearch?: (query: string) => void; + }) => SearchListState; + onSearch?: (query: string) => void; + /** Whether to reset selection to the top when items change (e.g. after search) */ + resetSelectionOnItemsChange?: boolean; +} + +/** + * A generic searchable list component with keyboard navigation. + */ +export function SearchableList({ + title, + items, + onSelect, + onClose, + searchPlaceholder = 'Search...', + renderItem, + header, + footer, + maxItemsToShow = 10, + useSearch, + onSearch, + resetSelectionOnItemsChange = false, +}: SearchableListProps): React.JSX.Element { + const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({ + items, + onSearch, + }); + + const selectionItems = useMemo( + () => + filteredItems.map((item) => ({ + key: item.key, + value: item, + })), + [filteredItems], + ); + + const handleSelectValue = useCallback( + (item: T) => { + onSelect(item); + }, + [onSelect], + ); + + const { activeIndex, setActiveIndex } = useSelectionList({ + items: selectionItems, + onSelect: handleSelectValue, + isFocused: true, + showNumbers: false, + wrapAround: true, + }); + + // Reset selection to top when items change if requested + const prevItemsRef = React.useRef(filteredItems); + React.useEffect(() => { + if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { + setActiveIndex(0); + } + prevItemsRef.current = filteredItems; + }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); + + // Handle global Escape key to close the list + useKeypress( + (key) => { + if (keyMatchers[Command.ESCAPE](key)) { + onClose(); + return true; + } + return false; + }, + { isActive: true }, + ); + + const scrollOffset = Math.max( + 0, + Math.min( + activeIndex - Math.floor(maxItemsToShow / 2), + Math.max(0, filteredItems.length - maxItemsToShow), + ), + ); + + const visibleItems = filteredItems.slice( + scrollOffset, + scrollOffset + maxItemsToShow, + ); + + const defaultRenderItem = ( + item: T, + isActive: boolean, + labelWidth: number, + ) => ( + + + {isActive ? '> ' : ' '} + {item.label.padEnd(labelWidth)} + + {item.description && ( + + + {item.description} + + + )} + + ); + + return ( + + {title && ( + + + {title} + + + )} + + {searchBuffer && ( + + + + )} + + {header && {header}} + + + {filteredItems.length === 0 ? ( + + No items found. + + ) : ( + visibleItems.map((item, index) => { + const isSelected = activeIndex === scrollOffset + index; + return ( + + {renderItem + ? renderItem(item, isSelected, maxLabelWidth) + : defaultRenderItem(item, isSelected, maxLabelWidth)} + + ); + }) + )} + + + {footer && ( + + {footer({ + startIndex: scrollOffset, + endIndex: scrollOffset + visibleItems.length, + totalVisible: filteredItems.length, + })} + + )} + + ); +} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap new file mode 100644 index 0000000000..e596373e01 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -0,0 +1,19 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`SearchableList > should match snapshot 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + > Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three +" +`; diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx new file mode 100644 index 0000000000..58f687eb6d --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '../../../test-utils/render.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ExtensionRegistryView } from './ExtensionRegistryView.js'; +import { type ExtensionManager } from '../../../config/extension-manager.js'; +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { type RegistryExtension } from '../../../config/extensionRegistryClient.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { KeypressProvider } from '../../contexts/KeypressContext.js'; +import { + type SearchListState, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { type TextBuffer } from '../shared/text-buffer.js'; + +// Mocks +vi.mock('../../hooks/useExtensionRegistry.js'); +vi.mock('../../hooks/useExtensionUpdates.js'); +vi.mock('../../hooks/useRegistrySearch.js'); +vi.mock('../../../config/extension-manager.js'); +vi.mock('../../contexts/UIStateContext.js'); +vi.mock('../../contexts/ConfigContext.js'); + +const mockExtensions: RegistryExtension[] = [ + { + id: 'ext1', + extensionName: 'Test Extension 1', + extensionDescription: 'Description 1', + fullName: 'author/ext1', + extensionVersion: '1.0.0', + rank: 1, + stars: 10, + url: 'http://example.com', + repoDescription: 'Repo Desc 1', + avatarUrl: 'http://avatar.com', + lastUpdated: '2023-01-01', + hasMCP: false, + hasContext: false, + hasHooks: false, + hasSkills: false, + hasCustomCommands: false, + isGoogleOwned: false, + licenseKey: 'mit', + }, + { + id: 'ext2', + extensionName: 'Test Extension 2', + extensionDescription: 'Description 2', + fullName: 'author/ext2', + extensionVersion: '2.0.0', + rank: 2, + stars: 20, + url: 'http://example.com/2', + repoDescription: 'Repo Desc 2', + avatarUrl: 'http://avatar.com/2', + lastUpdated: '2023-01-02', + hasMCP: true, + hasContext: true, + hasHooks: true, + hasSkills: true, + hasCustomCommands: true, + isGoogleOwned: true, + licenseKey: 'apache-2.0', + }, +]; + +describe('ExtensionRegistryView', () => { + let mockExtensionManager: ExtensionManager; + let mockOnSelect: ReturnType; + let mockOnClose: ReturnType; + let mockSearch: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockExtensionManager = { + getExtensions: vi.fn().mockReturnValue([]), + } as unknown as ExtensionManager; + + mockOnSelect = vi.fn(); + mockOnClose = vi.fn(); + mockSearch = vi.fn(); + + vi.mocked(useExtensionRegistry).mockReturnValue({ + extensions: mockExtensions, + loading: false, + error: null, + search: mockSearch, + }); + + vi.mocked(useExtensionUpdates).mockReturnValue({ + extensionsUpdateState: new Map(), + } as unknown as ReturnType); + + // Mock useRegistrySearch implementation + vi.mocked(useRegistrySearch).mockImplementation( + (props: { items: GenericListItem[]; onSearch?: (q: string) => void }) => + ({ + filteredItems: props.items, // Pass through items + searchBuffer: { + text: '', + cursorOffset: 0, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 0] as [number, number], + viewportVisualLines: [{ text: '', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: [''], + cursor: [0, 0] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: '', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + }) as unknown as SearchListState, + ); + + vi.mocked(useUIState).mockReturnValue({ + mainAreaWidth: 100, + } as unknown as ReturnType); + + vi.mocked(useConfig).mockReturnValue({ + getEnableExtensionReloading: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + }); + + const renderView = () => + render( + + + , + ); + + it('should render extensions', async () => { + const { lastFrame } = renderView(); + await waitFor(() => { + expect(lastFrame()).toContain('Test Extension 1'); + expect(lastFrame()).toContain('Test Extension 2'); + }); + }); + + it('should use useRegistrySearch hook', () => { + renderView(); + expect(useRegistrySearch).toHaveBeenCalled(); + }); + + it('should call search function when typing', async () => { + // Mock useRegistrySearch to trigger onSearch + vi.mocked(useRegistrySearch).mockImplementation( + (props: { + items: GenericListItem[]; + onSearch?: (q: string) => void; + }): SearchListState => { + const { onSearch } = props; + // Simulate typing + React.useEffect(() => { + if (onSearch) { + onSearch('test query'); + } + }, [onSearch]); + return { + filteredItems: props.items, + searchBuffer: { + text: 'test query', + cursorOffset: 10, + viewport: { width: 10, height: 1 }, + visualCursor: [0, 10] as [number, number], + viewportVisualLines: [{ text: 'test query', visualRowIndex: 0 }], + visualScrollRow: 0, + lines: ['test query'], + cursor: [0, 10] as [number, number], + selectionAnchor: undefined, + } as unknown as TextBuffer, + searchQuery: 'test query', + setSearchQuery: vi.fn(), + maxLabelWidth: 10, + } as unknown as SearchListState; + }, + ); + + renderView(); + + await waitFor(() => { + expect(useRegistrySearch).toHaveBeenCalledWith( + expect.objectContaining({ + onSearch: mockSearch, + }), + ); + }); + }); +}); diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx new file mode 100644 index 0000000000..9a7c15144a --- /dev/null +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useCallback } from 'react'; +import { Box, Text } from 'ink'; +import type { RegistryExtension } from '../../../config/extensionRegistryClient.js'; + +import { + SearchableList, + type GenericListItem, +} from '../shared/SearchableList.js'; +import { theme } from '../../semantic-colors.js'; + +import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js'; +import { ExtensionUpdateState } from '../../state/extensions.js'; +import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import type { ExtensionManager } from '../../../config/extension-manager.js'; +import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; + +interface ExtensionRegistryViewProps { + onSelect?: (extension: RegistryExtension) => void; + onClose?: () => void; + extensionManager: ExtensionManager; +} + +interface ExtensionItem extends GenericListItem { + extension: RegistryExtension; +} + +export function ExtensionRegistryView({ + onSelect, + onClose, + extensionManager, +}: ExtensionRegistryViewProps): React.JSX.Element { + const { extensions, loading, error, search } = useExtensionRegistry(); + const config = useConfig(); + + const { extensionsUpdateState } = useExtensionUpdates( + extensionManager, + () => 0, + config.getEnableExtensionReloading(), + ); + + const installedExtensions = extensionManager.getExtensions(); + + const items: ExtensionItem[] = useMemo( + () => + extensions.map((ext) => ({ + key: ext.id, + label: ext.extensionName, + description: ext.extensionDescription || ext.repoDescription, + extension: ext, + })), + [extensions], + ); + + const handleSelect = useCallback( + (item: ExtensionItem) => { + onSelect?.(item.extension); + }, + [onSelect], + ); + + const renderItem = useCallback( + (item: ExtensionItem, isActive: boolean, _labelWidth: number) => { + const isInstalled = installedExtensions.some( + (e) => e.name === item.extension.extensionName, + ); + const updateState = extensionsUpdateState.get( + item.extension.extensionName, + ); + const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE; + + return ( + + + + + {isActive ? '> ' : ' '} + + + + + {item.label} + + + + | + + {isInstalled && ( + + [Installed] + + )} + {hasUpdate && ( + + [Update available] + + )} + + + {item.description} + + + + + + + {' '} + {item.extension.stars || 0} + + + + ); + }, + [installedExtensions, extensionsUpdateState], + ); + + const header = useMemo( + () => ( + + + + Browse and search extensions from the registry. + + + + + {installedExtensions.length && + `${installedExtensions.length} installed`} + + + + ), + [installedExtensions.length], + ); + + const footer = useCallback( + ({ + startIndex, + endIndex, + totalVisible, + }: { + startIndex: number; + endIndex: number; + totalVisible: number; + }) => ( + + ({startIndex + 1}-{endIndex}) / {totalVisible} + + ), + [], + ); + + if (loading) { + return ( + + Loading extensions... + + ); + } + + if (error) { + return ( + + Error loading extensions: + {error} + + ); + } + + return ( + + title="Extensions" + items={items} + onSelect={handleSelect} + onClose={onClose || (() => {})} + searchPlaceholder="Search extension gallery" + renderItem={renderItem} + header={header} + footer={footer} + maxItemsToShow={8} + useSearch={useRegistrySearch} + onSearch={search} + resetSelectionOnItemsChange={true} + /> + ); +} diff --git a/packages/cli/src/ui/hooks/useExtensionRegistry.ts b/packages/cli/src/ui/hooks/useExtensionRegistry.ts new file mode 100644 index 0000000000..cfd85ef229 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionRegistry.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + ExtensionRegistryClient, + type RegistryExtension, +} from '../../config/extensionRegistryClient.js'; + +export interface UseExtensionRegistryResult { + extensions: RegistryExtension[]; + loading: boolean; + error: string | null; + search: (query: string) => void; +} + +export function useExtensionRegistry( + initialQuery = '', +): UseExtensionRegistryResult { + const [extensions, setExtensions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const client = useMemo(() => new ExtensionRegistryClient(), []); + + // Ref to track the latest query to avoid race conditions + const latestQueryRef = useRef(initialQuery); + + // Ref for debounce timeout + const debounceTimeoutRef = useRef(undefined); + + const searchExtensions = useCallback( + async (query: string) => { + try { + setLoading(true); + const results = await client.searchExtensions(query); + + // Only update if this is still the latest query + if (query === latestQueryRef.current) { + // Check if results are different from current extensions + setExtensions((prev) => { + if ( + prev.length === results.length && + prev.every((ext, i) => ext.id === results[i].id) + ) { + return prev; + } + return results; + }); + setError(null); + setLoading(false); + } + } catch (err) { + if (query === latestQueryRef.current) { + setError(err instanceof Error ? err.message : String(err)); + setExtensions([]); + setLoading(false); + } + } + }, + [client], + ); + + const search = useCallback( + (query: string) => { + latestQueryRef.current = query; + + // Clear existing timeout + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + + // Debounce + debounceTimeoutRef.current = setTimeout(() => { + void searchExtensions(query); + }, 300); + }, + [searchExtensions], + ); + + // Initial load + useEffect(() => { + void searchExtensions(initialQuery); + + return () => { + if (debounceTimeoutRef.current) { + clearTimeout(debounceTimeoutRef.current); + } + }; + }, [initialQuery, searchExtensions]); + + return { + extensions, + loading, + error, + search, + }; +} diff --git a/packages/cli/src/ui/hooks/useRegistrySearch.ts b/packages/cli/src/ui/hooks/useRegistrySearch.ts new file mode 100644 index 0000000000..e1a1c4191b --- /dev/null +++ b/packages/cli/src/ui/hooks/useRegistrySearch.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import type { GenericListItem } from '../components/shared/SearchableList.js'; + +const MIN_VIEWPORT_WIDTH = 20; +const VIEWPORT_WIDTH_OFFSET = 8; + +export interface UseRegistrySearchResult { + filteredItems: T[]; + searchBuffer: TextBuffer | undefined; + searchQuery: string; + setSearchQuery: (query: string) => void; + maxLabelWidth: number; +} + +export function useRegistrySearch(props: { + items: T[]; + initialQuery?: string; + onSearch?: (query: string) => void; +}): UseRegistrySearchResult { + const { items, initialQuery = '', onSearch } = props; + + const [searchQuery, setSearchQuery] = useState(initialQuery); + + useEffect(() => { + onSearch?.(searchQuery); + }, [searchQuery, onSearch]); + + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max( + MIN_VIEWPORT_WIDTH, + mainAreaWidth - VIEWPORT_WIDTH_OFFSET, + ); + + const searchBuffer = useTextBuffer({ + initialText: searchQuery, + initialCursorOffset: searchQuery.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange: (text) => setSearchQuery(text), + }); + + const maxLabelWidth = 0; + + const filteredItems = items; + + return { + filteredItems, + searchBuffer, + searchQuery, + setSearchQuery, + maxLabelWidth, + }; +} From be03e0619f030178de641c89ab1ea327bf756ad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=95=AF=E2=80=B5=D0=94=E2=80=B2=29=E2=95=AF=E5=BD=A1?= =?UTF-8?q?=E2=94=BB=E2=94=81=E2=94=BB=20=28=E2=98=951e6=29?= Date: Fri, 20 Feb 2026 12:48:42 -0500 Subject: [PATCH 20/52] fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) (#19038) --- packages/core/src/tools/mcp-client.test.ts | 84 ++++++++++++++++++++++ packages/core/src/tools/mcp-client.ts | 23 +++--- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 19430c2f9a..3e592825dd 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -2056,6 +2056,90 @@ describe('connectToMcpServer with OAuth', () => { capturedTransport._requestInit?.headers?.['Authorization']; expect(authHeader).toBe('Bearer test-access-token-from-discovery'); }); + + it('should use discoverOAuthFromWWWAuthenticate when it succeeds and skip discoverOAuthConfig', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test", resource_metadata="http://test-server.com/.well-known/oauth-protected-resource"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.discoverOAuthConfig).not.toHaveBeenCalled(); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); + + it('should fall back to extractBaseUrl + discoverOAuthConfig when discoverOAuthFromWWWAuthenticate returns null', async () => { + const serverUrl = 'http://test-server.com/mcp'; + const baseUrl = 'http://test-server.com'; + const authUrl = 'http://auth.example.com/auth'; + const tokenUrl = 'http://auth.example.com/token'; + const wwwAuthHeader = `Bearer realm="test"`; + + vi.mocked(mockedClient.connect).mockRejectedValueOnce( + new StreamableHTTPError( + 401, + `Unauthorized\nwww-authenticate: ${wwwAuthHeader}`, + ), + ); + + vi.mocked(OAuthUtils.discoverOAuthFromWWWAuthenticate).mockResolvedValue( + null, + ); + vi.mocked(OAuthUtils.extractBaseUrl).mockReturnValue(baseUrl); + vi.mocked(OAuthUtils.discoverOAuthConfig).mockResolvedValue({ + authorizationUrl: authUrl, + tokenUrl, + scopes: ['read'], + }); + + vi.mocked(mockedClient.connect).mockResolvedValueOnce(undefined); + + const client = await connectToMcpServer( + '0.0.1', + 'test-server', + { httpUrl: serverUrl, oauth: { enabled: true } }, + false, + workspaceContext, + EMPTY_CONFIG, + ); + + expect(client).toBe(mockedClient); + expect(OAuthUtils.discoverOAuthFromWWWAuthenticate).toHaveBeenCalledWith( + wwwAuthHeader, + serverUrl, + ); + expect(OAuthUtils.extractBaseUrl).toHaveBeenCalledWith(serverUrl); + expect(OAuthUtils.discoverOAuthConfig).toHaveBeenCalledWith(baseUrl); + expect(mockAuthProvider.authenticate).toHaveBeenCalledOnce(); + }); }); describe('connectToMcpServer - HTTP→SSE fallback', () => { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a838cf76e5..ccc6bbec3c 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -719,18 +719,17 @@ async function handleAutomaticOAuth( try { debugLogger.log(`🔐 '${mcpServerName}' requires OAuth authentication`); - // Always try to parse the resource metadata URI from the www-authenticate header - let oauthConfig; - const resourceMetadataUri = - OAuthUtils.parseWWWAuthenticateHeader(wwwAuthenticate); - if (resourceMetadataUri) { - oauthConfig = await OAuthUtils.discoverOAuthConfig(resourceMetadataUri); - } else if (hasNetworkTransport(mcpServerConfig)) { + const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; + + // Try to discover OAuth config from the WWW-Authenticate header first + let oauthConfig = await OAuthUtils.discoverOAuthFromWWWAuthenticate( + wwwAuthenticate, + serverUrl, + ); + + if (!oauthConfig && hasNetworkTransport(mcpServerConfig)) { // Fallback: try to discover OAuth config from the base URL - const serverUrl = new URL( - mcpServerConfig.httpUrl || mcpServerConfig.url!, - ); - const baseUrl = `${serverUrl.protocol}//${serverUrl.host}`; + const baseUrl = OAuthUtils.extractBaseUrl(serverUrl!); oauthConfig = await OAuthUtils.discoverOAuthConfig(baseUrl); } @@ -754,8 +753,6 @@ async function handleAutomaticOAuth( }; // Perform OAuth authentication - // Pass the server URL for proper discovery - const serverUrl = mcpServerConfig.httpUrl || mcpServerConfig.url; debugLogger.log( `Starting OAuth authentication for server '${mcpServerName}'...`, ); From 27b7fc04deba60f742c642ea78e40b258db9069a Mon Sep 17 00:00:00 2001 From: Alisa <62909685+alisa-alisa@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:54:28 -0800 Subject: [PATCH 21/52] Search updates (#19482) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- evals/grep_search_functionality.eval.ts | 170 ++++++++++++++++++ .../coreToolsModelSnapshots.test.ts.snap | 2 +- .../definitions/model-family-sets/gemini-3.ts | 2 +- 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 evals/grep_search_functionality.eval.ts diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts new file mode 100644 index 0000000000..77df3b950f --- /dev/null +++ b/evals/grep_search_functionality.eval.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 202 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { evalTest, TestRig } from './test-helper.js'; +import { + assertModelHasOutput, + checkModelOutputContent, +} from './test-helper.js'; + +describe('grep_search_functionality', () => { + const TEST_PREFIX = 'Grep Search Functionality: '; + + evalTest('USUALLY_PASSES', { + name: 'should find a simple string in a file', + files: { + 'test.txt': `hello + world + hello world`, + }, + prompt: 'Find "world" in test.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L2: world/, /L3: hello world/], + testName: `${TEST_PREFIX}simple search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should perform a case-sensitive search', + files: { + 'test.txt': `Hello + hello`, + }, + prompt: 'Find "Hello" in test.txt, case-sensitively.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.case_sensitive === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with case_sensitive: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/L1: Hello/], + forbiddenContent: [/L2: hello/], + testName: `${TEST_PREFIX}case-sensitive search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should return only file names when names_only is used', + files: { + 'file1.txt': 'match me', + 'file2.txt': 'match me', + }, + prompt: 'Find the files containing "match me".', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.names_only === true; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with names_only: true', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file1.txt/, /file2.txt/], + forbiddenContent: [/L1:/], + testName: `${TEST_PREFIX}names_only search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search only within the specified include glob', + files: { + 'file.js': 'my_function();', + 'file.ts': 'my_function();', + }, + prompt: 'Find "my_function" in .js files.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.include === '*.js'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with include: "*.js"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/file.js/], + forbiddenContent: [/file.ts/], + testName: `${TEST_PREFIX}include glob search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should search within a specific subdirectory', + files: { + 'src/main.js': 'unique_string_1', + 'lib/main.js': 'unique_string_2', + }, + prompt: 'Find "unique_string" in the src directory.', + assert: async (rig: TestRig, result: string) => { + const wasToolCalled = await rig.waitForToolCall( + 'grep_search', + undefined, + (args) => { + const params = JSON.parse(args); + return params.dir_path === 'src'; + }, + ); + expect( + wasToolCalled, + 'Expected grep_search to be called with dir_path: "src"', + ).toBe(true); + + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/unique_string_1/], + forbiddenContent: [/unique_string_2/], + testName: `${TEST_PREFIX}subdirectory search`, + }); + }, + }); + + evalTest('USUALLY_PASSES', { + name: 'should report no matches correctly', + files: { + 'file.txt': 'nothing to see here', + }, + prompt: 'Find "nonexistent" in file.txt', + assert: async (rig: TestRig, result: string) => { + await rig.waitForToolCall('grep_search'); + assertModelHasOutput(result); + checkModelOutputContent(result, { + expectedContent: [/No matches found/], + testName: `${TEST_PREFIX}no matches`, + }); + }, + }); +}); diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index 9767829f0e..cdbb5d44a8 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -1089,7 +1089,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: grep_search_ripgrep 1`] = ` { - "description": "Searches for a regular expression pattern within file contents.", + "description": "Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard \`run_shell_command("grep ...")\` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via \`total_max_matches\`).", "name": "grep_search", "parametersJsonSchema": { "properties": { diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 71e8aaec1c..ce5f3fe429 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -131,7 +131,7 @@ The user has the ability to modify \`content\`. If modified, this will be stated grep_search_ripgrep: { name: GREP_TOOL_NAME, description: - 'Searches for a regular expression pattern within file contents.', + 'Searches for a regular expression pattern within file contents. This tool is FAST and optimized, powered by ripgrep. PREFERRED over standard `run_shell_command("grep ...")` due to better performance and automatic output limiting (defaults to 100 matches, but can be increased via `total_max_matches`).', parametersJsonSchema: { type: 'object', properties: { From d54702185be9f05ff7740f4d70a2277ce664cde8 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Fri, 20 Feb 2026 10:09:10 -0800 Subject: [PATCH 22/52] feat(cli): add support for numpad SS3 sequences (#19659) --- .../src/ui/contexts/KeypressContext.test.tsx | 74 +++++++++++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 48 +++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 1635fd3c14..e25ff57642 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -758,6 +758,80 @@ describe('KeypressContext', () => { ); }); + describe('Numpad support', () => { + it.each([ + { + sequence: '\x1bOj', + expected: { name: '*', sequence: '*', insertable: true }, + }, + { + sequence: '\x1bOk', + expected: { name: '+', sequence: '+', insertable: true }, + }, + { + sequence: '\x1bOm', + expected: { name: '-', sequence: '-', insertable: true }, + }, + { + sequence: '\x1bOo', + expected: { name: '/', sequence: '/', insertable: true }, + }, + { + sequence: '\x1bOp', + expected: { name: '0', sequence: '0', insertable: true }, + }, + { + sequence: '\x1bOq', + expected: { name: '1', sequence: '1', insertable: true }, + }, + { + sequence: '\x1bOr', + expected: { name: '2', sequence: '2', insertable: true }, + }, + { + sequence: '\x1bOs', + expected: { name: '3', sequence: '3', insertable: true }, + }, + { + sequence: '\x1bOt', + expected: { name: '4', sequence: '4', insertable: true }, + }, + { + sequence: '\x1bOu', + expected: { name: '5', sequence: '5', insertable: true }, + }, + { + sequence: '\x1bOv', + expected: { name: '6', sequence: '6', insertable: true }, + }, + { + sequence: '\x1bOw', + expected: { name: '7', sequence: '7', insertable: true }, + }, + { + sequence: '\x1bOx', + expected: { name: '8', sequence: '8', insertable: true }, + }, + { + sequence: '\x1bOy', + expected: { name: '9', sequence: '9', insertable: true }, + }, + { + sequence: '\x1bOn', + expected: { name: '.', sequence: '.', insertable: true }, + }, + ])( + 'should recognize numpad sequence "$sequence" as $expected.name', + ({ sequence, expected }) => { + const { keyHandler } = setupKeypressTest(); + act(() => stdin.write(sequence)); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + }, + ); + }); + describe('Double-tap and batching', () => { it('should emit two delete events for double-tap CSI[3~', async () => { const { keyHandler } = setupKeypressTest(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 217f5182bb..7d1881644d 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -122,6 +122,25 @@ const KEY_INFO_MAP: Record< '[8^': { name: 'end', ctrl: true }, }; +// Numpad keys in Application Keypad Mode (SS3 sequences) +const NUMPAD_MAP: Record = { + Oj: '*', + Ok: '+', + Om: '-', + Oo: '/', + Op: '0', + Oq: '1', + Or: '2', + Os: '3', + Ot: '4', + Ou: '5', + Ov: '6', + Ow: '7', + Ox: '8', + Oy: '9', + On: '.', +}; + const kUTF16SurrogateThreshold = 0x10000; // 2 ** 16 function charLengthAt(str: string, i: number): number { if (str.length <= i) { @@ -538,18 +557,27 @@ function* emitKeys( insertable = true; } } else { - name = 'undefined'; - if ( - (ctrl || cmd || alt) && - (code.endsWith('u') || code.endsWith('~')) - ) { - // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) - const codeNumber = parseInt(code.slice(1, -1), 10); + const numpadChar = NUMPAD_MAP[code]; + if (numpadChar) { + name = numpadChar; + if (!ctrl && !cmd && !alt) { + sequence = numpadChar; + insertable = true; + } + } else { + name = 'undefined'; if ( - codeNumber >= 'a'.charCodeAt(0) && - codeNumber <= 'z'.charCodeAt(0) + (ctrl || cmd || alt) && + (code.endsWith('u') || code.endsWith('~')) ) { - name = String.fromCharCode(codeNumber); + // CSI-u or tilde-coded functional keys: ESC [ ; (u|~) + const codeNumber = parseInt(code.slice(1, -1), 10); + if ( + codeNumber >= 'a'.charCodeAt(0) && + codeNumber <= 'z'.charCodeAt(0) + ) { + name = String.fromCharCode(codeNumber); + } } } } From d24f10b087aa8def4a93ad3eeded0226f4782c4c Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:21:03 -0800 Subject: [PATCH 23/52] feat(cli): enhance folder trust with configuration discovery and security warnings (#19492) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- docs/cli/trusted-folders.md | 31 ++ packages/cli/src/ui/AppContainer.tsx | 11 +- .../cli/src/ui/components/DialogManager.tsx | 1 + .../ui/components/FolderTrustDialog.test.tsx | 306 +++++++++++++++++- .../src/ui/components/FolderTrustDialog.tsx | 253 +++++++++++++-- .../ui/components/shared/Scrollable.test.tsx | 26 +- .../src/ui/components/shared/Scrollable.tsx | 6 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/useFolderTrust.test.ts | 3 + packages/cli/src/ui/hooks/useFolderTrust.ts | 26 +- packages/core/package.json | 1 + packages/core/src/index.ts | 1 + .../FolderTrustDiscoveryService.test.ts | 161 +++++++++ .../services/FolderTrustDiscoveryService.ts | 215 ++++++++++++ 14 files changed, 994 insertions(+), 49 deletions(-) create mode 100644 packages/core/src/services/FolderTrustDiscoveryService.test.ts create mode 100644 packages/core/src/services/FolderTrustDiscoveryService.ts diff --git a/docs/cli/trusted-folders.md b/docs/cli/trusted-folders.md index 7f6e668c24..c271a0dba2 100644 --- a/docs/cli/trusted-folders.md +++ b/docs/cli/trusted-folders.md @@ -38,6 +38,37 @@ folder, a dialog will automatically appear, prompting you to make a choice: Your choice is saved in a central file (`~/.gemini/trustedFolders.json`), so you will only be asked once per folder. +## Understanding folder contents: The discovery phase + +Before you make a choice, the Gemini CLI performs a **discovery phase** to scan +the folder for potential configurations. This information is displayed in the +trust dialog to help you make an informed decision. + +The discovery UI lists the following categories of items found in the project: + +- **Commands**: Custom `.toml` command definitions that add new functionality. +- **MCP Servers**: Configured Model Context Protocol servers that the CLI will + attempt to connect to. +- **Hooks**: System or custom hooks that can intercept and modify CLI behavior. +- **Skills**: Local agent skills that provide specialized capabilities. +- **Setting overrides**: Any project-specific configurations that override your + global user settings. + +### Security warnings and errors + +The trust dialog also highlights critical information that requires your +attention: + +- **Security Warnings**: The CLI will explicitly flag potentially dangerous + settings, such as auto-approving certain tools or disabling the security + sandbox. +- **Discovery Errors**: If the CLI encounters issues while scanning the folder + (e.g., a malformed `settings.json` file), these errors will be displayed + prominently. + +By reviewing these details, you can ensure that you only grant trust to projects +that you know are safe. + ## Why trust matters: The impact of an untrusted workspace When a folder is **untrusted**, the Gemini CLI runs in a restricted "safe mode" diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b7945b0e10..b0a2ade4b3 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1436,15 +1436,18 @@ Logging in with Google... Restarting Gemini CLI to continue. type: TransientMessageType; }>(WARNING_PROMPT_DURATION_MS); - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = - useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); + const { + isFolderTrustDialogOpen, + discoveryResults: folderDiscoveryResults, + handleFolderTrustSelect, + isRestarting, + } = useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem); const policyUpdateConfirmationRequest = config.getPolicyUpdateConfirmationRequest(); const [isPolicyUpdateDialogOpen, setIsPolicyUpdateDialogOpen] = useState( !!policyUpdateConfirmationRequest, ); - const { needsRestart: ideNeedsRestart, restartReason: ideTrustRestartReason, @@ -2145,6 +2148,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen: isFolderTrustDialogOpen ?? false, + folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, @@ -2269,6 +2273,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isResuming, shouldShowIdePrompt, isFolderTrustDialogOpen, + folderDiscoveryResults, isPolicyUpdateDialogOpen, policyUpdateConfirmationRequest, isTrustedFolder, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 9fdd4718a6..3d56c68e5b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -164,6 +164,7 @@ export const DialogManager = ({ ); } diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 832edd1d8a..a227047fba 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -18,6 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({ const mockedExit = vi.hoisted(() => vi.fn()); const mockedCwd = vi.hoisted(() => vi.fn()); +const mockedRows = vi.hoisted(() => ({ current: 24 })); vi.mock('node:process', async () => { const actual = @@ -29,11 +30,20 @@ vi.mock('node:process', async () => { }; }); +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 80, terminalHeight: mockedRows.current }), +})); + describe('FolderTrustDialog', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); mockedCwd.mockReturnValue('/home/user/project'); + mockedRows.current = 24; + }); + + afterEach(() => { + vi.restoreAllMocks(); }); it('should render the dialog with title and description', async () => { @@ -42,13 +52,157 @@ describe('FolderTrustDialog', () => { ); await waitUntilReady(); - expect(lastFrame()).toContain('Do you trust this folder?'); + expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( - 'Trusting a folder allows Gemini to execute commands it suggests.', + 'Trusting a folder allows Gemini CLI to load its local configurations', ); unmount(); }); + it('should truncate discovery results when they exceed maxDiscoveryHeight', async () => { + // maxDiscoveryHeight = 24 - 15 = 9. + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), + hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), + skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), + settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should adjust maxHeight based on terminal rows', async () => { + mockedRows.current = 14; // maxHeight = 14 - 10 = 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 14 }, + }, + ); + + await waitUntilReady(); + // With maxHeight=4, the intro text (4 lines) will take most of the space. + // The discovery results will likely be hidden. + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should use minimum maxHeight of 4', async () => { + mockedRows.current = 8; // 8 - 10 = -2, should use 4 + const discoveryResults = { + commands: ['cmd1', 'cmd2', 'cmd3', 'cmd4', 'cmd5'], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: true, terminalHeight: 10 }, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('hidden'); + unmount(); + }); + + it('should toggle expansion when global Ctrl+O is handled', async () => { + const discoveryResults = { + commands: Array.from({ length: 10 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + + const { lastFrame, unmount } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + // Initially constrained + uiState: { constrainHeight: true, terminalHeight: 24 }, + }, + ); + + // Initial state: truncated + await waitFor(() => { + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame()).toContain('hidden'); + }); + + // We can't easily simulate global Ctrl+O toggle in this unit test + // because it's handled in AppContainer. + // But we can re-render with constrainHeight: false. + const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = + renderWithProviders( + , + { + width: 80, + useAlternateBuffer: false, + uiState: { constrainHeight: false, terminalHeight: 24 }, + }, + ); + + await waitFor(() => { + expect(lastFrameExpanded()).not.toContain('hidden'); + expect(lastFrameExpanded()).toContain('- cmd9'); + expect(lastFrameExpanded()).toContain('- cmd4'); + }); + + unmount(); + unmountExpanded(); + }); + 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( @@ -164,5 +318,153 @@ describe('FolderTrustDialog', () => { expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); + + it('should display discovery results when provided', async () => { + mockedRows.current = 40; // Increase height to show all results + const discoveryResults = { + commands: ['cmd1', 'cmd2'], + mcps: ['mcp1'], + hooks: ['hook1'], + skills: ['skill1'], + settings: ['general', 'ui'], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 80 }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('This folder contains:'); + expect(lastFrame()).toContain('• Commands (2):'); + expect(lastFrame()).toContain('- cmd1'); + expect(lastFrame()).toContain('- cmd2'); + expect(lastFrame()).toContain('• MCP Servers (1):'); + expect(lastFrame()).toContain('- mcp1'); + expect(lastFrame()).toContain('• Hooks (1):'); + expect(lastFrame()).toContain('- hook1'); + expect(lastFrame()).toContain('• Skills (1):'); + expect(lastFrame()).toContain('- skill1'); + expect(lastFrame()).toContain('• Setting overrides (2):'); + expect(lastFrame()).toContain('- general'); + expect(lastFrame()).toContain('- ui'); + unmount(); + }); + + it('should display security warnings when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: ['Dangerous setting detected!'], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Security Warnings:'); + expect(lastFrame()).toContain('Dangerous setting detected!'); + unmount(); + }); + + it('should display discovery errors when provided', async () => { + const discoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: ['Failed to load custom commands'], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('Discovery Errors:'); + expect(lastFrame()).toContain('Failed to load custom commands'); + unmount(); + }); + + it('should use scrolling instead of truncation when alternate buffer is enabled and expanded', async () => { + const discoveryResults = { + commands: Array.from({ length: 20 }, (_, i) => `cmd${i}`), + mcps: [], + hooks: [], + skills: [], + settings: [], + discoveryErrors: [], + securityWarnings: [], + }; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + width: 80, + useAlternateBuffer: true, + uiState: { constrainHeight: false, terminalHeight: 15 }, + }, + ); + + await waitUntilReady(); + // In alternate buffer + expanded, the title should be visible (StickyHeader) + expect(lastFrame()).toContain('Do you trust the files in this folder?'); + // And it should NOT use MaxSizedBox truncation + expect(lastFrame()).not.toContain('hidden'); + unmount(); + }); + + it('should strip ANSI codes from discovery results', async () => { + const ansiRed = '\u001b[31m'; + const ansiReset = '\u001b[39m'; + + const discoveryResults = { + commands: [`${ansiRed}cmd-with-ansi${ansiReset}`], + mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], + hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], + skills: [`${ansiRed}skill-with-ansi${ansiReset}`], + settings: [`${ansiRed}setting-with-ansi${ansiReset}`], + discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], + securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], + }; + + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { width: 100, uiState: { terminalHeight: 40 } }, + ); + + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('cmd-with-ansi'); + expect(output).toContain('mcp-with-ansi'); + expect(output).toContain('hook-with-ansi'); + expect(output).toContain('skill-with-ansi'); + expect(output).toContain('setting-with-ansi'); + expect(output).toContain('error-with-ansi'); + expect(output).toContain('warning-with-ansi'); + + unmount(); + }); }); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 9886e3b5e4..70cfd9fd4c 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -8,14 +8,25 @@ import { Box, Text } from 'ink'; import type React from 'react'; import { useEffect, useState, useCallback } from 'react'; import { theme } from '../semantic-colors.js'; +import stripAnsi from 'strip-ansi'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; +import { Scrollable } from './shared/Scrollable.js'; import { useKeypress } from '../hooks/useKeypress.js'; import * as process from 'node:process'; import * as path from 'node:path'; import { relaunchApp } from '../../utils/processUtils.js'; import { runExitCleanup } from '../../utils/cleanup.js'; -import { ExitCodes } from '@google/gemini-cli-core'; +import { + ExitCodes, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../contexts/OverflowContext.js'; +import { ShowMoreLines } from './ShowMoreLines.js'; +import { StickyHeader } from './StickyHeader.js'; export enum FolderTrustChoice { TRUST_FOLDER = 'trust_folder', @@ -26,13 +37,19 @@ export enum FolderTrustChoice { interface FolderTrustDialogProps { onSelect: (choice: FolderTrustChoice) => void; isRestarting?: boolean; + discoveryResults?: FolderDiscoveryResults | null; } export const FolderTrustDialog: React.FC = ({ onSelect, isRestarting, + discoveryResults, }) => { const [exiting, setExiting] = useState(false); + const { terminalHeight, terminalWidth, constrainHeight } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); + + const isExpanded = !constrainHeight; useEffect(() => { let timer: ReturnType; @@ -87,48 +104,214 @@ export const FolderTrustDialog: React.FC = ({ }, ]; - return ( - - - - - Do you trust this folder? - - - Trusting a folder allows Gemini to execute commands it suggests. - This is a security feature to prevent accidental execution in - untrusted directories. - - + const hasDiscovery = + discoveryResults && + (discoveryResults.commands.length > 0 || + discoveryResults.mcps.length > 0 || + discoveryResults.hooks.length > 0 || + discoveryResults.skills.length > 0 || + discoveryResults.settings.length > 0); - + const hasWarnings = + discoveryResults && discoveryResults.securityWarnings.length > 0; + + const hasErrors = + discoveryResults && + discoveryResults.discoveryErrors && + discoveryResults.discoveryErrors.length > 0; + + const dialogWidth = terminalWidth - 2; + const borderColor = theme.status.warning; + + // Header: 3 lines + // Options: options.length + 2 lines for margins + // Footer: 1 line + // Safety margin: 2 lines + const overhead = 3 + options.length + 2 + 1 + 2; + const scrollableHeight = Math.max(4, terminalHeight - overhead); + + const groups = [ + { label: 'Commands', items: discoveryResults?.commands ?? [] }, + { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, + { label: 'Hooks', items: discoveryResults?.hooks ?? [] }, + { label: 'Skills', items: discoveryResults?.skills ?? [] }, + { label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, + ].filter((g) => g.items.length > 0); + + const discoveryContent = ( + + + + Trusting a folder allows Gemini CLI to load its local configurations, + including custom commands, hooks, MCP servers, agent skills, and + settings. These configurations could execute code on your behalf or + change the behavior of the CLI. + - {isRestarting && ( - - - Gemini CLI is restarting to apply the trust changes... + + {hasErrors && ( + + + ❌ Discovery Errors: + {discoveryResults.discoveryErrors.map((error, i) => ( + + • {stripAnsi(error)} + + ))} )} - {exiting && ( - - - A folder trust level must be selected to continue. Exiting since - escape was pressed. + + {hasWarnings && ( + + + ⚠️ Security Warnings: + {discoveryResults.securityWarnings.map((warning, i) => ( + + • {stripAnsi(warning)} + + ))} + + )} + + {hasDiscovery && ( + + + This folder contains: + + {groups.map((group) => ( + + + • {group.label} ({group.items.length}): + + {group.items.map((item, idx) => ( + + - {stripAnsi(item)} + + ))} + + ))} )} ); + + const title = ( + + Do you trust the files in this folder? + + ); + + const selectOptions = ( + + ); + + const renderContent = () => { + if (isAlternateBuffer) { + return ( + + + {title} + + + + + + {discoveryContent} + + + + + {selectOptions} + + + + + + ); + } + + return ( + + {title} + + + {discoveryContent} + + + {selectOptions} + + ); + }; + + return ( + + + + {renderContent()} + + + + + + + {isRestarting && ( + + + Gemini CLI is restarting to apply the trust changes... + + + )} + {exiting && ( + + + A folder trust level must be selected to continue. Exiting since + escape was pressed. + + + )} + + + ); }; diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 8c765c5acc..db32a1a2e9 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -108,7 +108,27 @@ describe('', () => { throw new Error('capturedEntry is undefined'); } - // Initial state (starts at bottom because of auto-scroll logic) + // Initial state (starts at top by default) + expect(capturedEntry.getScrollState().scrollTop).toBe(0); + + // Initial state with scrollToBottom={true} + unmount(); + const { waitUntilReady: waitUntilReady2, unmount: unmount2 } = + renderWithProviders( + + Line 1 + Line 2 + Line 3 + Line 4 + Line 5 + Line 6 + Line 7 + Line 8 + Line 9 + Line 10 + , + ); + await waitUntilReady2(); expect(capturedEntry.getScrollState().scrollTop).toBe(5); // Call scrollBy multiple times (upwards) in the same tick @@ -116,14 +136,14 @@ describe('', () => { capturedEntry!.scrollBy(-1); capturedEntry!.scrollBy(-1); }); - // Should have moved up by 2 + // Should have moved up by 2 (5 -> 3) expect(capturedEntry.getScrollState().scrollTop).toBe(3); await act(async () => { capturedEntry!.scrollBy(-2); }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); - unmount(); + unmount2(); }); describe('keypress handling', () => { diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 8c53266d30..a830cbecfe 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -54,8 +54,7 @@ export const Scrollable: React.FC = ({ const childrenCountRef = useRef(0); // This effect needs to run on every render to correctly measure the container - // and scroll to the bottom if new children are added. The if conditions - // prevent infinite loops. + // and scroll to the bottom if new children are added. // eslint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (!ref.current) { @@ -64,7 +63,8 @@ export const Scrollable: React.FC = ({ const innerHeight = Math.round(getInnerHeight(ref.current)); const scrollHeight = Math.round(getScrollHeight(ref.current)); - const isAtBottom = scrollTop >= size.scrollHeight - size.innerHeight - 1; + const isAtBottom = + scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1; if ( size.innerHeight !== innerHeight || diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 56d4b83c09..a1c63759e9 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -27,6 +27,7 @@ import type { FallbackIntent, ValidationIntent, AgentDefinition, + FolderDiscoveryResults, PolicyUpdateConfirmationRequest, } from '@google/gemini-cli-core'; import { type TransientMessageType } from '../../utils/events.js'; @@ -113,6 +114,7 @@ export interface UIState { isResuming: boolean; shouldShowIdePrompt: boolean; isFolderTrustDialogOpen: boolean; + folderDiscoveryResults: FolderDiscoveryResults | null; isPolicyUpdateDialogOpen: boolean; policyUpdateConfirmationRequest: PolicyUpdateConfirmationRequest | undefined; isTrustedFolder: boolean | undefined; diff --git a/packages/cli/src/ui/hooks/useFolderTrust.test.ts b/packages/cli/src/ui/hooks/useFolderTrust.test.ts index 742ad61fed..277180404c 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.test.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.test.ts @@ -36,6 +36,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actual, isHeadlessMode: vi.fn().mockReturnValue(false), + FolderTrustDiscoveryService: { + discover: vi.fn(() => new Promise(() => {})), + }, }; }); diff --git a/packages/cli/src/ui/hooks/useFolderTrust.ts b/packages/cli/src/ui/hooks/useFolderTrust.ts index 3711cb8d05..e2a5373e34 100644 --- a/packages/cli/src/ui/hooks/useFolderTrust.ts +++ b/packages/cli/src/ui/hooks/useFolderTrust.ts @@ -14,7 +14,13 @@ import { } from '../../config/trustedFolders.js'; import * as process from 'node:process'; import { type HistoryItemWithoutId, MessageType } from '../types.js'; -import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core'; +import { + coreEvents, + ExitCodes, + isHeadlessMode, + FolderTrustDiscoveryService, + type FolderDiscoveryResults, +} from '@google/gemini-cli-core'; import { runExitCleanup } from '../../utils/cleanup.js'; export const useFolderTrust = ( @@ -24,6 +30,8 @@ export const useFolderTrust = ( ) => { const [isTrusted, setIsTrusted] = useState(undefined); const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(false); + const [discoveryResults, setDiscoveryResults] = + useState(null); const [isRestarting, setIsRestarting] = useState(false); const startupMessageSent = useRef(false); @@ -33,6 +41,19 @@ export const useFolderTrust = ( let isMounted = true; const { isTrusted: trusted } = isWorkspaceTrusted(settings.merged); + if (trusted === undefined || trusted === false) { + void FolderTrustDiscoveryService.discover(process.cwd()) + .then((results) => { + if (isMounted) { + setDiscoveryResults(results); + } + }) + .catch(() => { + // Silently ignore discovery errors as they are handled within the service + // and reported via results.discoveryErrors if successful. + }); + } + const showUntrustedMessage = () => { if (trusted === false && !startupMessageSent.current) { addItem( @@ -100,8 +121,6 @@ export const useFolderTrust = ( onTrustChange(currentIsTrusted); setIsTrusted(currentIsTrusted); - // logic: we restart if the trust state *effectively* changes from the previous state. - // previous state was `isTrusted`. If undefined, we assume false (untrusted). const wasTrusted = isTrusted ?? false; if (wasTrusted !== currentIsTrusted) { @@ -117,6 +136,7 @@ export const useFolderTrust = ( return { isTrusted, isFolderTrustDialogOpen, + discoveryResults, handleFolderTrustSelect, isRestarting, }; diff --git a/packages/core/package.json b/packages/core/package.json index 278100611f..e01efe9b3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,6 +72,7 @@ "shell-quote": "^1.8.3", "simple-git": "^3.28.0", "strip-ansi": "^7.1.0", + "strip-json-comments": "^3.1.1", "systeminformation": "^5.25.11", "tree-sitter-bash": "^0.25.0", "undici": "^7.10.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8ecd8cef7c..0b05b0b6fb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -111,6 +111,7 @@ export * from './utils/constants.js'; // Export services export * from './services/fileDiscoveryService.js'; export * from './services/gitService.js'; +export * from './services/FolderTrustDiscoveryService.js'; export * from './services/chatRecordingService.js'; export * from './services/fileSystemService.js'; export * from './services/sessionSummaryUtils.js'; diff --git a/packages/core/src/services/FolderTrustDiscoveryService.test.ts b/packages/core/src/services/FolderTrustDiscoveryService.test.ts new file mode 100644 index 0000000000..b6d7d7734a --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.test.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FolderTrustDiscoveryService } from './FolderTrustDiscoveryService.js'; +import { GEMINI_DIR } from '../utils/paths.js'; + +describe('FolderTrustDiscoveryService', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-discovery-test-'), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should discover commands, skills, mcps, and hooks', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + // Mock commands + const commandsDir = path.join(geminiDir, 'commands'); + await fs.mkdir(commandsDir); + await fs.writeFile( + path.join(commandsDir, 'test-cmd.toml'), + 'prompt = "test"', + ); + + // Mock skills + const skillsDir = path.join(geminiDir, 'skills'); + await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body'); + + // Mock settings (MCPs, Hooks, and general settings) + const settings = { + mcpServers: { + 'test-mcp': { command: 'node', args: ['test.js'] }, + }, + hooks: { + BeforeTool: [{ command: 'test-hook' }], + }, + general: { vimMode: true }, + ui: { theme: 'Dark' }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.commands).toContain('test-cmd'); + expect(results.skills).toContain('test-skill'); + expect(results.mcps).toContain('test-mcp'); + expect(results.hooks).toContain('test-hook'); + expect(results.settings).toContain('general'); + expect(results.settings).toContain('ui'); + expect(results.settings).not.toContain('mcpServers'); + expect(results.settings).not.toContain('hooks'); + }); + + it('should flag security warnings for sensitive settings', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + + const settings = { + tools: { + allowed: ['git'], + sandbox: false, + }, + experimental: { + enableAgents: true, + }, + security: { + folderTrust: { + enabled: false, + }, + }, + }; + await fs.writeFile( + path.join(geminiDir, 'settings.json'), + JSON.stringify(settings), + ); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + + expect(results.securityWarnings).toContain( + 'This project auto-approves certain tools (tools.allowed).', + ); + expect(results.securityWarnings).toContain( + 'This project enables autonomous agents (enableAgents).', + ); + expect(results.securityWarnings).toContain( + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + ); + expect(results.securityWarnings).toContain( + 'This project disables the security sandbox (tools.sandbox).', + ); + }); + + it('should handle missing .gemini directory', async () => { + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.commands).toHaveLength(0); + expect(results.skills).toHaveLength(0); + expect(results.mcps).toHaveLength(0); + expect(results.hooks).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle malformed settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'invalid json'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors[0]).toContain( + 'Failed to discover settings: Unexpected token', + ); + }); + + it('should handle null settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), 'null'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle array settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '[]'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); + + it('should handle string settings.json', async () => { + const geminiDir = path.join(tempDir, GEMINI_DIR); + await fs.mkdir(geminiDir, { recursive: true }); + await fs.writeFile(path.join(geminiDir, 'settings.json'), '"string"'); + + const results = await FolderTrustDiscoveryService.discover(tempDir); + expect(results.discoveryErrors).toHaveLength(0); + expect(results.settings).toHaveLength(0); + }); +}); diff --git a/packages/core/src/services/FolderTrustDiscoveryService.ts b/packages/core/src/services/FolderTrustDiscoveryService.ts new file mode 100644 index 0000000000..e81273af22 --- /dev/null +++ b/packages/core/src/services/FolderTrustDiscoveryService.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import stripJsonComments from 'strip-json-comments'; +import { GEMINI_DIR } from '../utils/paths.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { isNodeError } from '../utils/errors.js'; + +export interface FolderDiscoveryResults { + commands: string[]; + mcps: string[]; + hooks: string[]; + skills: string[]; + settings: string[]; + securityWarnings: string[]; + discoveryErrors: string[]; +} + +/** + * A safe, read-only service to discover local configurations in a folder + * before it is trusted. + */ +export class FolderTrustDiscoveryService { + /** + * Discovers configurations in the given workspace directory. + * @param workspaceDir The directory to scan. + * @returns A summary of discovered configurations. + */ + static async discover(workspaceDir: string): Promise { + const results: FolderDiscoveryResults = { + commands: [], + mcps: [], + hooks: [], + skills: [], + settings: [], + securityWarnings: [], + discoveryErrors: [], + }; + + const geminiDir = path.join(workspaceDir, GEMINI_DIR); + if (!(await this.exists(geminiDir))) { + return results; + } + + await Promise.all([ + this.discoverCommands(geminiDir, results), + this.discoverSkills(geminiDir, results), + this.discoverSettings(geminiDir, results), + ]); + + return results; + } + + private static async discoverCommands( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const commandsDir = path.join(geminiDir, 'commands'); + if (await this.exists(commandsDir)) { + try { + const files = await fs.readdir(commandsDir, { recursive: true }); + results.commands = files + .filter((f) => f.endsWith('.toml')) + .map((f) => path.basename(f, '.toml')); + } catch (e) { + results.discoveryErrors.push( + `Failed to discover commands: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSkills( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const skillsDir = path.join(geminiDir, 'skills'); + if (await this.exists(skillsDir)) { + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const skillMdPath = path.join(skillsDir, entry.name, 'SKILL.md'); + if (await this.exists(skillMdPath)) { + results.skills.push(entry.name); + } + } + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover skills: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + } + + private static async discoverSettings( + geminiDir: string, + results: FolderDiscoveryResults, + ) { + const settingsPath = path.join(geminiDir, 'settings.json'); + if (!(await this.exists(settingsPath))) return; + + try { + const content = await fs.readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(stripJsonComments(content)) as unknown; + + if (!this.isRecord(settings)) { + debugLogger.debug('Settings must be a JSON object'); + return; + } + + results.settings = Object.keys(settings).filter( + (key) => !['mcpServers', 'hooks', '$schema'].includes(key), + ); + + results.securityWarnings = this.collectSecurityWarnings(settings); + + const mcpServers = settings['mcpServers']; + if (this.isRecord(mcpServers)) { + results.mcps = Object.keys(mcpServers); + } + + const hooksConfig = settings['hooks']; + if (this.isRecord(hooksConfig)) { + const hooks = new Set(); + for (const event of Object.values(hooksConfig)) { + if (!Array.isArray(event)) continue; + for (const hook of event) { + if (this.isRecord(hook) && typeof hook['command'] === 'string') { + hooks.add(hook['command']); + } + } + } + results.hooks = Array.from(hooks); + } + } catch (e) { + results.discoveryErrors.push( + `Failed to discover settings: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private static collectSecurityWarnings( + settings: Record, + ): string[] { + const warnings: string[] = []; + + const tools = this.isRecord(settings['tools']) + ? settings['tools'] + : undefined; + + const experimental = this.isRecord(settings['experimental']) + ? settings['experimental'] + : undefined; + + const security = this.isRecord(settings['security']) + ? settings['security'] + : undefined; + + const folderTrust = + security && this.isRecord(security['folderTrust']) + ? security['folderTrust'] + : undefined; + + const allowedTools = tools?.['allowed']; + + const checks = [ + { + condition: Array.isArray(allowedTools) && allowedTools.length > 0, + message: 'This project auto-approves certain tools (tools.allowed).', + }, + { + condition: experimental?.['enableAgents'] === true, + message: 'This project enables autonomous agents (enableAgents).', + }, + { + condition: folderTrust?.['enabled'] === false, + message: + 'This project attempts to disable folder trust (security.folderTrust.enabled).', + }, + { + condition: tools?.['sandbox'] === false, + message: 'This project disables the security sandbox (tools.sandbox).', + }, + ]; + + for (const check of checks) { + if (check.condition) warnings.push(check.message); + } + + return warnings; + } + + private static isRecord(val: unknown): val is Record { + return !!val && typeof val === 'object' && !Array.isArray(val); + } + + private static async exists(filePath: string): Promise { + try { + await fs.stat(filePath); + return true; + } catch (e) { + if (isNodeError(e) && e.code === 'ENOENT') { + return false; + } + throw e; + } + } +} From fe428936d5cb3bb4de09a3d3e61cfadcff6d4415 Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 13:22:45 -0500 Subject: [PATCH 24/52] feat(ui): improve startup warnings UX with dismissal and show-count limits (#19584) --- packages/cli/src/gemini.test.tsx | 8 +- packages/cli/src/gemini.tsx | 14 +- packages/cli/src/ui/AppContainer.test.tsx | 17 +- packages/cli/src/ui/AppContainer.tsx | 3 +- .../src/ui/components/Notifications.test.tsx | 235 +++++++++++++++--- .../cli/src/ui/components/Notifications.tsx | 69 ++++- packages/cli/src/ui/contexts/AppContext.tsx | 3 +- packages/cli/src/utils/persistentState.ts | 1 + .../cli/src/utils/userStartupWarnings.test.ts | 72 +++--- packages/cli/src/utils/userStartupWarnings.ts | 26 +- packages/core/src/utils/compatibility.test.ts | 88 ++++++- packages/core/src/utils/compatibility.ts | 76 +++++- 12 files changed, 503 insertions(+), 109 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 976d832abd..538fb8ee4e 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -38,6 +38,8 @@ import { appEvents, AppEvent } from './utils/events.js'; import { type Config, type ResumedSessionData, + type StartupWarning, + WarningPriority, debugLogger, coreEvents, AuthType, @@ -1193,7 +1195,9 @@ describe('startInteractiveUI', () => { }, }, } as LoadedSettings; - const mockStartupWarnings = ['warning1']; + const mockStartupWarnings: StartupWarning[] = [ + { id: 'w1', message: 'warning1', priority: WarningPriority.High }, + ]; const mockWorkspaceRoot = '/root'; const mockInitializationResult = { authError: null, @@ -1226,7 +1230,7 @@ describe('startInteractiveUI', () => { async function startTestInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string, resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 7b385453bf..dc3d64046b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -11,6 +11,7 @@ import { loadCliConfig, parseArguments } from './config/config.js'; import * as cliConfig from './config/config.js'; import { readStdin } from './utils/readStdin.js'; import { basename } from 'node:path'; +import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; @@ -37,6 +38,8 @@ import { cleanupExpiredSessions, } from './utils/sessionCleanup.js'; import { + type StartupWarning, + WarningPriority, type Config, type ResumedSessionData, type OutputPayload, @@ -180,7 +183,7 @@ ${reason.stack}` export async function startInteractiveUI( config: Config, settings: LoadedSettings, - startupWarnings: string[], + startupWarnings: StartupWarning[], workspaceRoot: string = process.cwd(), resumedSessionData: ResumedSessionData | undefined, initializationResult: InitializationResult, @@ -668,8 +671,13 @@ export async function main() { } let input = config.getQuestion(); - const startupWarnings = [ - ...(await getStartupWarnings()), + const rawStartupWarnings = await getStartupWarnings(); + const startupWarnings: StartupWarning[] = [ + ...rawStartupWarnings.map((message) => ({ + id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, + message, + priority: WarningPriority.High, + })), ...(await getUserStartupWarnings(settings.merged)), ]; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5554ecb58a..3aeef34292 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -26,6 +26,8 @@ import { CoreEvent, type UserFeedbackPayload, type ResumedSessionData, + type StartupWarning, + WarningPriority, AuthType, type AgentDefinition, CoreToolCallStatus, @@ -248,7 +250,7 @@ describe('AppContainer State Management', () => { config?: Config; version?: string; initResult?: InitializationResult; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; resumedSessionData?: ResumedSessionData; } = {}) => ( @@ -501,7 +503,18 @@ describe('AppContainer State Management', () => { }); it('renders with startup warnings', async () => { - const startupWarnings = ['Warning 1', 'Warning 2']; + const startupWarnings: StartupWarning[] = [ + { + id: 'w1', + message: 'Warning 1', + priority: WarningPriority.High, + }, + { + id: 'w2', + message: 'Warning 2', + priority: WarningPriority.High, + }, + ]; let unmount: () => void; await act(async () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b0a2ade4b3..661a96d305 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -41,6 +41,7 @@ import { checkPermissions } from './hooks/atCommandProcessor.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; import { + type StartupWarning, type EditorType, type Config, type IdeInfo, @@ -186,7 +187,7 @@ function isToolAwaitingConfirmation( interface AppContainerProps { config: Config; - startupWarnings?: string[]; + startupWarnings?: StartupWarning[]; version: string; initializationResult: InitializationResult; resumedSessionData?: ResumedSessionData; diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 4929d61fd5..f732ff2f23 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -4,7 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render, persistentStateMock } from '../../test-utils/render.js'; +import { + persistentStateMock, + renderWithProviders, +} from '../../test-utils/render.js'; +import { createMockSettings } from '../../test-utils/settings.js'; +import type { LoadedSettings } from '../../config/settings.js'; import { waitFor } from '../../test-utils/async.js'; import { Notifications } from './Notifications.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -13,6 +18,7 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js'; import { useIsScreenReaderEnabled } from 'ink'; import * as fs from 'node:fs/promises'; import { act } from 'react'; +import { WarningPriority } from '@google/gemini-cli-core'; // Mock dependencies vi.mock('../contexts/AppContext.js'); @@ -61,22 +67,18 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, GEMINI_DIR: '.gemini', homedir: () => '/mock/home', + WarningPriority: { + Low: 'low', + High: 'high', + }, Storage: { ...actual.Storage, getGlobalTempDir: () => '/mock/temp', + getGlobalSettingsPath: () => '/mock/home/.gemini/settings.json', }, }; }); -vi.mock('../../config/settings.js', () => ({ - DEFAULT_MODEL_CONFIGS: {}, - LoadedSettings: class { - constructor() { - // this.merged = {}; - } - }, -})); - describe('Notifications', () => { const mockUseAppContext = vi.mocked(useAppContext); const mockUseUIState = vi.mocked(useUIState); @@ -84,9 +86,14 @@ describe('Notifications', () => { const mockFsAccess = vi.mocked(fs.access); const mockFsUnlink = vi.mocked(fs.unlink); + let settings: LoadedSettings; + beforeEach(() => { vi.clearAllMocks(); persistentStateMock.reset(); + settings = createMockSettings({ + ui: { useAlternateBuffer: true }, + }); mockUseAppContext.mockReturnValue({ startupWarnings: [], version: '1.0.0', @@ -100,60 +107,195 @@ describe('Notifications', () => { }); it('renders nothing when no notifications', async () => { - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); - it.each([[['Warning 1']], [['Warning 1', 'Warning 2']]])( - 'renders startup warnings: %s', - async (warnings) => { - mockUseAppContext.mockReturnValue({ - startupWarnings: warnings, - version: '1.0.0', - } as AppState); - const { lastFrame, waitUntilReady, unmount } = render(); - await waitUntilReady(); - const output = lastFrame(); - warnings.forEach((warning) => { - expect(output).toContain(warning); - }); - unmount(); - }, - ); + it.each([ + [[{ id: 'w1', message: 'Warning 1', priority: WarningPriority.High }]], + [ + [ + { id: 'w1', message: 'Warning 1', priority: WarningPriority.High }, + { id: 'w2', message: 'Warning 2', priority: WarningPriority.High }, + ], + ], + ])('renders startup warnings: %s', async (warnings) => { + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + warnings.forEach((warning) => { + expect(output).toContain(warning.message); + }); + unmount(); + }); + + it('increments show count for low priority warnings', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { waitUntilReady, unmount } = renderWithProviders(, { + appState, + settings, + width: 100, + }); + await waitUntilReady(); + + expect(persistentStateMock.set).toHaveBeenCalledWith( + 'startupWarningCounts', + { 'low-1': 1 }, + ); + unmount(); + }); + + it('filters out low priority warnings that exceeded max show count', async () => { + const warnings = [ + { id: 'low-1', message: 'Low priority 1', priority: WarningPriority.Low }, + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + persistentStateMock.setData({ + startupWarningCounts: { 'low-1': 3 }, + }); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + expect(output).not.toContain('Low priority 1'); + expect(output).toContain('High priority 1'); + unmount(); + }); + + it('dismisses warnings on keypress', async () => { + const warnings = [ + { + id: 'high-1', + message: 'High priority 1', + priority: WarningPriority.High, + }, + ]; + const appState = { + startupWarnings: warnings, + version: '1.0.0', + } as AppState; + mockUseAppContext.mockReturnValue(appState); + + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , + { + appState, + settings, + width: 100, + }, + ); + await waitUntilReady(); + expect(lastFrame()).toContain('High priority 1'); + + await act(async () => { + stdin.write('a'); + }); + await waitUntilReady(); + + expect(lastFrame({ allowEmpty: true })).not.toContain('High priority 1'); + unmount(); + }); it('renders init error', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'idle', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('does not render init error when streaming', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: 'Something went wrong', streamingState: 'responding', updateInfo: null, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); it('renders update notification', async () => { - mockUseUIState.mockReturnValue({ + const uiState = { initError: null, streamingState: 'idle', updateInfo: { message: 'Update available' }, - } as unknown as UIState); - const { lastFrame, waitUntilReady, unmount } = render(); + } as unknown as UIState; + mockUseUIState.mockReturnValue(uiState); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -164,7 +306,13 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: false }); mockFsAccess.mockRejectedValue(new Error('No legacy file')); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame()).toContain('screen reader-friendly view'); @@ -182,7 +330,10 @@ describe('Notifications', () => { persistentStateMock.setData({ hasSeenScreenReaderNudge: undefined }); mockFsAccess.mockResolvedValue(undefined); - const { waitUntilReady, unmount } = render(); + const { waitUntilReady, unmount } = renderWithProviders(, { + settings, + width: 100, + }); await act(async () => { await waitUntilReady(); @@ -201,7 +352,13 @@ describe('Notifications', () => { mockUseIsScreenReaderEnabled.mockReturnValue(true); persistentStateMock.setData({ hasSeenScreenReaderNudge: true }); - const { lastFrame, waitUntilReady, unmount } = render(); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + settings, + width: 100, + }, + ); await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 1573ef682c..4753f8f6cb 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -5,15 +5,22 @@ */ import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; import { UpdateNotification } from './UpdateNotification.js'; import { persistentState } from '../../utils/persistentState.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { KeypressPriority } from '../contexts/KeypressContext.js'; -import { GEMINI_DIR, Storage, homedir } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + homedir, + WarningPriority, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs/promises'; import path from 'node:path'; @@ -25,12 +32,13 @@ const screenReaderNudgeFilePath = path.join( 'seen_screen_reader_nudge.json', ); +const MAX_STARTUP_WARNING_SHOW_COUNT = 3; + export const Notifications = () => { const { startupWarnings } = useAppContext(); const { initError, streamingState, updateInfo } = useUIState(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const showStartupWarnings = startupWarnings.length > 0; const showInitError = initError && streamingState !== StreamingState.Responding; @@ -38,6 +46,57 @@ export const Notifications = () => { persistentState.get('hasSeenScreenReaderNudge'), ); + const [dismissed, setDismissed] = useState(false); + + // Track if we have already incremented the show count in this session + const hasIncrementedRef = useRef(false); + + // Filter warnings based on persistent state count if low priority + const visibleWarnings = useMemo(() => { + if (dismissed) return []; + + const counts = persistentState.get('startupWarningCounts') || {}; + return startupWarnings.filter((w) => { + if (w.priority === WarningPriority.Low) { + const count = counts[w.id] || 0; + return count < MAX_STARTUP_WARNING_SHOW_COUNT; + } + return true; + }); + }, [startupWarnings, dismissed]); + + const showStartupWarnings = visibleWarnings.length > 0; + + // Increment counts for low priority warnings when shown + useEffect(() => { + if (visibleWarnings.length > 0 && !hasIncrementedRef.current) { + const counts = { ...(persistentState.get('startupWarningCounts') || {}) }; + let changed = false; + visibleWarnings.forEach((w) => { + if (w.priority === WarningPriority.Low) { + counts[w.id] = (counts[w.id] || 0) + 1; + changed = true; + } + }); + if (changed) { + persistentState.set('startupWarningCounts', counts); + } + hasIncrementedRef.current = true; + } + }, [visibleWarnings]); + + const handleKeyPress = useCallback(() => { + if (showStartupWarnings) { + setDismissed(true); + } + return false; + }, [showStartupWarnings]); + + useKeypress(handleKeyPress, { + isActive: showStartupWarnings, + priority: KeypressPriority.Critical, + }); + useEffect(() => { const checkLegacyScreenReaderNudge = async () => { if (hasSeenScreenReaderNudge !== undefined) return; @@ -89,13 +148,13 @@ export const Notifications = () => { {updateInfo && } {showStartupWarnings && ( - {startupWarnings.map((warning, index) => ( + {visibleWarnings.map((warning, index) => ( - {warning} + {warning.message} ))} diff --git a/packages/cli/src/ui/contexts/AppContext.tsx b/packages/cli/src/ui/contexts/AppContext.tsx index 791c8a73ac..ea5de0c105 100644 --- a/packages/cli/src/ui/contexts/AppContext.tsx +++ b/packages/cli/src/ui/contexts/AppContext.tsx @@ -5,10 +5,11 @@ */ import { createContext, useContext } from 'react'; +import type { StartupWarning } from '@google/gemini-cli-core'; export interface AppState { version: string; - startupWarnings: string[]; + startupWarnings: StartupWarning[]; } export const AppContext = createContext(null); diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index cbdf1fc6cb..4fc51d0e14 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -15,6 +15,7 @@ interface PersistentStateData { tipsShown?: number; hasSeenScreenReaderNudge?: boolean; focusUiEnabled?: boolean; + startupWarningCounts?: Record; // Add other persistent state keys here as needed } diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 131d77f580..41ed061166 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -13,7 +13,10 @@ import { isFolderTrustEnabled, isWorkspaceTrusted, } from '../config/trustedFolders.js'; -import { getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + getCompatibilityWarnings, + WarningPriority, +} from '@google/gemini-cli-core'; // Mock os.homedir to control the home directory in tests vi.mock('os', async (importOriginal) => { @@ -31,6 +34,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, homedir: () => os.homedir(), getCompatibilityWarnings: vi.fn().mockReturnValue([]), + WarningPriority: { + Low: 'low', + High: 'high', + }, }; }); @@ -65,12 +72,13 @@ describe('getUserStartupWarnings', () => { it('should return a warning when running in home directory', async () => { const warnings = await getUserStartupWarnings({}, homeDir); expect(warnings).toContainEqual( - expect.stringContaining( - 'Warning you are running Gemini CLI in your home directory', - ), - ); - expect(warnings).toContainEqual( - expect.stringContaining('warning can be disabled in /settings'), + expect.objectContaining({ + id: 'home-directory', + message: expect.stringContaining( + 'Warning you are running Gemini CLI in your home directory', + ), + priority: WarningPriority.Low, + }), ); }); @@ -78,9 +86,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when showHomeDirectoryWarning is false', async () => { @@ -88,9 +94,7 @@ describe('getUserStartupWarnings', () => { { ui: { showHomeDirectoryWarning: false } }, homeDir, ); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); it('should not return a warning when folder trust is enabled and workspace is trusted', async () => { @@ -101,9 +105,7 @@ describe('getUserStartupWarnings', () => { }); const warnings = await getUserStartupWarnings({}, homeDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('home directory'), - ); + expect(warnings.find((w) => w.id === 'home-directory')).toBeUndefined(); }); }); @@ -112,10 +114,11 @@ describe('getUserStartupWarnings', () => { const rootDir = path.parse(testRootDir).root; const warnings = await getUserStartupWarnings({}, rootDir); expect(warnings).toContainEqual( - expect.stringContaining('root directory'), - ); - expect(warnings).toContainEqual( - expect.stringContaining('folder structure will be used'), + expect.objectContaining({ + id: 'root-directory', + message: expect.stringContaining('root directory'), + priority: WarningPriority.High, + }), ); }); @@ -123,9 +126,7 @@ describe('getUserStartupWarnings', () => { const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).not.toContainEqual( - expect.stringContaining('root directory'), - ); + expect(warnings.find((w) => w.id === 'root-directory')).toBeUndefined(); }); }); @@ -133,24 +134,37 @@ describe('getUserStartupWarnings', () => { it('should handle errors when checking directory', async () => { const nonExistentPath = path.join(testRootDir, 'non-existent'); const warnings = await getUserStartupWarnings({}, nonExistentPath); - const expectedWarning = + const expectedMessage = 'Could not verify the current directory due to a file system error.'; - expect(warnings).toEqual([expectedWarning, expectedWarning]); + expect(warnings).toEqual([ + expect.objectContaining({ message: expectedMessage }), + expect.objectContaining({ message: expectedMessage }), + ]); }); }); describe('compatibility warnings', () => { it('should include compatibility warnings by default', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); const warnings = await getUserStartupWarnings({}, projectDir); - expect(warnings).toContain('Comp warning 1'); + expect(warnings).toContainEqual(compWarning); }); it('should not include compatibility warnings when showCompatibilityWarnings is false', async () => { - vi.mocked(getCompatibilityWarnings).mockReturnValue(['Comp warning 1']); + const compWarning = { + id: 'comp-1', + message: 'Comp warning 1', + priority: WarningPriority.High, + }; + vi.mocked(getCompatibilityWarnings).mockReturnValue([compWarning]); const projectDir = path.join(testRootDir, 'project'); await fs.mkdir(projectDir); @@ -158,7 +172,7 @@ describe('getUserStartupWarnings', () => { { ui: { showCompatibilityWarnings: false } }, projectDir, ); - expect(warnings).not.toContain('Comp warning 1'); + expect(warnings).not.toContainEqual(compWarning); }); }); }); diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index cc2d2638d6..da9623acb6 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -7,7 +7,12 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; -import { homedir, getCompatibilityWarnings } from '@google/gemini-cli-core'; +import { + homedir, + getCompatibilityWarnings, + WarningPriority, + type StartupWarning, +} from '@google/gemini-cli-core'; import type { Settings } from '../config/settingsSchema.js'; import { isFolderTrustEnabled, @@ -17,11 +22,13 @@ import { type WarningCheck = { id: string; check: (workspaceRoot: string, settings: Settings) => Promise; + priority: WarningPriority; }; // Individual warning checks const homeDirectoryCheck: WarningCheck = { id: 'home-directory', + priority: WarningPriority.Low, check: async (workspaceRoot: string, settings: Settings) => { if (settings.ui?.showHomeDirectoryWarning === false) { return null; @@ -53,6 +60,7 @@ const homeDirectoryCheck: WarningCheck = { const rootDirectoryCheck: WarningCheck = { id: 'root-directory', + priority: WarningPriority.High, check: async (workspaceRoot: string, _settings: Settings) => { try { const workspaceRealPath = await fs.realpath(workspaceRoot); @@ -80,11 +88,21 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), -): Promise { +): Promise { const results = await Promise.all( - WARNING_CHECKS.map((check) => check.check(workspaceRoot, settings)), + WARNING_CHECKS.map(async (check) => { + const message = await check.check(workspaceRoot, settings); + if (message) { + return { + id: check.id, + message, + priority: check.priority, + }; + } + return null; + }), ); - const warnings = results.filter((msg) => msg !== null); + const warnings = results.filter((w): w is StartupWarning => w !== null); if (settings.ui?.showCompatibilityWarnings !== false) { warnings.push(...getCompatibilityWarnings()); diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index 8db512292a..c7819578f1 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -9,6 +9,7 @@ import os from 'node:os'; import { isWindows10, isJetBrainsTerminal, + supports256Colors, supportsTrueColor, getCompatibilityWarnings, } from './compatibility.js'; @@ -66,6 +67,25 @@ describe('compatibility', () => { }); }); + describe('supports256Colors', () => { + it('should return true when getColorDepth returns >= 8', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + expect(supports256Colors()).toBe(true); + }); + + it('should return true when TERM contains 256color', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + vi.stubEnv('TERM', 'xterm-256color'); + expect(supports256Colors()).toBe(true); + }); + + it('should return false when 256 colors are not supported', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + vi.stubEnv('TERM', 'xterm'); + expect(supports256Colors()).toBe(false); + }); + }); + describe('supportsTrueColor', () => { it('should return true when COLORTERM is truecolor', () => { vi.stubEnv('COLORTERM', 'truecolor'); @@ -103,8 +123,11 @@ describe('compatibility', () => { vi.stubEnv('TERMINAL_EMULATOR', ''); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'windows-10', + message: expect.stringContaining('Windows 10 detected'), + }), ); }); @@ -113,35 +136,78 @@ describe('compatibility', () => { vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining('JetBrains terminal detected'), + }), ); }); - it('should return true color warning when not supported', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); + it('should return 256-color warning when 256 colors are not supported', () => { + vi.mocked(os.platform).mockReturnValue('linux'); vi.stubEnv('TERMINAL_EMULATOR', ''); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM', 'xterm'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + + const warnings = getCompatibilityWarnings(); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: '256-color', + message: expect.stringContaining('256-color support not detected'), + priority: 'high', + }), + ); + // Should NOT show true-color warning if 256-color warning is shown + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + + it('should return true color warning when 256 colors are supported but true color is not, and not Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); const warnings = getCompatibilityWarnings(); - expect(warnings).toContain( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'true-color', + message: expect.stringContaining( + 'True color (24-bit) support not detected', + ), + priority: 'low', + }), ); }); + it('should NOT return true color warning for Apple Terminal', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('TERMINAL_EMULATOR', ''); + vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + + const warnings = getCompatibilityWarnings(); + expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); + }); + it('should return all warnings when all are detected', () => { vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.release).mockReturnValue('10.0.19041'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); vi.stubEnv('COLORTERM', ''); + vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); const warnings = getCompatibilityWarnings(); expect(warnings).toHaveLength(3); - expect(warnings[0]).toContain('Windows 10 detected'); - expect(warnings[1]).toContain('JetBrains terminal detected'); - expect(warnings[2]).toContain('True color (24-bit) support not detected'); + expect(warnings[0].message).toContain('Windows 10 detected'); + expect(warnings[1].message).toContain('JetBrains terminal detected'); + expect(warnings[2].message).toContain( + 'True color (24-bit) support not detected', + ); }); it('should return no warnings in a standard environment with true color', () => { diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index b1b6240a65..8099351ad0 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -30,6 +30,31 @@ export function isJetBrainsTerminal(): boolean { return process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'; } +/** + * Detects if the current terminal is the default Apple Terminal.app. + */ +export function isAppleTerminal(): boolean { + return process.env['TERM_PROGRAM'] === 'Apple_Terminal'; +} + +/** + * Detects if the current terminal supports 256 colors (8-bit). + */ +export function supports256Colors(): boolean { + // Check if stdout supports at least 8-bit color depth + if (process.stdout.getColorDepth && process.stdout.getColorDepth() >= 8) { + return true; + } + + // Check TERM environment variable + const term = process.env['TERM'] || ''; + if (term.includes('256color')) { + return true; + } + + return false; +} + /** * Detects if the current terminal supports true color (24-bit). */ @@ -53,25 +78,52 @@ export function supportsTrueColor(): boolean { /** * Returns a list of compatibility warnings based on the current environment. */ -export function getCompatibilityWarnings(): string[] { - const warnings: string[] = []; +export enum WarningPriority { + Low = 'low', + High = 'high', +} + +export interface StartupWarning { + id: string; + message: string; + priority: WarningPriority; +} + +export function getCompatibilityWarnings(): StartupWarning[] { + const warnings: StartupWarning[] = []; if (isWindows10()) { - warnings.push( - 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', - ); + warnings.push({ + id: 'windows-10', + message: + 'Warning: Windows 10 detected. Some UI features like smooth scrolling may be degraded. Windows 11 is recommended for the best experience.', + priority: WarningPriority.High, + }); } if (isJetBrainsTerminal()) { - warnings.push( - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', - ); + warnings.push({ + id: 'jetbrains-terminal', + message: + 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + priority: WarningPriority.High, + }); } - if (!supportsTrueColor()) { - warnings.push( - 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', - ); + if (!supports256Colors()) { + warnings.push({ + id: '256-color', + message: + 'Warning: 256-color support not detected. Using a terminal with at least 256-color support is recommended for a better visual experience.', + priority: WarningPriority.High, + }); + } else if (!supportsTrueColor() && !isAppleTerminal()) { + warnings.push({ + id: 'true-color', + message: + 'Warning: True color (24-bit) support not detected. Using a terminal with true color enabled will result in a better visual experience.', + priority: WarningPriority.Low, + }); } return warnings; From ce03156c9f3af08cf25975d764e9f76f3a9ffadf Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:55:36 -0500 Subject: [PATCH 25/52] feat(a2a): Add API key authentication provider (#19548) --- packages/core/src/agents/agentLoader.test.ts | 6 +- packages/core/src/agents/agentLoader.ts | 40 ++-- .../auth-provider/api-key-provider.test.ts | 180 ++++++++++++++++++ .../agents/auth-provider/api-key-provider.ts | 85 +++++++++ .../src/agents/auth-provider/base-provider.ts | 4 +- .../src/agents/auth-provider/factory.test.ts | 14 ++ .../core/src/agents/auth-provider/factory.ts | 9 +- .../core/src/agents/auth-provider/types.ts | 7 +- 8 files changed, 311 insertions(+), 34 deletions(-) create mode 100644 packages/core/src/agents/auth-provider/api-key-provider.test.ts create mode 100644 packages/core/src/agents/auth-provider/api-key-provider.ts diff --git a/packages/core/src/agents/agentLoader.test.ts b/packages/core/src/agents/agentLoader.test.ts index a54626b637..a62c0b02ba 100644 --- a/packages/core/src/agents/agentLoader.test.ts +++ b/packages/core/src/agents/agentLoader.test.ts @@ -373,7 +373,6 @@ agent_card_url: https://example.com/card auth: type: apiKey key: $MY_API_KEY - in: header name: X-Custom-Key --- `); @@ -385,7 +384,6 @@ auth: auth: { type: 'apiKey', key: '$MY_API_KEY', - in: 'header', name: 'X-Custom-Key', }, }); @@ -468,7 +466,7 @@ auth: --- `); await expect(parseAgentMarkdown(filePath)).rejects.toThrow( - /Basic scheme requires "username" and "password"/, + /Basic authentication requires "password"/, ); }); @@ -494,7 +492,6 @@ auth: auth: { type: 'apiKey' as const, key: '$API_KEY', - in: 'header' as const, }, }; @@ -505,7 +502,6 @@ auth: auth: { type: 'apiKey', key: '$API_KEY', - location: 'header', }, }); }); diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index cb2a605779..ed648c6191 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -48,7 +48,6 @@ interface FrontmatterAuthConfig { agent_card_requires_auth?: boolean; // API Key key?: string; - in?: 'header' | 'query' | 'cookie'; name?: string; // HTTP scheme?: 'Bearer' | 'Basic'; @@ -129,7 +128,6 @@ const apiKeyAuthSchema = z.object({ ...baseAuthFields, type: z.literal('apiKey'), key: z.string().min(1, 'API key is required'), - in: z.enum(['header', 'query', 'cookie']).optional(), name: z.string().optional(), }); @@ -138,24 +136,18 @@ const apiKeyAuthSchema = z.object({ * Note: Validation for scheme-specific fields is applied in authConfigSchema * since discriminatedUnion doesn't support refined schemas directly. */ -const httpAuthSchemaBase = z.object({ +const httpAuthSchema = z.object({ ...baseAuthFields, type: z.literal('http'), scheme: z.enum(['Bearer', 'Basic']), - token: z.string().optional(), - username: z.string().optional(), - password: z.string().optional(), + token: z.string().min(1).optional(), + username: z.string().min(1).optional(), + password: z.string().min(1).optional(), }); -/** - * Combined auth schema - discriminated union of all auth types. - * Note: We use the base schema for discriminatedUnion, then apply refinements - * via superRefine since discriminatedUnion doesn't support refined schemas directly. - */ const authConfigSchema = z - .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchemaBase]) + .discriminatedUnion('type', [apiKeyAuthSchema, httpAuthSchema]) .superRefine((data, ctx) => { - // Apply HTTP auth validation after union parsing if (data.type === 'http') { if (data.scheme === 'Bearer' && !data.token) { ctx.addIssue({ @@ -164,12 +156,21 @@ const authConfigSchema = z path: ['token'], }); } - if (data.scheme === 'Basic' && (!data.username || !data.password)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Basic scheme requires "username" and "password"', - path: data.username ? ['password'] : ['username'], - }); + if (data.scheme === 'Basic') { + if (!data.username) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "username"', + path: ['username'], + }); + } + if (!data.password) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Basic authentication requires "password"', + path: ['password'], + }); + } } } }); @@ -338,7 +339,6 @@ function convertFrontmatterAuthToConfig( ...base, type: 'apiKey', key: frontmatter.key, - location: frontmatter.in, name: frontmatter.name, }; diff --git a/packages/core/src/agents/auth-provider/api-key-provider.test.ts b/packages/core/src/agents/auth-provider/api-key-provider.test.ts new file mode 100644 index 0000000000..82d8c271e5 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.test.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; + +describe('ApiKeyAuthProvider', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('initialization', () => { + it('should initialize with literal API key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-api-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-api-key' }); + }); + + it('should resolve API key from environment variable', async () => { + vi.stubEnv('TEST_API_KEY', 'env-api-key'); + + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$TEST_API_KEY', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'env-api-key' }); + }); + + it('should throw if environment variable is not set', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$MISSING_KEY_12345', + }); + + await expect(provider.initialize()).rejects.toThrow( + "Environment variable 'MISSING_KEY_12345' is not set", + ); + }); + }); + + describe('headers', () => { + it('should throw if not initialized', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + + await expect(provider.headers()).rejects.toThrow('not initialized'); + }); + + it('should use custom header name', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + name: 'X-Custom-Auth', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-Custom-Auth': 'my-key' }); + }); + + it('should use default header name X-API-Key', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'my-key', + }); + await provider.initialize(); + + const headers = await provider.headers(); + expect(headers).toEqual({ 'X-API-Key': 'my-key' }); + }); + }); + + describe('shouldRetryWithHeaders', () => { + it('should return undefined for non-auth errors', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 500 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for literal keys on 401 (same headers would fail again)', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toBeUndefined(); + }); + + it('should return undefined for env-var keys on 403', async () => { + vi.stubEnv('RETRY_TEST_KEY', 'some-key'); + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '$RETRY_TEST_KEY', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 403 }), + ); + expect(result).toBeUndefined(); + }); + + it('should re-resolve and return headers for command keys on 401', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo refreshed-key', + }); + await provider.initialize(); + + const result = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(result).toEqual({ 'X-API-Key': 'refreshed-key' }); + }); + + it('should stop retrying after MAX_AUTH_RETRIES', async () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: '!echo rotating-key', + }); + await provider.initialize(); + + const r1 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r1).toBeDefined(); + + const r2 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r2).toBeDefined(); + + const r3 = await provider.shouldRetryWithHeaders( + {}, + new Response(null, { status: 401 }), + ); + expect(r3).toBeUndefined(); + }); + }); + + describe('type property', () => { + it('should have type apiKey', () => { + const provider = new ApiKeyAuthProvider({ + type: 'apiKey', + key: 'test', + }); + expect(provider.type).toBe('apiKey'); + }); + }); +}); diff --git a/packages/core/src/agents/auth-provider/api-key-provider.ts b/packages/core/src/agents/auth-provider/api-key-provider.ts new file mode 100644 index 0000000000..207c987271 --- /dev/null +++ b/packages/core/src/agents/auth-provider/api-key-provider.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { HttpHeaders } from '@a2a-js/sdk/client'; +import { BaseA2AAuthProvider } from './base-provider.js'; +import type { ApiKeyAuthConfig } from './types.js'; +import { resolveAuthValue, needsResolution } from './value-resolver.js'; +import { debugLogger } from '../../utils/debugLogger.js'; + +const DEFAULT_HEADER_NAME = 'X-API-Key'; + +/** + * Authentication provider for API Key authentication. + * Sends the API key as an HTTP header. + * + * The API key value can be: + * - A literal string + * - An environment variable reference ($ENV_VAR) + * - A shell command (!command) + */ +export class ApiKeyAuthProvider extends BaseA2AAuthProvider { + readonly type = 'apiKey' as const; + + private resolvedKey: string | undefined; + private readonly headerName: string; + + constructor(private readonly config: ApiKeyAuthConfig) { + super(); + this.headerName = config.name ?? DEFAULT_HEADER_NAME; + } + + override async initialize(): Promise { + if (needsResolution(this.config.key)) { + this.resolvedKey = await resolveAuthValue(this.config.key); + debugLogger.debug( + `[ApiKeyAuthProvider] Resolved API key from: ${this.config.key.startsWith('$') ? 'env var' : 'command'}`, + ); + } else { + this.resolvedKey = this.config.key; + } + } + + async headers(): Promise { + if (!this.resolvedKey) { + throw new Error( + 'ApiKeyAuthProvider not initialized. Call initialize() first.', + ); + } + return { [this.headerName]: this.resolvedKey }; + } + + /** + * Re-resolve command-based API keys on auth failure. + */ + override async shouldRetryWithHeaders( + _req: RequestInit, + res: Response, + ): Promise { + if (res.status !== 401 && res.status !== 403) { + this.authRetryCount = 0; + return undefined; + } + + // Only retry for command-based keys that may resolve to a new value. + // Literal and env-var keys would just resend the same failing headers. + if (!this.config.key.startsWith('!') || this.config.key.startsWith('!!')) { + return undefined; + } + + if (this.authRetryCount >= BaseA2AAuthProvider.MAX_AUTH_RETRIES) { + return undefined; + } + this.authRetryCount++; + + debugLogger.debug( + '[ApiKeyAuthProvider] Re-resolving API key after auth failure', + ); + this.resolvedKey = await resolveAuthValue(this.config.key); + + return this.headers(); + } +} diff --git a/packages/core/src/agents/auth-provider/base-provider.ts b/packages/core/src/agents/auth-provider/base-provider.ts index 7fb2e61acc..2c8fd3ee2a 100644 --- a/packages/core/src/agents/auth-provider/base-provider.ts +++ b/packages/core/src/agents/auth-provider/base-provider.ts @@ -23,8 +23,8 @@ export abstract class BaseA2AAuthProvider implements A2AAuthProvider { */ abstract headers(): Promise; - private static readonly MAX_AUTH_RETRIES = 2; - private authRetryCount = 0; + protected static readonly MAX_AUTH_RETRIES = 2; + protected authRetryCount = 0; /** * Check if a request should be retried with new headers. diff --git a/packages/core/src/agents/auth-provider/factory.test.ts b/packages/core/src/agents/auth-provider/factory.test.ts index 6aa7069fa9..17de791de9 100644 --- a/packages/core/src/agents/auth-provider/factory.test.ts +++ b/packages/core/src/agents/auth-provider/factory.test.ts @@ -478,5 +478,19 @@ describe('A2AAuthProviderFactory', () => { // Returns undefined - caller should prompt user to configure auth expect(result).toBeUndefined(); }); + + it('should create an ApiKeyAuthProvider for apiKey config', async () => { + const provider = await A2AAuthProviderFactory.create({ + authConfig: { + type: 'apiKey', + key: 'factory-test-key', + }, + }); + + expect(provider).toBeDefined(); + expect(provider!.type).toBe('apiKey'); + const headers = await provider!.headers(); + expect(headers).toEqual({ 'X-API-Key': 'factory-test-key' }); + }); }); }); diff --git a/packages/core/src/agents/auth-provider/factory.ts b/packages/core/src/agents/auth-provider/factory.ts index b79c8b4f77..9562737345 100644 --- a/packages/core/src/agents/auth-provider/factory.ts +++ b/packages/core/src/agents/auth-provider/factory.ts @@ -10,6 +10,7 @@ import type { A2AAuthProvider, AuthValidationResult, } from './types.js'; +import { ApiKeyAuthProvider } from './api-key-provider.js'; export interface CreateAuthProviderOptions { /** Required for OAuth/OIDC token storage. */ @@ -43,9 +44,11 @@ export class A2AAuthProviderFactory { // TODO: Implement throw new Error('google-credentials auth provider not yet implemented'); - case 'apiKey': - // TODO: Implement - throw new Error('apiKey auth provider not yet implemented'); + case 'apiKey': { + const provider = new ApiKeyAuthProvider(authConfig); + await provider.initialize(); + return provider; + } case 'http': // TODO: Implement diff --git a/packages/core/src/agents/auth-provider/types.ts b/packages/core/src/agents/auth-provider/types.ts index 67fce94ca8..7d41b1b4a9 100644 --- a/packages/core/src/agents/auth-provider/types.ts +++ b/packages/core/src/agents/auth-provider/types.ts @@ -34,14 +34,13 @@ export interface GoogleCredentialsAuthConfig extends BaseAuthConfig { scopes?: string[]; } -/** Client config corresponding to APIKeySecurityScheme. */ +/** Client config corresponding to APIKeySecurityScheme. Only header location is supported. */ +// TODO: Add 'query' and 'cookie' location support if needed. export interface ApiKeyAuthConfig extends BaseAuthConfig { type: 'apiKey'; /** The secret. Supports $ENV_VAR, !command, or literal. */ key: string; - /** Defaults to server's SecurityScheme.in value. */ - location?: 'header' | 'query' | 'cookie'; - /** Defaults to server's SecurityScheme.name value. */ + /** Header name. @default 'X-API-Key' */ name?: string; } From 788a40c445b4248b1709c182de487ac4eab5faf8 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 19:07:43 +0000 Subject: [PATCH 26/52] Send accepted/removed lines with ACCEPT_FILE telemetry. (#19670) --- .../core/src/code_assist/telemetry.test.ts | 10 +++++++ packages/core/src/code_assist/telemetry.ts | 26 +++++++++++++++++-- packages/core/src/code_assist/types.ts | 1 + packages/core/src/utils/fileDiffUtils.ts | 9 +++---- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/core/src/code_assist/telemetry.test.ts b/packages/core/src/code_assist/telemetry.test.ts index c838aeb943..c90040f22e 100644 --- a/packages/core/src/code_assist/telemetry.test.ts +++ b/packages/core/src/code_assist/telemetry.test.ts @@ -316,6 +316,14 @@ describe('telemetry', () => { prompt_id: 'p1', traceId: 'trace-1', }, + response: { + resultDisplay: { + diffStat: { + model_added_lines: 5, + model_removed_lines: 3, + }, + }, + }, outcome: ToolConfirmationOutcome.ProceedOnce, status: 'success', } as unknown as CompletedToolCall, @@ -327,6 +335,8 @@ describe('telemetry', () => { traceId: 'trace-1', status: ActionStatus.ACTION_STATUS_NO_ERROR, interaction: ConversationInteractionInteraction.ACCEPT_FILE, + acceptedLines: '5', + removedLines: '3', isAgentic: true, }); }); diff --git a/packages/core/src/code_assist/telemetry.ts b/packages/core/src/code_assist/telemetry.ts index ad02691d53..59ff179c50 100644 --- a/packages/core/src/code_assist/telemetry.ts +++ b/packages/core/src/code_assist/telemetry.ts @@ -22,6 +22,10 @@ import { EDIT_TOOL_NAMES } from '../tools/tool-names.js'; import { getErrorMessage } from '../utils/errors.js'; import type { CodeAssistServer } from './server.js'; import { ToolConfirmationOutcome } from '../tools/tools.js'; +import { + computeModelAddedAndRemovedLines, + getFileDiffFromResultDisplay, +} from '../utils/fileDiffUtils.js'; export async function recordConversationOffered( server: CodeAssistServer, @@ -110,6 +114,8 @@ function summarizeToolCalls( // Treat file edits as ACCEPT_FILE and everything else as unknown. let isEdit = false; + let acceptedLines = 0; + let removedLines = 0; // Iterate the tool calls and summarize them into a single conversation // interaction so that the ConversationOffered and ConversationInteraction @@ -136,7 +142,18 @@ function summarizeToolCalls( // Edits are ACCEPT_FILE, everything else is UNKNOWN. if (EDIT_TOOL_NAMES.has(toolCall.request.name)) { - isEdit ||= true; + isEdit = true; + + if (toolCall.status === 'success') { + const fileDiff = getFileDiffFromResultDisplay( + toolCall.response.resultDisplay, + ); + if (fileDiff?.diffStat) { + const lines = computeModelAddedAndRemovedLines(fileDiff.diffStat); + acceptedLines += lines.addedLines; + removedLines += lines.removedLines; + } + } } } } @@ -149,6 +166,8 @@ function summarizeToolCalls( isEdit ? ConversationInteractionInteraction.ACCEPT_FILE : ConversationInteractionInteraction.UNKNOWN, + isEdit ? String(acceptedLines) : undefined, + isEdit ? String(removedLines) : undefined, ) : undefined; } @@ -157,15 +176,18 @@ function createConversationInteraction( traceId: string, status: ActionStatus, interaction: ConversationInteractionInteraction, + acceptedLines?: string, + removedLines?: string, ): ConversationInteraction { return { traceId, status, interaction, + acceptedLines, + removedLines, isAgentic: true, }; } - function includesCode(resp: GenerateContentResponse): boolean { if (!resp.candidates) { return false; diff --git a/packages/core/src/code_assist/types.ts b/packages/core/src/code_assist/types.ts index 7845ceee89..0e2f353aa3 100644 --- a/packages/core/src/code_assist/types.ts +++ b/packages/core/src/code_assist/types.ts @@ -295,6 +295,7 @@ export interface ConversationInteraction { status?: ActionStatus; interaction?: ConversationInteractionInteraction; acceptedLines?: string; + removedLines?: string; language?: string; isAgentic?: boolean; } diff --git a/packages/core/src/utils/fileDiffUtils.ts b/packages/core/src/utils/fileDiffUtils.ts index bf9478627c..c0d3f64f37 100644 --- a/packages/core/src/utils/fileDiffUtils.ts +++ b/packages/core/src/utils/fileDiffUtils.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FileDiff } from '../tools/tools.js'; +import type { DiffStat, FileDiff } from '../tools/tools.js'; import type { ToolCallRecord } from '../services/chatRecordingService.js'; /** @@ -23,17 +23,14 @@ export function getFileDiffFromResultDisplay( typeof resultDisplay.diffStat === 'object' && resultDisplay.diffStat !== null ) { - const diffStat = resultDisplay.diffStat as FileDiff['diffStat']; - if (diffStat) { + if (resultDisplay.diffStat) { return resultDisplay; } } return undefined; } -export function computeModelAddedAndRemovedLines( - stats: FileDiff['diffStat'] | undefined, -): { +export function computeModelAddedAndRemovedLines(stats: DiffStat | undefined): { addedLines: number; removedLines: number; } { From f97b04cc9a8323e33eb7649cb0c464a60ad2ebc9 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 14:19:21 -0500 Subject: [PATCH 27/52] feat(models): support Gemini 3.1 Pro Preview and fixes (#19676) --- packages/cli/src/ui/components/AboutBox.tsx | 5 +- .../src/ui/components/ModelDialog.test.tsx | 118 +++++++++++++++++- .../cli/src/ui/components/ModelDialog.tsx | 34 +++-- .../cli/src/ui/components/StatsDisplay.tsx | 32 +++-- .../ui/components/messages/ModelMessage.tsx | 3 +- .../src/ui/hooks/useQuotaAndFallback.test.ts | 74 ++++++++++- .../cli/src/ui/hooks/useQuotaAndFallback.ts | 14 +-- .../cli/src/zed-integration/zedIntegration.ts | 5 +- .../core/src/availability/policyHelpers.ts | 5 +- .../src/code_assist/experiments/flagNames.ts | 1 + packages/core/src/config/config.ts | 51 +++++++- packages/core/src/config/models.test.ts | 116 +++++++++++++++++ packages/core/src/config/models.ts | 75 +++++++++-- packages/core/src/core/client.ts | 5 +- packages/core/src/core/contentGenerator.ts | 7 +- packages/core/src/core/geminiChat.ts | 5 +- packages/core/src/prompts/promptProvider.ts | 10 +- .../strategies/classifierStrategy.test.ts | 52 ++++++++ .../routing/strategies/classifierStrategy.ts | 7 ++ .../src/routing/strategies/defaultStrategy.ts | 5 +- .../routing/strategies/fallbackStrategy.ts | 5 +- .../numericalClassifierStrategy.test.ts | 71 +++++++++++ .../strategies/numericalClassifierStrategy.ts | 13 +- .../routing/strategies/overrideStrategy.ts | 5 +- .../src/services/chatCompressionService.ts | 2 + 25 files changed, 670 insertions(+), 50 deletions(-) diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index ea5512b48d..7ea744b0fe 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface AboutBoxProps { cliVersion: string; @@ -79,7 +80,9 @@ export const AboutBox: React.FC = ({ - {modelVersion} + + {getDisplayString(modelVersion)} + diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index e96694eeaf..6f347faa1d 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -9,11 +9,17 @@ import { act } from 'react'; import { ModelDialog } from './ModelDialog.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; +import { createMockSettings } from '../../test-utils/settings.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, + PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_FLASH_MODEL, + AuthType, } from '@google/gemini-cli-core'; import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core'; @@ -42,12 +48,14 @@ describe('', () => { const mockGetModel = vi.fn(); const mockOnClose = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn(); + const mockGetGemini31LaunchedSync = vi.fn(); interface MockConfig extends Partial { setModel: (model: string, isTemporary?: boolean) => void; getModel: () => string; getHasAccessToPreviewModel: () => boolean; getIdeMode: () => boolean; + getGemini31LaunchedSync: () => boolean; } const mockConfig: MockConfig = { @@ -55,12 +63,14 @@ describe('', () => { getModel: mockGetModel, getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, getIdeMode: () => false, + getGemini31LaunchedSync: mockGetGemini31LaunchedSync, }; beforeEach(() => { vi.resetAllMocks(); mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); mockGetHasAccessToPreviewModel.mockReturnValue(false); + mockGetGemini31LaunchedSync.mockReturnValue(false); // Default implementation for getDisplayString mockGetDisplayString.mockImplementation((val: string) => { @@ -70,9 +80,21 @@ describe('', () => { }); }); - const renderComponent = async (configValue = mockConfig as Config) => { + const renderComponent = async ( + configValue = mockConfig as Config, + authType = AuthType.LOGIN_WITH_GOOGLE, + ) => { + const settings = createMockSettings({ + security: { + auth: { + selectedType: authType, + }, + }, + }); + const result = renderWithProviders(, { config: configValue, + settings, }); await result.waitUntilReady(); return result; @@ -241,4 +263,98 @@ describe('', () => { }); unmount(); }); + + it('shows the preferred manual model in the main view option', async () => { + mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL); + const { lastFrame, unmount } = await renderComponent(); + + expect(lastFrame()).toContain(`Manual (${DEFAULT_GEMINI_MODEL})`); + unmount(); + }); + + describe('Preview Models', () => { + beforeEach(() => { + mockGetHasAccessToPreviewModel.mockReturnValue(true); + }); + + it('shows Auto (Preview) in main view when access is granted', async () => { + const { lastFrame, unmount } = await renderComponent(); + expect(lastFrame()).toContain('Auto (Preview)'); + unmount(); + }); + + it('shows Gemini 3 models in manual view when Gemini 3.1 is NOT launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(false); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('shows Gemini 3.1 models in manual view when Gemini 3.1 IS launched', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { lastFrame, stdin, waitUntilReady, unmount } = + await renderComponent(mockConfig as Config, AuthType.USE_VERTEX_AI); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain(PREVIEW_GEMINI_3_1_MODEL); + expect(output).toContain(PREVIEW_GEMINI_FLASH_MODEL); + unmount(); + }); + + it('uses custom tools model when Gemini 3.1 IS launched and auth is Gemini API Key', async () => { + mockGetGemini31LaunchedSync.mockReturnValue(true); + const { stdin, waitUntilReady, unmount } = await renderComponent( + mockConfig as Config, + AuthType.USE_GEMINI, + ); + + // Go to manual view + await act(async () => { + stdin.write('\u001B[B'); // Manual + }); + await waitUntilReady(); + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + // Select Gemini 3.1 (first item in preview section) + await act(async () => { + stdin.write('\r'); + }); + await waitUntilReady(); + + await waitFor(() => { + expect(mockSetModel).toHaveBeenCalledWith( + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + true, + ); + }); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 88be57b841..d50e9b7153 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -9,6 +9,7 @@ import { useCallback, useContext, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -18,11 +19,14 @@ import { ModelSlashCommandEvent, logModelSlashCommand, getDisplayString, + AuthType, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface ModelDialogProps { onClose: () => void; @@ -30,6 +34,7 @@ interface ModelDialogProps { export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); + const settings = useSettings(); const [view, setView] = useState<'main' | 'manual'>('main'); const [persistMode, setPersistMode] = useState(false); @@ -37,6 +42,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO; const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); + const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false; + const selectedAuthType = settings.merged.security.auth.selectedType; + const useCustomToolModel = + useGemini31 && selectedAuthType === AuthType.USE_GEMINI; const manualModelSelected = useMemo(() => { const manualModels = [ @@ -44,6 +53,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { DEFAULT_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_FLASH_LITE_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, ]; if (manualModels.includes(preferredModel)) { @@ -94,13 +105,14 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { list.unshift({ value: PREVIEW_GEMINI_MODEL_AUTO, title: getDisplayString(PREVIEW_GEMINI_MODEL_AUTO), - description: - 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', + description: useGemini31 + ? 'Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash' + : 'Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash', key: PREVIEW_GEMINI_MODEL_AUTO, }); } return list; - }, [shouldShowPreviewModels, manualModelSelected]); + }, [shouldShowPreviewModels, manualModelSelected, useGemini31]); const manualOptions = useMemo(() => { const list = [ @@ -122,11 +134,19 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ]; if (shouldShowPreviewModels) { + const previewProModel = useGemini31 + ? PREVIEW_GEMINI_3_1_MODEL + : PREVIEW_GEMINI_MODEL; + + const previewProValue = useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : previewProModel; + list.unshift( { - value: PREVIEW_GEMINI_MODEL, - title: PREVIEW_GEMINI_MODEL, - key: PREVIEW_GEMINI_MODEL, + value: previewProValue, + title: previewProModel, + key: previewProModel, }, { value: PREVIEW_GEMINI_FLASH_MODEL, @@ -136,7 +156,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { ); } return list; - }, [shouldShowPreviewModels]); + }, [shouldShowPreviewModels, useGemini31, useCustomToolModel]); const options = view === 'main' ? mainOptions : manualOptions; diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 3b42512424..d12dd4eb07 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -23,11 +23,13 @@ import { import { computeSessionStats } from '../utils/computeStats.js'; import { type RetrieveUserQuotaResponse, - VALID_GEMINI_MODELS, + isActiveModel, getDisplayString, isAutoModel, + AuthType, } from '@google/gemini-cli-core'; import { useSettings } from '../contexts/SettingsContext.js'; +import { useConfig } from '../contexts/ConfigContext.js'; import type { QuotaStats } from '../types.js'; import { QuotaStatsInfo } from './QuotaStatsInfo.js'; @@ -82,9 +84,13 @@ const Section: React.FC = ({ title, children }) => ( const buildModelRows = ( models: Record, quotas?: RetrieveUserQuotaResponse, + useGemini3_1 = false, + useCustomToolModel = false, ) => { const getBaseModelName = (name: string) => name.replace('-001', ''); - const usedModelNames = new Set(Object.keys(models).map(getBaseModelName)); + const usedModelNames = new Set( + Object.keys(models).map(getBaseModelName).map(getDisplayString), + ); // 1. Models with active usage const activeRows = Object.entries(models).map(([name, metrics]) => { @@ -93,7 +99,7 @@ const buildModelRows = ( const inputTokens = metrics.tokens.input; return { key: name, - modelName, + modelName: getDisplayString(modelName), requests: metrics.api.totalRequests, cachedTokens: cachedTokens.toLocaleString(), inputTokens: inputTokens.toLocaleString(), @@ -109,12 +115,12 @@ const buildModelRows = ( ?.filter( (b) => b.modelId && - VALID_GEMINI_MODELS.has(b.modelId) && - !usedModelNames.has(b.modelId), + isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && + !usedModelNames.has(getDisplayString(b.modelId)), ) .map((bucket) => ({ key: bucket.modelId!, - modelName: bucket.modelId!, + modelName: getDisplayString(bucket.modelId!), requests: '-', cachedTokens: '-', inputTokens: '-', @@ -135,6 +141,8 @@ const ModelUsageTable: React.FC<{ pooledRemaining?: number; pooledLimit?: number; pooledResetTime?: string; + useGemini3_1?: boolean; + useCustomToolModel?: boolean; }> = ({ models, quotas, @@ -144,8 +152,10 @@ const ModelUsageTable: React.FC<{ pooledRemaining, pooledLimit, pooledResetTime, + useGemini3_1, + useCustomToolModel, }) => { - const rows = buildModelRows(models, quotas); + const rows = buildModelRows(models, quotas, useGemini3_1, useCustomToolModel); if (rows.length === 0) { return null; @@ -403,7 +413,11 @@ export const StatsDisplay: React.FC = ({ const { models, tools, files } = metrics; const computed = computeSessionStats(metrics); const settings = useSettings(); - + const config = useConfig(); + const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const pooledRemaining = quotaStats?.remaining; const pooledLimit = quotaStats?.limit; const pooledResetTime = quotaStats?.resetTime; @@ -544,6 +558,8 @@ export const StatsDisplay: React.FC = ({ pooledRemaining={pooledRemaining} pooledLimit={pooledLimit} pooledResetTime={pooledResetTime} + useGemini3_1={useGemini3_1} + useCustomToolModel={useCustomToolModel} /> {renderFooter()} diff --git a/packages/cli/src/ui/components/messages/ModelMessage.tsx b/packages/cli/src/ui/components/messages/ModelMessage.tsx index bddbae8e8b..b313dab6f1 100644 --- a/packages/cli/src/ui/components/messages/ModelMessage.tsx +++ b/packages/cli/src/ui/components/messages/ModelMessage.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { Text, Box } from 'ink'; import { theme } from '../../semantic-colors.js'; +import { getDisplayString } from '@google/gemini-cli-core'; interface ModelMessageProps { model: string; @@ -15,7 +16,7 @@ interface ModelMessageProps { export const ModelMessage: React.FC = ({ model }) => ( - Responding with {model} + Responding with {getDisplayString(model)} ); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index d0f81bea60..5d6db5abfa 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -155,9 +155,10 @@ describe('useQuotaAndFallback', () => { expect(request?.isTerminalQuotaError).toBe(true); const message = request!.message; - expect(message).toContain('Usage limit reached for gemini-pro.'); + expect(message).toContain('Usage limit reached for all Pro models.'); expect(message).toContain('Access resets at'); // From getResetTimeMessage expect(message).toContain('/stats model for usage details'); + expect(message).toContain('/model to switch models.'); expect(message).toContain('/auth to switch to API key.'); expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); @@ -176,6 +177,77 @@ describe('useQuotaAndFallback', () => { expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(1); }); + it('should show the model name for a terminal quota error on a non-pro model', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError( + 'flash quota', + mockGoogleApiError, + 1000 * 60 * 5, + ); + act(() => { + promise = handler('gemini-flash', 'gemini-pro', error); + }); + + const request = result.current.proQuotaRequest; + expect(request).not.toBeNull(); + expect(request?.failedModel).toBe('gemini-flash'); + + const message = request!.message; + expect(message).toContain('Usage limit reached for gemini-flash.'); + expect(message).not.toContain('all Pro models'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + + it('should handle terminal quota error without retry delay', async () => { + const { result } = renderHook(() => + useQuotaAndFallback({ + config: mockConfig, + historyManager: mockHistoryManager, + userTier: UserTierId.FREE, + setModelSwitchedFromQuotaError: mockSetModelSwitchedFromQuotaError, + onShowAuthSelection: mockOnShowAuthSelection, + }), + ); + + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + let promise: Promise; + const error = new TerminalQuotaError('no delay', mockGoogleApiError); + act(() => { + promise = handler('gemini-pro', 'gemini-flash', error); + }); + + const request = result.current.proQuotaRequest; + const message = request!.message; + expect(message).not.toContain('Access resets at'); + expect(message).toContain('Usage limit reached for all Pro models.'); + + act(() => { + result.current.handleProQuotaChoice('retry_later'); + }); + + await promise!; + }); + it('should handle race conditions by stopping subsequent requests', async () => { const { result } = renderHook(() => useQuotaAndFallback({ diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 60c91f3143..1ba03f2a47 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -14,9 +14,9 @@ import { TerminalQuotaError, ModelNotFoundError, type UserTierId, - PREVIEW_GEMINI_MODEL, - DEFAULT_GEMINI_MODEL, VALID_GEMINI_MODELS, + isProModel, + getDisplayString, } from '@google/gemini-cli-core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { type UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -67,11 +67,9 @@ export function useQuotaAndFallback({ let message: string; let isTerminalQuotaError = false; let isModelNotFoundError = false; - const usageLimitReachedModel = - failedModel === DEFAULT_GEMINI_MODEL || - failedModel === PREVIEW_GEMINI_MODEL - ? 'all Pro models' - : failedModel; + const usageLimitReachedModel = isProModel(failedModel) + ? 'all Pro models' + : failedModel; if (error instanceof TerminalQuotaError) { isTerminalQuotaError = true; // Common part of the message for both tiers @@ -87,7 +85,7 @@ export function useQuotaAndFallback({ isModelNotFoundError = true; if (VALID_GEMINI_MODELS.has(failedModel)) { const messageLines = [ - `It seems like you don't have access to ${failedModel}.`, + `It seems like you don't have access to ${getDisplayString(failedModel)}.`, `Your admin might have disabled the access. Contact them to enable the Preview Release Channel.`, ]; message = messageLines.join('\n'); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 44b1890ce2..f04caf01f7 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -520,7 +520,10 @@ export class Session { const functionCalls: FunctionCall[] = []; try { - const model = resolveModel(this.config.getModel()); + const model = resolveModel( + this.config.getModel(), + (await this.config.getGemini31Launched?.()) ?? false, + ); const responseStream = await chat.sendMessageStream( { model }, nextMessage?.parts ?? [], diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index 569157561f..6cf22d6388 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -44,7 +44,10 @@ export function resolvePolicyChain( const configuredModel = config.getModel(); let chain; - const resolvedModel = resolveModel(modelFromConfig); + const resolvedModel = resolveModel( + modelFromConfig, + config.getGemini31LaunchedSync?.() ?? false, + ); const isAutoPreferred = preferredModel ? isAutoModel(preferredModel) : false; const isAutoConfigured = isAutoModel(configuredModel); const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true; diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 03b6aaac0a..e1ae2a1af2 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -16,6 +16,7 @@ export const ExperimentFlags = { MASKING_PROTECTION_THRESHOLD: 45758817, MASKING_PRUNABLE_THRESHOLD: 45758818, MASKING_PROTECT_LATEST_TURN: 45758819, + GEMINI_3_1_PRO_LAUNCHED: 45760185, } as const; export type ExperimentFlagName = diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 406835310a..fc4f7c2ff7 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1071,6 +1071,12 @@ export class Config { // Reset availability status when switching auth (e.g. from limited key to OAuth) this.modelAvailabilityService.reset(); + // Clear stale authType to ensure getGemini31LaunchedSync doesn't return stale results + // during the transition. + if (this.contentGeneratorConfig) { + this.contentGeneratorConfig.authType = undefined; + } + const newContentGeneratorConfig = await createContentGeneratorConfig( this, authMethod, @@ -1350,7 +1356,10 @@ export class Config { if (pooled.remaining !== undefined) { return pooled.remaining; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.remaining; } @@ -1359,7 +1368,10 @@ export class Config { if (pooled.limit !== undefined) { return pooled.limit; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.limit; } @@ -1368,7 +1380,10 @@ export class Config { if (pooled.resetTime !== undefined) { return pooled.resetTime; } - const primaryModel = resolveModel(this.getModel()); + const primaryModel = resolveModel( + this.getModel(), + this.getGemini31LaunchedSync(), + ); return this.modelQuotas.get(primaryModel)?.resetTime; } @@ -2253,6 +2268,36 @@ export class Config { ); } + /** + * Returns whether Gemini 3.1 has been launched. + * This method is async and ensures that experiments are loaded before returning the result. + */ + async getGemini31Launched(): Promise { + await this.ensureExperimentsLoaded(); + return this.getGemini31LaunchedSync(); + } + + /** + * Returns whether Gemini 3.1 has been launched. + * + * Note: This method should only be called after startup, once experiments have been loaded. + * If you need to call this during startup or from an async context, use + * getGemini31Launched instead. + */ + getGemini31LaunchedSync(): boolean { + const authType = this.contentGeneratorConfig?.authType; + if ( + authType === AuthType.USE_GEMINI || + authType === AuthType.USE_VERTEX_AI + ) { + return true; + } + return ( + this.experiments?.flags[ExperimentFlags.GEMINI_3_1_PRO_LAUNCHED] + ?.boolValue ?? false + ); + } + private async ensureExperimentsLoaded(): Promise { if (!this.experimentsPromise) { return; diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 2b2ddb1041..c16cf49781 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -25,8 +25,41 @@ import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, + isActiveModel, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + isPreviewModel, + isProModel, } from './models.js'; +describe('isPreviewModel', () => { + it('should return true for preview models', () => { + expect(isPreviewModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_FLASH_MODEL)).toBe(true); + expect(isPreviewModel(PREVIEW_GEMINI_MODEL_AUTO)).toBe(true); + }); + + it('should return false for non-preview models', () => { + expect(isPreviewModel(DEFAULT_GEMINI_MODEL)).toBe(false); + expect(isPreviewModel('gemini-1.5-pro')).toBe(false); + }); +}); + +describe('isProModel', () => { + it('should return true for models containing "pro"', () => { + expect(isProModel('gemini-3-pro-preview')).toBe(true); + expect(isProModel('gemini-2.5-pro')).toBe(true); + expect(isProModel('pro')).toBe(true); + }); + + it('should return false for models without "pro"', () => { + expect(isProModel('gemini-3-flash-preview')).toBe(false); + expect(isProModel('gemini-2.5-flash')).toBe(false); + expect(isProModel('auto')).toBe(false); + }); +}); + describe('isCustomModel', () => { it('should return true for models not starting with gemini-', () => { expect(isCustomModel('testing')).toBe(true); @@ -115,6 +148,12 @@ describe('getDisplayString', () => { ); }); + it('should return PREVIEW_GEMINI_3_1_MODEL for PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL', () => { + expect(getDisplayString(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL)).toBe( + PREVIEW_GEMINI_3_1_MODEL, + ); + }); + it('should return the model name as is for other models', () => { expect(getDisplayString('custom-model')).toBe('custom-model'); expect(getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL)).toBe( @@ -146,6 +185,16 @@ describe('resolveModel', () => { expect(model).toBe(PREVIEW_GEMINI_MODEL); }); + it('should return Gemini 3.1 Pro when auto-gemini-3 is requested and useGemini3_1 is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => { + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + it('should return the Default Pro model when auto-gemini-2.5 is requested', () => { const model = resolveModel(DEFAULT_GEMINI_MODEL_AUTO); expect(model).toBe(DEFAULT_GEMINI_MODEL); @@ -239,4 +288,71 @@ describe('resolveClassifierModel', () => { resolveClassifierModel(PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO), ).toBe(PREVIEW_GEMINI_MODEL); }); + + it('should return Gemini 3.1 Pro when alias is pro and useGemini3_1 is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should return Gemini 3.1 Pro Custom Tools when alias is pro, useGemini3_1 is true, and useCustomToolModel is true', () => { + expect( + resolveClassifierModel( + PREVIEW_GEMINI_MODEL_AUTO, + GEMINI_MODEL_ALIAS_PRO, + true, + true, + ), + ).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); +}); + +describe('isActiveModel', () => { + it('should return true for valid models when useGemini3_1 is false', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true); + expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true); + }); + + it('should return true for unknown models and aliases', () => { + expect(isActiveModel('invalid-model')).toBe(false); + expect(isActiveModel(GEMINI_MODEL_ALIAS_AUTO)).toBe(false); + }); + + it('should return false for PREVIEW_GEMINI_MODEL when useGemini3_1 is true', () => { + expect(isActiveModel(PREVIEW_GEMINI_MODEL, true)).toBe(false); + }); + + it('should return true for other valid models when useGemini3_1 is true', () => { + expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true); + }); + + it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => { + // When custom tools are preferred, standard 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true), + ).toBe(true); + + // When custom tools are NOT preferred, custom tools 3.1 should be inactive + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false), + ).toBe(false); + }); + + it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => { + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true), + ).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false), + ).toBe(false); + }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index 9f12944333..d0ec49f005 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -5,6 +5,9 @@ */ export const PREVIEW_GEMINI_MODEL = 'gemini-3-pro-preview'; +export const PREVIEW_GEMINI_3_1_MODEL = 'gemini-3.1-pro-preview'; +export const PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL = + 'gemini-3.1-pro-preview-customtools'; export const PREVIEW_GEMINI_FLASH_MODEL = 'gemini-3-flash-preview'; export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-pro'; export const DEFAULT_GEMINI_FLASH_MODEL = 'gemini-2.5-flash'; @@ -12,6 +15,8 @@ export const DEFAULT_GEMINI_FLASH_LITE_MODEL = 'gemini-2.5-flash-lite'; export const VALID_GEMINI_MODELS = new Set([ PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_FLASH_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, @@ -37,20 +42,29 @@ export const DEFAULT_THINKING_MODE = 8192; * to a concrete model name. * * @param requestedModel The model alias or concrete model name requested by the user. + * @param useGemini3_1 Whether to use Gemini 3.1 Pro Preview for auto/pro aliases. * @returns The resolved concrete model name. */ -export function resolveModel(requestedModel: string): string { +export function resolveModel( + requestedModel: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): string { switch (requestedModel) { - case PREVIEW_GEMINI_MODEL_AUTO: { + case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_MODEL_AUTO: + case GEMINI_MODEL_ALIAS_AUTO: + case GEMINI_MODEL_ALIAS_PRO: { + if (useGemini3_1) { + return useCustomToolModel + ? PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + : PREVIEW_GEMINI_3_1_MODEL; + } return PREVIEW_GEMINI_MODEL; } case DEFAULT_GEMINI_MODEL_AUTO: { return DEFAULT_GEMINI_MODEL; } - case GEMINI_MODEL_ALIAS_AUTO: - case GEMINI_MODEL_ALIAS_PRO: { - return PREVIEW_GEMINI_MODEL; - } case GEMINI_MODEL_ALIAS_FLASH: { return PREVIEW_GEMINI_FLASH_MODEL; } @@ -73,6 +87,8 @@ export function resolveModel(requestedModel: string): string { export function resolveClassifierModel( requestedModel: string, modelAlias: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, ): string { if (modelAlias === GEMINI_MODEL_ALIAS_FLASH) { if ( @@ -89,7 +105,7 @@ export function resolveClassifierModel( } return resolveModel(GEMINI_MODEL_ALIAS_FLASH); } - return resolveModel(requestedModel); + return resolveModel(requestedModel, useGemini3_1, useCustomToolModel); } export function getDisplayString(model: string) { switch (model) { @@ -101,6 +117,8 @@ export function getDisplayString(model: string) { return PREVIEW_GEMINI_MODEL; case GEMINI_MODEL_ALIAS_FLASH: return PREVIEW_GEMINI_FLASH_MODEL; + case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL: + return PREVIEW_GEMINI_3_1_MODEL; default: return model; } @@ -115,11 +133,22 @@ export function getDisplayString(model: string) { export function isPreviewModel(model: string): boolean { return ( model === PREVIEW_GEMINI_MODEL || + model === PREVIEW_GEMINI_3_1_MODEL || model === PREVIEW_GEMINI_FLASH_MODEL || model === PREVIEW_GEMINI_MODEL_AUTO ); } +/** + * Checks if the model is a Pro model. + * + * @param model The model name to check. + * @returns True if the model is a Pro model. + */ +export function isProModel(model: string): boolean { + return model.toLowerCase().includes('pro'); +} + /** * Checks if the model is a Gemini 3 model. * @@ -188,3 +217,35 @@ export function isAutoModel(model: string): boolean { export function supportsMultimodalFunctionResponse(model: string): boolean { return model.startsWith('gemini-3-'); } + +/** + * Checks if the given model is considered active based on the current configuration. + * + * @param model The model name to check. + * @param useGemini3_1 Whether Gemini 3.1 Pro Preview is enabled. + * @returns True if the model is active. + */ +export function isActiveModel( + model: string, + useGemini3_1: boolean = false, + useCustomToolModel: boolean = false, +): boolean { + if (!VALID_GEMINI_MODELS.has(model)) { + return false; + } + if (useGemini3_1) { + if (model === PREVIEW_GEMINI_MODEL) { + return false; + } + if (useCustomToolModel) { + return model !== PREVIEW_GEMINI_3_1_MODEL; + } else { + return model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL; + } + } else { + return ( + model !== PREVIEW_GEMINI_3_1_MODEL && + model !== PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL + ); + } +} diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 0951eb397b..efa35a868b 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -542,7 +542,10 @@ export class GeminiClient { // Availability logic: The configured model is the source of truth, // including any permanent fallbacks (config.setModel) or manual overrides. - return resolveModel(this.config.getActiveModel()); + return resolveModel( + this.config.getActiveModel(), + this.config.getGemini31LaunchedSync?.() ?? false, + ); } private async *processTurn( diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index bfd8221f75..7adae874aa 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -146,7 +146,12 @@ export async function createContentGenerator( return new LoggingContentGenerator(fakeGenerator, gcConfig); } const version = await getVersion(); - const model = resolveModel(gcConfig.getModel()); + const model = resolveModel( + gcConfig.getModel(), + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI || + ((await gcConfig.getGemini31Launched?.()) ?? false), + ); const customHeadersEnv = process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined; const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`; diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 6b1ede738c..14f90cea9d 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -496,13 +496,14 @@ export class GeminiChat { const initialActiveModel = this.config.getActiveModel(); const apiCall = async () => { + const useGemini3_1 = (await this.config.getGemini31Launched?.()) ?? false; // Default to the last used model (which respects arguments/availability selection) - let modelToUse = resolveModel(lastModelToUse); + let modelToUse = resolveModel(lastModelToUse, useGemini3_1); // If the active model has changed (e.g. due to a fallback updating the config), // we switch to the new active model. if (this.config.getActiveModel() !== initialActiveModel) { - modelToUse = resolveModel(this.config.getActiveModel()); + modelToUse = resolveModel(this.config.getActiveModel(), useGemini3_1); } if (modelToUse !== lastModelToUse) { diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 36ffddf71c..4f1a3afbff 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -58,7 +58,10 @@ export class PromptProvider { const enabledToolNames = new Set(toolNames); const approvedPlanPath = config.getApprovedPlanPath(); - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; const contextFilenames = getAllGeminiMdFilenames(); @@ -231,7 +234,10 @@ export class PromptProvider { } getCompressionPrompt(config: Config): string { - const desiredModel = resolveModel(config.getActiveModel()); + const desiredModel = resolveModel( + config.getActiveModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); const isModernModel = supportsModernFeatures(desiredModel); const activeSnippets = isModernModel ? snippets : legacySnippets; return activeSnippets.getCompressionPrompt(); diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index b2c7a8797e..7e024b790a 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -18,11 +18,14 @@ import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, } from '../../config/models.js'; import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -53,6 +56,10 @@ describe('ClassifierStrategy', () => { }, getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -339,4 +346,49 @@ describe('ClassifierStrategy', () => { // Since requestedModel is Pro, and choice is flash, it should resolve to Flash expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + reasoning: 'Complex task', + model_choice: 'pro', + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 980e89829d..7e54d161de 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -21,6 +21,7 @@ import { } from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 4; @@ -169,9 +170,15 @@ export class ClassifierStrategy implements RoutingStrategy { const reasoning = routerResponse.reasoning; const latencyMs = Date.now() - startTime; + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; const selectedModel = resolveClassifierModel( model, routerResponse.model_choice, + useGemini3_1, + useCustomToolModel, ); return { diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index e5b89eb1b3..1f5b7e54c2 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -21,7 +21,10 @@ export class DefaultStrategy implements TerminalStrategy { config: Config, _baseLlmClient: BaseLlmClient, ): Promise { - const defaultModel = resolveModel(config.getModel()); + const defaultModel = resolveModel( + config.getModel(), + config.getGemini31LaunchedSync?.() ?? false, + ); return { model: defaultModel, metadata: { diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index d568039cbc..a18e4fc4dd 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -23,7 +23,10 @@ export class FallbackStrategy implements RoutingStrategy { _baseLlmClient: BaseLlmClient, ): Promise { const requestedModel = context.requestedModel ?? config.getModel(); - const resolvedModel = resolveModel(requestedModel); + const resolvedModel = resolveModel( + requestedModel, + config.getGemini31LaunchedSync?.() ?? false, + ); const service = config.getModelAvailabilityService(); const snapshot = service.snapshot(resolvedModel); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index 8767709f68..b8f6c50282 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -12,6 +12,8 @@ import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL, + PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, PREVIEW_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL_AUTO, DEFAULT_GEMINI_MODEL, @@ -20,6 +22,7 @@ import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -52,6 +55,10 @@ describe('NumericalClassifierStrategy', () => { getSessionId: vi.fn().mockReturnValue('control-group-id'), // Default to Control Group (Hash 71 >= 50) getNumericalRoutingEnabled: vi.fn().mockResolvedValue(true), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), + getGemini31Launched: vi.fn().mockResolvedValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), } as unknown as Config; mockBaseLlmClient = { generateJson: vi.fn(), @@ -535,4 +542,68 @@ describe('NumericalClassifierStrategy', () => { ), ); }); + + describe('Gemini 3.1 and Custom Tools Routing', () => { + it('should route to PREVIEW_GEMINI_3_1_MODEL when Gemini 3.1 is launched', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + it('should route to PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL when Gemini 3.1 is launched and auth is USE_GEMINI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); + }); + + it('should NOT route to custom tools model when auth is USE_VERTEX_AI', async () => { + vi.mocked(mockConfig.getGemini31Launched).mockResolvedValue(true); + vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({ + authType: AuthType.USE_VERTEX_AI, + }); + const mockApiResponse = { + complexity_reasoning: 'Complex task', + complexity_score: 80, + }; + vi.mocked(mockBaseLlmClient.generateJson).mockResolvedValue( + mockApiResponse, + ); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + ); + + expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); + }); + }); }); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index d4ddf99b8d..32cc6ccbb7 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -17,6 +17,7 @@ import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { LlmRole } from '../../telemetry/types.js'; +import { AuthType } from '../../core/contentGenerator.js'; // The number of recent history turns to provide to the router for context. const HISTORY_TURNS_FOR_CONTEXT = 8; @@ -182,8 +183,16 @@ export class NumericalClassifierStrategy implements RoutingStrategy { config, config.getSessionId() || 'unknown-session', ); - - const selectedModel = resolveClassifierModel(model, modelAlias); + const useGemini3_1 = (await config.getGemini31Launched?.()) ?? false; + const useCustomToolModel = + useGemini3_1 && + config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; + const selectedModel = resolveClassifierModel( + model, + modelAlias, + useGemini3_1, + useCustomToolModel, + ); const latencyMs = Date.now() - startTime; diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index b8382407bd..5101ba9fe7 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -33,7 +33,10 @@ export class OverrideStrategy implements RoutingStrategy { // Return the overridden model name. return { - model: resolveModel(overrideModel), + model: resolveModel( + overrideModel, + config.getGemini31LaunchedSync?.() ?? false, + ), metadata: { source: this.name, latencyMs: 0, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 44ffe90cf2..432c08dd1e 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -29,6 +29,7 @@ import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_FLASH_MODEL, + PREVIEW_GEMINI_3_1_MODEL, } from '../config/models.js'; import { PreCompressTrigger } from '../hooks/types.js'; import { LlmRole } from '../telemetry/types.js'; @@ -101,6 +102,7 @@ export function findCompressSplitPoint( export function modelStringToModelConfigAlias(model: string): string { switch (model) { case PREVIEW_GEMINI_MODEL: + case PREVIEW_GEMINI_3_1_MODEL: return 'chat-compression-3-pro'; case PREVIEW_GEMINI_FLASH_MODEL: return 'chat-compression-3-flash'; From 6cfd29ef9bbdaf88c3cc16d341963a8b6b5d0203 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 20 Feb 2026 13:33:04 -0600 Subject: [PATCH 28/52] feat(plan): enforce read-only constraints in Plan Mode (#19433) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Jerop Kipruto --- packages/core/src/policy/policies/plan.toml | 8 ++++ packages/core/src/prompts/snippets.ts | 2 +- packages/core/src/tools/tool-registry.ts | 44 ++++++++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index e7129208c8..6b963f72d2 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -55,3 +55,11 @@ decision = "allow" priority = 70 modes = ["plan"] argsPattern = "\"file_path\":\"[^\"]+/\\.gemini/tmp/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+/plans/[a-zA-Z0-9_-]+\\.md\"" + +# Explicitly Deny other write operations in Plan mode with a clear message. +[[rule]] +toolName = ["write_file", "edit"] +decision = "deny" +priority = 65 +modes = ["plan"] +deny_message = "You are in Plan Mode and cannot modify source code. You may ONLY use write_file or replace to save plans to the designated plans directory as .md files." diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 3791a856bf..c5f4c13360 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -460,7 +460,7 @@ ${options.planModeToolsList} ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. 2. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. 3. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call ${formatToolName( diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 60b1451838..abcf34e1f8 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -12,6 +12,7 @@ import type { } from './tools.js'; import { Kind, BaseDeclarativeTool, BaseToolInvocation } from './tools.js'; import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../policy/types.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; import { DiscoveredMCPTool } from './mcp-tool.js'; @@ -25,6 +26,9 @@ import { DISCOVERED_TOOL_PREFIX, TOOL_LEGACY_ALIASES, getToolAliases, + PLAN_MODE_TOOLS, + WRITE_FILE_TOOL_NAME, + EDIT_TOOL_NAME, } from './tool-names.js'; type ToolParams = Record; @@ -484,6 +488,31 @@ export class ToolRegistry { excludeTools ??= this.expandExcludeToolsWithAliases(this.config.getExcludeTools()) ?? new Set([]); + + // Filter tools in Plan Mode to only allow approved read-only tools. + const isPlanMode = + typeof this.config.getApprovalMode === 'function' && + this.config.getApprovalMode() === ApprovalMode.PLAN; + if (isPlanMode) { + const allowedToolNames = new Set(PLAN_MODE_TOOLS); + // We allow write_file and replace for writing plans specifically. + allowedToolNames.add(WRITE_FILE_TOOL_NAME); + allowedToolNames.add(EDIT_TOOL_NAME); + + // Discovered MCP tools are allowed if they are read-only. + if ( + tool instanceof DiscoveredMCPTool && + tool.isReadOnly && + !allowedToolNames.has(tool.name) + ) { + allowedToolNames.add(tool.name); + } + + if (!allowedToolNames.has(tool.name)) { + return false; + } + } + const normalizedClassName = tool.constructor.name.replace(/^_+/, ''); const possibleNames = [tool.name, normalizedClassName]; if (tool instanceof DiscoveredMCPTool) { @@ -507,9 +536,22 @@ export class ToolRegistry { * @returns An array of FunctionDeclarations. */ getFunctionDeclarations(modelId?: string): FunctionDeclaration[] { + const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; + const plansDir = this.config.storage.getPlansDir(); + const declarations: FunctionDeclaration[] = []; this.getActiveTools().forEach((tool) => { - declarations.push(tool.getSchema(modelId)); + let schema = tool.getSchema(modelId); + if ( + isPlanMode && + (tool.name === WRITE_FILE_TOOL_NAME || tool.name === EDIT_TOOL_NAME) + ) { + schema = { + ...schema, + description: `ONLY FOR PLANS: ${schema.description}. You are currently in Plan Mode and may ONLY use this tool to write or update plans (.md files) in the plans directory: ${plansDir}/. You cannot use this tool to modify source code directly.`, + }; + } + declarations.push(schema); }); return declarations; } From 239aa0909c2c36ebbb89773c0aafe2a671b7c3af Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 20 Feb 2026 14:46:48 -0500 Subject: [PATCH 29/52] fix(cli): allow perfect match @-path completions to submit on Enter (#19562) --- packages/cli/src/test-utils/render.tsx | 31 ++++++++- .../src/ui/components/InputPrompt.test.tsx | 63 +++++++++++++++++-- .../cli/src/ui/components/InputPrompt.tsx | 16 ++--- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index d84c04d01e..257ea84466 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render as inkRenderDirect, type Instance as InkInstance } from 'ink'; +import { + render as inkRenderDirect, + type Instance as InkInstance, + type RenderOptions, +} from 'ink'; import { EventEmitter } from 'node:events'; import { Box } from 'ink'; import type React from 'react'; @@ -68,6 +72,25 @@ type TerminalState = { rows: number; }; +type RenderMetrics = Parameters>[0]; + +interface InkRenderMetrics extends RenderMetrics { + output: string; + staticOutput?: string; +} + +function isInkRenderMetrics( + metrics: RenderMetrics, +): metrics is InkRenderMetrics { + const m = metrics as Record; + return ( + typeof m === 'object' && + m !== null && + 'output' in m && + typeof m['output'] === 'string' + ); +} + class XtermStdout extends EventEmitter { private state: TerminalState; private pendingWrites = 0; @@ -357,8 +380,10 @@ export const render = ( debug: false, exitOnCtrlC: false, patchConsole: false, - onRender: (metrics: { output: string; staticOutput?: string }) => { - stdout.onRender(metrics.staticOutput ?? '', metrics.output); + onRender: (metrics: RenderMetrics) => { + if (isInkRenderMetrics(metrics)) { + stdout.onRender(metrics.staticOutput ?? '', metrics.output); + } }, }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 154c680809..1576cef2e8 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1065,7 +1065,7 @@ describe('InputPrompt', () => { unmount(); }); - it('should NOT submit on Enter when an @-path is a perfect match', async () => { + it('should submit on Enter when an @-path is a perfect match', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, showSuggestions: true, @@ -1085,13 +1085,38 @@ describe('InputPrompt', () => { }); await waitFor(() => { - // Should handle autocomplete but NOT submit - expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0); - expect(props.onSubmit).not.toHaveBeenCalled(); + // Should submit directly + expect(props.onSubmit).toHaveBeenCalledWith('@file.txt'); }); unmount(); }); + it('should NOT submit on Shift+Enter even if an @-path is a perfect match', async () => { + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [{ label: 'file.txt', value: 'file.txt' }], + activeSuggestionIndex: 0, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + await act(async () => { + // Simulate Shift+Enter using CSI u sequence + stdin.write('\x1b[13;2u'); + }); + + // Should NOT submit, should call newline instead + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should auto-execute commands with autoExecute: true on Enter', async () => { const aboutCommand: SlashCommand = { name: 'about', @@ -2285,6 +2310,36 @@ describe('InputPrompt', () => { unmount(); }); + it('should prevent perfect match auto-submission immediately after an unsafe paste', async () => { + // isTerminalPasteTrusted will be false due to beforeEach setup. + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + isPerfectMatch: true, + completionMode: CompletionMode.AT, + }); + props.buffer.text = '@file.txt'; + + const { stdin, unmount } = renderWithProviders( + , + ); + + // Simulate an unsafe paste of a perfect match + await act(async () => { + stdin.write(`\x1b[200~@file.txt\x1b[201~`); + }); + + // Simulate an Enter key press immediately after paste + await act(async () => { + stdin.write('\r'); + }); + + // Verify that onSubmit was NOT called due to recent paste protection + expect(props.onSubmit).not.toHaveBeenCalled(); + // It should call newline() instead + expect(props.buffer.newline).toHaveBeenCalled(); + unmount(); + }); + it('should allow submission after unsafe paste protection timeout', async () => { // isTerminalPasteTrusted will be false due to beforeEach setup. props.buffer.text = 'pasted text'; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8a4f068df1..689df105ca 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -890,14 +890,20 @@ export const InputPrompt: React.FC = ({ // We prioritize execution unless the user is explicitly selecting a different suggestion. if ( completion.isPerfectMatch && - completion.completionMode !== CompletionMode.AT && - keyMatchers[Command.RETURN](key) && + keyMatchers[Command.SUBMIT](key) && + recentUnsafePasteTime === null && (!completion.showSuggestions || completion.activeSuggestionIndex <= 0) ) { handleSubmit(buffer.text); return true; } + // Newline insertion + if (keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); + return true; + } + if (completion.showSuggestions) { if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { @@ -1078,12 +1084,6 @@ export const InputPrompt: React.FC = ({ return true; } - // Newline insertion - if (keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); - return true; - } - // Ctrl+A (Home) / Ctrl+E (End) if (keyMatchers[Command.HOME](key)) { buffer.move('home'); From 723f269df64c8de56ae1df736f14b556f0d26735 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 14:51:53 -0500 Subject: [PATCH 30/52] fix(core): treat 503 Service Unavailable as retryable quota error (#19642) --- .../scripts/fetch-pr-info.js | 3 ++- .../core/src/utils/googleQuotaErrors.test.ts | 18 +++++++++++++++++- packages/core/src/utils/googleQuotaErrors.ts | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js index 772f8d18a4..de99def0ce 100755 --- a/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js +++ b/.gemini/skills/pr-address-comments/scripts/fetch-pr-info.js @@ -20,7 +20,8 @@ async function run(cmd) { stdio: ['pipe', 'pipe', 'ignore'], }); return stdout.trim(); - } catch (_e) { // eslint-disable-line @typescript-eslint/no-unused-vars + } catch (_e) { + // eslint-disable-line @typescript-eslint/no-unused-vars return null; } } diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index c75eb8de4f..06bde6444b 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -65,7 +65,23 @@ describe('classifyGoogleError', () => { expect((result as RetryableQuotaError).message).toBe(rawError.message); }); - it('should return original error if code is not 429', () => { + it('should return RetryableQuotaError for 503 Service Unavailable', () => { + const apiError: GoogleApiError = { + code: 503, + message: 'Service Unavailable', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error('Service Unavailable'); + const result = classifyGoogleError(originalError); + expect(result).toBeInstanceOf(RetryableQuotaError); + if (result instanceof RetryableQuotaError) { + expect(result.cause).toBe(apiError); + expect(result.message).toBe('Service Unavailable'); + } + }); + + it('should return original error if code is not 429 or 503', () => { const apiError: GoogleApiError = { code: 500, message: 'Server error', diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 0ecc14d93f..40c1c34361 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -202,6 +202,21 @@ export function classifyGoogleError(error: unknown): unknown { } } + // Check for 503 Service Unavailable errors + if (status === 503) { + const errorMessage = + googleApiError?.message || + (error instanceof Error ? error.message : String(error)); + return new RetryableQuotaError( + errorMessage, + googleApiError ?? { + code: 503, + message: errorMessage, + details: [], + }, + ); + } + if ( !googleApiError || googleApiError.code !== 429 || From cdf157e52a8b74cef2878cf6d9d1a2a13bb0240f Mon Sep 17 00:00:00 2001 From: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:54:26 -0800 Subject: [PATCH 31/52] Update sidebar.json for to allow top nav tabs. (#19595) --- docs/sidebar.json | 378 ++++++++++++++++++++++++++-------------------- 1 file changed, 213 insertions(+), 165 deletions(-) diff --git a/docs/sidebar.json b/docs/sidebar.json index ef9989884f..1a47f8adc9 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -1,199 +1,247 @@ [ { - "label": "Get started", - "items": [ - { "label": "Overview", "slug": "docs" }, - { "label": "Quickstart", "slug": "docs/get-started" }, - { "label": "Installation", "slug": "docs/get-started/installation" }, - { "label": "Authentication", "slug": "docs/get-started/authentication" }, - { "label": "Examples", "slug": "docs/get-started/examples" }, - { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, - { "label": "Gemini 3 on Gemini CLI", "slug": "docs/get-started/gemini-3" } - ] - }, - { - "label": "Use Gemini CLI", + "label": "docs_tab", "items": [ { - "label": "File management", - "slug": "docs/cli/tutorials/file-management" + "label": "Get started", + "items": [ + { "label": "Overview", "slug": "docs" }, + { "label": "Quickstart", "slug": "docs/get-started" }, + { "label": "Installation", "slug": "docs/get-started/installation" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Examples", "slug": "docs/get-started/examples" }, + { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, + { + "label": "Gemini 3 on Gemini CLI", + "slug": "docs/get-started/gemini-3" + } + ] }, { - "label": "Get started with Agent skills", - "slug": "docs/cli/tutorials/skills-getting-started" + "label": "Use Gemini CLI", + "items": [ + { + "label": "File management", + "slug": "docs/cli/tutorials/file-management" + }, + { + "label": "Get started with Agent skills", + "slug": "docs/cli/tutorials/skills-getting-started" + }, + { + "label": "Manage context and memory", + "slug": "docs/cli/tutorials/memory-management" + }, + { + "label": "Execute shell commands", + "slug": "docs/cli/tutorials/shell-commands" + }, + { + "label": "Manage sessions and history", + "slug": "docs/cli/tutorials/session-management" + }, + { + "label": "Plan tasks with todos", + "slug": "docs/cli/tutorials/task-planning" + }, + { + "label": "Web search and fetch", + "slug": "docs/cli/tutorials/web-tools" + }, + { + "label": "Set up an MCP server", + "slug": "docs/cli/tutorials/mcp-setup" + }, + { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } + ] }, { - "label": "Manage context and memory", - "slug": "docs/cli/tutorials/memory-management" + "label": "Features", + "items": [ + { "label": "Agent Skills", "slug": "docs/cli/skills" }, + { + "label": "Authentication", + "slug": "docs/get-started/authentication" + }, + { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, + { + "label": "Extensions", + "slug": "docs/extensions/index" + }, + { "label": "Headless mode", "slug": "docs/cli/headless" }, + { "label": "Help", "link": "/docs/reference/commands/#help-or" }, + { "label": "Hooks", "slug": "docs/hooks" }, + { "label": "IDE integration", "slug": "docs/ide-integration" }, + { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, + { + "label": "Memory management", + "link": "/docs/reference/commands/#memory" + }, + { "label": "Model routing", "slug": "docs/cli/model-routing" }, + { "label": "Model selection", "slug": "docs/cli/model" }, + { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, + { + "label": "Subagents", + "badge": "🧪", + "slug": "docs/core/subagents" + }, + { + "label": "Remote subagents", + "badge": "🧪", + "slug": "docs/core/remote-agents" + }, + { "label": "Rewind", "slug": "docs/cli/rewind" }, + { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "Shell", + "link": "/docs/reference/commands/#shells-or-bashes" + }, + { + "label": "Stats", + "link": "/docs/reference/commands/#stats" + }, + { "label": "Telemetry", "slug": "docs/cli/telemetry" }, + { "label": "Token caching", "slug": "docs/cli/token-caching" }, + { "label": "Tools", "link": "/docs/reference/commands/#tools" } + ] }, { - "label": "Execute shell commands", - "slug": "docs/cli/tutorials/shell-commands" + "label": "Configuration", + "items": [ + { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, + { + "label": "Enterprise configuration", + "slug": "docs/cli/enterprise" + }, + { + "label": "Ignore files (.geminiignore)", + "slug": "docs/cli/gemini-ignore" + }, + { + "label": "Model configuration", + "slug": "docs/cli/generation-settings" + }, + { + "label": "Project context (GEMINI.md)", + "slug": "docs/cli/gemini-md" + }, + { "label": "Settings", "slug": "docs/cli/settings" }, + { + "label": "System prompt override", + "slug": "docs/cli/system-prompt" + }, + { "label": "Themes", "slug": "docs/cli/themes" }, + { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } + ] }, - { - "label": "Manage sessions and history", - "slug": "docs/cli/tutorials/session-management" - }, - { - "label": "Plan tasks with todos", - "slug": "docs/cli/tutorials/task-planning" - }, - { - "label": "Web search and fetch", - "slug": "docs/cli/tutorials/web-tools" - }, - { - "label": "Set up an MCP server", - "slug": "docs/cli/tutorials/mcp-setup" - }, - { "label": "Automate tasks", "slug": "docs/cli/tutorials/automation" } - ] - }, - { - "label": "Features", - "items": [ - { "label": "Agent Skills", "slug": "docs/cli/skills" }, - { - "label": "Authentication", - "slug": "docs/get-started/authentication" - }, - { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Extensions", - "slug": "docs/extensions/index" - }, - { "label": "Headless mode", "slug": "docs/cli/headless" }, - { "label": "Help", "link": "/docs/reference/commands/#help-or" }, - { "label": "Hooks", "slug": "docs/hooks" }, - { "label": "IDE integration", "slug": "docs/ide-integration" }, - { "label": "MCP servers", "slug": "docs/tools/mcp-server" }, - { - "label": "Memory management", - "link": "/docs/reference/commands/#memory" - }, - { "label": "Model routing", "slug": "docs/cli/model-routing" }, - { "label": "Model selection", "slug": "docs/cli/model" }, - { "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" }, - { "label": "Subagents", "badge": "🧪", "slug": "docs/core/subagents" }, - { - "label": "Remote subagents", - "badge": "🧪", - "slug": "docs/core/remote-agents" - }, - { "label": "Rewind", "slug": "docs/cli/rewind" }, - { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { - "label": "Shell", - "link": "/docs/reference/commands/#shells-or-bashes" + "items": [ + { + "label": "Overview", + "slug": "docs/extensions" + }, + { + "label": "User guide: Install and manage", + "link": "/docs/extensions/#manage-extensions" + }, + { + "label": "Developer guide: Build extensions", + "slug": "docs/extensions/writing-extensions" + }, + { + "label": "Developer guide: Best practices", + "slug": "docs/extensions/best-practices" + }, + { + "label": "Developer guide: Releasing", + "slug": "docs/extensions/releasing" + }, + { + "label": "Developer guide: Reference", + "slug": "docs/extensions/reference" + } + ] }, { - "label": "Stats", - "link": "/docs/reference/commands/#stats" - }, - { "label": "Telemetry", "slug": "docs/cli/telemetry" }, - { "label": "Token caching", "slug": "docs/cli/token-caching" }, - { "label": "Tools", "link": "/docs/reference/commands/#tools" } - ] - }, - { - "label": "Configuration", - "items": [ - { "label": "Custom commands", "slug": "docs/cli/custom-commands" }, - { "label": "Enterprise configuration", "slug": "docs/cli/enterprise" }, - { - "label": "Ignore files (.geminiignore)", - "slug": "docs/cli/gemini-ignore" - }, - { - "label": "Model configuration", - "slug": "docs/cli/generation-settings" - }, - { "label": "Project context (GEMINI.md)", "slug": "docs/cli/gemini-md" }, - { "label": "Settings", "slug": "docs/cli/settings" }, - { "label": "System prompt override", "slug": "docs/cli/system-prompt" }, - { "label": "Themes", "slug": "docs/cli/themes" }, - { "label": "Trusted folders", "slug": "docs/cli/trusted-folders" } - ] - }, - { - "label": "Extensions", - "items": [ - { - "label": "Overview", - "slug": "docs/extensions" - }, - { - "label": "User guide: Install and manage", - "link": "/docs/extensions/#manage-extensions" - }, - { - "label": "Developer guide: Build extensions", - "slug": "docs/extensions/writing-extensions" - }, - { - "label": "Developer guide: Best practices", - "slug": "docs/extensions/best-practices" - }, - { - "label": "Developer guide: Releasing", - "slug": "docs/extensions/releasing" - }, - { - "label": "Developer guide: Reference", - "slug": "docs/extensions/reference" + "label": "Development", + "items": [ + { "label": "Contribution guide", "slug": "docs/contributing" }, + { "label": "Integration testing", "slug": "docs/integration-tests" }, + { + "label": "Issue and PR automation", + "slug": "docs/issue-and-pr-automation" + }, + { "label": "Local development", "slug": "docs/local-development" }, + { "label": "NPM package structure", "slug": "docs/npm" } + ] } ] }, { - "label": "Reference", + "label": "reference_tab", "items": [ - { "label": "Command reference", "slug": "docs/reference/commands" }, { - "label": "Configuration reference", - "slug": "docs/reference/configuration" - }, - { - "label": "Keyboard shortcuts", - "slug": "docs/reference/keyboard-shortcuts" - }, - { "label": "Memory import processor", "slug": "docs/reference/memport" }, - { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, - { "label": "Tools API", "slug": "docs/reference/tools-api" } + "label": "Reference", + "items": [ + { "label": "Command reference", "slug": "docs/reference/commands" }, + { + "label": "Configuration reference", + "slug": "docs/reference/configuration" + }, + { + "label": "Keyboard shortcuts", + "slug": "docs/reference/keyboard-shortcuts" + }, + { + "label": "Memory import processor", + "slug": "docs/reference/memport" + }, + { "label": "Policy engine", "slug": "docs/reference/policy-engine" }, + { "label": "Tools API", "slug": "docs/reference/tools-api" } + ] + } ] }, { - "label": "Resources", + "label": "resources_tab", "items": [ - { "label": "FAQ", "slug": "docs/resources/faq" }, { - "label": "Quota and pricing", - "slug": "docs/resources/quota-and-pricing" - }, - { "label": "Terms and privacy", "slug": "docs/resources/tos-privacy" }, - { "label": "Troubleshooting", "slug": "docs/resources/troubleshooting" }, - { "label": "Uninstall", "slug": "docs/resources/uninstall" } + "label": "Resources", + "items": [ + { "label": "FAQ", "slug": "docs/resources/faq" }, + { + "label": "Quota and pricing", + "slug": "docs/resources/quota-and-pricing" + }, + { + "label": "Terms and privacy", + "slug": "docs/resources/tos-privacy" + }, + { + "label": "Troubleshooting", + "slug": "docs/resources/troubleshooting" + }, + { "label": "Uninstall", "slug": "docs/resources/uninstall" } + ] + } ] }, { - "label": "Development", + "label": "releases_tab", "items": [ - { "label": "Contribution guide", "slug": "docs/contributing" }, - { "label": "Integration testing", "slug": "docs/integration-tests" }, { - "label": "Issue and PR automation", - "slug": "docs/issue-and-pr-automation" - }, - { "label": "Local development", "slug": "docs/local-development" }, - { "label": "NPM package structure", "slug": "docs/npm" } - ] - }, - { - "label": "Releases", - "items": [ - { "label": "Release notes", "slug": "docs/changelogs/" }, - { "label": "Stable release", "slug": "docs/changelogs/latest" }, - { "label": "Preview release", "slug": "docs/changelogs/preview" } + "label": "Releases", + "items": [ + { "label": "Release notes", "slug": "docs/changelogs/" }, + { "label": "Stable release", "slug": "docs/changelogs/latest" }, + { "label": "Preview release", "slug": "docs/changelogs/preview" } + ] + } ] } ] From 7cf4c05c664679ede3a44244137270e35efbf239 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 20:03:57 +0000 Subject: [PATCH 32/52] Fixes 'input.on' is not a function error in Gemini CLI (#19691) --- packages/core/src/code_assist/server.test.ts | 42 ++++++++++++++++++++ packages/core/src/code_assist/server.ts | 6 +-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 89ce45e1aa..8ec8cb8dad 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -408,6 +408,48 @@ describe('CodeAssistServer', () => { expect(results[1].candidates?.[0].content?.parts?.[0].text).toBe(' World'); }); + it('should handle Web ReadableStream in generateContentStream', async () => { + const { server, mockRequest } = createTestServer(); + + // Create a mock Web ReadableStream + const mockWebStream = new ReadableStream({ + start(controller) { + const mockResponseData = { + response: { + candidates: [{ content: { parts: [{ text: 'Hello Web' }] } }], + }, + }; + controller.enqueue( + new TextEncoder().encode( + 'data: ' + JSON.stringify(mockResponseData) + '\n\n', + ), + ); + controller.close(); + }, + }); + + mockRequest.mockResolvedValue({ data: mockWebStream }); + + const stream = await server.generateContentStream( + { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'request' }] }], + }, + 'user-prompt-id', + LlmRole.MAIN, + ); + + const results = []; + for await (const res of stream) { + results.push(res); + } + + expect(results).toHaveLength(1); + expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe( + 'Hello Web', + ); + }); + it('should ignore malformed SSE data', async () => { const { server, mockRequest } = createTestServer(); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index 871af4cbfa..ba9b96bcb3 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -36,6 +36,7 @@ import type { GenerateContentResponse, } from '@google/genai'; import * as readline from 'node:readline'; +import { Readable } from 'node:stream'; import type { ContentGenerator } from '../core/contentGenerator.js'; import { UserTierId } from './types.js'; import type { @@ -341,7 +342,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise> { - const res = await this.client.request({ + const res = await this.client.request>({ url: this.getMethodUrl(method), method: 'POST', params: { @@ -358,8 +359,7 @@ export class CodeAssistServer implements ContentGenerator { return (async function* (): AsyncGenerator { const rl = readline.createInterface({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - input: res.data as NodeJS.ReadableStream, + input: Readable.from(res.data), crlfDelay: Infinity, // Recognizes '\r\n' and '\n' as line breaks }); From aed348a99cd7503d101710f2b2ea62accf39992b Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:04:32 -0500 Subject: [PATCH 33/52] security: strip deceptive Unicode characters from terminal output (#19026) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../messages/ToolConfirmationMessage.test.tsx | 32 +++++++++++++++++++ .../messages/ToolConfirmationMessage.tsx | 19 +++++++---- .../ToolConfirmationMessage.test.tsx.snap | 12 +++++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 4 ++- packages/cli/src/ui/utils/TableRenderer.tsx | 11 +++++-- packages/cli/src/ui/utils/textUtils.test.ts | 29 +++++++++++++++++ packages/cli/src/ui/utils/textUtils.ts | 13 ++++++-- 7 files changed, 109 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index bef187ea22..c3f686762f 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import type { SerializableConfirmationDetails, + ToolCallConfirmationDetails, Config, } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; @@ -372,4 +373,35 @@ describe('ToolConfirmationMessage', () => { unmount(); }); }); + + it('should strip BiDi characters from MCP tool and server names', async () => { + const confirmationDetails: ToolCallConfirmationDetails = { + type: 'mcp', + title: 'Confirm MCP Tool', + serverName: 'test\u202Eserver', + toolName: 'test\u202Dtool', + toolDisplayName: 'Test Tool', + onConfirm: vi.fn(), + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); + + const output = lastFrame(); + // BiDi characters \u202E and \u202D should be stripped + expect(output).toContain('MCP Server: testserver'); + expect(output).toContain('Tool: testtool'); + expect(output).toContain('Allow execution of MCP tool "testtool"'); + expect(output).toContain('from server "testserver"?'); + expect(output).toMatchSnapshot(); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 42642d66f9..84fb90d14f 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -21,7 +21,10 @@ import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; -import { sanitizeForDisplay } from '../../utils/textUtils.js'; +import { + sanitizeForDisplay, + stripUnsafeCharacters, +} from '../../utils/textUtils.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -324,15 +327,15 @@ export const ToolConfirmationMessage: React.FC< } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; - question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; + question = `Allow execution of MCP tool "${sanitizeForDisplay(mcpProps.toolName)}" from server "${sanitizeForDisplay(mcpProps.serverName)}"?`; } if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { bodyContent = ( @@ -449,8 +452,12 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - MCP Server: {mcpProps.serverName} - Tool: {mcpProps.toolName} + + MCP Server: {sanitizeForDisplay(mcpProps.serverName)} + + + Tool: {sanitizeForDisplay(mcpProps.toolName)} + ); } diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap index 69574a60c6..72eda055d5 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -35,6 +35,18 @@ Do you want to proceed? " `; +exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = ` +"MCP Server: testserver +Tool: testtool +Allow execution of MCP tool "testtool" from server "testserver"? + +● 1. Allow once + 2. Allow tool for this session + 3. Allow all server tools for this session + 4. No, suggest changes (esc) +" +`; + exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 0418582919..430b27eeb3 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { stripUnsafeCharacters } from './textUtils.js'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" @@ -23,9 +24,10 @@ interface RenderInlineProps { } const RenderInlineInternal: React.FC = ({ - text, + text: rawText, defaultColor, }) => { + const text = stripUnsafeCharacters(rawText); const baseColor = defaultColor ?? theme.text.primary; // Early return for plain text without markdown or URLs if (!/[*_~`<[https?:]/.test(text)) { diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index fd19b51000..4689d461ff 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -17,6 +17,7 @@ import { } from 'ink'; import { theme } from '../semantic-colors.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; +import { stripUnsafeCharacters } from './textUtils.js'; interface TableRendererProps { headers: string[]; @@ -60,12 +61,18 @@ export const TableRenderer: React.FC = ({ ); const styledHeaders = useMemo( - () => cleanedHeaders.map((header) => toStyledCharacters(header)), + () => + cleanedHeaders.map((header) => + toStyledCharacters(stripUnsafeCharacters(header)), + ), [cleanedHeaders], ); const styledRows = useMemo( - () => rows.map((row) => row.map((cell) => toStyledCharacters(cell))), + () => + rows.map((row) => + row.map((cell) => toStyledCharacters(stripUnsafeCharacters(cell))), + ), [rows], ); diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index be7f69d9f6..4927486d43 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -332,6 +332,35 @@ describe('textUtils', () => { }); }); + describe('BiDi and deceptive Unicode characters', () => { + it('should strip BiDi override characters', () => { + const input = 'safe\u202Etxt.sh'; + // When stripped, it should be 'safetxt.sh' + expect(stripUnsafeCharacters(input)).toBe('safetxt.sh'); + }); + + it('should strip all BiDi control characters (LRM, RLM, U+202A-U+202E, U+2066-U+2069)', () => { + const bidiChars = + '\u200E\u200F\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069'; + expect(stripUnsafeCharacters('a' + bidiChars + 'b')).toBe('ab'); + }); + + it('should strip zero-width characters (U+200B, U+FEFF)', () => { + const zeroWidthChars = '\u200B\uFEFF'; + expect(stripUnsafeCharacters('a' + zeroWidthChars + 'b')).toBe('ab'); + }); + + it('should preserve ZWJ (U+200D) for complex emojis', () => { + const input = 'Family: 👨‍👩‍👧‍👦'; + expect(stripUnsafeCharacters(input)).toBe('Family: 👨‍👩‍👧‍👦'); + }); + + it('should preserve ZWNJ (U+200C)', () => { + const input = 'hello\u200Cworld'; + expect(stripUnsafeCharacters(input)).toBe('hello\u200Cworld'); + }); + }); + describe('performance: regex vs array-based', () => { it('should handle real-world terminal output with control chars', () => { // Simulate terminal output with various control sequences diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index d2ad40c148..bd7e2aca75 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -106,9 +106,13 @@ export function cpSlice(str: string, start: number, end?: number): string { * - VT control sequences (via Node.js util.stripVTControlCharacters) * - C0 control chars (0x00-0x1F) except TAB(0x09), LF(0x0A), CR(0x0D) * - C1 control chars (0x80-0x9F) that can cause display issues + * - BiDi control chars (U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) + * - Zero-width chars (U+200B, U+FEFF) * * Characters preserved: * - All printable Unicode including emojis + * - ZWJ (U+200D) - needed for complex emoji sequences + * - ZWNJ (U+200C) - preserve zero-width non-joiner * - DEL (0x7F) - handled functionally by applyOperations, not a display issue * - CR/LF (0x0D/0x0A) - needed for line breaks * - TAB (0x09) - preserve tabs @@ -120,8 +124,13 @@ export function stripUnsafeCharacters(str: string): string { // Use a regex to strip remaining unsafe control characters // C0: 0x00-0x1F except 0x09 (TAB), 0x0A (LF), 0x0D (CR) // C1: 0x80-0x9F - // eslint-disable-next-line no-control-regex - return strippedVT.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F]/g, ''); + // BiDi: U+200E (LRM), U+200F (RLM), U+202A-U+202E, U+2066-U+2069 + // Zero-width: U+200B (ZWSP), U+FEFF (BOM) + return strippedVT.replace( + // eslint-disable-next-line no-control-regex + /[\x00-\x08\x0B\x0C\x0E-\x1F\x80-\x9F\u200E\u200F\u202A-\u202E\u2066-\u2069\u200B\uFEFF]/g, + '', + ); } /** From 49b2e76ee1c808a104d5b39d7b3127ca8e67a54a Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:08:49 -0500 Subject: [PATCH 34/52] Revert "feat(ui): add source indicators to slash commands" (#19695) --- .../cli/src/services/CommandService.test.ts | 141 ++++++++---------- packages/cli/src/services/CommandService.ts | 121 +++++---------- .../src/services/FileCommandLoader.test.ts | 116 +------------- .../cli/src/services/FileCommandLoader.ts | 25 ++-- packages/cli/src/ui/commands/types.ts | 6 - 5 files changed, 116 insertions(+), 293 deletions(-) diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 6d888d4b2d..ea906a3da6 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -10,13 +10,8 @@ import { type ICommandLoader } from './types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; import { debugLogger } from '@google/gemini-cli-core'; -const createMockCommand = ( - name: string, - kind: CommandKind, - namespace?: string, -): SlashCommand => ({ +const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({ name, - namespace, description: `Description for ${name}`, kind, action: vi.fn(), @@ -184,18 +179,18 @@ describe('CommandService', () => { expect(loader2.loadCommands).toHaveBeenCalledWith(signal); }); - it('should apply namespaces to commands from user and extensions', async () => { + it('should rename extension commands when they conflict', async () => { const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); - const userCommand = createMockCommand('sync', CommandKind.FILE, 'user'); + const userCommand = createMockCommand('sync', CommandKind.FILE); const extensionCommand1 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', - description: 'Deploy to Firebase', + description: '[firebase] Deploy to Firebase', }; const extensionCommand2 = { - ...createMockCommand('sync', CommandKind.FILE, 'git-helper'), + ...createMockCommand('sync', CommandKind.FILE), extensionName: 'git-helper', - description: 'Sync with remote', + description: '[git-helper] Sync with remote', }; const mockLoader1 = new MockCommandLoader([builtinCommand]); @@ -213,28 +208,30 @@ describe('CommandService', () => { const commands = service.getCommands(); expect(commands).toHaveLength(4); - // Built-in command keeps original name because it has no namespace + // Built-in command keeps original name const deployBuiltin = commands.find( (cmd) => cmd.name === 'deploy' && !cmd.extensionName, ); expect(deployBuiltin).toBeDefined(); expect(deployBuiltin?.kind).toBe(CommandKind.BUILT_IN); - // Extension command gets namespaced, preventing conflict with built-in + // Extension command conflicting with built-in gets renamed const deployExtension = commands.find( - (cmd) => cmd.name === 'firebase:deploy', + (cmd) => cmd.name === 'firebase.deploy', ); expect(deployExtension).toBeDefined(); expect(deployExtension?.extensionName).toBe('firebase'); - // User command gets namespaced - const syncUser = commands.find((cmd) => cmd.name === 'user:sync'); + // User command keeps original name + const syncUser = commands.find( + (cmd) => cmd.name === 'sync' && !cmd.extensionName, + ); expect(syncUser).toBeDefined(); expect(syncUser?.kind).toBe(CommandKind.FILE); - // Extension command gets namespaced + // Extension command conflicting with user command gets renamed const syncExtension = commands.find( - (cmd) => cmd.name === 'git-helper:sync', + (cmd) => cmd.name === 'git-helper.sync', ); expect(syncExtension).toBeDefined(); expect(syncExtension?.extensionName).toBe('git-helper'); @@ -272,16 +269,16 @@ describe('CommandService', () => { expect(deployCommand?.kind).toBe(CommandKind.FILE); }); - it('should handle namespaced name conflicts when renaming extension commands', async () => { - // User has both /deploy and /gcp:deploy commands + it('should handle secondary conflicts when renaming extension commands', async () => { + // User has both /deploy and /gcp.deploy commands const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); - // Extension also has a deploy command that will resolve to /gcp:deploy and conflict with userCommand2 + // Extension also has a deploy command that will conflict with user's /deploy const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'gcp', - description: 'Deploy to Google Cloud', + description: '[gcp] Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -304,31 +301,31 @@ describe('CommandService', () => { ); expect(deployUser).toBeDefined(); - // User's command keeps its name + // User's dot notation command keeps its name const gcpDeployUser = commands.find( - (cmd) => cmd.name === 'gcp:deploy' && !cmd.extensionName, + (cmd) => cmd.name === 'gcp.deploy' && !cmd.extensionName, ); expect(gcpDeployUser).toBeDefined(); - // Extension command gets renamed with suffix due to namespaced name conflict + // Extension command gets renamed with suffix due to secondary conflict const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp:deploy1' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp.deploy1' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - it('should handle multiple namespaced name conflicts with incrementing suffixes', async () => { - // User has /deploy, /gcp:deploy, and /gcp:deploy1 + it('should handle multiple secondary conflicts with incrementing suffixes', async () => { + // User has /deploy, /gcp.deploy, and /gcp.deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); - const userCommand2 = createMockCommand('gcp:deploy', CommandKind.FILE); - const userCommand3 = createMockCommand('gcp:deploy1', CommandKind.FILE); + const userCommand2 = createMockCommand('gcp.deploy', CommandKind.FILE); + const userCommand3 = createMockCommand('gcp.deploy1', CommandKind.FILE); - // Extension has a deploy command which resolves to /gcp:deploy + // Extension has a deploy command const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'gcp'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'gcp', - description: 'Deploy to Google Cloud', + description: '[gcp] Deploy to Google Cloud', }; const mockLoader = new MockCommandLoader([ @@ -348,19 +345,16 @@ describe('CommandService', () => { // Extension command gets renamed with suffix 2 due to multiple conflicts const deployExtension = commands.find( - (cmd) => cmd.name === 'gcp:deploy2' && cmd.extensionName === 'gcp', + (cmd) => cmd.name === 'gcp.deploy2' && cmd.extensionName === 'gcp', ); expect(deployExtension).toBeDefined(); - expect(deployExtension?.description).toBe('Deploy to Google Cloud'); + expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - it('should report extension namespaced name conflicts via getConflicts', async () => { - const builtinCommand = createMockCommand( - 'firebase:deploy', - CommandKind.BUILT_IN, - ); + it('should report conflicts via getConflicts', async () => { + const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); const extensionCommand = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', }; @@ -378,29 +372,29 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'firebase:deploy', + name: 'deploy', winner: builtinCommand, losers: [ { - renamedTo: 'firebase:deploy1', + renamedTo: 'firebase.deploy', command: expect.objectContaining({ name: 'deploy', - namespace: 'firebase', + extensionName: 'firebase', }), }, ], }); }); - it('should report extension vs extension namespaced name conflicts correctly', async () => { - // Both extensions try to register 'firebase:deploy' + it('should report extension vs extension conflicts correctly', async () => { + // Both extensions try to register 'deploy' const extension1Command = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), + ...createMockCommand('deploy', CommandKind.FILE), extensionName: 'firebase', }; const extension2Command = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'aws', }; const mockLoader = new MockCommandLoader([ @@ -417,37 +411,32 @@ describe('CommandService', () => { expect(conflicts).toHaveLength(1); expect(conflicts[0]).toMatchObject({ - name: 'firebase:deploy', + name: 'deploy', winner: expect.objectContaining({ - name: 'firebase:deploy', + name: 'deploy', extensionName: 'firebase', }), losers: [ { - renamedTo: 'firebase:deploy1', + renamedTo: 'aws.deploy', // ext2 is 'aws' and it lost because it was second in the list command: expect.objectContaining({ name: 'deploy', - extensionName: 'firebase', + extensionName: 'aws', }), }, ], }); }); - it('should report multiple extension namespaced name conflicts for the same name', async () => { - // Built-in command is 'firebase:deploy' - const builtinCommand = createMockCommand( - 'firebase:deploy', - CommandKind.BUILT_IN, - ); - // Two extension commands from extension 'firebase' also try to be 'firebase:deploy' + it('should report multiple conflicts for the same command name', async () => { + const builtinCommand = createMockCommand('deploy', CommandKind.BUILT_IN); const ext1 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'ext1', }; const ext2 = { - ...createMockCommand('deploy', CommandKind.FILE, 'firebase'), - extensionName: 'firebase', + ...createMockCommand('deploy', CommandKind.FILE), + extensionName: 'ext2', }; const mockLoader = new MockCommandLoader([builtinCommand, ext1, ext2]); @@ -459,23 +448,17 @@ describe('CommandService', () => { const conflicts = service.getConflicts(); expect(conflicts).toHaveLength(1); - expect(conflicts[0].name).toBe('firebase:deploy'); + expect(conflicts[0].name).toBe('deploy'); expect(conflicts[0].losers).toHaveLength(2); expect(conflicts[0].losers).toEqual( expect.arrayContaining([ expect.objectContaining({ - renamedTo: 'firebase:deploy1', - command: expect.objectContaining({ - name: 'deploy', - namespace: 'firebase', - }), + renamedTo: 'ext1.deploy', + command: expect.objectContaining({ extensionName: 'ext1' }), }), expect.objectContaining({ - renamedTo: 'firebase:deploy2', - command: expect.objectContaining({ - name: 'deploy', - namespace: 'firebase', - }), + renamedTo: 'ext2.deploy', + command: expect.objectContaining({ extensionName: 'ext2' }), }), ]), ); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 570bfee36f..bd42226a32 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -79,100 +79,61 @@ export class CommandService { const conflictsMap = new Map(); for (const cmd of allCommands) { - let fullName = this.resolveFullName(cmd); + let finalName = cmd.name; + // Extension commands get renamed if they conflict with existing commands - if (cmd.extensionName && commandMap.has(fullName)) { - fullName = this.resolveConflict( - fullName, - cmd, - commandMap, - conflictsMap, - ); + if (cmd.extensionName && commandMap.has(cmd.name)) { + const winner = commandMap.get(cmd.name)!; + let renamedName = `${cmd.extensionName}.${cmd.name}`; + let suffix = 1; + + // Keep trying until we find a name that doesn't conflict + while (commandMap.has(renamedName)) { + renamedName = `${cmd.extensionName}.${cmd.name}${suffix}`; + suffix++; + } + + finalName = renamedName; + + if (!conflictsMap.has(cmd.name)) { + conflictsMap.set(cmd.name, { + name: cmd.name, + winner, + losers: [], + }); + } + + conflictsMap.get(cmd.name)!.losers.push({ + command: cmd, + renamedTo: finalName, + }); } - commandMap.set(fullName, { + commandMap.set(finalName, { ...cmd, - name: fullName, + name: finalName, }); } const conflicts = Array.from(conflictsMap.values()); - this.emitConflicts(conflicts); + if (conflicts.length > 0) { + coreEvents.emitSlashCommandConflicts( + conflicts.flatMap((c) => + c.losers.map((l) => ({ + name: c.name, + renamedTo: l.renamedTo, + loserExtensionName: l.command.extensionName, + winnerExtensionName: c.winner.extensionName, + })), + ), + ); + } const finalCommands = Object.freeze(Array.from(commandMap.values())); const finalConflicts = Object.freeze(conflicts); return new CommandService(finalCommands, finalConflicts); } - /** - * Prepends the namespace to the command name if provided and not already present. - */ - private static resolveFullName(cmd: SlashCommand): string { - if (!cmd.namespace) { - return cmd.name; - } - - const prefix = `${cmd.namespace}:`; - return cmd.name.startsWith(prefix) ? cmd.name : `${prefix}${cmd.name}`; - } - - /** - * Resolves a naming conflict by generating a unique name for an extension command. - * Also records the conflict for reporting. - */ - private static resolveConflict( - fullName: string, - cmd: SlashCommand, - commandMap: Map, - conflictsMap: Map, - ): string { - const winner = commandMap.get(fullName)!; - let renamedName = fullName; - let suffix = 1; - - // Generate a unique name by appending an incrementing numeric suffix. - while (commandMap.has(renamedName)) { - renamedName = `${fullName}${suffix}`; - suffix++; - } - - // Record the conflict details for downstream reporting. - if (!conflictsMap.has(fullName)) { - conflictsMap.set(fullName, { - name: fullName, - winner, - losers: [], - }); - } - - conflictsMap.get(fullName)!.losers.push({ - command: cmd, - renamedTo: renamedName, - }); - - return renamedName; - } - - /** - * Emits conflict events for all detected collisions. - */ - private static emitConflicts(conflicts: CommandConflict[]): void { - if (conflicts.length === 0) { - return; - } - - coreEvents.emitSlashCommandConflicts( - conflicts.flatMap((c) => - c.losers.map((l) => ({ - name: c.name, - renamedTo: l.renamedTo, - loserExtensionName: l.command.extensionName, - winnerExtensionName: c.winner.extensionName, - })), - ), - ); - } - /** * Retrieves the currently loaded and de-duplicated list of slash commands. * diff --git a/packages/cli/src/services/FileCommandLoader.test.ts b/packages/cli/src/services/FileCommandLoader.test.ts index 4a92543add..077b8c45fe 100644 --- a/packages/cli/src/services/FileCommandLoader.test.ts +++ b/packages/cli/src/services/FileCommandLoader.test.ts @@ -32,9 +32,6 @@ vi.mock('./prompt-processors/atFileProcessor.js', () => ({ process: mockAtFileProcess, })), })); -vi.mock('../utils/osUtils.js', () => ({ - getUsername: vi.fn().mockReturnValue('mock-user'), -})); vi.mock('./prompt-processors/shellProcessor.js', () => ({ ShellProcessor: vi.fn().mockImplementation(() => ({ process: mockShellProcess, @@ -585,7 +582,7 @@ describe('FileCommandLoader', () => { const extCommand = commands.find((cmd) => cmd.name === 'ext'); expect(extCommand?.extensionName).toBe('test-ext'); - expect(extCommand?.description).toBe('Custom command from ext.toml'); + expect(extCommand?.description).toMatch(/^\[test-ext\]/); }); it('extension commands have extensionName metadata for conflict resolution', async () => { @@ -673,7 +670,7 @@ describe('FileCommandLoader', () => { expect(commands[2].name).toBe('deploy'); expect(commands[2].extensionName).toBe('test-ext'); - expect(commands[2].description).toBe('Custom command from deploy.toml'); + expect(commands[2].description).toMatch(/^\[test-ext\]/); const result2 = await commands[2].action?.( createMockCommandContext({ invocation: { @@ -750,7 +747,7 @@ describe('FileCommandLoader', () => { expect(commands).toHaveLength(1); expect(commands[0].name).toBe('active'); expect(commands[0].extensionName).toBe('active-ext'); - expect(commands[0].description).toBe('Custom command from active.toml'); + expect(commands[0].description).toMatch(/^\[active-ext\]/); }); it('handles missing extension commands directory gracefully', async () => { @@ -833,7 +830,7 @@ describe('FileCommandLoader', () => { const nestedCmd = commands.find((cmd) => cmd.name === 'b:c'); expect(nestedCmd?.extensionName).toBe('a'); - expect(nestedCmd?.description).toBe('Custom command from c.toml'); + expect(nestedCmd?.description).toMatch(/^\[a\]/); expect(nestedCmd).toBeDefined(); const result = await nestedCmd!.action?.( createMockCommandContext({ @@ -1405,109 +1402,4 @@ describe('FileCommandLoader', () => { expect(commands[0].description).toBe('d'.repeat(97) + '...'); }); }); - - describe('command namespace', () => { - it('is "user" for user commands', async () => { - const userCommandsDir = Storage.getUserCommandsDir(); - mock({ - [userCommandsDir]: { - 'test.toml': 'prompt = "User prompt"', - }, - }); - - const loader = new FileCommandLoader(null); - const commands = await loader.loadCommands(signal); - - expect(commands[0].name).toBe('test'); - expect(commands[0].namespace).toBe('user'); - expect(commands[0].description).toBe('Custom command from test.toml'); - }); - - it.each([ - { - name: 'standard path', - projectRoot: '/path/to/my-awesome-project', - }, - { - name: 'Windows-style path', - projectRoot: 'C:\\Users\\test\\projects\\win-project', - }, - ])( - 'is "workspace" for project commands ($name)', - async ({ projectRoot }) => { - const projectCommandsDir = path.join( - projectRoot, - GEMINI_DIR, - 'commands', - ); - - mock({ - [projectCommandsDir]: { - 'project.toml': 'prompt = "Project prompt"', - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => projectRoot), - getExtensions: vi.fn(() => []), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - storage: new Storage(projectRoot), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); - - const projectCmd = commands.find((c) => c.name === 'project'); - expect(projectCmd).toBeDefined(); - expect(projectCmd?.namespace).toBe('workspace'); - expect(projectCmd?.description).toBe( - `Custom command from project.toml`, - ); - }, - ); - - it('is the extension name for extension commands', async () => { - const extensionDir = path.join( - process.cwd(), - GEMINI_DIR, - 'extensions', - 'my-ext', - ); - - mock({ - [extensionDir]: { - 'gemini-extension.json': JSON.stringify({ - name: 'my-ext', - version: '1.0.0', - }), - commands: { - 'ext.toml': 'prompt = "Extension prompt"', - }, - }, - }); - - const mockConfig = { - getProjectRoot: vi.fn(() => process.cwd()), - getExtensions: vi.fn(() => [ - { - name: 'my-ext', - version: '1.0.0', - isActive: true, - path: extensionDir, - }, - ]), - getFolderTrust: vi.fn(() => false), - isTrustedFolder: vi.fn(() => false), - } as unknown as Config; - - const loader = new FileCommandLoader(mockConfig); - const commands = await loader.loadCommands(signal); - - const extCmd = commands.find((c) => c.name === 'ext'); - expect(extCmd).toBeDefined(); - expect(extCmd?.namespace).toBe('my-ext'); - expect(extCmd?.description).toBe('Custom command from ext.toml'); - }); - }); }); diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index ea46efbfec..fb27327ead 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -37,7 +37,6 @@ import { sanitizeForDisplay } from '../ui/utils/textUtils.js'; interface CommandDirectory { path: string; - namespace: string; extensionName?: string; extensionId?: string; } @@ -112,7 +111,6 @@ export class FileCommandLoader implements ICommandLoader { this.parseAndAdaptFile( path.join(dirInfo.path, file), dirInfo.path, - dirInfo.namespace, dirInfo.extensionName, dirInfo.extensionId, ), @@ -153,16 +151,10 @@ export class FileCommandLoader implements ICommandLoader { const storage = this.config?.storage ?? new Storage(this.projectRoot); // 1. User commands - dirs.push({ - path: Storage.getUserCommandsDir(), - namespace: 'user', - }); + dirs.push({ path: Storage.getUserCommandsDir() }); // 2. Project commands (override user commands) - dirs.push({ - path: storage.getProjectCommandsDir(), - namespace: 'workspace', - }); + dirs.push({ path: storage.getProjectCommandsDir() }); // 3. Extension commands (processed last to detect all conflicts) if (this.config) { @@ -173,7 +165,6 @@ export class FileCommandLoader implements ICommandLoader { const extensionCommandDirs = activeExtensions.map((ext) => ({ path: path.join(ext.path, 'commands'), - namespace: ext.name, extensionName: ext.name, extensionId: ext.id, })); @@ -188,16 +179,14 @@ export class FileCommandLoader implements ICommandLoader { * Parses a single .toml file and transforms it into a SlashCommand object. * @param filePath The absolute path to the .toml file. * @param baseDir The root command directory for name calculation. - * @param namespace The namespace of the command. * @param extensionName Optional extension name to prefix commands with. * @returns A promise resolving to a SlashCommand, or null if the file is invalid. */ private async parseAndAdaptFile( filePath: string, baseDir: string, - namespace: string, - extensionName: string | undefined, - extensionId: string | undefined, + extensionName?: string, + extensionId?: string, ): Promise { let fileContent: string; try { @@ -256,11 +245,16 @@ export class FileCommandLoader implements ICommandLoader { }) .join(':'); + // Add extension name tag for extension commands const defaultDescription = `Custom command from ${path.basename(filePath)}`; let description = validDef.description || defaultDescription; description = sanitizeForDisplay(description, 100); + if (extensionName) { + description = `[${extensionName}] ${description}`; + } + const processors: IPromptProcessor[] = []; const usesArgs = validDef.prompt.includes(SHORTHAND_ARGS_PLACEHOLDER); const usesShellInjection = validDef.prompt.includes( @@ -291,7 +285,6 @@ export class FileCommandLoader implements ICommandLoader { return { name: baseCommandName, - namespace, description, kind: CommandKind.FILE, extensionName, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 11029cd2f4..2cbb9da9a7 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -191,12 +191,6 @@ export interface SlashCommand { kind: CommandKind; - /** - * Optional namespace for the command (e.g., 'user', 'workspace', 'extensionName'). - * If provided, the command will be registered as 'namespace:name'. - */ - namespace?: string; - /** * Controls whether the command auto-executes when selected with Enter. * From a01d7e9a05bcc13154a5ad1ee996e7861ecd7462 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:21:31 -0500 Subject: [PATCH 35/52] security: implement deceptive URL detection and disclosure in tool confirmations (#19288) --- .../messages/ToolConfirmationMessage.test.tsx | 116 ++++++++++++++++++ .../messages/ToolConfirmationMessage.tsx | 73 +++++++++-- .../cli/src/ui/utils/urlSecurityUtils.test.ts | 65 ++++++++++ packages/cli/src/ui/utils/urlSecurityUtils.ts | 90 ++++++++++++++ 4 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/ui/utils/urlSecurityUtils.test.ts create mode 100644 packages/cli/src/ui/utils/urlSecurityUtils.ts diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index c3f686762f..22d522e06c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -88,6 +88,122 @@ describe('ToolConfirmationMessage', () => { unmount(); }); + it('should display WarningMessage for deceptive URLs in info type', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'https://täst.com', + urls: ['https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://täst.com'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--tst-qla.com/', + ); + unmount(); + }); + + it('should display WarningMessage for deceptive URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain( + 'Actual Host (Punycode): https://xn--xample-2of.com/', + ); + unmount(); + }); + + it('should exclude shell delimiters from extracted URLs in exec type commands', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: 'curl https://еxample.com;ls', + rootCommand: 'curl', + rootCommands: ['curl'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + // It should extract "https://еxample.com" and NOT "https://еxample.com;ls" + expect(output).toContain('Original: https://еxample.com/'); + // The command itself still contains 'ls', so we check specifically that 'ls' is not part of the URL line. + expect(output).not.toContain('Original: https://еxample.com/;ls'); + unmount(); + }); + + it('should aggregate multiple deceptive URLs into a single WarningMessage', async () => { + const confirmationDetails: SerializableConfirmationDetails = { + type: 'info', + title: 'Confirm Web Fetch', + prompt: 'Fetch both', + urls: ['https://еxample.com', 'https://täst.com'], + }; + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + ); + + await waitUntilReady(); + + const output = lastFrame(); + expect(output).toContain('Deceptive URL(s) detected'); + expect(output).toContain('Original: https://еxample.com/'); + expect(output).toContain('Original: https://täst.com/'); + unmount(); + }); + it('should display multiple commands for exec type when provided', async () => { const confirmationDetails: SerializableConfirmationDetails = { type: 'exec', diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 84fb90d14f..c4e73b73f6 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -37,6 +37,12 @@ import { } from '../../textConstants.js'; import { AskUserDialog } from '../AskUserDialog.js'; import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js'; +import { WarningMessage } from './WarningMessage.js'; +import { + getDeceptiveUrlDetails, + toUnicodeUrl, + type DeceptiveUrlDetails, +} from '../../utils/urlSecurityUtils.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -102,6 +108,37 @@ export const ToolConfirmationMessage: React.FC< [handleConfirm], ); + const deceptiveUrlWarnings = useMemo(() => { + const urls: string[] = []; + if (confirmationDetails.type === 'info' && confirmationDetails.urls) { + urls.push(...confirmationDetails.urls); + } else if (confirmationDetails.type === 'exec') { + const commands = + confirmationDetails.commands && confirmationDetails.commands.length > 0 + ? confirmationDetails.commands + : [confirmationDetails.command]; + for (const cmd of commands) { + const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); + if (matches) urls.push(...matches); + } + } + + const uniqueUrls = Array.from(new Set(urls)); + return uniqueUrls + .map(getDeceptiveUrlDetails) + .filter((d): d is DeceptiveUrlDetails => d !== null); + }, [confirmationDetails]); + + const deceptiveUrlWarningText = useMemo(() => { + if (deceptiveUrlWarnings.length === 0) return null; + return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings + .map( + (w) => + ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, + ) + .join('\n\n')}`; + }, [deceptiveUrlWarnings]); + const getOptions = useCallback(() => { const options: Array> = []; @@ -262,11 +299,21 @@ export const ToolConfirmationMessage: React.FC< return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); }, [availableTerminalHeight, getOptions, handlesOwnUI]); - const { question, bodyContent, options } = useMemo(() => { + const { question, bodyContent, options, securityWarnings } = useMemo<{ + question: string; + bodyContent: React.ReactNode; + options: Array>; + securityWarnings: React.ReactNode; + }>(() => { let bodyContent: React.ReactNode | null = null; + let securityWarnings: React.ReactNode | null = null; let question = ''; const options = getOptions(); + if (deceptiveUrlWarningText) { + securityWarnings = ; + } + if (confirmationDetails.type === 'ask_user') { bodyContent = ( ); - return { question: '', bodyContent, options: [] }; + return { + question: '', + bodyContent, + options: [], + securityWarnings: null, + }; } if (confirmationDetails.type === 'exit_plan_mode') { @@ -307,7 +359,7 @@ export const ToolConfirmationMessage: React.FC< availableHeight={availableBodyContentHeight()} /> ); - return { question: '', bodyContent, options: [] }; + return { question: '', bodyContent, options: [], securityWarnings: null }; } if (confirmationDetails.type === 'edit') { @@ -436,10 +488,10 @@ export const ToolConfirmationMessage: React.FC< {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: - {infoProps.urls.map((url) => ( - + {infoProps.urls.map((urlString) => ( + {' '} - - + - ))} @@ -462,13 +514,14 @@ export const ToolConfirmationMessage: React.FC< ); } - return { question, bodyContent, options }; + return { question, bodyContent, options, securityWarnings }; }, [ confirmationDetails, getOptions, availableBodyContentHeight, terminalWidth, handleConfirm, + deceptiveUrlWarningText, ]); if (confirmationDetails.type === 'edit') { @@ -512,6 +565,12 @@ export const ToolConfirmationMessage: React.FC< + {securityWarnings && ( + + {securityWarnings} + + )} + {question} diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.test.ts b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts new file mode 100644 index 0000000000..3bec00a534 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.test.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getDeceptiveUrlDetails, toUnicodeUrl } from './urlSecurityUtils.js'; + +describe('urlSecurityUtils', () => { + describe('toUnicodeUrl', () => { + it('should convert a Punycode URL string to its Unicode version', () => { + expect(toUnicodeUrl('https://xn--tst-qla.com/')).toBe( + 'https://täst.com/', + ); + }); + + it('should convert a URL object to its Unicode version', () => { + const urlObj = new URL('https://xn--tst-qla.com/path'); + expect(toUnicodeUrl(urlObj)).toBe('https://täst.com/path'); + }); + + it('should handle complex URLs with credentials and ports', () => { + const complexUrl = 'https://user:pass@xn--tst-qla.com:8080/path?q=1#hash'; + expect(toUnicodeUrl(complexUrl)).toBe( + 'https://user:pass@täst.com:8080/path?q=1#hash', + ); + }); + + it('should correctly reconstruct the URL even if the hostname appears in the path', () => { + const urlWithHostnameInPath = + 'https://xn--tst-qla.com/some/path/xn--tst-qla.com/index.html'; + expect(toUnicodeUrl(urlWithHostnameInPath)).toBe( + 'https://täst.com/some/path/xn--tst-qla.com/index.html', + ); + }); + + it('should return the original string if URL parsing fails', () => { + expect(toUnicodeUrl('not a url')).toBe('not a url'); + }); + + it('should return the original string for already safe URLs', () => { + expect(toUnicodeUrl('https://google.com/')).toBe('https://google.com/'); + }); + }); + + describe('getDeceptiveUrlDetails', () => { + it('should return full details for a deceptive URL', () => { + const details = getDeceptiveUrlDetails('https://еxample.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://еxample.com/'); + expect(details?.punycodeUrl).toBe('https://xn--xample-2of.com/'); + }); + + it('should return null for safe URLs', () => { + expect(getDeceptiveUrlDetails('https://google.com')).toBeNull(); + }); + + it('should handle already Punycoded hostnames', () => { + const details = getDeceptiveUrlDetails('https://xn--tst-qla.com'); + expect(details).not.toBeNull(); + expect(details?.originalUrl).toBe('https://täst.com/'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/urlSecurityUtils.ts b/packages/cli/src/ui/utils/urlSecurityUtils.ts new file mode 100644 index 0000000000..c3a5ca20a2 --- /dev/null +++ b/packages/cli/src/ui/utils/urlSecurityUtils.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import url from 'node:url'; + +/** + * Details about a deceptive URL. + */ +export interface DeceptiveUrlDetails { + /** The Unicode version of the visually deceptive URL. */ + originalUrl: string; + /** The ASCII-safe Punycode version of the URL. */ + punycodeUrl: string; +} + +/** + * Whether a hostname contains non-ASCII or Punycode markers. + * + * @param hostname The hostname to check. + * @returns true if deceptive markers are found, false otherwise. + */ +function containsDeceptiveMarkers(hostname: string): boolean { + return ( + // eslint-disable-next-line no-control-regex + hostname.toLowerCase().includes('xn--') || /[^\x00-\x7F]/.test(hostname) + ); +} + +/** + * Converts a URL (string or object) to its visually deceptive Unicode version. + * + * This function manually reconstructs the URL to bypass the automatic Punycode + * conversion performed by the WHATWG URL class when setting the hostname. + * + * @param urlInput The URL string or URL object to convert. + * @returns The reconstructed URL string with the hostname in Unicode. + */ +export function toUnicodeUrl(urlInput: string | URL): string { + try { + const urlObj = typeof urlInput === 'string' ? new URL(urlInput) : urlInput; + const punycodeHost = urlObj.hostname; + const unicodeHost = url.domainToUnicode(punycodeHost); + + // Reconstruct the URL manually because the WHATWG URL class automatically + // Punycodes the hostname if we try to set it. + const protocol = urlObj.protocol + '//'; + const credentials = urlObj.username + ? `${urlObj.username}${urlObj.password ? ':' + urlObj.password : ''}@` + : ''; + const port = urlObj.port ? ':' + urlObj.port : ''; + + return `${protocol}${credentials}${unicodeHost}${port}${urlObj.pathname}${urlObj.search}${urlObj.hash}`; + } catch { + return typeof urlInput === 'string' ? urlInput : urlInput.href; + } +} + +/** + * Extracts deceptive URL details if a URL hostname contains non-ASCII characters + * or is already in Punycode. + * + * @param urlString The URL string to check. + * @returns DeceptiveUrlDetails if a potential deceptive URL is detected, otherwise null. + */ +export function getDeceptiveUrlDetails( + urlString: string, +): DeceptiveUrlDetails | null { + try { + if (!urlString.includes('://')) { + return null; + } + + const urlObj = new URL(urlString); + + if (!containsDeceptiveMarkers(urlObj.hostname)) { + return null; + } + + return { + originalUrl: toUnicodeUrl(urlObj), + punycodeUrl: urlObj.href, + }; + } catch { + // If URL parsing fails, it's not a valid URL we can safely analyze. + return null; + } +} From c04602f209b326bb037e107512ba70743a92757f Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Fri, 20 Feb 2026 15:31:43 -0500 Subject: [PATCH 36/52] fix(core): restore auth consent in headless mode and add unit tests (#19689) --- packages/core/src/code_assist/oauth2.test.ts | 10 ++ packages/core/src/mcp/oauth-provider.test.ts | 17 +++ packages/core/src/utils/authConsent.test.ts | 151 ++++++++++++------- packages/core/src/utils/authConsent.ts | 19 ++- 4 files changed, 133 insertions(+), 64 deletions(-) diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index e23f86fe6e..ae45c3a6b3 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -32,6 +32,7 @@ import { writeToStdout } from '../utils/stdio.js'; import { FatalCancellationError } from '../utils/errors.js'; import process from 'node:process'; import { coreEvents } from '../utils/events.js'; +import { isHeadlessMode } from '../utils/headless.js'; vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -54,6 +55,9 @@ vi.mock('http'); vi.mock('open'); vi.mock('crypto'); vi.mock('node:readline'); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(), +})); vi.mock('../utils/browser.js', () => ({ shouldAttemptBrowserLaunch: () => true, })); @@ -98,6 +102,12 @@ global.fetch = vi.fn(); describe('oauth2', () => { beforeEach(() => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + (readline.createInterface as Mock).mockReturnValue({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + }); vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); vi.spyOn(coreEvents, 'emitConsentRequest').mockImplementation((payload) => { payload.onConfirm(true); diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index 5aa90292aa..facefe176a 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -36,6 +36,23 @@ vi.mock('../utils/events.js', () => ({ vi.mock('../utils/authConsent.js', () => ({ getConsentForOauth: vi.fn(() => Promise.resolve(true)), })); +vi.mock('../utils/headless.js', () => ({ + isHeadlessMode: vi.fn(() => false), +})); +vi.mock('node:readline', () => ({ + default: { + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), + }, + createInterface: vi.fn(() => ({ + question: vi.fn((_query, callback) => callback('')), + close: vi.fn(), + on: vi.fn(), + })), +})); import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; diff --git a/packages/core/src/utils/authConsent.test.ts b/packages/core/src/utils/authConsent.test.ts index d2188ded17..c46df2d250 100644 --- a/packages/core/src/utils/authConsent.test.ts +++ b/packages/core/src/utils/authConsent.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { Mock } from 'vitest'; import readline from 'node:readline'; import process from 'node:process'; @@ -27,73 +27,116 @@ vi.mock('./stdio.js', () => ({ })); describe('getConsentForOauth', () => { - it('should use coreEvents when listeners are present', async () => { + beforeEach(() => { vi.restoreAllMocks(); - const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(1); + }); - mockEmitConsentRequest.mockImplementation((payload) => { - payload.onConfirm(true); + describe('in interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(false); }); - const result = await getConsentForOauth('Login required.'); + it('should emit consent request when UI listeners are present', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); - expect(result).toBe(true); - expect(mockEmitConsentRequest).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - }), - ); + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(true); + }); - mockListenerCount.mockRestore(); - mockEmitConsentRequest.mockRestore(); + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(true); + expect(mockEmitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining( + 'Login required. Opening authentication page in your browser.', + ), + }), + ); + }); + + it('should return false when user declines via UI', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(false); + }); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); + + it('should throw FatalAuthenticationError when no UI listeners are present', async () => { + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); + + await expect(getConsentForOauth('Login required.')).rejects.toThrow( + FatalAuthenticationError, + ); + }); }); - it('should use readline when no listeners are present and not headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(false); + describe('in non-interactive mode', () => { + beforeEach(() => { + (isHeadlessMode as Mock).mockReturnValue(true); + }); - const mockReadline = { - on: vi.fn((event, callback) => { - if (event === 'line') { - callback('y'); - } - }), - close: vi.fn(), - }; - (readline.createInterface as Mock).mockReturnValue(mockReadline); + it('should use readline to prompt for consent', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('y'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - const result = await getConsentForOauth('Login required.'); + const result = await getConsentForOauth('Login required.'); - expect(result).toBe(true); - expect(readline.createInterface).toHaveBeenCalled(); - expect(writeToStdout).toHaveBeenCalledWith( - expect.stringContaining( - 'Login required. Opening authentication page in your browser.', - ), - ); + expect(result).toBe(true); + expect(readline.createInterface).toHaveBeenCalledWith( + expect.objectContaining({ + terminal: true, + }), + ); + expect(writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('Login required.'), + ); + }); - mockListenerCount.mockRestore(); - }); + it('should accept empty response as "yes"', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback(''); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); - it('should throw FatalAuthenticationError when no listeners and headless', async () => { - vi.restoreAllMocks(); - const mockListenerCount = vi - .spyOn(coreEvents, 'listenerCount') - .mockReturnValue(0); - (isHeadlessMode as Mock).mockReturnValue(true); + const result = await getConsentForOauth('Login required.'); - await expect(getConsentForOauth('Login required.')).rejects.toThrow( - FatalAuthenticationError, - ); + expect(result).toBe(true); + }); - mockListenerCount.mockRestore(); + it('should return false when user declines via readline', async () => { + const mockReadline = { + on: vi.fn((event, callback) => { + if (event === 'line') { + callback('n'); + } + }), + close: vi.fn(), + }; + (readline.createInterface as Mock).mockReturnValue(mockReadline); + + const result = await getConsentForOauth('Login required.'); + + expect(result).toBe(false); + }); }); }); diff --git a/packages/core/src/utils/authConsent.ts b/packages/core/src/utils/authConsent.ts index 65ef633dd4..ef8b52b02e 100644 --- a/packages/core/src/utils/authConsent.ts +++ b/packages/core/src/utils/authConsent.ts @@ -12,22 +12,21 @@ import { isHeadlessMode } from './headless.js'; /** * Requests consent from the user for OAuth login. - * Handles both TTY and non-TTY environments. + * Handles both interactive and non-interactive (headless) modes. */ export async function getConsentForOauth(prompt: string): Promise { const finalPrompt = prompt + ' Opening authentication page in your browser. '; - if (coreEvents.listenerCount(CoreEvent.ConsentRequest) === 0) { - if (isHeadlessMode()) { - throw new FatalAuthenticationError( - 'Interactive consent could not be obtained.\n' + - 'Please run Gemini CLI in an interactive terminal to authenticate, or use NO_BROWSER=true for manual authentication.', - ); - } + if (isHeadlessMode()) { return getOauthConsentNonInteractive(finalPrompt); + } else if (coreEvents.listenerCount(CoreEvent.ConsentRequest) > 0) { + return getOauthConsentInteractive(finalPrompt); } - - return getOauthConsentInteractive(finalPrompt); + throw new FatalAuthenticationError( + 'Authentication consent could not be obtained.\n' + + 'Please run Gemini CLI in an interactive terminal to authenticate, ' + + 'or use NO_BROWSER=true for manual authentication.', + ); } async function getOauthConsentNonInteractive(prompt: string) { From b7555ab1e17de6bf7a16429e117d2b30596140a1 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 20:44:23 +0000 Subject: [PATCH 37/52] Fix unsafe assertions in code_assist folder. (#19706) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/code_assist/converter.ts | 15 ++++- .../code_assist/experiments/experiments.ts | 5 +- .../code_assist/oauth-credential-storage.ts | 3 +- packages/core/src/code_assist/oauth2.ts | 13 ++-- packages/core/src/code_assist/server.ts | 64 ++++++++++++------- 5 files changed, 62 insertions(+), 38 deletions(-) diff --git a/packages/core/src/code_assist/converter.ts b/packages/core/src/code_assist/converter.ts index 1f2b4417ac..1d41101f31 100644 --- a/packages/core/src/code_assist/converter.ts +++ b/packages/core/src/code_assist/converter.ts @@ -181,6 +181,16 @@ function maybeToContent(content?: ContentUnion): Content | undefined { return toContent(content); } +function isPart(c: ContentUnion): c is PartUnion { + return ( + typeof c === 'object' && + c !== null && + !Array.isArray(c) && + !('parts' in c) && + !('role' in c) + ); +} + function toContent(content: ContentUnion): Content { if (Array.isArray(content)) { // it's a PartsUnion[] @@ -196,7 +206,7 @@ function toContent(content: ContentUnion): Content { parts: [{ text: content }], }; } - if ('parts' in content) { + if (!isPart(content)) { // it's a Content - process parts to handle thought filtering return { ...content, @@ -208,8 +218,7 @@ function toContent(content: ContentUnion): Content { // it's a Part return { role: 'user', - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - parts: [toPart(content as Part)], + parts: [toPart(content)], }; } diff --git a/packages/core/src/code_assist/experiments/experiments.ts b/packages/core/src/code_assist/experiments/experiments.ts index 614fbda43e..94b99b311a 100644 --- a/packages/core/src/code_assist/experiments/experiments.ts +++ b/packages/core/src/code_assist/experiments/experiments.ts @@ -35,7 +35,7 @@ export async function getExperiments( const expPath = process.env['GEMINI_EXP']; debugLogger.debug('Reading experiments from', expPath); const content = await fs.promises.readFile(expPath, 'utf8'); - const response = JSON.parse(content); + const response: ListExperimentsResponse = JSON.parse(content); if ( (response.flags && !Array.isArray(response.flags)) || (response.experimentIds && !Array.isArray(response.experimentIds)) @@ -44,8 +44,7 @@ export async function getExperiments( 'Invalid format for experiments file: `flags` and `experimentIds` must be arrays if present.', ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return parseExperiments(response as ListExperimentsResponse); + return parseExperiments(response); } catch (e) { debugLogger.debug('Failed to read experiments from GEMINI_EXP', e); } diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts index 836fe1c4c3..39163384b9 100644 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ b/packages/core/src/code_assist/oauth-credential-storage.ts @@ -125,8 +125,7 @@ export class OAuthCredentialStorage { throw error; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const credentials = JSON.parse(credsJson) as Credentials; + const credentials: Credentials = JSON.parse(credsJson); // Save to new storage await this.saveCredentials(credentials); diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 9676f2aa74..e5ad7e584a 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -115,9 +115,9 @@ async function initOauthClient( if ( credentials && - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (credentials as { type?: string }).type === - 'external_account_authorized_user' + typeof credentials === 'object' && + 'type' in credentials && + credentials.type === 'external_account_authorized_user' ) { const auth = new GoogleAuth({ scopes: OAUTH_SCOPE, @@ -603,9 +603,10 @@ export function getAvailablePort(): Promise { } const server = net.createServer(); server.listen(0, () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const address = server.address()! as net.AddressInfo; - port = address.port; + const address = server.address(); + if (address && typeof address === 'object') { + port = address.port; + } }); server.on('listening', () => { server.close(); diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index ba9b96bcb3..ff5fb76e07 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -7,7 +7,6 @@ import type { AuthClient } from 'google-auth-library'; import type { CodeAssistGlobalUserSettingResponse, - GoogleRpcResponse, LoadCodeAssistRequest, LoadCodeAssistResponse, LongRunningOperationResponse, @@ -296,7 +295,7 @@ export class CodeAssistServer implements ContentGenerator { req: object, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url: this.getMethodUrl(method), method: 'POST', headers: { @@ -307,15 +306,14 @@ export class CodeAssistServer implements ContentGenerator { body: JSON.stringify(req), signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } private async makeGetRequest( url: string, signal?: AbortSignal, ): Promise { - const res = await this.client.request({ + const res = await this.client.request({ url, method: 'GET', headers: { @@ -325,8 +323,7 @@ export class CodeAssistServer implements ContentGenerator { responseType: 'json', signal, }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return res.data as T; + return res.data; } async requestGet(method: string, signal?: AbortSignal): Promise { @@ -371,8 +368,7 @@ export class CodeAssistServer implements ContentGenerator { if (bufferedLines.length === 0) { continue; // no data to yield } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - yield JSON.parse(bufferedLines.join('\n')) as T; + yield JSON.parse(bufferedLines.join('\n')); bufferedLines = []; // Reset the buffer after yielding } // Ignore other lines like comments or id fields @@ -397,23 +393,43 @@ export class CodeAssistServer implements ContentGenerator { } } -function isVpcScAffectedUser(error: unknown): boolean { - if (error && typeof error === 'object' && 'response' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const gaxiosError = error as { - response?: { - data?: unknown; +interface VpcScErrorResponse { + response: { + data: { + error: { + details: unknown[]; }; }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const response = gaxiosError.response?.data as - | GoogleRpcResponse - | undefined; - if (Array.isArray(response?.error?.details)) { - return response.error.details.some( - (detail) => detail.reason === 'SECURITY_POLICY_VIOLATED', - ); - } + }; +} + +function isVpcScErrorResponse(error: unknown): error is VpcScErrorResponse { + return ( + !!error && + typeof error === 'object' && + 'response' in error && + !!error.response && + typeof error.response === 'object' && + 'data' in error.response && + !!error.response.data && + typeof error.response.data === 'object' && + 'error' in error.response.data && + !!error.response.data.error && + typeof error.response.data.error === 'object' && + 'details' in error.response.data.error && + Array.isArray(error.response.data.error.details) + ); +} + +function isVpcScAffectedUser(error: unknown): boolean { + if (isVpcScErrorResponse(error)) { + return error.response.data.error.details.some( + (detail: unknown) => + detail && + typeof detail === 'object' && + 'reason' in detail && + detail.reason === 'SECURITY_POLICY_VIOLATED', + ); } return false; } From 089aec8b8dad56518a2a0d16afa2b7f9f0ed9fe1 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 20 Feb 2026 13:06:35 -0800 Subject: [PATCH 38/52] feat(cli): make JetBrains warning more specific (#19687) --- packages/cli/src/gemini.tsx | 8 +- packages/cli/src/utils/userStartupWarnings.ts | 7 +- packages/core/src/utils/compatibility.test.ts | 243 ++++++++++++------ packages/core/src/utils/compatibility.ts | 23 +- 4 files changed, 194 insertions(+), 87 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index dc3d64046b..8dbff89128 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -671,6 +671,10 @@ export async function main() { } let input = config.getQuestion(); + const useAlternateBuffer = shouldEnterAlternateScreen( + isAlternateBufferEnabled(settings), + config.getScreenReader(), + ); const rawStartupWarnings = await getStartupWarnings(); const startupWarnings: StartupWarning[] = [ ...rawStartupWarnings.map((message) => ({ @@ -678,7 +682,9 @@ export async function main() { message, priority: WarningPriority.High, })), - ...(await getUserStartupWarnings(settings.merged)), + ...(await getUserStartupWarnings(settings.merged, undefined, { + isAlternateBuffer: useAlternateBuffer, + })), ]; // Handle --resume flag diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index da9623acb6..6174e6c420 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -88,6 +88,7 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), + options?: { isAlternateBuffer?: boolean }, ): Promise { const results = await Promise.all( WARNING_CHECKS.map(async (check) => { @@ -105,7 +106,11 @@ export async function getUserStartupWarnings( const warnings = results.filter((w): w is StartupWarning => w !== null); if (settings.ui?.showCompatibilityWarnings !== false) { - warnings.push(...getCompatibilityWarnings()); + warnings.push( + ...getCompatibilityWarnings({ + isAlternateBuffer: options?.isAlternateBuffer, + }), + ); } return warnings; diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index c7819578f1..faf0dd579d 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -12,6 +12,7 @@ import { supports256Colors, supportsTrueColor, getCompatibilityWarnings, + WarningPriority, } from './compatibility.js'; vi.mock('node:os', () => ({ @@ -31,83 +32,128 @@ describe('compatibility', () => { }); describe('isWindows10', () => { - it('should return true for Windows 10 (build < 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.19041'); - expect(isWindows10()).toBe(true); - }); - - it('should return false for Windows 11 (build >= 22000)', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.22000'); - expect(isWindows10()).toBe(false); - }); - - it('should return false for non-Windows platforms', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.mocked(os.release).mockReturnValue('20.6.0'); - expect(isWindows10()).toBe(false); - }); + it.each<{ + platform: NodeJS.Platform; + release: string; + expected: boolean; + desc: string; + }>([ + { + platform: 'win32', + release: '10.0.19041', + expected: true, + desc: 'Windows 10 (build < 22000)', + }, + { + platform: 'win32', + release: '10.0.22000', + expected: false, + desc: 'Windows 11 (build >= 22000)', + }, + { + platform: 'darwin', + release: '20.6.0', + expected: false, + desc: 'non-Windows platforms', + }, + ])( + 'should return $expected for $desc', + ({ platform, release, expected }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + expect(isWindows10()).toBe(expected); + }, + ); }); describe('isJetBrainsTerminal', () => { - it('should return true when TERMINAL_EMULATOR is JetBrains-JediTerm', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - expect(isJetBrainsTerminal()).toBe(true); - }); - - it('should return false for other terminals', () => { - vi.stubEnv('TERMINAL_EMULATOR', 'something-else'); - expect(isJetBrainsTerminal()).toBe(false); - }); - - it('should return false when TERMINAL_EMULATOR is not set', () => { - vi.stubEnv('TERMINAL_EMULATOR', ''); - expect(isJetBrainsTerminal()).toBe(false); + it.each<{ env: string; expected: boolean; desc: string }>([ + { + env: 'JetBrains-JediTerm', + expected: true, + desc: 'TERMINAL_EMULATOR is JetBrains-JediTerm', + }, + { env: 'something-else', expected: false, desc: 'other terminals' }, + { env: '', expected: false, desc: 'TERMINAL_EMULATOR is not set' }, + ])('should return $expected when $desc', ({ env, expected }) => { + vi.stubEnv('TERMINAL_EMULATOR', env); + expect(isJetBrainsTerminal()).toBe(expected); }); }); describe('supports256Colors', () => { - it('should return true when getColorDepth returns >= 8', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - expect(supports256Colors()).toBe(true); - }); - - it('should return true when TERM contains 256color', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(4); - vi.stubEnv('TERM', 'xterm-256color'); - expect(supports256Colors()).toBe(true); - }); - - it('should return false when 256 colors are not supported', () => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(4); - vi.stubEnv('TERM', 'xterm'); - expect(supports256Colors()).toBe(false); + it.each<{ + depth: number; + term?: string; + expected: boolean; + desc: string; + }>([ + { + depth: 8, + term: undefined, + expected: true, + desc: 'getColorDepth returns >= 8', + }, + { + depth: 4, + term: 'xterm-256color', + expected: true, + desc: 'TERM contains 256color', + }, + { + depth: 4, + term: 'xterm', + expected: false, + desc: '256 colors are not supported', + }, + ])('should return $expected when $desc', ({ depth, term, expected }) => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + if (term !== undefined) { + vi.stubEnv('TERM', term); + } + expect(supports256Colors()).toBe(expected); }); }); describe('supportsTrueColor', () => { - it('should return true when COLORTERM is truecolor', () => { - vi.stubEnv('COLORTERM', 'truecolor'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when COLORTERM is 24bit', () => { - vi.stubEnv('COLORTERM', '24bit'); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return true when getColorDepth returns >= 24', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(24); - expect(supportsTrueColor()).toBe(true); - }); - - it('should return false when true color is not supported', () => { - vi.stubEnv('COLORTERM', ''); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - expect(supportsTrueColor()).toBe(false); - }); + it.each<{ + colorterm: string; + depth: number; + expected: boolean; + desc: string; + }>([ + { + colorterm: 'truecolor', + depth: 8, + expected: true, + desc: 'COLORTERM is truecolor', + }, + { + colorterm: '24bit', + depth: 8, + expected: true, + desc: 'COLORTERM is 24bit', + }, + { + colorterm: '', + depth: 24, + expected: true, + desc: 'getColorDepth returns >= 24', + }, + { + colorterm: '', + depth: 8, + expected: false, + desc: 'true color is not supported', + }, + ])( + 'should return $expected when $desc', + ({ colorterm, depth, expected }) => { + vi.stubEnv('COLORTERM', colorterm); + process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); + expect(supportsTrueColor()).toBe(expected); + }, + ); }); describe('getCompatibilityWarnings', () => { @@ -131,17 +177,58 @@ describe('compatibility', () => { ); }); - it('should return JetBrains warning when detected', () => { + it.each<{ + platform: NodeJS.Platform; + release: string; + externalTerminal: string; + desc: string; + }>([ + { + platform: 'darwin', + release: '20.6.0', + externalTerminal: 'iTerm2 or Ghostty', + desc: 'macOS', + }, + { + platform: 'win32', + release: '10.0.22000', + externalTerminal: 'Windows Terminal', + desc: 'Windows', + }, // Valid Windows 11 release to not trigger the Windows 10 warning + { + platform: 'linux', + release: '5.10.0', + externalTerminal: 'Ghostty', + desc: 'Linux', + }, + ])( + 'should return JetBrains warning when detected and in alternate buffer ($desc)', + ({ platform, release, externalTerminal }) => { + vi.mocked(os.platform).mockReturnValue(platform); + vi.mocked(os.release).mockReturnValue(release); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + message: expect.stringContaining( + `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal (e.g., ${externalTerminal}) is recommended.`, + ), + priority: WarningPriority.High, + }), + ); + }, + ); + + it('should not return JetBrains warning when detected but NOT in alternate buffer', () => { vi.mocked(os.platform).mockReturnValue('darwin'); vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - const warnings = getCompatibilityWarnings(); - expect(warnings).toContainEqual( - expect.objectContaining({ - id: 'jetbrains-terminal', - message: expect.stringContaining('JetBrains terminal detected'), - }), - ); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: false }); + expect( + warnings.find((w) => w.id === 'jetbrains-terminal'), + ).toBeUndefined(); }); it('should return 256-color warning when 256 colors are not supported', () => { @@ -156,7 +243,7 @@ describe('compatibility', () => { expect.objectContaining({ id: '256-color', message: expect.stringContaining('256-color support not detected'), - priority: 'high', + priority: WarningPriority.High, }), ); // Should NOT show true-color warning if 256-color warning is shown @@ -177,7 +264,7 @@ describe('compatibility', () => { message: expect.stringContaining( 'True color (24-bit) support not detected', ), - priority: 'low', + priority: WarningPriority.Low, }), ); }); @@ -201,10 +288,10 @@ describe('compatibility', () => { vi.stubEnv('TERM_PROGRAM', 'xterm'); process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - const warnings = getCompatibilityWarnings(); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); expect(warnings).toHaveLength(3); expect(warnings[0].message).toContain('Windows 10 detected'); - expect(warnings[1].message).toContain('JetBrains terminal detected'); + expect(warnings[1].message).toContain('JetBrains'); expect(warnings[2].message).toContain( 'True color (24-bit) support not detected', ); diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index 8099351ad0..15b2ae24b4 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -75,9 +75,6 @@ export function supportsTrueColor(): boolean { return false; } -/** - * Returns a list of compatibility warnings based on the current environment. - */ export enum WarningPriority { Low = 'low', High = 'high', @@ -89,7 +86,12 @@ export interface StartupWarning { priority: WarningPriority; } -export function getCompatibilityWarnings(): StartupWarning[] { +/** + * Returns a list of compatibility warnings based on the current environment. + */ +export function getCompatibilityWarnings(options?: { + isAlternateBuffer?: boolean; +}): StartupWarning[] { const warnings: StartupWarning[] = []; if (isWindows10()) { @@ -101,11 +103,18 @@ export function getCompatibilityWarnings(): StartupWarning[] { }); } - if (isJetBrainsTerminal()) { + if (isJetBrainsTerminal() && options?.isAlternateBuffer) { + const platformTerminals: Partial> = { + win32: 'Windows Terminal', + darwin: 'iTerm2 or Ghostty', + linux: 'Ghostty', + }; + const suggestion = platformTerminals[os.platform()]; + const suggestedTerminals = suggestion ? ` (e.g., ${suggestion})` : ''; + warnings.push({ id: 'jetbrains-terminal', - message: - 'Warning: JetBrains terminal detected. You may experience rendering or scrolling issues. Using an external terminal (e.g., Windows Terminal, iTerm2) is recommended.', + message: `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal${suggestedTerminals} is recommended.`, priority: WarningPriority.High, }); } From 9a8e5d3940f9465bb2e07dcf9c6b68e27bf1734e Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Fri, 20 Feb 2026 13:08:24 -0800 Subject: [PATCH 39/52] fix(cli): extensions dialog UX polish (#19685) --- .../cli/src/ui/components/SettingsDialog.tsx | 18 +--- .../components/shared/SearchableList.test.tsx | 9 +- .../ui/components/shared/SearchableList.tsx | 88 ++++++++++++------- .../SearchableList.test.tsx.snap | 60 +++++++++++-- .../views/ExtensionRegistryView.test.tsx | 2 + .../views/ExtensionRegistryView.tsx | 25 +++++- .../cli/src/ui/hooks/useRegistrySearch.ts | 40 ++++----- packages/cli/src/ui/hooks/useSearchBuffer.ts | 41 +++++++++ 8 files changed, 203 insertions(+), 80 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useSearchBuffer.ts diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index fe3acbd1f1..e95692275a 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -39,8 +39,8 @@ import { } from '../../config/settingsSchema.js'; import { coreEvents, debugLogger } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; -import { useUIState } from '../contexts/UIStateContext.js'; -import { useTextBuffer } from './shared/text-buffer.js'; + +import { useSearchBuffer } from '../hooks/useSearchBuffer.js'; import { BaseSettingsDialog, type SettingsDialogItem, @@ -207,20 +207,10 @@ export function SettingsDialog({ return max; }, [selectedScope, settings]); - // Get mainAreaWidth for search buffer viewport - const { mainAreaWidth } = useUIState(); - const viewportWidth = mainAreaWidth - 8; - // Search input buffer - const searchBuffer = useTextBuffer({ + const searchBuffer = useSearchBuffer({ initialText: '', - initialCursorOffset: 0, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), + onChange: setSearchQuery, }); // Generate items for BaseSettingsDialog diff --git a/packages/cli/src/ui/components/shared/SearchableList.test.tsx b/packages/cli/src/ui/components/shared/SearchableList.test.tsx index 42b118e251..e156c12695 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.test.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.test.tsx @@ -131,8 +131,9 @@ describe('SearchableList', () => { await waitFor(() => { const frame = lastFrame(); - expect(frame).toContain('> Item Two'); + expect(frame).toContain('● Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); await React.act(async () => { stdin.write('One'); @@ -143,6 +144,7 @@ describe('SearchableList', () => { expect(frame).toContain('Item One'); expect(frame).not.toContain('Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); await React.act(async () => { // Backspace "One" (3 chars) @@ -152,9 +154,10 @@ describe('SearchableList', () => { await waitFor(() => { const frame = lastFrame(); expect(frame).toContain('Item Two'); - expect(frame).toContain('> Item One'); - expect(frame).not.toContain('> Item Two'); + expect(frame).toContain('● Item One'); + expect(frame).not.toContain('● Item Two'); }); + expect(lastFrame()).toMatchSnapshot(); }); it('should filter items based on search query', async () => { diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx index a20a44be42..1611bc2842 100644 --- a/packages/cli/src/ui/components/shared/SearchableList.tsx +++ b/packages/cli/src/ui/components/shared/SearchableList.tsx @@ -112,13 +112,36 @@ export function SearchableList({ isFocused: true, showNumbers: false, wrapAround: true, + priority: true, }); + const [scrollOffsetState, setScrollOffsetState] = React.useState(0); + + // Compute effective scroll offset during render to avoid visual flicker + let scrollOffset = scrollOffsetState; + + if (activeIndex < scrollOffset) { + scrollOffset = activeIndex; + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + scrollOffset = activeIndex - maxItemsToShow + 1; + } + + const maxScroll = Math.max(0, filteredItems.length - maxItemsToShow); + if (scrollOffset > maxScroll) { + scrollOffset = maxScroll; + } + + // Update state to match derived value if it changed + if (scrollOffsetState !== scrollOffset) { + setScrollOffsetState(scrollOffset); + } + // Reset selection to top when items change if requested const prevItemsRef = React.useRef(filteredItems); - React.useEffect(() => { + React.useLayoutEffect(() => { if (resetSelectionOnItemsChange && filteredItems !== prevItemsRef.current) { setActiveIndex(0); + setScrollOffsetState(0); } prevItemsRef.current = filteredItems; }, [filteredItems, setActiveIndex, resetSelectionOnItemsChange]); @@ -135,14 +158,6 @@ export function SearchableList({ { isActive: true }, ); - const scrollOffset = Math.max( - 0, - Math.min( - activeIndex - Math.floor(maxItemsToShow / 2), - Math.max(0, filteredItems.length - maxItemsToShow), - ), - ); - const visibleItems = filteredItems.slice( scrollOffset, scrollOffset + maxItemsToShow, @@ -153,21 +168,22 @@ export function SearchableList({ isActive: boolean, labelWidth: number, ) => ( - - - {isActive ? '> ' : ' '} - {item.label.padEnd(labelWidth)} - - {item.description && ( - + + + + {isActive ? '●' : ''} + + + + + {item.label.padEnd(labelWidth)} + + {item.description && ( {item.description} - - )} + )} + ); @@ -204,16 +220,28 @@ export function SearchableList({ No items found. ) : ( - visibleItems.map((item, index) => { - const isSelected = activeIndex === scrollOffset + index; - return ( - - {renderItem - ? renderItem(item, isSelected, maxLabelWidth) - : defaultRenderItem(item, isSelected, maxLabelWidth)} + <> + {filteredItems.length > maxItemsToShow && ( + + - ); - }) + )} + {visibleItems.map((item, index) => { + const isSelected = activeIndex === scrollOffset + index; + return ( + + {renderItem + ? renderItem(item, isSelected, maxLabelWidth) + : defaultRenderItem(item, isSelected, maxLabelWidth)} + + ); + })} + {filteredItems.length > maxItemsToShow && ( + + + + )} + )} diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap index e596373e01..35f21daee3 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -7,13 +7,61 @@ exports[`SearchableList > should match snapshot 1`] = ` │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - > Item One - Description for item one + ● Item One + Description for item one - Item Two - Description for item two + Item Two + Description for item two - Item Three - Description for item three + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 1`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Item One + Description for item one + + ● Item Two + Description for item two + + Item Three + Description for item three +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 2`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ One │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one +" +`; + +exports[`SearchableList > should reset selection to top when items change if resetSelectionOnItemsChange is true 3`] = ` +" Test List + + ╭────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ Search... │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ + + ● Item One + Description for item one + + Item Two + Description for item two + + Item Three + Description for item three " `; diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx index 58f687eb6d..954dff1f07 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.test.tsx @@ -126,6 +126,8 @@ describe('ExtensionRegistryView', () => { vi.mocked(useUIState).mockReturnValue({ mainAreaWidth: 100, + terminalHeight: 40, + staticExtraHeight: 5, } as unknown as ReturnType); vi.mocked(useConfig).mockReturnValue({ diff --git a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx index 9a7c15144a..1f6fba96ea 100644 --- a/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx +++ b/packages/cli/src/ui/components/views/ExtensionRegistryView.tsx @@ -22,6 +22,8 @@ import { useConfig } from '../../contexts/ConfigContext.js'; import type { ExtensionManager } from '../../../config/extension-manager.js'; import { useRegistrySearch } from '../../hooks/useRegistrySearch.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; + interface ExtensionRegistryViewProps { onSelect?: (extension: RegistryExtension) => void; onClose?: () => void; @@ -39,6 +41,7 @@ export function ExtensionRegistryView({ }: ExtensionRegistryViewProps): React.JSX.Element { const { extensions, loading, error, search } = useExtensionRegistry(); const config = useConfig(); + const { terminalHeight, staticExtraHeight } = useUIState(); const { extensionsUpdateState } = useExtensionUpdates( extensionManager, @@ -83,7 +86,7 @@ export function ExtensionRegistryView({ - {isActive ? '> ' : ' '} + {isActive ? '● ' : ' '} @@ -164,6 +167,24 @@ export function ExtensionRegistryView({ [], ); + const maxItemsToShow = useMemo(() => { + // SearchableList layout overhead: + // Container paddingY: 0 + // Title (marginBottom 1): 2 + // Search buffer (border 2, marginBottom 1): 4 + // Header (marginBottom 1): 2 + // Footer (marginTop 1): 2 + // List item (marginBottom 1): 2 per item + // Total static height = 2 + 4 + 2 + 2 = 10 + const staticHeight = 10; + const availableTerminalHeight = terminalHeight - staticExtraHeight; + const remainingHeight = Math.max(0, availableTerminalHeight - staticHeight); + const itemHeight = 2; // Each item takes 2 lines (content + marginBottom 1) + + // Ensure we show at least a few items and not more than we have + return Math.max(4, Math.floor(remainingHeight / itemHeight)); + }, [terminalHeight, staticExtraHeight]); + if (loading) { return ( @@ -191,7 +212,7 @@ export function ExtensionRegistryView({ renderItem={renderItem} header={header} footer={footer} - maxItemsToShow={8} + maxItemsToShow={maxItemsToShow} useSearch={useRegistrySearch} onSearch={search} resetSelectionOnItemsChange={true} diff --git a/packages/cli/src/ui/hooks/useRegistrySearch.ts b/packages/cli/src/ui/hooks/useRegistrySearch.ts index e1a1c4191b..3a93c61a0a 100644 --- a/packages/cli/src/ui/hooks/useRegistrySearch.ts +++ b/packages/cli/src/ui/hooks/useRegistrySearch.ts @@ -4,16 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect } from 'react'; -import { - useTextBuffer, - type TextBuffer, -} from '../components/shared/text-buffer.js'; -import { useUIState } from '../contexts/UIStateContext.js'; +import { useState, useEffect, useRef } from 'react'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; import type { GenericListItem } from '../components/shared/SearchableList.js'; - -const MIN_VIEWPORT_WIDTH = 20; -const VIEWPORT_WIDTH_OFFSET = 8; +import { useSearchBuffer } from './useSearchBuffer.js'; export interface UseRegistrySearchResult { filteredItems: T[]; @@ -31,26 +25,22 @@ export function useRegistrySearch(props: { const { items, initialQuery = '', onSearch } = props; const [searchQuery, setSearchQuery] = useState(initialQuery); + const isFirstRender = useRef(true); + const onSearchRef = useRef(onSearch); + + onSearchRef.current = onSearch; useEffect(() => { - onSearch?.(searchQuery); - }, [searchQuery, onSearch]); + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + onSearchRef.current?.(searchQuery); + }, [searchQuery]); - const { mainAreaWidth } = useUIState(); - const viewportWidth = Math.max( - MIN_VIEWPORT_WIDTH, - mainAreaWidth - VIEWPORT_WIDTH_OFFSET, - ); - - const searchBuffer = useTextBuffer({ + const searchBuffer = useSearchBuffer({ initialText: searchQuery, - initialCursorOffset: searchQuery.length, - viewport: { - width: viewportWidth, - height: 1, - }, - singleLine: true, - onChange: (text) => setSearchQuery(text), + onChange: setSearchQuery, }); const maxLabelWidth = 0; diff --git a/packages/cli/src/ui/hooks/useSearchBuffer.ts b/packages/cli/src/ui/hooks/useSearchBuffer.ts new file mode 100644 index 0000000000..d1c8f9f8b8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSearchBuffer.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + useTextBuffer, + type TextBuffer, +} from '../components/shared/text-buffer.js'; +import { useUIState } from '../contexts/UIStateContext.js'; + +const MIN_VIEWPORT_WIDTH = 20; +const VIEWPORT_WIDTH_OFFSET = 8; + +export interface UseSearchBufferProps { + initialText?: string; + onChange: (text: string) => void; +} + +export function useSearchBuffer({ + initialText = '', + onChange, +}: UseSearchBufferProps): TextBuffer { + const { mainAreaWidth } = useUIState(); + const viewportWidth = Math.max( + MIN_VIEWPORT_WIDTH, + mainAreaWidth - VIEWPORT_WIDTH_OFFSET, + ); + + return useTextBuffer({ + initialText, + initialCursorOffset: initialText.length, + viewport: { + width: viewportWidth, + height: 1, + }, + singleLine: true, + onChange, + }); +} From b48970da152dba4fcf4eb79f2fff100585217c7c Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 17:03:32 -0500 Subject: [PATCH 40/52] fix(cli): use getDisplayString for manual model selection in dialog (#19726) --- .../src/ui/components/ModelDialog.test.tsx | 25 ++++++++++++++----- .../cli/src/ui/components/ModelDialog.tsx | 12 ++++----- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 6f347faa1d..d5c89215b8 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -109,7 +109,15 @@ describe('', () => { unmount(); }); - it('switches to "manual" view when "Manual" is selected', async () => { + it('switches to "manual" view when "Manual" is selected and uses getDisplayString for models', async () => { + mockGetDisplayString.mockImplementation((val: string) => { + if (val === DEFAULT_GEMINI_MODEL) return 'Formatted Pro Model'; + if (val === DEFAULT_GEMINI_FLASH_MODEL) return 'Formatted Flash Model'; + if (val === DEFAULT_GEMINI_FLASH_LITE_MODEL) + return 'Formatted Lite Model'; + return val; + }); + const { lastFrame, stdin, waitUntilReady, unmount } = await renderComponent(); @@ -129,9 +137,9 @@ describe('', () => { // Should now show manual options await waitFor(() => { const output = lastFrame(); - expect(output).toContain(DEFAULT_GEMINI_MODEL); - expect(output).toContain(DEFAULT_GEMINI_FLASH_MODEL); - expect(output).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL); + expect(output).toContain('Formatted Pro Model'); + expect(output).toContain('Formatted Flash Model'); + expect(output).toContain('Formatted Lite Model'); }); unmount(); }); @@ -264,11 +272,16 @@ describe('', () => { unmount(); }); - it('shows the preferred manual model in the main view option', async () => { + it('shows the preferred manual model in the main view option using getDisplayString', async () => { mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL); + mockGetDisplayString.mockImplementation((val: string) => { + if (val === DEFAULT_GEMINI_MODEL) return 'My Custom Model Display'; + if (val === 'auto-gemini-2.5') return 'Auto (Gemini 2.5)'; + return val; + }); const { lastFrame, unmount } = await renderComponent(); - expect(lastFrame()).toContain(`Manual (${DEFAULT_GEMINI_MODEL})`); + expect(lastFrame()).toContain('Manual (My Custom Model Display)'); unmount(); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index d50e9b7153..7d7fea4d86 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -94,7 +94,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { { value: 'Manual', title: manualModelSelected - ? `Manual (${manualModelSelected})` + ? `Manual (${getDisplayString(manualModelSelected)})` : 'Manual', description: 'Manually select a model', key: 'Manual', @@ -118,17 +118,17 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const list = [ { value: DEFAULT_GEMINI_MODEL, - title: DEFAULT_GEMINI_MODEL, + title: getDisplayString(DEFAULT_GEMINI_MODEL), key: DEFAULT_GEMINI_MODEL, }, { value: DEFAULT_GEMINI_FLASH_MODEL, - title: DEFAULT_GEMINI_FLASH_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_MODEL), key: DEFAULT_GEMINI_FLASH_MODEL, }, { value: DEFAULT_GEMINI_FLASH_LITE_MODEL, - title: DEFAULT_GEMINI_FLASH_LITE_MODEL, + title: getDisplayString(DEFAULT_GEMINI_FLASH_LITE_MODEL), key: DEFAULT_GEMINI_FLASH_LITE_MODEL, }, ]; @@ -145,12 +145,12 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { list.unshift( { value: previewProValue, - title: previewProModel, + title: getDisplayString(previewProModel), key: previewProModel, }, { value: PREVIEW_GEMINI_FLASH_MODEL, - title: PREVIEW_GEMINI_FLASH_MODEL, + title: getDisplayString(PREVIEW_GEMINI_FLASH_MODEL), key: PREVIEW_GEMINI_FLASH_MODEL, }, ); From c5baf39dbd78a66128c10ff97d6b5e5cb5716e32 Mon Sep 17 00:00:00 2001 From: Abhijit Balaji Date: Fri, 20 Feb 2026 14:07:20 -0800 Subject: [PATCH 41/52] feat(policy): repurpose "Always Allow" persistence to workspace level (#19707) --- packages/cli/src/config/policy.ts | 3 +- packages/cli/src/gemini.tsx | 2 +- packages/core/src/config/storage.ts | 9 +++ packages/core/src/policy/config.ts | 70 +++++++++++-------- packages/core/src/policy/persistence.test.ts | 68 +++++++++++++----- .../core/src/policy/policy-updater.test.ts | 19 +++-- 6 files changed, 113 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index e689094f94..ef6164efb7 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -41,8 +41,9 @@ export async function createPolicyEngineConfig( export function createPolicyUpdater( policyEngine: PolicyEngine, messageBus: MessageBus, + storage: Storage, ) { - return createCorePolicyUpdater(policyEngine, messageBus); + return createCorePolicyUpdater(policyEngine, messageBus, storage); } export interface WorkspacePolicyState { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 8dbff89128..412ba01b31 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -583,7 +583,7 @@ export async function main() { const policyEngine = config.getPolicyEngine(); const messageBus = config.getMessageBus(); - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, config.storage); // Register SessionEnd hook to fire on graceful exit // This runs before telemetry shutdown in runExitCleanup() diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bd04123c34..3099f39d1e 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -23,6 +23,8 @@ const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const AGENTS_DIR_NAME = '.agents'; +export const AUTO_SAVED_POLICY_FILENAME = 'auto-saved.toml'; + export class Storage { private readonly targetDir: string; private readonly sessionId: string | undefined; @@ -154,6 +156,13 @@ export class Storage { return path.join(this.getGeminiDir(), 'policies'); } + getAutoSavedPolicyPath(): string { + return path.join( + this.getWorkspacePoliciesDir(), + AUTO_SAVED_POLICY_FILENAME, + ); + } + ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index 50fbc0ef2a..7de415cb37 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -43,6 +43,20 @@ export const WORKSPACE_POLICY_TIER = 2; export const USER_POLICY_TIER = 3; export const ADMIN_POLICY_TIER = 4; +// Specific priority offsets and derived priorities for dynamic/settings rules. +// These are added to the tier base (e.g., USER_POLICY_TIER). + +// Workspace tier (2) + high priority (950/1000) = ALWAYS_ALLOW_PRIORITY +// This ensures user "always allow" selections are high priority +// within the workspace tier but still lose to user/admin policies. +export const ALWAYS_ALLOW_PRIORITY = WORKSPACE_POLICY_TIER + 0.95; + +export const MCP_EXCLUDED_PRIORITY = USER_POLICY_TIER + 0.9; +export const EXCLUDE_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.4; +export const ALLOWED_TOOLS_FLAG_PRIORITY = USER_POLICY_TIER + 0.3; +export const TRUSTED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.2; +export const ALLOWED_MCP_SERVER_PRIORITY = USER_POLICY_TIER + 0.1; + /** * Gets the list of directories to search for policy files, in order of increasing priority * (Default -> User -> Project -> Admin). @@ -233,13 +247,14 @@ export async function createPolicyEngineConfig( // This ensures Admin > User > Workspace > Default hierarchy is always preserved, // while allowing user-specified priorities to work within each tier. // - // Settings-based and dynamic rules (all in user tier 3.x): - // 3.95: Tools that the user has selected as "Always Allow" in the interactive UI - // 3.9: MCP servers excluded list (security: persistent server blocks) - // 3.4: Command line flag --exclude-tools (explicit temporary blocks) - // 3.3: Command line flag --allowed-tools (explicit temporary allows) - // 3.2: MCP servers with trust=true (persistent trusted servers) - // 3.1: MCP servers allowed list (persistent general server allows) + // Settings-based and dynamic rules (mixed tiers): + // MCP_EXCLUDED_PRIORITY: MCP servers excluded list (security: persistent server blocks) + // EXCLUDE_TOOLS_FLAG_PRIORITY: Command line flag --exclude-tools (explicit temporary blocks) + // ALLOWED_TOOLS_FLAG_PRIORITY: Command line flag --allowed-tools (explicit temporary allows) + // TRUSTED_MCP_SERVER_PRIORITY: MCP servers with trust=true (persistent trusted servers) + // ALLOWED_MCP_SERVER_PRIORITY: MCP servers allowed list (persistent general server allows) + // ALWAYS_ALLOW_PRIORITY: Tools that the user has selected as "Always Allow" in the interactive UI + // (Workspace tier 2.x - scoped to the project) // // TOML policy priorities (before transformation): // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) @@ -250,33 +265,33 @@ export async function createPolicyEngineConfig( // 999: YOLO mode allow-all (becomes 1.999 in default tier) // MCP servers that are explicitly excluded in settings.mcp.excluded - // Priority: 3.9 (highest in user tier for security - persistent server blocks) + // Priority: MCP_EXCLUDED_PRIORITY (highest in user tier for security - persistent server blocks) if (settings.mcp?.excluded) { for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.DENY, - priority: 3.9, + priority: MCP_EXCLUDED_PRIORITY, source: 'Settings (MCP Excluded)', }); } } // Tools that are explicitly excluded in the settings. - // Priority: 3.4 (user tier - explicit temporary blocks) + // Priority: EXCLUDE_TOOLS_FLAG_PRIORITY (user tier - explicit temporary blocks) if (settings.tools?.exclude) { for (const tool of settings.tools.exclude) { rules.push({ toolName: tool, decision: PolicyDecision.DENY, - priority: 3.4, + priority: EXCLUDE_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Excluded)', }); } } // Tools that are explicitly allowed in the settings. - // Priority: 3.3 (user tier - explicit temporary allows) + // Priority: ALLOWED_TOOLS_FLAG_PRIORITY (user tier - explicit temporary allows) if (settings.tools?.allowed) { for (const tool of settings.tools.allowed) { // Check for legacy format: toolName(args) @@ -296,7 +311,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 3.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, argsPattern: new RegExp(pattern), source: 'Settings (Tools Allowed)', }); @@ -308,7 +323,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 3.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Allowed)', }); } @@ -320,7 +335,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName, decision: PolicyDecision.ALLOW, - priority: 3.3, + priority: ALLOWED_TOOLS_FLAG_PRIORITY, source: 'Settings (Tools Allowed)', }); } @@ -328,7 +343,7 @@ export async function createPolicyEngineConfig( } // MCP servers that are trusted in the settings. - // Priority: 3.2 (user tier - persistent trusted servers) + // Priority: TRUSTED_MCP_SERVER_PRIORITY (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -339,7 +354,7 @@ export async function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 3.2, + priority: TRUSTED_MCP_SERVER_PRIORITY, source: 'Settings (MCP Trusted)', }); } @@ -347,13 +362,13 @@ export async function createPolicyEngineConfig( } // MCP servers that are explicitly allowed in settings.mcp.allowed - // Priority: 3.1 (user tier - persistent general server allows) + // Priority: ALLOWED_MCP_SERVER_PRIORITY (user tier - persistent general server allows) if (settings.mcp?.allowed) { for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 3.1, + priority: ALLOWED_MCP_SERVER_PRIORITY, source: 'Settings (MCP Allowed)', }); } @@ -381,6 +396,7 @@ interface TomlRule { export function createPolicyUpdater( policyEngine: PolicyEngine, messageBus: MessageBus, + storage: Storage, ) { // Use a sequential queue for persistence to avoid lost updates from concurrent events. let persistenceQueue = Promise.resolve(); @@ -400,10 +416,7 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (3) + high priority (950/1000) = 3.95 - // This ensures user "always allow" selections are high priority - // but still lose to admin policies (4.xxx) and settings excludes (300) - priority: 3.95, + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp(pattern), source: 'Dynamic (Confirmed)', }); @@ -425,10 +438,7 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - // User tier (3) + high priority (950/1000) = 3.95 - // This ensures user "always allow" selections are high priority - // but still lose to admin policies (4.xxx) and settings excludes (300) - priority: 3.95, + priority: ALWAYS_ALLOW_PRIORITY, argsPattern, source: 'Dynamic (Confirmed)', }); @@ -437,9 +447,9 @@ export function createPolicyUpdater( if (message.persist) { persistenceQueue = persistenceQueue.then(async () => { try { - const userPoliciesDir = Storage.getUserPoliciesDir(); - await fs.mkdir(userPoliciesDir, { recursive: true }); - const policyFile = path.join(userPoliciesDir, 'auto-saved.toml'); + const workspacePoliciesDir = storage.getWorkspacePoliciesDir(); + await fs.mkdir(workspacePoliciesDir, { recursive: true }); + const policyFile = storage.getAutoSavedPolicyPath(); // Read existing file let existingData: { rule?: TomlRule[] } = {}; diff --git a/packages/core/src/policy/persistence.test.ts b/packages/core/src/policy/persistence.test.ts index 3acf7c714d..43f52a956d 100644 --- a/packages/core/src/policy/persistence.test.ts +++ b/packages/core/src/policy/persistence.test.ts @@ -15,11 +15,11 @@ import { } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { createPolicyUpdater } from './config.js'; +import { createPolicyUpdater, ALWAYS_ALLOW_PRIORITY } from './config.js'; import { PolicyEngine } from './policy-engine.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType } from '../confirmation-bus/types.js'; -import { Storage } from '../config/storage.js'; +import { Storage, AUTO_SAVED_POLICY_FILENAME } from '../config/storage.js'; import { ApprovalMode } from './types.js'; vi.mock('node:fs/promises'); @@ -28,6 +28,7 @@ vi.mock('../config/storage.js'); describe('createPolicyUpdater', () => { let policyEngine: PolicyEngine; let messageBus: MessageBus; + let mockStorage: Storage; beforeEach(() => { policyEngine = new PolicyEngine({ @@ -36,6 +37,7 @@ describe('createPolicyUpdater', () => { approvalMode: ApprovalMode.DEFAULT, }); messageBus = new MessageBus(policyEngine); + mockStorage = new Storage('/mock/project'); vi.clearAllMocks(); }); @@ -44,10 +46,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy when persist flag is true', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -70,8 +79,8 @@ describe('createPolicyUpdater', () => { // Wait for async operations (microtasks) await new Promise((resolve) => setTimeout(resolve, 0)); - expect(Storage.getUserPoliciesDir).toHaveBeenCalled(); - expect(fs.mkdir).toHaveBeenCalledWith(userPoliciesDir, { + expect(mockStorage.getWorkspacePoliciesDir).toHaveBeenCalled(); + expect(fs.mkdir).toHaveBeenCalledWith(workspacePoliciesDir, { recursive: true, }); @@ -85,12 +94,12 @@ describe('createPolicyUpdater', () => { ); expect(fs.rename).toHaveBeenCalledWith( expect.stringMatching(/\.tmp$/), - path.join(userPoliciesDir, 'auto-saved.toml'), + policyFile, ); }); it('should not persist policy when persist flag is false or undefined', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -104,10 +113,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy with commandPrefix when provided', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -136,7 +152,7 @@ describe('createPolicyUpdater', () => { const rules = policyEngine.getRules(); const addedRule = rules.find((r) => r.toolName === toolName); expect(addedRule).toBeDefined(); - expect(addedRule?.priority).toBe(3.95); + expect(addedRule?.priority).toBe(ALWAYS_ALLOW_PRIORITY); expect(addedRule?.argsPattern).toEqual( new RegExp(`"command":"git\\ status(?:[\\s"]|\\\\")`), ); @@ -150,10 +166,17 @@ describe('createPolicyUpdater', () => { }); it('should persist policy with mcpName and toolName when provided', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), @@ -189,10 +212,17 @@ describe('createPolicyUpdater', () => { }); it('should escape special characters in toolName and mcpName', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); - const userPoliciesDir = '/mock/user/policies'; - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(userPoliciesDir); + const workspacePoliciesDir = '/mock/project/.gemini/policies'; + const policyFile = path.join( + workspacePoliciesDir, + AUTO_SAVED_POLICY_FILENAME, + ); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + workspacePoliciesDir, + ); + vi.spyOn(mockStorage, 'getAutoSavedPolicyPath').mockReturnValue(policyFile); (fs.mkdir as unknown as Mock).mockResolvedValue(undefined); (fs.readFile as unknown as Mock).mockRejectedValue( new Error('File not found'), diff --git a/packages/core/src/policy/policy-updater.test.ts b/packages/core/src/policy/policy-updater.test.ts index 928d84408b..40780a1850 100644 --- a/packages/core/src/policy/policy-updater.test.ts +++ b/packages/core/src/policy/policy-updater.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; -import { createPolicyUpdater } from './config.js'; +import { createPolicyUpdater, ALWAYS_ALLOW_PRIORITY } from './config.js'; import { PolicyEngine } from './policy-engine.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import { MessageBusType } from '../confirmation-bus/types.js'; @@ -41,6 +41,7 @@ interface TestableShellToolInvocation { describe('createPolicyUpdater', () => { let policyEngine: PolicyEngine; let messageBus: MessageBus; + let mockStorage: Storage; beforeEach(() => { vi.resetAllMocks(); @@ -48,8 +49,9 @@ describe('createPolicyUpdater', () => { vi.spyOn(policyEngine, 'addRule'); messageBus = new MessageBus(policyEngine); - vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue( - '/mock/user/policies', + mockStorage = new Storage('/mock/project'); + vi.spyOn(mockStorage, 'getWorkspacePoliciesDir').mockReturnValue( + '/mock/project/.gemini/policies', ); }); @@ -58,7 +60,7 @@ describe('createPolicyUpdater', () => { }); it('should add multiple rules when commandPrefix is an array', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -72,6 +74,7 @@ describe('createPolicyUpdater', () => { 1, expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"echo(?:[\\s"]|\\\\")'), }), ); @@ -79,13 +82,14 @@ describe('createPolicyUpdater', () => { 2, expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"ls(?:[\\s"]|\\\\")'), }), ); }); it('should add a single rule when commandPrefix is a string', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, @@ -98,13 +102,14 @@ describe('createPolicyUpdater', () => { expect(policyEngine.addRule).toHaveBeenCalledWith( expect.objectContaining({ toolName: 'run_shell_command', + priority: ALWAYS_ALLOW_PRIORITY, argsPattern: new RegExp('"command":"git(?:[\\s"]|\\\\")'), }), ); }); it('should persist multiple rules correctly to TOML', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' }); vi.mocked(fs.mkdir).mockResolvedValue(undefined); @@ -139,7 +144,7 @@ describe('createPolicyUpdater', () => { }); it('should reject unsafe regex patterns', async () => { - createPolicyUpdater(policyEngine, messageBus); + createPolicyUpdater(policyEngine, messageBus, mockStorage); await messageBus.publish({ type: MessageBusType.UPDATE_POLICY, From b746524a1b2e69cad41b55b7d0ba60ec4786d2eb Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 20 Feb 2026 17:21:26 -0500 Subject: [PATCH 42/52] fix(cli): re-enable CLI banner (#19741) --- packages/cli/src/ui/AppContainer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 661a96d305..bdf9501c4e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2078,8 +2078,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const fetchBannerTexts = async () => { const [defaultBanner, warningBanner] = await Promise.all([ - // TODO: temporarily disabling the banner, it will be re-added. - '', + config.getBannerTextNoCapacityIssues(), config.getBannerTextCapacityIssues(), ]); From 58d637f919f5c27526e772acd168fd3e5e057c44 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Fri, 20 Feb 2026 22:28:55 +0000 Subject: [PATCH 43/52] Disallow and suppress unsafe assignment (#19736) --- eslint.config.js | 1 + packages/a2a-server/src/commands/restore.ts | 1 + packages/a2a-server/src/config/settings.ts | 3 ++- packages/a2a-server/src/http/app.ts | 11 ++++++++--- packages/a2a-server/src/persistence/gcs.ts | 3 +++ packages/cli/src/config/extension-manager.ts | 1 + packages/cli/src/config/extensionRegistryClient.ts | 1 + packages/cli/src/test-utils/AppRig.tsx | 3 ++- packages/cli/src/test-utils/customMatchers.ts | 2 +- packages/cli/src/test-utils/mockCommandContext.ts | 9 +++++++-- packages/cli/src/test-utils/settings.ts | 3 ++- packages/cli/src/ui/components/SettingsDialog.tsx | 1 + .../cli/src/ui/components/triage/TriageDuplicates.tsx | 1 + .../cli/src/ui/components/triage/TriageIssues.tsx | 1 + packages/cli/src/ui/hooks/useAtCompletion.ts | 2 ++ packages/cli/src/ui/hooks/useSessionBrowser.ts | 1 + packages/cli/src/ui/hooks/useSlashCompletion.ts | 1 + packages/cli/src/ui/hooks/useStateAndRef.ts | 1 + packages/cli/src/ui/utils/TableRenderer.tsx | 1 + packages/cli/src/ui/utils/terminalSetup.ts | 1 + packages/cli/src/ui/utils/textUtils.ts | 2 ++ packages/cli/src/utils/envVarResolver.ts | 1 + packages/cli/src/utils/gitUtils.ts | 1 + packages/cli/src/utils/jsonoutput.ts | 1 + packages/cli/src/utils/persistentState.ts | 1 + packages/cli/src/utils/readStdin.ts | 1 + packages/cli/src/utils/sessionUtils.ts | 2 ++ packages/cli/src/utils/settingsUtils.ts | 1 + packages/cli/src/zed-integration/acpErrors.ts | 2 ++ packages/cli/src/zed-integration/fileSystemService.ts | 1 + packages/cli/src/zed-integration/zedIntegration.ts | 1 + packages/core/src/agents/acknowledgedAgents.ts | 1 + packages/core/src/agents/agent-scheduler.ts | 1 + packages/core/src/agents/local-executor.ts | 3 ++- packages/core/src/agents/registry.ts | 1 + packages/core/src/code_assist/admin/admin_controls.ts | 1 + .../core/src/code_assist/experiments/experiments.ts | 1 + .../core/src/code_assist/oauth-credential-storage.ts | 1 + packages/core/src/code_assist/oauth2.ts | 1 + packages/core/src/core/logger.ts | 2 ++ packages/core/src/hooks/hookRunner.ts | 8 +++++--- packages/core/src/hooks/trustedHooks.ts | 1 + packages/core/src/ide/ide-client.ts | 7 +++++-- packages/core/src/ide/ide-connection-utils.ts | 5 +++++ packages/core/src/ide/process-utils.ts | 1 + packages/core/src/mcp/oauth-provider.test.ts | 3 +-- packages/core/src/mcp/oauth-utils.ts | 1 + .../src/mcp/token-storage/keychain-token-storage.ts | 2 ++ packages/core/src/safety/checker-runner.ts | 1 + packages/core/src/services/sessionSummaryUtils.ts | 3 +++ packages/core/src/services/shellExecutionService.ts | 6 ++++++ .../src/telemetry/clearcut-logger/clearcut-logger.ts | 1 + .../core/src/telemetry/integration.test.circular.ts | 3 +++ packages/core/src/telemetry/loggers.test.circular.ts | 3 +++ packages/core/src/telemetry/loggers.ts | 2 ++ packages/core/src/tools/mcp-client.ts | 4 +++- packages/core/src/tools/ripGrep.ts | 4 ++++ packages/core/src/tools/tool-registry.ts | 1 + packages/core/src/tools/xcode-mcp-fix-transport.ts | 5 ++++- packages/core/src/utils/filesearch/fileSearch.ts | 1 + packages/core/src/utils/getPty.ts | 4 ++++ packages/core/src/utils/googleErrors.ts | 7 +++++++ packages/core/src/utils/memoryDiscovery.ts | 2 ++ packages/core/src/utils/safeJsonStringify.ts | 1 + packages/core/src/utils/schemaValidator.ts | 8 +++++--- packages/core/src/utils/shell-utils.ts | 1 + packages/core/src/utils/stdio.ts | 2 ++ packages/core/src/utils/userAccountManager.ts | 3 +++ packages/devtools/src/index.ts | 1 + packages/sdk/src/session.ts | 2 ++ packages/vscode-ide-companion/src/extension.ts | 4 ++++ 71 files changed, 149 insertions(+), 22 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 7dc3b5ace7..e2a7fa3e8a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -200,6 +200,7 @@ export default tseslint.config( ignores: ['**/*.test.ts', '**/*.test.tsx'], rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', }, }, { diff --git a/packages/a2a-server/src/commands/restore.ts b/packages/a2a-server/src/commands/restore.ts index 5b4839a2e4..c7567a3b24 100644 --- a/packages/a2a-server/src/commands/restore.ts +++ b/packages/a2a-server/src/commands/restore.ts @@ -69,6 +69,7 @@ export class RestoreCommand implements Command { throw error; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const toolCallData = JSON.parse(data); const ToolCallDataSchema = getToolCallDataSchema(); const parseResult = ToolCallDataSchema.safeParse(toolCallData); diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 8d15247128..3f4528c3b2 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -7,8 +7,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { MCPServerConfig } from '@google/gemini-cli-core'; import { + type MCPServerConfig, debugLogger, GEMINI_DIR, getErrorMessage, @@ -122,6 +122,7 @@ export function loadSettings(workspaceDir: string): Settings { function resolveEnvVarsInString(value: string): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const varName = varName1 || varName2; if (process && process.env && typeof process.env[varName] === 'string') { return process.env[varName]; diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts index c061d4e3b3..161139279b 100644 --- a/packages/a2a-server/src/http/app.ts +++ b/packages/a2a-server/src/http/app.ts @@ -7,8 +7,8 @@ import express from 'express'; import type { AgentCard, Message } from '@a2a-js/sdk'; -import type { TaskStore } from '@a2a-js/sdk/server'; import { + type TaskStore, DefaultRequestHandler, InMemoryTaskStore, DefaultExecutionEventBus, @@ -25,9 +25,12 @@ import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; import { loadSettings } from '../config/settings.js'; import { loadExtensions } from '../config/extension.js'; import { commandRegistry } from '../commands/command-registry.js'; -import { debugLogger, SimpleExtensionLoader } from '@google/gemini-cli-core'; +import { + debugLogger, + SimpleExtensionLoader, + GitService, +} from '@google/gemini-cli-core'; import type { Command, CommandArgument } from '../commands/types.js'; -import { GitService } from '@google/gemini-cli-core'; type CommandResponse = { name: string; @@ -88,6 +91,7 @@ async function handleExecuteCommand( }, ) { logger.info('[CoreAgent] Received /executeCommand request: ', req.body); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { command, args } = req.body; try { if (typeof command !== 'string') { @@ -211,6 +215,7 @@ export async function createApp() { const agentSettings = req.body.agentSettings as | AgentSettings | undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const contextId = req.body.contextId || uuidv4(); const wrapper = await agentExecutor.createTask( taskId, diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts index ec6b86e56a..215b45837f 100644 --- a/packages/a2a-server/src/persistence/gcs.ts +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -246,6 +246,7 @@ export class GCSTaskStore implements TaskStore { } const [compressedMetadata] = await metadataFile.download(); const jsonData = gunzipSync(compressedMetadata).toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const loadedMetadata = JSON.parse(jsonData); logger.info(`Task ${taskId} metadata loaded from GCS.`); @@ -282,12 +283,14 @@ export class GCSTaskStore implements TaskStore { return { id: taskId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contextId: loadedMetadata._contextId || uuidv4(), kind: 'task', status: { state: persistedState._taskState, timestamp: new Date().toISOString(), }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment metadata: loadedMetadata, history: [], artifacts: [], diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 4756b95b97..179959d83e 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -866,6 +866,7 @@ Would you like to attempt to install via "git clone" instead?`, try { const hooksContent = await fs.promises.readFile(hooksFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawHooks = JSON.parse(hooksContent); if ( diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index 3735f0a798..073b78ad79 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -81,6 +81,7 @@ export class ExtensionRegistryClient { `${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`, fuzzy: true, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(query); return results.map((r: { item: RegistryExtension }) => r.item); } diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index a0884ee024..018ce1502b 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -217,13 +217,14 @@ export class AppRig { } private stubRefreshAuth() { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment const gcConfig = this.config as any; gcConfig.refreshAuth = async (authMethod: AuthType) => { gcConfig.modelAvailabilityService.reset(); const newContentGeneratorConfig = { authType: authMethod, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment proxy: gcConfig.getProxy(), apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key', }; diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index 0351c7011c..0259c064a6 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -21,7 +21,7 @@ import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; const invalidCharsRegex = /[\b\x1b]/; function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const { isNot } = this as any; let pass = true; const invalidLines: Array<{ line: number; content: string }> = []; diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index c2f1bbcfd3..6bf7aafdbc 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -45,7 +45,7 @@ export const createMockCommandContext = ( forScope: vi.fn().mockReturnValue({ settings: {} }), } as unknown as LoadedSettings, git: undefined as GitService | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment logger: { log: vi.fn(), logMessage: vi.fn(), @@ -54,7 +54,7 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // Cast because Logger is a class. }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment ui: { addItem: vi.fn(), clear: vi.fn(), @@ -94,11 +94,14 @@ export const createMockCommandContext = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any const merge = (target: any, source: any): any => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = { ...target }; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sourceValue = source[key]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const targetValue = output[key]; if ( @@ -106,9 +109,11 @@ export const createMockCommandContext = ( Object.prototype.toString.call(sourceValue) === '[object Object]' && Object.prototype.toString.call(targetValue) === '[object Object]' ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = merge(targetValue, sourceValue); } else { // If not, we do a direct assignment. This preserves Date objects and others. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment output[key] = sourceValue; } } diff --git a/packages/cli/src/test-utils/settings.ts b/packages/cli/src/test-utils/settings.ts index 77e8450a9c..dd498b6625 100644 --- a/packages/cli/src/test-utils/settings.ts +++ b/packages/cli/src/test-utils/settings.ts @@ -46,6 +46,7 @@ export const createMockSettings = ( workspace, isTrusted, errors, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment merged: mergedOverride, ...settingsOverrides } = overrides; @@ -75,7 +76,7 @@ export const createMockSettings = ( // Assign any function overrides (e.g., vi.fn() for methods) for (const key in overrides) { if (typeof overrides[key] === 'function') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment (loaded as any)[key] = overrides[key]; } } diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index e95692275a..e426e9bbe3 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -115,6 +115,7 @@ export function SettingsDialog({ } const doSearch = async () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzfInstance.find(searchQuery); if (!active) return; diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx index abc749b6d3..878cacfed0 100644 --- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx +++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx @@ -451,6 +451,7 @@ Return a JSON object with: '--limit', String(limit), ]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const issues: Issue[] = JSON.parse(stdout); if (issues.length === 0) { setState((s) => ({ diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx index 3a654a40de..595384a124 100644 --- a/packages/cli/src/ui/components/triage/TriageIssues.tsx +++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx @@ -137,6 +137,7 @@ export const TriageIssues = ({ '--limit', String(limit), ]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const issues: Issue[] = JSON.parse(stdout); if (issues.length === 0) { setState((s) => ({ diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index a4c5317de8..af827f1b12 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -166,6 +166,7 @@ async function searchResourceCandidates( const fzf = new AsyncFzf(candidates, { selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW * 3, }); @@ -188,6 +189,7 @@ async function searchAgentCandidates( const fzf = new AsyncFzf(candidates, { selector: (s: Suggestion) => s.label, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW, }); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index de6495c3b9..79baacbee1 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -57,6 +57,7 @@ export const useSessionBrowser = ( const originalFilePath = path.join(chatsDir, fileName); // Load up the conversation. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const conversation: ConversationRecord = JSON.parse( await fs.readFile(originalFilePath, 'utf8'), ); diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 7809d6cf0f..a53a469571 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -271,6 +271,7 @@ function useCommandSuggestions( const fzfInstance = getFzfForCommands(commandsToSearch); if (fzfInstance) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const fzfResults = await fzfInstance.fzf.find(partial); if (signal.aborted) return; const uniqueCommands = new Set(); diff --git a/packages/cli/src/ui/hooks/useStateAndRef.ts b/packages/cli/src/ui/hooks/useStateAndRef.ts index d8dce68ec8..5d2a81a92c 100644 --- a/packages/cli/src/ui/hooks/useStateAndRef.ts +++ b/packages/cli/src/ui/hooks/useStateAndRef.ts @@ -22,6 +22,7 @@ export const useStateAndRef = < (newStateOrCallback) => { let newValue: T; if (typeof newStateOrCallback === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment newValue = newStateOrCallback(ref.current); } else { newValue = newStateOrCallback; diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 4689d461ff..e57e2f7faa 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -243,6 +243,7 @@ export const TableRenderer: React.FC = ({ isHeader = false, ): React.ReactNode => { const renderedCells = cells.map((cell, index) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const width = adjustedWidths[index] || 0; return renderCell(cell, width, isHeader); }); diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index 820497cc2f..5132df4996 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -179,6 +179,7 @@ async function configureVSCodeStyle( await backupFile(keybindingsFile); try { const cleanContent = stripJsonComments(content); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedContent = JSON.parse(cleanContent); if (!Array.isArray(parsedContent)) { return { diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index bd7e2aca75..a039a43991 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -233,7 +233,9 @@ export function escapeAnsiCtrlCodes(obj: T): T { let newArr: unknown[] | null = null; for (let i = 0; i < obj.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = obj[i]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const escapedValue = escapeAnsiCtrlCodes(value); if (escapedValue !== value) { if (newArr === null) { diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts index fac43682a5..4bd1d6f82f 100644 --- a/packages/cli/src/utils/envVarResolver.ts +++ b/packages/cli/src/utils/envVarResolver.ts @@ -23,6 +23,7 @@ export function resolveEnvVarsInString( ): string { const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} return value.replace(envVarRegex, (match, varName1, varName2) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const varName = varName1 || varName2; if (customEnv && typeof customEnv[varName] === 'string') { return customEnv[varName]; diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index b415dadc6c..c25366f72a 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -78,6 +78,7 @@ export const getLatestGitHubRelease = async ( ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const releaseTag = (await response.json()).tag_name; if (!releaseTag) { throw new Error(`Response did not include tag_name field`); diff --git a/packages/cli/src/utils/jsonoutput.ts b/packages/cli/src/utils/jsonoutput.ts index ae170ec591..f600b7e165 100644 --- a/packages/cli/src/utils/jsonoutput.ts +++ b/packages/cli/src/utils/jsonoutput.ts @@ -29,6 +29,7 @@ export function tryParseJSON(input: string): object | null { if (!checkInput(input)) return null; const trimmed = input.trim(); try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(trimmed); if (parsed === null || typeof parsed !== 'object') { return null; diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts index 4fc51d0e14..6ad5695097 100644 --- a/packages/cli/src/utils/persistentState.ts +++ b/packages/cli/src/utils/persistentState.ts @@ -38,6 +38,7 @@ export class PersistentState { const filePath = this.getPath(); if (fs.existsSync(filePath)) { const content = fs.readFileSync(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.cache = JSON.parse(content); } else { this.cache = {}; diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts index 30834e0d14..3b5b17fe04 100644 --- a/packages/cli/src/utils/readStdin.ts +++ b/packages/cli/src/utils/readStdin.ts @@ -23,6 +23,7 @@ export async function readStdin(): Promise { const onReadable = () => { let chunk; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment while ((chunk = process.stdin.read()) !== null) { if (pipedInputTimerId) { clearTimeout(pipedInputTimerId); diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index bc2cfbc0e2..559a04dccf 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -254,6 +254,7 @@ export const getAllSessionFiles = async ( async (file): Promise => { const filePath = path.join(chatsDir, file); try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const content: ConversationRecord = JSON.parse( await fs.readFile(filePath, 'utf8'), ); @@ -498,6 +499,7 @@ export class SessionSelector { const sessionPath = path.join(chatsDir, sessionInfo.fileName); try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const sessionData: ConversationRecord = JSON.parse( await fs.readFile(sessionPath, 'utf8'), ); diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index f5aa18a41e..8d313cf688 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -371,6 +371,7 @@ export function setPendingSettingValue( pendingSettings: Settings, ): Settings { const path = key.split('.'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const newSettings = JSON.parse(JSON.stringify(pendingSettings)); setNestedValue(newSettings, path, value); return newSettings; diff --git a/packages/cli/src/zed-integration/acpErrors.ts b/packages/cli/src/zed-integration/acpErrors.ts index 2e111b2876..57067115bf 100644 --- a/packages/cli/src/zed-integration/acpErrors.ts +++ b/packages/cli/src/zed-integration/acpErrors.ts @@ -25,7 +25,9 @@ function extractRecursiveMessage(input: string): string { (trimmed.startsWith('[') && trimmed.endsWith(']')) ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(trimmed); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const next = parsed?.error?.message || parsed?.[0]?.error?.message || diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/zed-integration/fileSystemService.ts index 51a32f2779..fc4672b4b5 100644 --- a/packages/cli/src/zed-integration/fileSystemService.ts +++ b/packages/cli/src/zed-integration/fileSystemService.ts @@ -23,6 +23,7 @@ export class AcpFileSystemService implements FileSystemService { return this.fallback.readTextFile(filePath); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const response = await this.connection.readTextFile({ path: filePath, sessionId: this.sessionId, diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index f04caf01f7..6f18cab7d6 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -702,6 +702,7 @@ export class Session { }, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const output = await this.connection.requestPermission(params); const outcome = output.outcome.outcome === CoreToolCallStatus.Cancelled diff --git a/packages/core/src/agents/acknowledgedAgents.ts b/packages/core/src/agents/acknowledgedAgents.ts index 230b62443a..98c90afb96 100644 --- a/packages/core/src/agents/acknowledgedAgents.ts +++ b/packages/core/src/agents/acknowledgedAgents.ts @@ -27,6 +27,7 @@ export class AcknowledgedAgentsService { const filePath = Storage.getAcknowledgedAgentsPath(); try { const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.acknowledgedAgents = JSON.parse(content); } catch (error: unknown) { if (!isNodeError(error) || error.code !== 'ENOENT') { diff --git a/packages/core/src/agents/agent-scheduler.ts b/packages/core/src/agents/agent-scheduler.ts index 4b2e0fa587..ecb4ed960a 100644 --- a/packages/core/src/agents/agent-scheduler.ts +++ b/packages/core/src/agents/agent-scheduler.ts @@ -54,6 +54,7 @@ export async function scheduleAgentTools( } = options; // Create a proxy/override of the config to provide the agent-specific tool registry. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const agentConfig: Config = Object.create(config); agentConfig.getToolRegistry = () => toolRegistry; diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index bcb4e888ce..e6557785db 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -34,6 +34,7 @@ import { AgentStartEvent, AgentFinishEvent, RecoveryAttemptEvent, + LlmRole, } from '../telemetry/types.js'; import type { LocalAgentDefinition, @@ -59,7 +60,6 @@ import { getVersion } from '../utils/version.js'; import { getToolCallContext } from '../utils/toolCallContext.js'; import { scheduleAgentTools } from './agent-scheduler.js'; import { DeadlineTimer } from '../utils/deadlineTimer.js'; -import { LlmRole } from '../telemetry/types.js'; import { formatUserHintsForModel } from '../utils/fastAckHelper.js'; /** A callback function to report on agent activity. */ @@ -925,6 +925,7 @@ export class LocalAgentExecutor { continue; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const validatedOutput = validationResult.data; if (this.definition.processOutput) { submittedOutput = this.definition.processOutput(validatedOutput); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 85747c3964..bcd4d65878 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -394,6 +394,7 @@ export class AgentRegistry { } // Use Object.create to preserve lazy getters on the definition object + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const merged: LocalAgentDefinition = Object.create(definition); if (overrides.runConfig) { diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index d4117c2107..d18fcf3d66 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -31,6 +31,7 @@ export function sanitizeAdminSettings( if (sanitized.mcpSetting?.mcpConfigJson) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(sanitized.mcpSetting.mcpConfigJson); const validationResult = McpConfigDefinitionSchema.safeParse(parsed); diff --git a/packages/core/src/code_assist/experiments/experiments.ts b/packages/core/src/code_assist/experiments/experiments.ts index 94b99b311a..b46f88a17f 100644 --- a/packages/core/src/code_assist/experiments/experiments.ts +++ b/packages/core/src/code_assist/experiments/experiments.ts @@ -35,6 +35,7 @@ export async function getExperiments( const expPath = process.env['GEMINI_EXP']; debugLogger.debug('Reading experiments from', expPath); const content = await fs.promises.readFile(expPath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const response: ListExperimentsResponse = JSON.parse(content); if ( (response.flags && !Array.isArray(response.flags)) || diff --git a/packages/core/src/code_assist/oauth-credential-storage.ts b/packages/core/src/code_assist/oauth-credential-storage.ts index 39163384b9..c7c0209cfa 100644 --- a/packages/core/src/code_assist/oauth-credential-storage.ts +++ b/packages/core/src/code_assist/oauth-credential-storage.ts @@ -125,6 +125,7 @@ export class OAuthCredentialStorage { throw error; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const credentials: Credentials = JSON.parse(credsJson); // Save to new storage diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index e5ad7e584a..fd2f5ea178 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -695,6 +695,7 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise { return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const userInfo = await response.json(); await userAccountManager.cacheGoogleAccount(userInfo.email); } catch (error) { diff --git a/packages/core/src/core/logger.ts b/packages/core/src/core/logger.ts index 83f4183ce4..362601f895 100644 --- a/packages/core/src/core/logger.ts +++ b/packages/core/src/core/logger.ts @@ -88,6 +88,7 @@ export class Logger { } try { const fileContent = await fs.readFile(this.logFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedLogs = JSON.parse(fileContent); if (!Array.isArray(parsedLogs)) { debugLogger.debug( @@ -352,6 +353,7 @@ export class Logger { const path = await this._getCheckpointPath(tag); try { const fileContent = await fs.readFile(path, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedContent = JSON.parse(fileContent); // Handle legacy format (just an array of Content) diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 47706b0eb4..05f81d1983 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -5,10 +5,8 @@ */ import { spawn } from 'node:child_process'; -import type { HookConfig } from './types.js'; -import { HookEventName, ConfigSource } from './types.js'; -import type { Config } from '../config/config.js'; import type { + HookConfig, HookInput, HookOutput, HookExecutionResult, @@ -17,6 +15,8 @@ import type { BeforeModelOutput, BeforeToolInput, } from './types.js'; +import { HookEventName, ConfigSource } from './types.js'; +import type { Config } from '../config/config.js'; import type { LLMRequest } from './hookTranslator.js'; import { debugLogger } from '../utils/debugLogger.js'; import { sanitizeEnvironment } from '../services/environmentSanitization.js'; @@ -356,8 +356,10 @@ export class HookRunner { const textToParse = stdout.trim() || stderr.trim(); if (textToParse) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment let parsed = JSON.parse(textToParse); if (typeof parsed === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsed = JSON.parse(parsed); } if (parsed && typeof parsed === 'object') { diff --git a/packages/core/src/hooks/trustedHooks.ts b/packages/core/src/hooks/trustedHooks.ts index 1c9b5b5f18..562f0e76bb 100644 --- a/packages/core/src/hooks/trustedHooks.ts +++ b/packages/core/src/hooks/trustedHooks.ts @@ -34,6 +34,7 @@ export class TrustedHooksManager { try { if (fs.existsSync(this.configPath)) { const content = fs.readFileSync(this.configPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.trustedHooks = JSON.parse(content); } } catch (error) { diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 58067d70ab..2b3b29ac3c 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -16,8 +16,10 @@ import { getIdeProcessInfo } from './process-utils.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; -import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { + CallToolResultSchema, + ListToolsResultSchema, +} from '@modelcontextprotocol/sdk/types.js'; import { IDE_REQUEST_TIMEOUT_MS } from './constants.js'; import { debugLogger } from '../utils/debugLogger.js'; import { @@ -343,6 +345,7 @@ export class IdeClient { if (textPart?.text) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedJson = JSON.parse(textPart.text); if (parsedJson && typeof parsedJson.content === 'string') { return parsedJson.content; diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index ce01b5997c..81a9740327 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -89,8 +89,10 @@ export function getStdioConfigFromEnv(): StdioConfig | undefined { let args: string[] = []; if (argsStr) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedArgs = JSON.parse(argsStr); if (Array.isArray(parsedArgs)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args = parsedArgs; } else { logger.error( @@ -188,6 +190,7 @@ export async function getConnectionConfigFromFile( } if (validWorkspaces.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[0]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { @@ -202,6 +205,7 @@ export async function getConnectionConfigFromFile( (content) => String(content.port) === portFromEnv, ); if (matchingPortIndex !== -1) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[matchingPortIndex]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { @@ -213,6 +217,7 @@ export async function getConnectionConfigFromFile( } } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const selected = validWorkspaces[0]; const fileIndex = parsedContents.indexOf(selected); if (fileIndex !== -1) { diff --git a/packages/core/src/ide/process-utils.ts b/packages/core/src/ide/process-utils.ts index 5c1ca570a6..4b4680df51 100644 --- a/packages/core/src/ide/process-utils.ts +++ b/packages/core/src/ide/process-utils.ts @@ -47,6 +47,7 @@ async function getProcessTableWindows(): Promise> { let processes: RawProcessInfo | RawProcessInfo[]; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment processes = JSON.parse(stdout); } catch (_e) { return processMap; diff --git a/packages/core/src/mcp/oauth-provider.test.ts b/packages/core/src/mcp/oauth-provider.test.ts index facefe176a..77c46305a6 100644 --- a/packages/core/src/mcp/oauth-provider.test.ts +++ b/packages/core/src/mcp/oauth-provider.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; // Mock dependencies AT THE TOP const mockOpenBrowserSecurely = vi.hoisted(() => vi.fn()); @@ -54,7 +54,6 @@ vi.mock('node:readline', () => ({ })), })); -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as http from 'node:http'; import * as crypto from 'node:crypto'; import type { diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index 815ed7c089..320c3b9685 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -409,6 +409,7 @@ export class OAuthUtils { */ static parseTokenExpiry(idToken: string): number | undefined { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const payload = JSON.parse( Buffer.from(idToken.split('.')[1], 'base64').toString(), ); diff --git a/packages/core/src/mcp/token-storage/keychain-token-storage.ts b/packages/core/src/mcp/token-storage/keychain-token-storage.ts index 8936823196..4be0d082e5 100644 --- a/packages/core/src/mcp/token-storage/keychain-token-storage.ts +++ b/packages/core/src/mcp/token-storage/keychain-token-storage.ts @@ -45,7 +45,9 @@ export class KeychainTokenStorage try { // Try to import keytar without any timeout - let the OS handle it const moduleName = 'keytar'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(moduleName); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.keytarModule = module.default || module; } catch (_) { //Keytar is optional so we shouldn't raise an error of log anything. diff --git a/packages/core/src/safety/checker-runner.ts b/packages/core/src/safety/checker-runner.ts index 02f824d980..a46c3e6dbd 100644 --- a/packages/core/src/safety/checker-runner.ts +++ b/packages/core/src/safety/checker-runner.ts @@ -229,6 +229,7 @@ export class CheckerRunner { // Try to parse the output try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const rawResult = JSON.parse(stdout); const result = SafetyCheckResultSchema.parse(rawResult); diff --git a/packages/core/src/services/sessionSummaryUtils.ts b/packages/core/src/services/sessionSummaryUtils.ts index ed51cecd2b..c64f19870d 100644 --- a/packages/core/src/services/sessionSummaryUtils.ts +++ b/packages/core/src/services/sessionSummaryUtils.ts @@ -26,6 +26,7 @@ async function generateAndSaveSummary( ): Promise { // Read session file const content = await fs.readFile(sessionPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const conversation: ConversationRecord = JSON.parse(content); // Skip if summary already exists @@ -69,6 +70,7 @@ async function generateAndSaveSummary( // Re-read the file before writing to handle race conditions const freshContent = await fs.readFile(sessionPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const freshConversation: ConversationRecord = JSON.parse(freshContent); // Check if summary was added by another process @@ -127,6 +129,7 @@ export async function getPreviousSession( try { const content = await fs.readFile(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const conversation: ConversationRecord = JSON.parse(content); if (conversation.summary) { diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 96cae8c269..c21eeb1136 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -568,6 +568,7 @@ export class ShellExecutionService { const guardedCommand = ensurePromptvarsDisabled(commandToExecute, shell); const args = [...argsPrefix, guardedCommand]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ptyProcess = ptyInfo.module.spawn(executable, args, { cwd, name: 'xterm-256color', @@ -598,6 +599,7 @@ export class ShellExecutionService { headlessTerminal.scrollToTop(); this.activePtys.set(ptyProcess.pid, { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment ptyProcess, headlessTerminal, maxSerializedLines: shellExecutionConfig.maxSerializedLines, @@ -831,6 +833,7 @@ export class ShellExecutionService { signal: signal ?? null, error, aborted: abortSignal.aborted, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pid: ptyProcess.pid, executionMethod: ptyInfo?.name ?? 'node-pty', }); @@ -862,9 +865,11 @@ export class ShellExecutionService { const abortHandler = async () => { if (ptyProcess.pid && !exited) { await killProcessGroup({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pid: ptyProcess.pid, escalate: true, isExited: () => exited, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment pty: ptyProcess, }); } @@ -873,6 +878,7 @@ export class ShellExecutionService { abortSignal.addEventListener('abort', abortHandler, { once: true }); }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { pid: ptyProcess.pid, result }; } catch (e) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index d0407aa0d6..9a0900d86d 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -716,6 +716,7 @@ export class ClearcutLogger { event.function_name === ASK_USER_TOOL_NAME && event.metadata['ask_user'] ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const askUser = event.metadata['ask_user']; const askUserMapping: { [key: string]: EventMetadataKey } = { question_types: EventMetadataKey.GEMINI_CLI_ASK_USER_QUESTION_TYPES, diff --git a/packages/core/src/telemetry/integration.test.circular.ts b/packages/core/src/telemetry/integration.test.circular.ts index af09b3f8b0..6cb9e84faf 100644 --- a/packages/core/src/telemetry/integration.test.circular.ts +++ b/packages/core/src/telemetry/integration.test.circular.ts @@ -36,11 +36,13 @@ describe('Circular Reference Integration Test', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const socketLike: any = { _httpMessage: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment agent: proxyAgentLike, socket: null, }, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment socketLike._httpMessage.socket = socketLike; // Create circular reference proxyAgentLike.sockets['cloudcode-pa.googleapis.com:443'] = [socketLike]; @@ -49,6 +51,7 @@ describe('Circular Reference Integration Test', () => { error: new Error('Network error'), function_args: { filePath: '/test/file.txt', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment httpAgent: proxyAgentLike, // This would cause the circular reference }, }; diff --git a/packages/core/src/telemetry/loggers.test.circular.ts b/packages/core/src/telemetry/loggers.test.circular.ts index b8c365cd2c..d6b6ea86ce 100644 --- a/packages/core/src/telemetry/loggers.test.circular.ts +++ b/packages/core/src/telemetry/loggers.test.circular.ts @@ -39,8 +39,10 @@ describe('Circular Reference Handling', () => { sockets: {}, agent: null, }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment circularObject.agent = circularObject; // Create circular reference circularObject.sockets['test-host'] = [ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment { _httpMessage: { agent: circularObject } }, ]; @@ -48,6 +50,7 @@ describe('Circular Reference Handling', () => { const mockRequest: ToolCallRequestInfo = { callId: 'test-call-id', name: 'ReadFile', + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args: circularObject, // This would cause the original error isClientInitiated: false, prompt_id: 'test-prompt-id', diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 54bec22a65..b4935f3af7 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -145,12 +145,14 @@ export function logToolCall(config: Config, event: ToolCallEvent): void { }); if (event.metadata) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const added = event.metadata['model_added_lines']; if (typeof added === 'number' && added > 0) { recordLinesChanged(config, added, 'added', { function_name: event.function_name, }); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const removed = event.metadata['model_removed_lines']; if (typeof removed === 'number' && removed > 0) { recordLinesChanged(config, removed, 'removed', { diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index ccc6bbec3c..5e802e8157 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -22,6 +22,7 @@ import type { Prompt, ReadResourceResult, Resource, + Tool as McpTool, } from '@modelcontextprotocol/sdk/types.js'; import { ListResourcesResultSchema, @@ -31,7 +32,6 @@ import { ToolListChangedNotificationSchema, PromptListChangedNotificationSchema, ProgressNotificationSchema, - type Tool as McpTool, } from '@modelcontextprotocol/sdk/types.js'; import { ApprovalMode, PolicyDecision } from '../policy/types.js'; import { parse } from 'shell-quote'; @@ -1996,6 +1996,7 @@ export async function createTransport( // The `XcodeMcpBridgeFixTransport` wrapper hides the underlying `StdioClientTransport`, // which exposes `stderr` for debug logging. We need to unwrap it to attach the listener. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const underlyingTransport = transport instanceof XcodeMcpBridgeFixTransport ? // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion @@ -2007,6 +2008,7 @@ export async function createTransport( underlyingTransport.stderr ) { underlyingTransport.stderr.on('data', (data) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const stderrStr = data.toString().trim(); debugLogger.debug( `[DEBUG] [MCP STDERR (${mcpServerName})]: `, diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 5fe516c335..7f49774c05 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -481,8 +481,10 @@ class GrepToolInvocation extends BaseToolInvocation< basePath: string, ): GrepMatch | null { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const json = JSON.parse(line); if (json.type === 'match' || json.type === 'context') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const data = json.data; // Defensive check: ensure text properties exist (skips binary/invalid encoding) if (data.path?.text && data.lines?.text) { @@ -500,7 +502,9 @@ class GrepToolInvocation extends BaseToolInvocation< return { filePath: relativeFilePath || path.basename(absoluteFilePath), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment lineNumber: data.line_number, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment line: data.lines.text.trimEnd(), isContext: json.type === 'context', }; diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index abcf34e1f8..95bac200be 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -390,6 +390,7 @@ export class ToolRegistry { // execute discovery command and extract function declarations (w/ or w/o "tool" wrappers) const functions: FunctionDeclaration[] = []; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const discoveredItems = JSON.parse(stdout.trim()); if (!discoveredItems || !Array.isArray(discoveredItems)) { diff --git a/packages/core/src/tools/xcode-mcp-fix-transport.ts b/packages/core/src/tools/xcode-mcp-fix-transport.ts index 7daabef87e..9f7785e8c9 100644 --- a/packages/core/src/tools/xcode-mcp-fix-transport.ts +++ b/packages/core/src/tools/xcode-mcp-fix-transport.ts @@ -75,7 +75,7 @@ export class XcodeMcpBridgeFixTransport // We can cast because we verified 'result' is in response, // but TS might still be picky if the type is a strict union. // Let's treat it safely. - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const result = response.result as any; // Check if we have content but missing structuredContent @@ -85,12 +85,15 @@ export class XcodeMcpBridgeFixTransport result.content.length > 0 && !result.structuredContent ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const firstItem = result.content[0]; if (firstItem.type === 'text' && typeof firstItem.text === 'string') { try { // Attempt to parse the text as JSON + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(firstItem.text); // If successful, populate structuredContent + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment result.structuredContent = parsed; } catch (_) { // Ignored: Content is likely plain text, not JSON. diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 6aedaf7276..21d1e19168 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -145,6 +145,7 @@ class RecursiveFileSearch implements FileSearch { if (pattern.includes('*') || !this.fzf) { filteredCandidates = await filter(candidates, pattern, options.signal); } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment filteredCandidates = await this.fzf .find(pattern) .then((results: Array>) => diff --git a/packages/core/src/utils/getPty.ts b/packages/core/src/utils/getPty.ts index 35712b6de3..b5d53ca473 100644 --- a/packages/core/src/utils/getPty.ts +++ b/packages/core/src/utils/getPty.ts @@ -23,12 +23,16 @@ export const getPty = async (): Promise => { } try { const lydell = '@lydell/node-pty'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(lydell); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { module, name: 'lydell-node-pty' }; } catch (_e) { try { const nodePty = 'node-pty'; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const module = await import(nodePty); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment return { module, name: 'node-pty' }; } catch (_e2) { return null; diff --git a/packages/core/src/utils/googleErrors.ts b/packages/core/src/utils/googleErrors.ts index 70c7098118..3ae74f0de8 100644 --- a/packages/core/src/utils/googleErrors.ts +++ b/packages/core/src/utils/googleErrors.ts @@ -166,10 +166,12 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { depth < maxDepth ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedMessage = JSON.parse( currentError.message.replace(/\u00A0/g, '').replace(/\n/g, ' '), ); if (parsedMessage.error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment currentError = parsedMessage.error; depth++; } else { @@ -243,6 +245,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined { if (typeof data === 'string') { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data); } catch (_) { // Not a JSON string, can't parse. @@ -250,6 +253,7 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined { } if (Array.isArray(data) && data.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = data[0]; } @@ -288,6 +292,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined { if (typeof data === 'string') { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data); } catch (_) { // Not a JSON string, can't parse. @@ -297,6 +302,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined { const lastBrace = data.lastIndexOf('}'); if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = JSON.parse(data.substring(firstBrace, lastBrace + 1)); } catch (__) { // Still failed @@ -307,6 +313,7 @@ function fromApiError(errorObj: object): ErrorShape | undefined { } if (Array.isArray(data) && data.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment data = data[0]; } diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index aef6ff50b5..c35d009e1d 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -127,6 +127,7 @@ async function getGeminiMdFilePathsInternal( result.value.global.forEach((p) => globalPaths.add(p)); result.value.project.forEach((p) => projectPaths.add(p)); } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const error = result.reason; const message = error instanceof Error ? error.message : String(error); logger.error(`Error discovering files in directory: ${message}`); @@ -299,6 +300,7 @@ export async function readGeminiMdFiles( } else { // This case shouldn't happen since we catch all errors above, // but handle it for completeness + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const error = result.reason; const message = error instanceof Error ? error.message : String(error); logger.error(`Unexpected error processing file: ${message}`); diff --git a/packages/core/src/utils/safeJsonStringify.ts b/packages/core/src/utils/safeJsonStringify.ts index fd03e7965d..611519d09b 100644 --- a/packages/core/src/utils/safeJsonStringify.ts +++ b/packages/core/src/utils/safeJsonStringify.ts @@ -37,6 +37,7 @@ export function safeJsonStringify( function removeEmptyObjects(data: any): object { const cleanedObject: { [key: string]: unknown } = {}; for (const k in data) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const v = data[k]; if (v !== null && v !== undefined && typeof v === 'boolean') { cleanedObject[k] = v; diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 8d8579f647..e58b7b8d9b 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -12,9 +12,9 @@ import * as addFormats from 'ajv-formats'; import { debugLogger } from './debugLogger.js'; // Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const AjvClass = (AjvPkg as any).default || AjvPkg; -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const Ajv2020Class = (Ajv2020Pkg as any).default || Ajv2020Pkg; const ajvOptions = { @@ -29,12 +29,14 @@ const ajvOptions = { }; // Draft-07 validator (default) +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ajvDefault: Ajv = new AjvClass(ajvOptions); // Draft-2020-12 validator for MCP servers using rmcp +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ajv2020: Ajv = new Ajv2020Class(ajvOptions); -// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion +// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const addFormatsFunc = (addFormats as any).default || addFormats; addFormatsFunc(ajvDefault); addFormatsFunc(ajv2020); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 7daeb063f5..6f92ec6386 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -479,6 +479,7 @@ function parsePowerShellCommandDetails( hasRedirection?: boolean; } | null = null; try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment parsed = JSON.parse(output); } catch { return { details: [], hasError: true }; diff --git a/packages/core/src/utils/stdio.ts b/packages/core/src/utils/stdio.ts index 8f62906399..22fd2e8447 100644 --- a/packages/core/src/utils/stdio.ts +++ b/packages/core/src/utils/stdio.ts @@ -88,6 +88,7 @@ export function createWorkingStdio() { if (prop === 'write') { return writeToStdout; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return value.bind(target); @@ -101,6 +102,7 @@ export function createWorkingStdio() { if (prop === 'write') { return writeToStderr; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return value.bind(target); diff --git a/packages/core/src/utils/userAccountManager.ts b/packages/core/src/utils/userAccountManager.ts index 4434a18027..342565a6ca 100644 --- a/packages/core/src/utils/userAccountManager.ts +++ b/packages/core/src/utils/userAccountManager.ts @@ -30,6 +30,7 @@ export class UserAccountManager { return defaultState; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsed = JSON.parse(content); // Inlined validation logic @@ -50,7 +51,9 @@ export class UserAccountManager { } return { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment active: parsed.active ?? null, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment old: parsed.old ?? [], }; } diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts index 81cb909957..7f8fea8d3d 100644 --- a/packages/devtools/src/index.ts +++ b/packages/devtools/src/index.ts @@ -260,6 +260,7 @@ export class DevTools extends EventEmitter { ws.on('message', (data: Buffer) => { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const message = JSON.parse(data.toString()); // Handle registration first diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index d5e6b9ee8a..8332ef29d0 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -210,6 +210,7 @@ export class GeminiCliSession { const toolCall = event.value; let args = toolCall.args; if (typeof args === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment args = JSON.parse(args); } toolCallsToSchedule.push({ @@ -238,6 +239,7 @@ export class GeminiCliSession { }; const originalRegistry = this.config.getToolRegistry(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const scopedRegistry: ToolRegistry = Object.create(originalRegistry); scopedRegistry.getTool = (name: string) => { const tool = originalRegistry.getTool(name); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts index bcfe6e40dd..456ec6e872 100644 --- a/packages/vscode-ide-companion/src/extension.ts +++ b/packages/vscode-ide-companion/src/extension.ts @@ -38,6 +38,7 @@ async function checkForUpdates( isManagedExtensionSurface: boolean, ) { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const currentVersion = context.extension.packageJSON.version; // Fetch extension details from the VSCode Marketplace. @@ -75,9 +76,12 @@ async function checkForUpdates( return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const extension = data?.results?.[0]?.extensions?.[0]; // The versions are sorted by date, so the first one is the latest. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const latestVersion = extension?.versions?.[0]?.version; if ( From 547f5d45f56f68c979edaec609f433630b0dffe7 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:59:18 -0500 Subject: [PATCH 44/52] feat(core): migrate read_file to 1-based start_line/end_line parameters (#19526) --- .../core/__snapshots__/prompts.test.ts.snap | 28 +++++----- packages/core/src/prompts/snippets.ts | 2 +- .../__snapshots__/read-file.test.ts.snap | 4 +- .../coreToolsModelSnapshots.test.ts.snap | 28 +++++----- .../model-family-sets/default-legacy.ts | 10 ++-- .../definitions/model-family-sets/gemini-3.ts | 10 ++-- packages/core/src/tools/read-file.test.ts | 35 +++++++----- packages/core/src/tools/read-file.ts | 40 ++++++++------ packages/core/src/utils/fileUtils.test.ts | 35 ++++++------ packages/core/src/utils/fileUtils.ts | 54 +++++++++++-------- 10 files changed, 140 insertions(+), 106 deletions(-) diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index bf73399537..0028a052de 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -549,7 +549,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -705,7 +705,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -827,7 +827,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1422,7 +1422,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1574,7 +1574,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1717,7 +1717,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1860,7 +1860,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -1999,7 +1999,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2138,7 +2138,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2276,7 +2276,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2656,7 +2656,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -2795,7 +2795,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -3046,7 +3046,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. @@ -3185,7 +3185,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index c5f4c13360..f7ea9b1eee 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -192,7 +192,7 @@ Use the following guidelines to optimize your search and read patterns. - **Searching:** utilize search tools like ${GREP_TOOL_NAME} and ${GLOB_TOOL_NAME} with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). - **Searching and editing:** utilize search tools like ${GREP_TOOL_NAME} with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. -- **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with an offset and a limit to reduce the impact on context. Minmize extra turns, unless unavoidable due to the file being too large. +- **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. - **Navigating:** read the minimum required to not require additional turns spent reading the file. diff --git a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap index c6adf2819d..de36bd639e 100644 --- a/packages/core/src/tools/__snapshots__/read-file.test.ts.snap +++ b/packages/core/src/tools/__snapshots__/read-file.test.ts.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; +exports[`ReadFileTool > getSchema > should return the base schema when no modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; -exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; +exports[`ReadFileTool > getSchema > should return the schema from the resolver when modelId is provided 1`] = `"Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges."`; diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index cdbb5d44a8..7e3e1dcf80 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -411,20 +411,20 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snapshot for tool: read_file 1`] = ` { - "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", + "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", "name": "read_file", "parametersJsonSchema": { "properties": { + "end_line": { + "description": "Optional: The 1-based line number to end reading at (inclusive).", + "type": "number", + }, "file_path": { "description": "The path to the file to read.", "type": "string", }, - "limit": { - "description": "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", - "type": "number", - }, - "offset": { - "description": "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + "start_line": { + "description": "Optional: The 1-based line number to start reading from.", "type": "number", }, }, @@ -1200,20 +1200,20 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > snapshot for tool: read_file 1`] = ` { - "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", + "description": "Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.", "name": "read_file", "parametersJsonSchema": { "properties": { + "end_line": { + "description": "Optional: The 1-based line number to end reading at (inclusive).", + "type": "number", + }, "file_path": { "description": "The path to the file to read.", "type": "string", }, - "limit": { - "description": "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", - "type": "number", - }, - "offset": { - "description": "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + "start_line": { + "description": "Optional: The 1-based line number to start reading from.", "type": "number", }, }, diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index bae510be9e..fad72047a9 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -35,7 +35,7 @@ import { export const DEFAULT_LEGACY_SET: CoreToolSet = { read_file: { name: READ_FILE_TOOL_NAME, - description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, + description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, parametersJsonSchema: { type: 'object', properties: { @@ -43,14 +43,14 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { description: 'The path to the file to read.', type: 'string', }, - offset: { + start_line: { description: - "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + 'Optional: The 1-based line number to start reading from.', type: 'number', }, - limit: { + end_line: { description: - "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", + 'Optional: The 1-based line number to end reading at (inclusive).', type: 'number', }, }, diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index ce5f3fe429..1ceca46d9f 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -38,7 +38,7 @@ import { export const GEMINI_3_SET: CoreToolSet = { read_file: { name: READ_FILE_TOOL_NAME, - description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'offset' and 'limit' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, + description: `Reads and returns the content of a specified file. If the file is large, the content will be truncated. The tool's response will clearly indicate if truncation has occurred and will provide details on how to read more of the file using the 'start_line' and 'end_line' parameters. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), audio files (MP3, WAV, AIFF, AAC, OGG, FLAC), and PDF files. For text files, it can read specific line ranges.`, parametersJsonSchema: { type: 'object', properties: { @@ -46,14 +46,14 @@ export const GEMINI_3_SET: CoreToolSet = { description: 'The path to the file to read.', type: 'string', }, - offset: { + start_line: { description: - "Optional: For text files, the 0-based line number to start reading from. Requires 'limit' to be set. Use for paginating through large files.", + 'Optional: The 1-based line number to start reading from.', type: 'number', }, - limit: { + end_line: { description: - "Optional: For text files, maximum number of lines to read. Use with 'offset' to paginate through large files. If omitted, reads the entire file (if feasible, up to a default limit).", + 'Optional: The 1-based line number to end reading at (inclusive).', type: 'number', }, }, diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index 494b007dec..5457b8337b 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -130,29 +130,36 @@ describe('ReadFileTool', () => { ); }); - it('should throw error if offset is negative', () => { + it('should throw error if start_line is less than 1', () => { const params: ReadFileToolParams = { file_path: path.join(tempRootDir, 'test.txt'), - offset: -1, + start_line: 0, }; - expect(() => tool.build(params)).toThrow( - 'Offset must be a non-negative number', - ); + expect(() => tool.build(params)).toThrow('start_line must be at least 1'); }); - it('should throw error if limit is zero or negative', () => { + it('should throw error if end_line is less than 1', () => { const params: ReadFileToolParams = { file_path: path.join(tempRootDir, 'test.txt'), - limit: 0, + end_line: 0, + }; + expect(() => tool.build(params)).toThrow('end_line must be at least 1'); + }); + + it('should throw error if start_line is greater than end_line', () => { + const params: ReadFileToolParams = { + file_path: path.join(tempRootDir, 'test.txt'), + start_line: 10, + end_line: 5, }; expect(() => tool.build(params)).toThrow( - 'Limit must be a positive number', + 'start_line cannot be greater than end_line', ); }); }); describe('getDescription', () => { - it('should return relative path without limit/offset', () => { + it('should return relative path without ranges', () => { const subDir = path.join(tempRootDir, 'sub', 'dir'); const params: ReadFileToolParams = { file_path: path.join(subDir, 'file.txt'), @@ -393,7 +400,7 @@ describe('ReadFileTool', () => { expect(result.returnDisplay).toBe(''); }); - it('should support offset and limit for text files', async () => { + it('should support start_line and end_line for text files', async () => { const filePath = path.join(tempRootDir, 'paginated.txt'); const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); const fileContent = lines.join('\n'); @@ -401,8 +408,8 @@ describe('ReadFileTool', () => { const params: ReadFileToolParams = { file_path: filePath, - offset: 5, // Start from line 6 - limit: 3, + start_line: 6, + end_line: 8, }; const invocation = tool.build(params); @@ -569,6 +576,10 @@ describe('ReadFileTool', () => { const schema = tool.getSchema(); expect(schema.name).toBe(ReadFileTool.Name); expect(schema.description).toMatchSnapshot(); + expect( + (schema.parametersJsonSchema as { properties: Record }) + .properties, + ).not.toHaveProperty('offset'); }); it('should return the schema from the resolver when modelId is provided', () => { diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index c43f79ded0..170cccf905 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -36,14 +36,14 @@ export interface ReadFileToolParams { file_path: string; /** - * The line number to start reading from (optional) + * The line number to start reading from (optional, 1-based) */ - offset?: number; + start_line?: number; /** - * The number of lines to read (optional) + * The line number to end reading at (optional, 1-based, inclusive) */ - limit?: number; + end_line?: number; } class ReadFileToolInvocation extends BaseToolInvocation< @@ -74,7 +74,12 @@ class ReadFileToolInvocation extends BaseToolInvocation< } override toolLocations(): ToolLocation[] { - return [{ path: this.resolvedPath, line: this.params.offset }]; + return [ + { + path: this.resolvedPath, + line: this.params.start_line, + }, + ]; } async execute(): Promise { @@ -97,8 +102,8 @@ class ReadFileToolInvocation extends BaseToolInvocation< this.resolvedPath, this.config.getTargetDir(), this.config.getFileSystemService(), - this.params.offset, - this.params.limit, + this.params.start_line, + this.params.end_line, ); if (result.error) { @@ -116,13 +121,11 @@ class ReadFileToolInvocation extends BaseToolInvocation< if (result.isTruncated) { const [start, end] = result.linesShown!; const total = result.originalLineCount!; - const nextOffset = this.params.offset - ? this.params.offset + end - start + 1 - : end; + llmContent = ` IMPORTANT: The file content has been truncated. Status: Showing lines ${start}-${end} of ${total} total lines. -Action: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: ${nextOffset}. +Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${end + 1}. --- FILE CONTENT (truncated) --- ${result.llmContent}`; @@ -207,11 +210,18 @@ export class ReadFileTool extends BaseDeclarativeTool< return validationError; } - if (params.offset !== undefined && params.offset < 0) { - return 'Offset must be a non-negative number'; + if (params.start_line !== undefined && params.start_line < 1) { + return 'start_line must be at least 1'; } - if (params.limit !== undefined && params.limit <= 0) { - return 'Limit must be a positive number'; + if (params.end_line !== undefined && params.end_line < 1) { + return 'end_line must be at least 1'; + } + if ( + params.start_line !== undefined && + params.end_line !== undefined && + params.start_line > params.end_line + ) { + return 'start_line cannot be greater than end_line'; } const fileFilteringOptions = this.config.getFileFilteringOptions(); diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index ef24dfca03..c2f413a27e 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -930,7 +930,7 @@ describe('fileUtils', () => { expect(result.returnDisplay).toContain('Path is a directory'); }); - it('should paginate text files correctly (offset and limit)', async () => { + it('should paginate text files correctly (startLine and endLine)', async () => { const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); @@ -938,9 +938,9 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 5, - 5, - ); // Read lines 6-10 + 6, + 10, + ); // Read lines 6-10 (1-based) const expectedContent = lines.slice(5, 10).join('\n'); expect(result.llmContent).toBe(expectedContent); @@ -954,13 +954,13 @@ describe('fileUtils', () => { const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); - // Read from line 11 to 20. The start is not 0, so it's truncated. + // Read from line 11 to 20. The start is not 1, so it's truncated. const result = await processSingleFileContent( testTextFilePath, tempRootDir, new StandardFileSystemService(), - 10, - 10, + 11, + 20, ); const expectedContent = lines.slice(10, 20).join('\n'); @@ -971,7 +971,7 @@ describe('fileUtils', () => { expect(result.linesShown).toEqual([11, 20]); }); - it('should handle limit exceeding file length', async () => { + it('should handle endLine exceeding file length', async () => { const lines = ['Line 1', 'Line 2']; actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); @@ -979,7 +979,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 10, ); const expectedContent = lines.join('\n'); @@ -1015,21 +1015,22 @@ describe('fileUtils', () => { expect(result.isTruncated).toBe(true); }); - it('should truncate when line count exceeds the limit', async () => { - const lines = Array.from({ length: 11 }, (_, i) => `Line ${i + 1}`); + it('should truncate when line count exceeds the default limit', async () => { + const lines = Array.from({ length: 2500 }, (_, i) => `Line ${i + 1}`); actualNodeFs.writeFileSync(testTextFilePath, lines.join('\n')); - // Read 5 lines, but there are 11 total + // No ranges provided, should use default limit (2000) const result = await processSingleFileContent( testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, - 5, ); expect(result.isTruncated).toBe(true); - expect(result.returnDisplay).toBe('Read lines 1-5 of 11 from test.txt'); + expect(result.returnDisplay).toBe( + 'Read lines 1-2000 of 2500 from test.txt', + ); + expect(result.linesShown).toEqual([1, 2000]); }); it('should truncate when a line length exceeds the character limit', async () => { @@ -1043,7 +1044,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 11, ); @@ -1069,7 +1070,7 @@ describe('fileUtils', () => { testTextFilePath, tempRootDir, new StandardFileSystemService(), - 0, + 1, 10, ); expect(result.isTruncated).toBe(true); diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index 32f32129c0..a5b32a3cb4 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -53,7 +53,7 @@ export async function loadWasmBinary( } // Constants for text file processing -const DEFAULT_MAX_LINES_TEXT_FILE = 2000; +export const DEFAULT_MAX_LINES_TEXT_FILE = 2000; const MAX_LINE_LENGTH_TEXT_FILE = 2000; // Default values for encoding and separator format @@ -399,16 +399,17 @@ export interface ProcessedFileReadResult { * Reads and processes a single file, handling text, images, and PDFs. * @param filePath Absolute path to the file. * @param rootDirectory Absolute path to the project root for relative path display. - * @param offset Optional offset for text files (0-based line number). - * @param limit Optional limit for text files (number of lines to read). + * @param _fileSystemService Currently unused in this function; kept for signature stability. + * @param startLine Optional 1-based line number to start reading from. + * @param endLine Optional 1-based line number to end reading at (inclusive). * @returns ProcessedFileReadResult object. */ export async function processSingleFileContent( filePath: string, rootDirectory: string, - fileSystemService: FileSystemService, - offset?: number, - limit?: number, + _fileSystemService: FileSystemService, + startLine?: number, + endLine?: number, ): Promise { try { if (!fs.existsSync(filePath)) { @@ -474,14 +475,24 @@ export async function processSingleFileContent( const lines = content.split('\n'); const originalLineCount = lines.length; - const startLine = offset || 0; - const effectiveLimit = - limit === undefined ? DEFAULT_MAX_LINES_TEXT_FILE : limit; - // Ensure endLine does not exceed originalLineCount - const endLine = Math.min(startLine + effectiveLimit, originalLineCount); - // Ensure selectedLines doesn't try to slice beyond array bounds if startLine is too high - const actualStartLine = Math.min(startLine, originalLineCount); - const selectedLines = lines.slice(actualStartLine, endLine); + let sliceStart = 0; + let sliceEnd = originalLineCount; + + if (startLine !== undefined || endLine !== undefined) { + sliceStart = startLine ? startLine - 1 : 0; + sliceEnd = endLine + ? Math.min(endLine, originalLineCount) + : Math.min( + sliceStart + DEFAULT_MAX_LINES_TEXT_FILE, + originalLineCount, + ); + } else { + sliceEnd = Math.min(DEFAULT_MAX_LINES_TEXT_FILE, originalLineCount); + } + + // Ensure selectedLines doesn't try to slice beyond array bounds + const actualStart = Math.min(sliceStart, originalLineCount); + const selectedLines = lines.slice(actualStart, sliceEnd); let linesWereTruncatedInLength = false; const formattedLines = selectedLines.map((line) => { @@ -494,17 +505,18 @@ export async function processSingleFileContent( return line; }); - const contentRangeTruncated = - startLine > 0 || endLine < originalLineCount; - const isTruncated = contentRangeTruncated || linesWereTruncatedInLength; + const isTruncated = + actualStart > 0 || + sliceEnd < originalLineCount || + linesWereTruncatedInLength; const llmContent = formattedLines.join('\n'); // By default, return nothing to streamline the common case of a successful read_file. let returnDisplay = ''; - if (contentRangeTruncated) { + if (actualStart > 0 || sliceEnd < originalLineCount) { returnDisplay = `Read lines ${ - actualStartLine + 1 - }-${endLine} of ${originalLineCount} from ${relativePathForDisplay}`; + actualStart + 1 + }-${sliceEnd} of ${originalLineCount} from ${relativePathForDisplay}`; if (linesWereTruncatedInLength) { returnDisplay += ' (some lines were shortened)'; } @@ -517,7 +529,7 @@ export async function processSingleFileContent( returnDisplay, isTruncated, originalLineCount, - linesShown: [actualStartLine + 1, endLine], + linesShown: [actualStart + 1, sliceEnd], }; } case 'image': From 727f9b67b19f595156ca5adaf33d7e9ba19f8437 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan <150866123+jwhelangoog@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:26:11 -0800 Subject: [PATCH 45/52] feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010) Co-authored-by: jacob314 --- packages/cli/src/gemini.tsx | 25 +- packages/cli/src/test-utils/render.tsx | 89 +++-- packages/cli/src/ui/AppContainer.test.tsx | 342 +++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 114 +++++- .../ui/components/FolderTrustDialog.test.tsx | 4 +- .../src/ui/components/FolderTrustDialog.tsx | 58 +-- .../src/ui/components/HistoryItemDisplay.tsx | 3 + .../src/ui/components/MainContent.test.tsx | 10 +- .../cli/src/ui/components/MainContent.tsx | 82 +++-- .../src/ui/components/ShowMoreLines.test.tsx | 57 ++- .../cli/src/ui/components/ShowMoreLines.tsx | 25 +- .../src/ui/components/ToastDisplay.test.tsx | 28 ++ .../cli/src/ui/components/ToastDisplay.tsx | 12 +- .../components/ToolConfirmationQueue.test.tsx | 9 +- .../ui/components/ToolConfirmationQueue.tsx | 20 +- ...ternateBufferQuittingDisplay.test.tsx.snap | 2 - .../ConfigInitDisplay.test.tsx.snap | 12 - .../ExitPlanModeDialog.test.tsx.snap | 108 ------ .../__snapshots__/MainContent.test.tsx.snap | 48 +-- .../ToolConfirmationQueue.test.tsx.snap | 3 +- .../ui/components/messages/GeminiMessage.tsx | 10 +- .../messages/ShellToolMessage.test.tsx | 72 +++- .../components/messages/ShellToolMessage.tsx | 73 +--- .../messages/ToolGroupMessage.test.tsx | 191 ++++++++++ .../components/messages/ToolGroupMessage.tsx | 152 ++++++-- .../ToolOverflowConsistencyChecks.test.tsx | 115 ++++++ .../messages/ToolResultDisplay.test.tsx | 32 +- .../components/messages/ToolResultDisplay.tsx | 50 ++- .../ToolResultDisplayOverflow.test.tsx | 11 +- .../ShellToolMessage.test.tsx.snap | 133 ++++++- .../ToolGroupMessage.test.tsx.snap | 12 +- .../ToolResultDisplay.test.tsx.snap | 6 +- .../ToolResultDisplayOverflow.test.tsx.snap | 6 +- .../ToolStickyHeaderRegression.test.tsx.snap | 4 +- packages/cli/src/ui/constants.ts | 1 + .../cli/src/ui/contexts/OverflowContext.tsx | 12 +- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + packages/cli/src/ui/utils/CodeColorizer.tsx | 8 +- packages/cli/src/ui/utils/toolLayoutUtils.ts | 110 ++++++ 39 files changed, 1622 insertions(+), 428 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx create mode 100644 packages/cli/src/ui/utils/toolLayoutUtils.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 412ba01b31..aa830c0250 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -102,6 +102,7 @@ import { createPolicyUpdater } from './config/policy.js'; import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js'; import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { OverflowProvider } from './ui/contexts/OverflowContext.js'; import { setupTerminalAndTheme } from './utils/terminalTheme.js'; import { profiler } from './ui/components/DebugProfiler.js'; @@ -238,17 +239,19 @@ export async function startInteractiveUI( > - - - - - + + + + + + + diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 257ea84466..a13d6e2558 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -35,6 +35,13 @@ import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js'; import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js'; import { AskUserActionsProvider } from '../ui/contexts/AskUserActionsContext.js'; import { TerminalProvider } from '../ui/contexts/TerminalContext.js'; +import { + OverflowProvider, + useOverflowActions, + useOverflowState, + type OverflowActions, + type OverflowState, +} from '../ui/contexts/OverflowContext.js'; import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; @@ -335,6 +342,8 @@ export type RenderInstance = { lastFrame: (options?: { allowEmpty?: boolean }) => string; terminal: Terminal; waitUntilReady: () => Promise; + capturedOverflowState: OverflowState | undefined; + capturedOverflowActions: OverflowActions | undefined; }; const instances: InkInstance[] = []; @@ -343,7 +352,10 @@ const instances: InkInstance[] = []; export const render = ( tree: React.ReactElement, terminalWidth?: number, -): RenderInstance => { +): Omit< + RenderInstance, + 'capturedOverflowState' | 'capturedOverflowActions' +> => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct // value was used (e.g. 40 rows). The alternatives to make things worse are @@ -562,6 +574,16 @@ const mockUIActions: UIActions = { handleNewAgentsSelect: vi.fn(), }; +let capturedOverflowState: OverflowState | undefined; +let capturedOverflowActions: OverflowActions | undefined; +const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + capturedOverflowState = useOverflowState(); + capturedOverflowActions = useOverflowActions(); + return <>{children}; +}; + export const renderWithProviders = ( component: React.ReactElement, { @@ -663,6 +685,9 @@ export const renderWithProviders = ( .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') .flatMap((item) => item.tools); + capturedOverflowState = undefined; + capturedOverflowActions = undefined; + const renderResult = render( @@ -675,35 +700,39 @@ export const renderWithProviders = ( value={finalUiState.streamingState} > - - + - - - - - - {component} - - - - - - - + + + + + + + + {component} + + + + + + + + + @@ -718,6 +747,8 @@ export const renderWithProviders = ( return { ...renderResult, + capturedOverflowState, + capturedOverflowActions, simulateClick: (col: number, row: number, button?: 0 | 1 | 2) => simulateClick(renderResult.stdin, col, row, button), }; diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3aeef34292..b3610a6d72 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -105,6 +105,11 @@ import { type UIActions, } from './contexts/UIActionsContext.js'; import { KeypressProvider } from './contexts/KeypressContext.js'; +import { OverflowProvider } from './contexts/OverflowContext.js'; +import { + useOverflowActions, + type OverflowActions, +} from './contexts/OverflowContext.js'; // Mock useStdout to capture terminal title writes vi.mock('ink', async (importOriginal) => { @@ -120,9 +125,11 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; +let capturedOverflowActions: OverflowActions; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; + capturedOverflowActions = useOverflowActions()!; return null; } @@ -229,7 +236,10 @@ import { disableMouseEvents, } from '@google/gemini-cli-core'; import { type ExtensionManager } from '../config/extension-manager.js'; -import { WARNING_PROMPT_DURATION_MS } from './constants.js'; +import { + WARNING_PROMPT_DURATION_MS, + EXPAND_HINT_DURATION_MS, +} from './constants.js'; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -255,13 +265,15 @@ describe('AppContainer State Management', () => { } = {}) => ( - + + + ); @@ -2687,12 +2699,14 @@ describe('AppContainer State Management', () => { const getTree = (settings: LoadedSettings) => ( - - + + + + ); @@ -3303,6 +3317,306 @@ describe('AppContainer State Management', () => { }); }); + describe('Submission Handling', () => { + it('resets expansion state on submission when not in alternate buffer', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout to clear any initial writes + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should refresh static (which clears terminal in non-alternate buffer) + expect(mocks.mockStdout.write).toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + + it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { + const { checkPermissions } = await import( + './hooks/atCommandProcessor.js' + ); + vi.mocked(checkPermissions).mockResolvedValue([]); + + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, + }, + } as LoadedSettings, + }).unmount; + }); + + await waitFor(() => expect(capturedUIActions).toBeTruthy()); + + // Expand first + act(() => capturedUIActions.setConstrainHeight(false)); + expect(capturedUIState.constrainHeight).toBe(false); + + // Reset mock stdout + mocks.mockStdout.write.mockClear(); + + // Submit + await act(async () => capturedUIActions.handleFinalSubmit('test prompt')); + + // Should be reset + expect(capturedUIState.constrainHeight).toBe(true); + // Should NOT refresh static's clearTerminal in alternate buffer + expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( + ansiEscapes.clearTerminal, + ); + unmount!(); + }); + }); + + describe('Overflow Hint Handling', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + // Should show hint because we are in Standard Mode (default settings) and have overflow + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance just before the timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance to hit the timeout mark + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Simulate Ctrl+O + act(() => { + stdin.write('\x0f'); // \x0f is Ctrl+O + }); + + await waitFor(() => { + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Advance enough that the original timer would have expired if it hadn't reset + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 + 1000); + }); + + // We expect it to still be true because Ctrl+O should have reset the timer + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Advance remaining time to reach the new timeout + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2 - 1000); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Initial state is constrainHeight = true + expect(capturedUIState.constrainHeight).toBe(true); + + // Trigger overflow so the hint starts showing + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(true); + }); + + // Advance half the duration + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS / 2); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // First toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Second toggle 'off' (collapsed) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(true); + }); + + // Wait 1 second + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Third toggle 'on' (expanded) + act(() => { + stdin.write('\x0f'); // Ctrl+O + }); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); + + // Now we wait just before the timeout from the LAST toggle. + // It should still be true. + act(() => { + vi.advanceTimersByTime(EXPAND_HINT_DURATION_MS - 100); + }); + expect(capturedUIState.showIsExpandableHint).toBe(true); + + // Wait 0.1s more to hit exactly the timeout since the last toggle. + // It should hide now. + act(() => { + vi.advanceTimersByTime(100); + }); + await waitFor(() => { + expect(capturedUIState.showIsExpandableHint).toBe(false); + }); + + unmount!(); + }); + + it('does NOT set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { + const alternateSettings = mergeSettings({}, {}, {}, {}, true); + const settingsWithAlternateBuffer = { + merged: { + ...alternateSettings, + ui: { + ...alternateSettings.ui, + useAlternateBuffer: true, + }, + }, + } as unknown as LoadedSettings; + + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ + settings: settingsWithAlternateBuffer, + }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + + // Trigger overflow + act(() => { + capturedOverflowActions.addOverflowingId('test-id'); + }); + + // Should NOT show hint because we are in Alternate Buffer Mode + expect(capturedUIState.showIsExpandableHint).toBe(false); + + unmount!(); + }); + }); + describe('Permission Handling', () => { it('shows permission dialog when checkPermissions returns paths', async () => { const { checkPermissions } = await import( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index bdf9501c4e..d0ba22d455 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -95,6 +95,10 @@ import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; +import { + useOverflowActions, + useOverflowState, +} from './contexts/OverflowContext.js'; import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; @@ -151,6 +155,7 @@ import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js' import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, + EXPAND_HINT_DURATION_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; @@ -214,6 +219,7 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); + const { reset } = useOverflowActions()!; const notificationsEnabled = isNotificationsEnabled(settings); const historyManager = useHistory({ @@ -262,6 +268,54 @@ export const AppContainer = (props: AppContainerProps) => { ); const [newAgents, setNewAgents] = useState(null); + const [constrainHeight, setConstrainHeight] = useState(true); + const [showIsExpandableHint, setShowIsExpandableHint] = useState(false); + const expandHintTimerRef = useRef(null); + const overflowState = useOverflowState(); + const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0; + const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight; + + /** + * Manages the visibility and x-second timer for the expansion hint. + * + * This effect triggers the timer countdown whenever an overflow is detected + * or the user manually toggles the expansion state with Ctrl+O. We use a stable + * boolean dependency (hasOverflowState) to ensure the timer only resets on + * genuine state transitions, preventing it from infinitely resetting during + * active text streaming. + */ + useEffect(() => { + if (isAlternateBuffer) { + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + return; + } + + if (hasOverflowState) { + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + }, [hasOverflowState, isAlternateBuffer, constrainHeight]); + + /** + * Safe cleanup to ensure the expansion hint timer is cancelled when the + * component unmounts, preventing memory leaks. + */ + useEffect( + () => () => { + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + }, + [], + ); const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); @@ -1189,6 +1243,19 @@ Logging in with Google... Restarting Gemini CLI to continue. const handleFinalSubmit = useCallback( async (submittedValue: string) => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when a new turn begins. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + if (!constrainHeight) { + setConstrainHeight(true); + if (!isAlternateBuffer) { + refreshStatic(); + } + } + const isSlash = isSlashCommand(submittedValue.trim()); const isIdle = streamingState === StreamingState.Idle; const isAgentRunning = @@ -1247,15 +1314,32 @@ Logging in with Google... Restarting Gemini CLI to continue. pendingSlashCommandHistoryItems, pendingGeminiHistoryItems, config, + constrainHeight, + setConstrainHeight, + isAlternateBuffer, + refreshStatic, + reset, handleHintSubmit, ], ); const handleClearScreen = useCallback(() => { + reset(); + // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. + setShowIsExpandableHint(false); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } historyManager.clearItems(); clearConsoleMessagesState(); refreshStatic(); - }, [historyManager, clearConsoleMessagesState, refreshStatic]); + }, [ + historyManager, + clearConsoleMessagesState, + refreshStatic, + reset, + setShowIsExpandableHint, + ]); const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); @@ -1425,7 +1509,7 @@ Logging in with Google... Restarting Gemini CLI to continue. windowMs: WARNING_PROMPT_DURATION_MS, onRepeat: handleExitRepeat, }); - const [constrainHeight, setConstrainHeight] = useState(true); + const [ideContextState, setIdeContextState] = useState< IdeContext | undefined >(); @@ -1655,6 +1739,19 @@ Logging in with Google... Restarting Gemini CLI to continue. if (!constrainHeight) { enteringConstrainHeightMode = true; setConstrainHeight(true); + if (keyMatchers[Command.SHOW_MORE_LINES](key)) { + // If the user manually collapses the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + } + if (!isAlternateBuffer) { + refreshStatic(); + } } if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) { @@ -1698,6 +1795,17 @@ Logging in with Google... Restarting Gemini CLI to continue. !enteringConstrainHeightMode ) { setConstrainHeight(false); + // If the user manually expands the view, show the hint and reset the x-second timer. + setShowIsExpandableHint(true); + if (expandHintTimerRef.current) { + clearTimeout(expandHintTimerRef.current); + } + expandHintTimerRef.current = setTimeout(() => { + setShowIsExpandableHint(false); + }, EXPAND_HINT_DURATION_MS); + if (!isAlternateBuffer) { + refreshStatic(); + } return true; } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || @@ -2218,6 +2326,7 @@ Logging in with Google... Restarting Gemini CLI to continue. isBackgroundShellListOpen, adminSettingsChanged, newAgents, + showIsExpandableHint, hintMode: config.isModelSteeringEnabled() && isToolExecuting([ @@ -2344,6 +2453,7 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, adminSettingsChanged, newAgents, + showIsExpandableHint, ], ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index a227047fba..07693db151 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -173,7 +173,9 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + // In standard terminal mode, the expansion hint is handled globally by ToastDisplay + // via AppContainer, so it should not be present in the dialog's local frame. + expect(lastFrame()).not.toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.tsx b/packages/cli/src/ui/components/FolderTrustDialog.tsx index 70cfd9fd4c..2067a5dc3a 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.tsx @@ -285,33 +285,37 @@ export const FolderTrustDialog: React.FC = ({ ); }; - return ( - - - - {renderContent()} - - - - - - - {isRestarting && ( - - - Gemini CLI is restarting to apply the trust changes... - - - )} - {exiting && ( - - - A folder trust level must be selected to continue. Exiting since - escape was pressed. - - - )} + const content = ( + + + {renderContent()} - + + + + + + {isRestarting && ( + + + Gemini CLI is restarting to apply the trust changes... + + + )} + {exiting && ( + + + A folder trust level must be selected to continue. Exiting since + escape was pressed. + + + )} + + ); + + return isAlternateBuffer ? ( + {content} + ) : ( + content ); }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f863e2272a..2fceacdf2f 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -46,6 +46,7 @@ interface HistoryItemDisplayProps { isPending: boolean; commands?: readonly SlashCommand[]; availableTerminalHeightGemini?: number; + isExpandable?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -55,6 +56,7 @@ export const HistoryItemDisplay: React.FC = ({ isPending, commands, availableTerminalHeightGemini, + isExpandable, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -180,6 +182,7 @@ export const HistoryItemDisplay: React.FC = ({ terminalWidth={terminalWidth} borderTop={itemForDisplay.borderTop} borderBottom={itemForDisplay.borderBottom} + isExpandable={isExpandable} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index fce375c306..598d19240f 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -493,7 +493,8 @@ describe('MainContent', () => { isAlternateBuffer: true, embeddedShellFocused: true, constrainHeight: true, - shouldShowLine1: true, + shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'ASB mode - Unfocused shell', @@ -501,6 +502,7 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Constrained height', @@ -508,13 +510,15 @@ describe('MainContent', () => { embeddedShellFocused: false, constrainHeight: true, shouldShowLine1: false, + staticAreaMaxItemHeight: 15, }, { name: 'Normal mode - Unconstrained height', isAlternateBuffer: false, embeddedShellFocused: false, constrainHeight: false, - shouldShowLine1: false, + shouldShowLine1: true, + staticAreaMaxItemHeight: 15, }, ]; @@ -525,6 +529,7 @@ describe('MainContent', () => { embeddedShellFocused, constrainHeight, shouldShowLine1, + staticAreaMaxItemHeight, }) => { vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer); const ptyId = 123; @@ -554,6 +559,7 @@ describe('MainContent', () => { }, ], availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines + staticAreaMaxItemHeight, terminalHeight: 50, terminalWidth: 100, mainAreaWidth: 100, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index cba57756e3..fbcc962663 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -47,32 +47,61 @@ export const MainContent = () => { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, - availableTerminalHeight, cleanUiDetailsVisible, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; + const lastUserPromptIndex = useMemo(() => { + for (let i = uiState.history.length - 1; i >= 0; i--) { + const type = uiState.history[i].type; + if (type === 'user' || type === 'user_shell') { + return i; + } + } + return -1; + }, [uiState.history]); + const historyItems = useMemo( () => - uiState.history.map((h) => ( - - )), + uiState.history.map((h, index) => { + const isExpandable = index > lastUserPromptIndex; + return ( + + ); + }), [ uiState.history, mainAreaWidth, staticAreaMaxItemHeight, uiState.slashCommands, + uiState.constrainHeight, + lastUserPromptIndex, ], ); + const staticHistoryItems = useMemo( + () => historyItems.slice(0, lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + + const lastResponseHistoryItems = useMemo( + () => historyItems.slice(lastUserPromptIndex + 1), + [historyItems, lastUserPromptIndex], + ); + const pendingItems = useMemo( () => ( @@ -80,14 +109,12 @@ export const MainContent = () => { ))} {showConfirmationQueue && confirmingTool && ( @@ -98,8 +125,7 @@ export const MainContent = () => { [ pendingHistoryItems, uiState.constrainHeight, - isAlternateBuffer, - availableTerminalHeight, + staticAreaMaxItemHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, @@ -109,10 +135,14 @@ export const MainContent = () => { const virtualizedData = useMemo( () => [ { type: 'header' as const }, - ...uiState.history.map((item) => ({ type: 'history' as const, item })), + ...uiState.history.map((item, index) => ({ + type: 'history' as const, + item, + isExpandable: index > lastUserPromptIndex, + })), { type: 'pending' as const }, ], - [uiState.history], + [uiState.history, lastUserPromptIndex], ); const renderItem = useCallback( @@ -129,12 +159,17 @@ export const MainContent = () => { return ( ); } else { @@ -147,6 +182,8 @@ export const MainContent = () => { mainAreaWidth, uiState.slashCommands, pendingItems, + uiState.constrainHeight, + staticAreaMaxItemHeight, ], ); @@ -176,7 +213,8 @@ export const MainContent = () => { key={uiState.historyRemountKey} items={[ , - ...historyItems, + ...staticHistoryItems, + ...lastResponseHistoryItems, ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/ShowMoreLines.test.tsx b/packages/cli/src/ui/components/ShowMoreLines.test.tsx index 699e2b7f01..4a6829809a 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.test.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.test.tsx @@ -29,7 +29,6 @@ describe('ShowMoreLines', () => { it.each([ [new Set(), StreamingState.Idle, true], // No overflow [new Set(['1']), StreamingState.Idle, false], // Not constraining height - [new Set(['1']), StreamingState.Responding, true], // Streaming ])( 'renders nothing when: overflow=%s, streaming=%s, constrain=%s', async (overflowingIds, streamingState, constrainHeight) => { @@ -46,9 +45,28 @@ describe('ShowMoreLines', () => { }, ); - it.each([[StreamingState.Idle], [StreamingState.WaitingForConfirmation]])( - 'renders message when overflowing and state is %s', + it('renders nothing in STANDARD mode even if overflowing', async () => { + mockUseAlternateBuffer.mockReturnValue(false); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it.each([ + [StreamingState.Idle], + [StreamingState.WaitingForConfirmation], + [StreamingState.Responding], + ])( + 'renders message in ASB mode when overflowing and state is %s', async (streamingState) => { + mockUseAlternateBuffer.mockReturnValue(true); mockUseOverflowState.mockReturnValue({ overflowingIds: new Set(['1']), } as NonNullable>); @@ -57,8 +75,39 @@ describe('ShowMoreLines', () => { , ); await waitUntilReady(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); unmount(); }, ); + + it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => { + mockUseAlternateBuffer.mockReturnValue(true); + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame().toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => { + mockUseOverflowState.mockReturnValue({ + overflowingIds: new Set(['1']), + } as NonNullable>); + mockUseStreamingContext.mockReturnValue(StreamingState.Idle); + const { lastFrame, waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); }); diff --git a/packages/cli/src/ui/components/ShowMoreLines.tsx b/packages/cli/src/ui/components/ShowMoreLines.tsx index a3317d4dc6..92acd2b29a 100644 --- a/packages/cli/src/ui/components/ShowMoreLines.tsx +++ b/packages/cli/src/ui/components/ShowMoreLines.tsx @@ -9,31 +9,42 @@ import { useOverflowState } from '../contexts/OverflowContext.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { theme } from '../semantic-colors.js'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; interface ShowMoreLinesProps { constrainHeight: boolean; + isOverflowing?: boolean; } -export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => { +export const ShowMoreLines = ({ + constrainHeight, + isOverflowing: isOverflowingProp, +}: ShowMoreLinesProps) => { + const isAlternateBuffer = useAlternateBuffer(); const overflowState = useOverflowState(); const streamingState = useStreamingContext(); + const isOverflowing = + isOverflowingProp ?? + (overflowState !== undefined && overflowState.overflowingIds.size > 0); + if ( - overflowState === undefined || - overflowState.overflowingIds.size === 0 || + !isAlternateBuffer || + !isOverflowing || !constrainHeight || !( streamingState === StreamingState.Idle || - streamingState === StreamingState.WaitingForConfirmation + streamingState === StreamingState.WaitingForConfirmation || + streamingState === StreamingState.Responding ) ) { return null; } return ( - - - Press ctrl-o to show more lines + + + Press Ctrl+O to show more lines ); diff --git a/packages/cli/src/ui/components/ToastDisplay.test.tsx b/packages/cli/src/ui/components/ToastDisplay.test.tsx index da50999204..f2ef9a287b 100644 --- a/packages/cli/src/ui/components/ToastDisplay.test.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.test.tsx @@ -35,12 +35,22 @@ describe('ToastDisplay', () => { buffer: { text: '' } as TextBuffer, history: [] as HistoryItem[], queueErrorMessage: null, + showIsExpandableHint: false, }; it('returns false for default state', () => { expect(shouldShowToast(baseState as UIState)).toBe(false); }); + it('returns true when showIsExpandableHint is true', () => { + expect( + shouldShowToast({ + ...baseState, + showIsExpandableHint: true, + } as UIState), + ).toBe(true); + }); + it('returns true when ctrlCPressedOnce is true', () => { expect( shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState), @@ -170,4 +180,22 @@ describe('ToastDisplay', () => { await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); + + it('renders expansion hint when showIsExpandableHint is true', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: true, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to show more lines'); + }); + + it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderToastDisplay({ + showIsExpandableHint: true, + constrainHeight: false, + }); + await waitUntilReady(); + expect(lastFrame()).toContain('Press Ctrl+O to collapse lines'); + }); }); diff --git a/packages/cli/src/ui/components/ToastDisplay.tsx b/packages/cli/src/ui/components/ToastDisplay.tsx index 37d2997e33..e383201219 100644 --- a/packages/cli/src/ui/components/ToastDisplay.tsx +++ b/packages/cli/src/ui/components/ToastDisplay.tsx @@ -17,7 +17,8 @@ export function shouldShowToast(uiState: UIState): boolean { uiState.ctrlDPressedOnce || (uiState.showEscapePrompt && (uiState.buffer.text.length > 0 || uiState.history.length > 0)) || - Boolean(uiState.queueErrorMessage) + Boolean(uiState.queueErrorMessage) || + uiState.showIsExpandableHint ); } @@ -73,5 +74,14 @@ export const ToastDisplay: React.FC = () => { return {uiState.queueErrorMessage}; } + if (uiState.showIsExpandableHint) { + const action = uiState.constrainHeight ? 'show more' : 'collapse'; + return ( + + Press Ctrl+O to {action} lines for the most recent response + + ); + } + return null; }; diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index 345d00a263..ab7d080b37 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -161,7 +161,7 @@ describe('ToolConfirmationQueue', () => { , { config: mockConfig, - useAlternateBuffer: false, + useAlternateBuffer: true, uiState: { terminalWidth: 80, terminalHeight: 20, @@ -173,10 +173,11 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).toContain('Press ctrl-o to show more lines'); unmount(); }); @@ -324,7 +325,7 @@ describe('ToolConfirmationQueue', () => { await waitUntilReady(); const output = lastFrame(); - expect(output).not.toContain('Press ctrl-o to show more lines'); + expect(output).not.toContain('Press CTRL-O to show more lines'); expect(output).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index e3c18e0231..c89c98f8d4 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -71,13 +71,12 @@ export const ToolConfirmationQueue: React.FC = ({ // - 2 lines for the rounded border // - 2 lines for the Header (text + margin) // - 2 lines for Tool Identity (text + margin) - const availableContentHeight = - constrainHeight && !isAlternateBuffer - ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) - : undefined; + const availableContentHeight = constrainHeight + ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) + : undefined; - return ( - + const content = ( + <> = ({ /> - + + ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 3da5a05c0c..8fb49b8b71 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -43,7 +43,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ @@ -90,7 +89,6 @@ Tips for getting started: │ ✓ tool1 Description for tool 1 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool2 Description for tool 2 │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap index 28929deee5..8d03baaa49 100644 --- a/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ConfigInitDisplay.test.tsx.snap @@ -18,20 +18,8 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more " `; -exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = ` -" -Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more -" -`; - exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = ` " Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 " `; - -exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = ` -" -Spinner Connecting to MCP servers... (1/2) - Waiting for: server2 -" -`; diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index faa759a050..587ded8f29 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -27,33 +27,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -81,33 +54,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: false > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: false > displays error state when file read fails 1`] = ` " Error reading plan: File not found " @@ -194,33 +140,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > bubbles up Ctrl+C when feedback is empty while editing 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Type your feedback... - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 1`] = ` "Overview @@ -248,33 +167,6 @@ Enter to select · ↑/↓ to navigate · Esc to cancel " `; -exports[`ExitPlanModeDialog > useAlternateBuffer: true > calls onFeedback when feedback is typed and submitted 2`] = ` -"Overview - -Add user authentication to the CLI application. - -Implementation Steps - - 1. Create src/auth/AuthService.ts with login/logout methods - 2. Add session storage in src/storage/SessionStore.ts - 3. Update src/commands/index.ts to check auth status - 4. Add tests in src/auth/__tests__/ - -Files to Modify - - - src/index.ts - Add auth middleware - - src/config.ts - Add auth configuration options - - 1. Yes, automatically accept edits - Approves plan and allows tools to run automatically - 2. Yes, manually accept edits - Approves plan but requires confirmation for each tool -● 3. Add tests - -Enter to submit · Esc to cancel -" -`; - exports[`ExitPlanModeDialog > useAlternateBuffer: true > displays error state when file read fails 1`] = ` " Error reading plan: File not found " 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 29eda04bab..c7a1d0f48b 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -6,27 +6,17 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 1 │ -│ Line 2 │ -│ Line 3 │ -│ Line 4 │ -│ Line 5 │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ │ Line 14 │ -│ Line 15 │ -│ Line 16 │ -│ Line 17 │ -│ Line 18 │ -│ Line 19 │ -│ Line 20 │ -│ │ +│ Line 15 █ │ +│ Line 16 █ │ +│ Line 17 █ │ +│ Line 18 █ │ +│ Line 19 █ │ +│ Line 20 █ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ ShowMoreLines " @@ -38,15 +28,11 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 ▄ │ -│ Line 10 █ │ -│ Line 11 █ │ -│ Line 12 █ │ -│ Line 13 █ │ -│ Line 14 █ │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -63,12 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ -│ Line 6 │ -│ Line 7 │ -│ Line 8 │ -│ Line 9 │ -│ Line 10 │ -│ Line 11 │ +│ ... first 11 lines hidden ... │ │ Line 12 │ │ Line 13 │ │ Line 14 │ @@ -88,6 +69,11 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command Running a long command... │ │ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ │ Line 6 │ │ Line 7 │ │ Line 8 │ diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 56dcb64d70..b5e013ef48 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines " `; @@ -107,7 +106,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx index 3c17a3850f..0bdf9b65e9 100644 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ b/packages/cli/src/ui/components/messages/GeminiMessage.tsx @@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { useUIState } from '../../contexts/UIStateContext.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; interface GeminiMessageProps { text: string; @@ -31,7 +32,7 @@ export const GeminiMessage: React.FC = ({ const prefixWidth = prefix.length; const isAlternateBuffer = useAlternateBuffer(); - return ( + const content = ( @@ -61,4 +62,11 @@ export const GeminiMessage: React.FC = ({ ); + + return isAlternateBuffer ? ( + /* Shadow the global provider to maintain isolation in ASB mode. */ + {content} + ) : ( + content + ); }; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 6359b5b250..72ce8cec5f 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -191,7 +191,7 @@ describe('', () => { true, ], [ - 'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined', + 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined', undefined, ACTIVE_SHELL_MAX_LINES, false, @@ -219,5 +219,75 @@ describe('', () => { expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); expect(frame).toMatchSnapshot(); }); + + it('fully expands in standard mode when availableTerminalHeight is undefined', async () => { + const { lastFrame } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Executing, + }, + { useAlternateBuffer: false }, + ); + + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + }); + + it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: true, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should show all 100 lines because constrainHeight is false and isExpandable is true + expect(frame.match(/Line \d+/g)?.length).toBe(100); + }); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => { + const { lastFrame, waitUntilReady } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight: undefined, + status: CoreToolCallStatus.Success, + isExpandable: false, + }, + { + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + + await waitUntilReady(); + await waitFor(() => { + const frame = lastFrame(); + // Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false + expect(frame.match(/Line \d+/g)?.length).toBe(15); + }); + expect(lastFrame()).toMatchSnapshot(); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 50af3bc1e6..54abbc09d3 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -15,24 +15,21 @@ import { ToolStatusIndicator, ToolInfo, TrailingIndicator, - STATUS_INDICATOR_WIDTH, isThisShellFocusable as checkIsShellFocusable, isThisShellFocused as checkIsShellFocused, useFocusHint, FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; -import { - ACTIVE_SHELL_MAX_LINES, - COMPLETED_SHELL_MAX_LINES, -} from '../../constants.js'; +import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; -import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; - import { useUIState } from '../../contexts/UIStateContext.js'; +import { type Config } from '@google/gemini-cli-core'; +import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js'; export interface ShellToolMessageProps extends ToolMessageProps { config?: Config; + isExpandable?: boolean; } export const ShellToolMessage: React.FC = ({ @@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC = ({ borderColor, borderDimColor, + isExpandable, }) => { - const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState(); + const { + activePtyId: activeShellPtyId, + embeddedShellFocused, + constrainHeight, + } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); + const isThisShellFocused = checkIsShellFocused( name, status, @@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} - maxLines={getShellMaxLines( + maxLines={calculateShellMaxLines({ status, isAlternateBuffer, isThisShellFocused, availableTerminalHeight, - )} + constrainHeight, + isExpandable, + })} /> {isThisShellFocused && config && ( - - - + )} ); }; - -/** - * Calculates the maximum number of lines to display for shell output. - * - * For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES. - * For active processes, it returns the available terminal height if in alternate buffer mode - * and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES. - * - * This function ensures a finite number of lines is always returned to prevent performance issues. - */ -function getShellMaxLines( - status: CoreToolCallStatus, - isAlternateBuffer: boolean, - isThisShellFocused: boolean, - availableTerminalHeight: number | undefined, -): number { - if ( - status === CoreToolCallStatus.Success || - status === CoreToolCallStatus.Error || - status === CoreToolCallStatus.Cancelled - ) { - return COMPLETED_SHELL_MAX_LINES; - } - - if (availableTerminalHeight === undefined) { - return ACTIVE_SHELL_MAX_LINES; - } - - const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); - - if (isAlternateBuffer && isThisShellFocused) { - return maxLinesBasedOnHeight; - } - - return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES); -} diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx index b3e0275a1b..1ead1503e5 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx @@ -6,6 +6,7 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { describe, it, expect, vi, afterEach } from 'vitest'; +import { act } from 'react'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import type { HistoryItem, @@ -678,4 +679,194 @@ describe('', () => { }, ); }); + + describe('Manual Overflow Detection', () => { + it('detects overflow for string results exceeding available height', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('detects overflow for array results exceeding available height', async () => { + // resultDisplay when array is expected to be AnsiLine[] + // AnsiLine is AnsiToken[] + const toolCalls = [ + createToolCall({ + resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]), + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => { + const toolCalls = [ + createToolCall({ + name: 'run_shell_command', + status: CoreToolCallStatus.Executing, + ptyId: 1, + resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit) + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + activePtyId: 1, + embeddedShellFocused: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ); + unmount(); + }); + + it('does not show expansion hint when content is within limits', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'small result', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('hides expansion hint when constrainHeight is false', async () => { + const toolCalls = [ + createToolCall({ + resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5', + }), + ]; + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: false, + }, + }, + ); + await waitUntilReady(); + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + + it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => { + // In this test, the tool output is SHORT (no local overflow). + // We will inject a dummy ID into the global overflow state. + // ToolGroupMessage should still NOT show the hint because it calculates + // overflow locally and passes it as a prop. + const toolCalls = [ + createToolCall({ + resultDisplay: 'short result', + }), + ]; + const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } = + renderWithProviders( + , + { + config: baseMockConfig, + useAlternateBuffer: true, + uiState: { + constrainHeight: true, + }, + }, + ); + await waitUntilReady(); + + // Manually trigger a global overflow + act(() => { + expect(capturedOverflowActions).toBeDefined(); + capturedOverflowActions!.addOverflowingId('unrelated-global-id'); + }); + + // The hint should NOT appear because ToolGroupMessage is isolated by its prop logic + expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines'); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index f4e1c200db..3c3dcf56d3 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js'; import { ShellToolMessage } from './ShellToolMessage.js'; import { theme } from '../../semantic-colors.js'; import { useConfig } from '../../contexts/ConfigContext.js'; -import { isShellTool } from './ToolShared.js'; +import { isShellTool, isThisShellFocused } from './ToolShared.js'; import { shouldHideToolCall } from '@google/gemini-cli-core'; import { ShowMoreLines } from '../ShowMoreLines.js'; import { useUIState } from '../../contexts/UIStateContext.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { + calculateShellMaxLines, + calculateToolContentMaxLines, +} from '../../utils/toolLayoutUtils.js'; import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js'; interface ToolGroupMessageProps { @@ -31,6 +36,7 @@ interface ToolGroupMessageProps { onShellInputSubmit?: (input: string) => void; borderTop?: boolean; borderBottom?: boolean; + isExpandable?: boolean; } // Main component renders the border and maps the tools using ToolMessage @@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC = ({ terminalWidth, borderTop: borderTopOverride, borderBottom: borderBottomOverride, + isExpandable, }) => { // Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations). const toolCalls = useMemo( @@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC = ({ backgroundShells, pendingHistoryItems, } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); const { borderColor, borderDimColor } = useMemo( () => @@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC = ({ const staticHeight = /* border */ 2; - // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), - // only render if we need to close a border from previous - // tool groups. borderBottomOverride=true means we must render the closing border; - // undefined or false means there's nothing to display. - if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { - return null; - } - let countToolCallsWithResults = 0; for (const tool of visibleToolCalls) { if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') { @@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC = ({ const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; - return ( - // This box doesn't have a border even though it conceptually does because - // we need to allow the sticky headers to render the borders themselves so - // that the top border can be sticky. + /* + * ToolGroupMessage calculates its own overflow state locally and passes + * it as a prop to ShowMoreLines. This isolates it from global overflow + * reports in ASB mode, while allowing it to contribute to the global + * 'Toast' hint in Standard mode. + * + * Because of this prop-based isolation and the explicit mode-checks in + * AppContainer, we do not need to shadow the OverflowProvider here. + */ + const hasOverflow = useMemo(() => { + if (!availableTerminalHeightPerToolMessage) return false; + return visibleToolCalls.some((tool) => { + const isShellToolCall = isShellTool(tool.name); + const isFocused = isThisShellFocused( + tool.name, + tool.status, + tool.ptyId, + activePtyId, + embeddedShellFocused, + ); + + let maxLines: number | undefined; + + if (isShellToolCall) { + maxLines = calculateShellMaxLines({ + status: tool.status, + isAlternateBuffer, + isThisShellFocused: isFocused, + availableTerminalHeight: availableTerminalHeightPerToolMessage, + constrainHeight, + isExpandable, + }); + } + + // Standard tools and Shell tools both eventually use ToolResultDisplay's logic. + // ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget. + const contentMaxLines = calculateToolContentMaxLines({ + availableTerminalHeight: availableTerminalHeightPerToolMessage, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); + + if (!contentMaxLines) return false; + + if (typeof tool.resultDisplay === 'string') { + const text = tool.resultDisplay; + const hasTrailingNewline = text.endsWith('\n'); + const contentText = hasTrailingNewline ? text.slice(0, -1) : text; + const lineCount = contentText.split('\n').length; + return lineCount > contentMaxLines; + } + if (Array.isArray(tool.resultDisplay)) { + return tool.resultDisplay.length > contentMaxLines; + } + return false; + }); + }, [ + visibleToolCalls, + availableTerminalHeightPerToolMessage, + activePtyId, + embeddedShellFocused, + isAlternateBuffer, + constrainHeight, + isExpandable, + ]); + + // If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools), + // only render if we need to close a border from previous + // tool groups. borderBottomOverride=true means we must render the closing border; + // undefined or false means there's nothing to display. + if (visibleToolCalls.length === 0 && borderBottomOverride !== true) { + return null; + } + + const content = ( {visibleToolCalls.map((tool, index) => { const isFirst = index === 0; @@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC = ({ : isFirst, borderColor, borderDimColor, + isExpandable, }; return ( @@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC = ({ ) : ( )} - - {tool.outputFile && ( + {tool.outputFile && ( + Output too long and was saved to: {tool.outputFile} - )} - + + )} ); })} { /* - We have to keep the bottom border separate so it doesn't get - drawn over by the sticky header directly inside it. - */ + We have to keep the bottom border separate so it doesn't get + drawn over by the sticky header directly inside it. + */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( = ({ ) } {(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && ( - + )} ); + + return content; }; diff --git a/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx new file mode 100644 index 0000000000..f7629945d9 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolOverflowConsistencyChecks.test.tsx @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { waitFor } from '../../../test-utils/async.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; + +describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => { + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. ASB mode reserves 1 + 6 = 7 lines. + * 3. Line budget = 10 - 7 = 3 lines. + * 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: true, + }, + ); + + // In ASB mode, the hint should appear because hasOverflow is now correctly calculated. + await waitFor(() => + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), + ); + }); + + it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => { + /** + * Logic: + * 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool. + * 2. Standard mode reserves 1 + 2 = 3 lines. + * 3. Line budget = 10 - 3 = 7 lines. + * 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE. + */ + + const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`); + const resultDisplay = lines.join('\n'); + + const toolCalls: IndividualToolCallDisplay[] = [ + { + callId: 'call-1', + name: 'test-tool', + description: 'a test tool', + status: CoreToolCallStatus.Success, + resultDisplay, + confirmationDetails: undefined, + }, + ]; + + const { lastFrame } = renderWithProviders( + + + , + { + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + useAlternateBuffer: false, + }, + ); + + // Verify truncation is occurring (standard mode uses MaxSizedBox) + await waitFor(() => expect(lastFrame()).toContain('hidden ...')); + + // In Standard mode, ToolGroupMessage calculates hasOverflow correctly now. + // While Standard mode doesn't render the inline hint (ShowMoreLines returns null), + // the logic inside ToolGroupMessage is now synchronized. + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index c24fb8e58b..f7d158d68c 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => { inverse: false, }, ], + [ + { + text: 'Line 4', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + [ + { + text: 'Line 5', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], ]; const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); - expect(output).toContain('Line 2'); - expect(output).toContain('Line 3'); + expect(output).not.toContain('Line 2'); + expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 4'); + expect(output).toContain('Line 5'); unmount(); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 61f1540017..8e0fc4442a 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; - -const STATIC_HEIGHT = 1; -const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint -const MIN_LINES_SHOWN = 2; // show at least this many lines +import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC = ({ const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); - let availableHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, - MIN_LINES_SHOWN + 1, // enforce minimum lines shown - ) - : undefined; - - if (maxLines && availableHeight) { - availableHeight = Math.min(availableHeight, maxLines); - } + const availableHeight = calculateToolContentMaxLines({ + availableTerminalHeight, + isAlternateBuffer, + maxLinesLimit: maxLines, + }); const combinedPaddingAndBorderWidth = 4; const childWidth = terminalWidth - combinedPaddingAndBorderWidth; @@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC = ({ [], ); - const truncatedResultDisplay = React.useMemo(() => { + const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => { + let hiddenLines = 0; // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. if (typeof resultDisplay === 'string' && !isAlternateBuffer) { @@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC = ({ const contentText = hasTrailingNewline ? text.slice(0, -1) : text; const lines = contentText.split('\n'); if (lines.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + hiddenLines = lines.length - targetLines; text = - lines.slice(-maxLines).join('\n') + + lines.slice(-targetLines).join('\n') + (hasTrailingNewline ? '\n' : ''); } } - return text; + return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines }; } - return resultDisplay; + + if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) { + if (resultDisplay.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + return { + truncatedResultDisplay: resultDisplay.slice(-targetLines), + hiddenLinesCount: resultDisplay.length - targetLines, + }; + } + } + + return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 }; }, [resultDisplay, isAlternateBuffer, maxLines]); if (!truncatedResultDisplay) return null; @@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC = ({ return ( - + {content} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index dd3184a19c..a196b8d989 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => { toolCalls={toolCalls} availableTerminalHeight={15} // Small height to force overflow terminalWidth={80} + isExpandable={true} /> , { @@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => { streamingState: StreamingState.Idle, constrainHeight: true, }, - useAlternateBuffer: false, + useAlternateBuffer: true, }, ); // ResizeObserver might take a tick await waitFor(() => - expect(lastFrame()).toContain('Press ctrl-o to show more lines'), + expect(lastFrame()?.toLowerCase()).toContain( + 'press ctrl+o to show more lines', + ), ); const frame = lastFrame(); expect(frame).toBeDefined(); if (frame) { - expect(frame).toContain('Press ctrl-o to show more lines'); + expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); // Ensure it's AFTER the bottom border const linesOfOutput = frame.split('\n'); const bottomBorderIndex = linesOfOutput.findLastIndex((l) => l.includes('╰─'), ); const hintIndex = linesOfOutput.findIndex((l) => - l.includes('Press ctrl-o to show more lines'), + l.toLowerCase().includes('press ctrl+o to show more lines'), ); expect(hintIndex).toBeGreaterThan(bottomBorderIndex); expect(frame).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 7e3d34a577..0d34c7e49d 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = ` +exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ │ │ @@ -22,6 +22,113 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA " `; +exports[` > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +│ Line 21 │ +│ Line 22 │ +│ Line 23 │ +│ Line 24 │ +│ Line 25 │ +│ Line 26 │ +│ Line 27 │ +│ Line 28 │ +│ Line 29 │ +│ Line 30 │ +│ Line 31 │ +│ Line 32 │ +│ Line 33 │ +│ Line 34 │ +│ Line 35 │ +│ Line 36 │ +│ Line 37 │ +│ Line 38 │ +│ Line 39 │ +│ Line 40 │ +│ Line 41 │ +│ Line 42 │ +│ Line 43 │ +│ Line 44 │ +│ Line 45 │ +│ Line 46 │ +│ Line 47 │ +│ Line 48 │ +│ Line 49 │ +│ Line 50 │ +│ Line 51 │ +│ Line 52 │ +│ Line 53 │ +│ Line 54 │ +│ Line 55 │ +│ Line 56 │ +│ Line 57 │ +│ Line 58 │ +│ Line 59 │ +│ Line 60 │ +│ Line 61 │ +│ Line 62 │ +│ Line 63 │ +│ Line 64 │ +│ Line 65 │ +│ Line 66 │ +│ Line 67 │ +│ Line 68 │ +│ Line 69 │ +│ Line 70 │ +│ Line 71 │ +│ Line 72 │ +│ Line 73 │ +│ Line 74 │ +│ Line 75 │ +│ Line 76 │ +│ Line 77 │ +│ Line 78 │ +│ Line 79 │ +│ Line 80 │ +│ Line 81 │ +│ Line 82 │ +│ Line 83 │ +│ Line 84 │ +│ Line 85 │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 │ +│ Line 99 │ +│ Line 100 │ +" +`; + exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -37,6 +144,28 @@ exports[` > Height Constraints > respects availableTerminalH " `; +exports[` > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 ▄ │ +│ Line 99 █ │ +│ Line 100 █ │ +" +`; + exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ @@ -161,7 +290,6 @@ exports[` > Height Constraints > uses full availableTerminal │ Line 98 █ │ │ Line 99 █ │ │ Line 100 █ │ -│ │ " `; @@ -170,7 +298,6 @@ exports[` > Snapshots > renders in Alternate Buffer mode whi │ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │ │ │ │ Test result │ -│ │ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index c8a9707004..6adcb80a5c 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -55,13 +55,13 @@ exports[` > Golden Snapshots > renders header when scrolled "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-1 Description 1. This is a long description that will need to b… │ │──────────────────────────────────────────────────────────────────────────│ -│ │ ▄ +│ line5 │ █ +│ │ █ │ ✓ tool-2 Description 2 │ █ │ │ █ │ line1 │ █ │ line2 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; @@ -111,12 +111,12 @@ exports[` > Golden Snapshots > renders tool call with output `; exports[` > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ +"╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ tool-2 Description 2 │ -│ │ -│ line1 │ ▄ +│ │ ▄ +│ line1 │ █ ╰──────────────────────────────────────────────────────────────────────────╯ █ - █ " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index 1d9b58f0ce..d1e4b16d2f 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp `; exports[`ToolResultDisplay > truncates very long string results 1`] = ` -"... first 252 lines hidden ... +"... first 248 lines hidden ... +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 3854b291db..aab4b690a1 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap @@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co "╭──────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool a test tool │ │ │ -│ ... first 45 lines hidden ... │ +│ line 45 │ │ line 46 │ │ line 47 │ │ line 48 │ │ line 49 │ -│ line 50 │ +│ line 50 █ │ ╰──────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines + Press Ctrl+O to show more lines " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 66ca527b4b..dda93c1c21 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ ▀ +│ ✓ Shell Command Description for Shell Command │ █ │ │ │ shell-01 │ │ shell-02 │ @@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` "╭────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ +│ ✓ Shell Command Description for Shell Command │ ▄ │────────────────────────────────────────────────────────────────────────│ █ │ shell-06 │ ▀ │ shell-07 │ diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 197711f82f..93a3198ca8 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -33,6 +33,7 @@ export const WARNING_PROMPT_DURATION_MS = 3000; export const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000; export const SHELL_ACTION_REQUIRED_TITLE_DELAY_MS = 30000; export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; +export const EXPAND_HINT_DURATION_MS = 5000; export const DEFAULT_BACKGROUND_OPACITY = 0.16; export const DEFAULT_INPUT_BACKGROUND_OPACITY = 0.24; diff --git a/packages/cli/src/ui/contexts/OverflowContext.tsx b/packages/cli/src/ui/contexts/OverflowContext.tsx index 9d8b78d4c7..cee02090b6 100644 --- a/packages/cli/src/ui/contexts/OverflowContext.tsx +++ b/packages/cli/src/ui/contexts/OverflowContext.tsx @@ -13,13 +13,14 @@ import { useMemo, } from 'react'; -interface OverflowState { +export interface OverflowState { overflowingIds: ReadonlySet; } -interface OverflowActions { +export interface OverflowActions { addOverflowingId: (id: string) => void; removeOverflowingId: (id: string) => void; + reset: () => void; } const OverflowStateContext = createContext( @@ -63,6 +64,10 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ }); }, []); + const reset = useCallback(() => { + setOverflowingIds(new Set()); + }, []); + const stateValue = useMemo( () => ({ overflowingIds, @@ -74,8 +79,9 @@ export const OverflowProvider: React.FC<{ children: React.ReactNode }> = ({ () => ({ addOverflowingId, removeOverflowingId, + reset, }), - [addOverflowingId, removeOverflowingId], + [addOverflowingId, removeOverflowingId, reset], ); return ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index a1c63759e9..9fb2852361 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -182,6 +182,7 @@ export interface UIState { isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; + showIsExpandableHint: boolean; hintMode: boolean; hintBuffer: string; transientMessage: { diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx index 1034e7372e..56e34eefa4 100644 --- a/packages/cli/src/ui/utils/CodeColorizer.tsx +++ b/packages/cli/src/ui/utils/CodeColorizer.tsx @@ -22,7 +22,6 @@ import { } from '../components/shared/MaxSizedBox.js'; import type { LoadedSettings } from '../../config/settings.js'; import { debugLogger } from '@google/gemini-cli-core'; -import { isAlternateBufferEnabled } from '../hooks/useAlternateBuffer.js'; // Configure theming and parsing utilities. const lowlight = createLowlight(common); @@ -152,7 +151,6 @@ export function colorizeCode({ ? false : settings.merged.ui.showLineNumbers; - const useMaxSizedBox = !isAlternateBufferEnabled(settings); try { // Render the HAST tree using the adapted theme // Apply the theme's default foreground color to the top-level Text element @@ -162,7 +160,7 @@ export function colorizeCode({ let hiddenLinesCount = 0; // Optimization to avoid highlighting lines that cannot possibly be displayed. - if (availableHeight !== undefined && useMaxSizedBox) { + if (availableHeight !== undefined) { availableHeight = Math.max(availableHeight, MINIMUM_MAX_HEIGHT); if (lines.length > availableHeight) { const sliceIndex = lines.length - availableHeight; @@ -200,7 +198,7 @@ export function colorizeCode({ ); }); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return ( )); - if (useMaxSizedBox) { + if (availableHeight !== undefined) { return ( Date: Sat, 21 Feb 2026 00:36:10 +0000 Subject: [PATCH 46/52] Utilize pipelining of grep_search -> read_file to eliminate turns (#19574) --- packages/core/src/tools/diff-utils.test.ts | 66 +++++++ packages/core/src/tools/diff-utils.ts | 75 ++++++++ packages/core/src/tools/edit.ts | 11 ++ packages/core/src/tools/grep-utils.ts | 212 +++++++++++++++++++++ packages/core/src/tools/grep.test.ts | 31 ++- packages/core/src/tools/grep.ts | 72 +------ packages/core/src/tools/ripGrep.test.ts | 81 ++++---- packages/core/src/tools/ripGrep.ts | 140 ++++++++------ packages/core/src/tools/write-file.test.ts | 57 ++++++ packages/core/src/tools/write-file.ts | 10 + 10 files changed, 590 insertions(+), 165 deletions(-) create mode 100644 packages/core/src/tools/diff-utils.test.ts create mode 100644 packages/core/src/tools/diff-utils.ts create mode 100644 packages/core/src/tools/grep-utils.ts diff --git a/packages/core/src/tools/diff-utils.test.ts b/packages/core/src/tools/diff-utils.test.ts new file mode 100644 index 0000000000..7d19f7af19 --- /dev/null +++ b/packages/core/src/tools/diff-utils.test.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { getDiffContextSnippet } from './diff-utils.js'; + +describe('getDiffContextSnippet', () => { + it('should return the whole new content if originalContent is empty', () => { + const original = ''; + const modified = 'line1\nline2\nline3'; + expect(getDiffContextSnippet(original, modified)).toBe(modified); + }); + + it('should return the whole content if there are no changes', () => { + const content = 'line1\nline2\nline3'; + expect(getDiffContextSnippet(content, content)).toBe(content); + }); + + it('should show added lines with context', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n5\nadded\n6\n7\n8\n9\n10'; + // Default context is 5 lines. + expect(getDiffContextSnippet(original, modified)).toBe(modified); + }); + + it('should use ellipses for changes far apart', () => { + const original = Array.from({ length: 20 }, (_, i) => `${i + 1}`).join( + '\n', + ); + const modified = original + .replace('2\n', '2\nadded1\n') + .replace('19', '19\nadded2'); + const snippet = getDiffContextSnippet(original, modified, 2); + + expect(snippet).toContain('1\n2\nadded1\n3\n4'); + expect(snippet).toContain('...'); + expect(snippet).toContain('18\n19\nadded2\n20'); + }); + + it('should respect custom contextLines', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n5\nadded\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('...\n5\nadded\n6\n...'); + }); + + it('should handle multiple changes close together by merging ranges', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\nadded1\n2\nadded2\n3\n4\n5\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('1\nadded1\n2\nadded2\n3\n...'); + }); + + it('should handle removals', () => { + const original = '1\n2\n3\n4\n5\n6\n7\n8\n9\n10'; + const modified = '1\n2\n3\n4\n6\n7\n8\n9\n10'; + const snippet = getDiffContextSnippet(original, modified, 1); + + expect(snippet).toBe('...\n4\n6\n...'); + }); +}); diff --git a/packages/core/src/tools/diff-utils.ts b/packages/core/src/tools/diff-utils.ts new file mode 100644 index 0000000000..9c44a1756e --- /dev/null +++ b/packages/core/src/tools/diff-utils.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Diff from 'diff'; + +/** + * Generates a snippet of the diff between two strings, including a few lines of context around the changes. + */ +export function getDiffContextSnippet( + originalContent: string, + newContent: string, + contextLines = 5, +): string { + if (!originalContent) { + return newContent; + } + + const changes = Diff.diffLines(originalContent, newContent); + const newLines = newContent.split(/\r?\n/); + const ranges: Array<{ start: number; end: number }> = []; + let newLineIdx = 0; + + for (const change of changes) { + if (change.added) { + ranges.push({ start: newLineIdx, end: newLineIdx + (change.count ?? 0) }); + newLineIdx += change.count ?? 0; + } else if (change.removed) { + ranges.push({ start: newLineIdx, end: newLineIdx }); + } else { + newLineIdx += change.count ?? 0; + } + } + + if (ranges.length === 0) { + return newContent; + } + + const expandedRanges = ranges.map((r) => ({ + start: Math.max(0, r.start - contextLines), + end: Math.min(newLines.length, r.end + contextLines), + })); + expandedRanges.sort((a, b) => a.start - b.start); + const mergedRanges: Array<{ start: number; end: number }> = []; + + if (expandedRanges.length > 0) { + let current = expandedRanges[0]; + for (let i = 1; i < expandedRanges.length; i++) { + const next = expandedRanges[i]; + if (next.start <= current.end) { + current.end = Math.max(current.end, next.end); + } else { + mergedRanges.push(current); + current = next; + } + } + mergedRanges.push(current); + } + + const outputParts: string[] = []; + let lastEnd = 0; + + for (const range of mergedRanges) { + if (range.start > lastEnd) outputParts.push('...'); + outputParts.push(newLines.slice(range.start, range.end).join('\n')); + lastEnd = range.end; + } + + if (lastEnd < newLines.length) { + outputParts.push('...'); + } + return outputParts.join('\n'); +} diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 130a05a8fe..3df9c21b5e 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -30,6 +30,7 @@ import { ApprovalMode } from '../policy/types.js'; import { CoreToolCallStatus } from '../scheduler/types.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; +import { getDiffContextSnippet } from './diff-utils.js'; import { type ModifiableDeclarativeTool, type ModifyContext, @@ -871,6 +872,16 @@ class EditToolInvocation ? `Created new file: ${this.params.file_path} with provided content.` : `Successfully modified file: ${this.params.file_path} (${editData.occurrences} replacements).`, ]; + + // Return a diff of the file before and after the write so that the agent + // can avoid the need to spend a turn doing a verification read. + const snippet = getDiffContextSnippet( + editData.currentContent ?? '', + finalContent, + 5, + ); + llmSuccessMessageParts.push(`Here is the updated code: +${snippet}`); const fuzzyFeedback = getFuzzyMatchFeedback(editData); if (fuzzyFeedback) { llmSuccessMessageParts.push(fuzzyFeedback); diff --git a/packages/core/src/tools/grep-utils.ts b/packages/core/src/tools/grep-utils.ts new file mode 100644 index 0000000000..27c744f60c --- /dev/null +++ b/packages/core/src/tools/grep-utils.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fsPromises from 'node:fs/promises'; +import { debugLogger } from '../utils/debugLogger.js'; + +/** + * Result object for a single grep match + */ +export interface GrepMatch { + filePath: string; + absolutePath: string; + lineNumber: number; + line: string; + isContext?: boolean; +} + +/** + * Groups matches by their file path and ensures they are sorted by line number. + */ +export function groupMatchesByFile( + allMatches: GrepMatch[], +): Record { + const groups: Record = {}; + + for (const match of allMatches) { + if (!groups[match.filePath]) { + groups[match.filePath] = []; + } + groups[match.filePath].push(match); + } + + for (const filePath in groups) { + groups[filePath].sort((a, b) => a.lineNumber - b.lineNumber); + } + + return groups; +} + +/** + * Reads the content of a file and splits it into lines. + * Returns null if the file cannot be read. + */ +export async function readFileLines( + absolutePath: string, +): Promise { + try { + const content = await fsPromises.readFile(absolutePath, 'utf8'); + return content.split(/\r?\n/); + } catch (err) { + debugLogger.warn(`Failed to read file for context: ${absolutePath}`, err); + return null; + } +} + +/** + * Automatically enriches grep results with surrounding context if the match count is low + * and no specific context was requested. This optimization can enable the agent + * to skip turns that would be spent reading files after grep calls. + */ +export async function enrichWithAutoContext( + matchesByFile: Record, + matchCount: number, + params: { + names_only?: boolean; + context?: number; + before?: number; + after?: number; + }, +): Promise { + const { names_only, context, before, after } = params; + + if ( + matchCount >= 1 && + matchCount <= 3 && + !names_only && + context === undefined && + before === undefined && + after === undefined + ) { + const contextLines = matchCount === 1 ? 50 : 15; + for (const filePath in matchesByFile) { + const fileMatches = matchesByFile[filePath]; + if (fileMatches.length === 0) continue; + + const fileLines = await readFileLines(fileMatches[0].absolutePath); + + if (fileLines) { + const newFileMatches: GrepMatch[] = []; + const seenLines = new Set(); + + // Sort matches to process them in order + fileMatches.sort((a, b) => a.lineNumber - b.lineNumber); + + for (const match of fileMatches) { + const startLine = Math.max(0, match.lineNumber - 1 - contextLines); + const endLine = Math.min( + fileLines.length, + match.lineNumber - 1 + contextLines + 1, + ); + + for (let i = startLine; i < endLine; i++) { + const lineNum = i + 1; + if (!seenLines.has(lineNum)) { + newFileMatches.push({ + absolutePath: match.absolutePath, + filePath: match.filePath, + lineNumber: lineNum, + line: fileLines[i], + isContext: lineNum !== match.lineNumber, + }); + seenLines.add(lineNum); + } else if (lineNum === match.lineNumber) { + const existing = newFileMatches.find( + (m) => m.lineNumber === lineNum, + ); + if (existing) { + existing.isContext = false; + } + } + } + } + matchesByFile[filePath] = newFileMatches.sort( + (a, b) => a.lineNumber - b.lineNumber, + ); + } + } + } +} + +/** + * Formats the grep results for the LLM, including optional context. + */ +export async function formatGrepResults( + allMatches: GrepMatch[], + params: { + pattern: string; + names_only?: boolean; + include?: string; + // Context params to determine if auto-context should be skipped + context?: number; + before?: number; + after?: number; + }, + searchLocationDescription: string, + totalMaxMatches: number, +): Promise<{ llmContent: string; returnDisplay: string }> { + const { pattern, names_only, include } = params; + + if (allMatches.length === 0) { + const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}.`; + return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; + } + + const matchesByFile = groupMatchesByFile(allMatches); + + const matchesOnly = allMatches.filter((m) => !m.isContext); + const matchCount = matchesOnly.length; // Count actual matches, not context lines + const matchTerm = matchCount === 1 ? 'match' : 'matches'; + + // If the result count is low and Gemini didn't request before/after lines of context + // add a small amount anyways to enable the agent to avoid one or more extra turns + // reading the matched files. This optimization reduces turns count by ~10% in SWEBench. + await enrichWithAutoContext(matchesByFile, matchCount, params); + + const wasTruncated = matchCount >= totalMaxMatches; + + if (names_only) { + const filePaths = Object.keys(matchesByFile).sort(); + let llmContent = `Found ${filePaths.length} files with matches for pattern "${pattern}" ${searchLocationDescription}${ + include ? ` (filter: "${include}")` : '' + }${ + wasTruncated + ? ` (results limited to ${totalMaxMatches} matches for performance)` + : '' + }:\n`; + llmContent += filePaths.join('\n'); + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, + }; + } + + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}`; + + if (wasTruncated) { + llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; + } + + llmContent += `:\n---\n`; + + for (const filePath in matchesByFile) { + llmContent += `File: ${filePath}\n`; + matchesByFile[filePath].forEach((match) => { + // If isContext is undefined, assume it's a match (false) + const separator = match.isContext ? '-' : ':'; + // trimEnd to avoid double newlines if line has them, but we want to preserve indentation + llmContent += `L${match.lineNumber}${separator} ${match.line.trimEnd()}\n`; + }); + llmContent += '---\n'; + } + + return { + llmContent: llmContent.trim(), + returnDisplay: `Found ${matchCount} ${matchTerm}${ + wasTruncated ? ' (limited)' : '' + }`, + }; +} diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index cecc32d5f1..f696495253 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -493,7 +493,9 @@ describe('GrepTool', () => { // sub/fileC.txt has 1 world, so total matches = 2. expect(result.llmContent).toContain('Found 2 matches'); expect(result.llmContent).toContain('File: fileA.txt'); + // Should be a match expect(result.llmContent).toContain('L1: hello world'); + // Should NOT be a match (but might be in context as L2-) expect(result.llmContent).not.toContain('L2: second line with world'); expect(result.llmContent).toContain('File: sub/fileC.txt'); expect(result.llmContent).toContain('L1: another world in sub dir'); @@ -530,8 +532,33 @@ describe('GrepTool', () => { expect(result.llmContent).toContain('Found 1 match'); expect(result.llmContent).toContain('copyright.txt'); - expect(result.llmContent).toContain('Copyright 2025 Google LLC'); - expect(result.llmContent).not.toContain('Copyright 2026 Google LLC'); + // Should be a match + expect(result.llmContent).toContain('L1: Copyright 2025 Google LLC'); + // Should NOT be a match (but might be in context as L2-) + expect(result.llmContent).not.toContain('L2: Copyright 2026 Google LLC'); + }); + + it('should include context when matches are <= 3', async () => { + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + lines[50] = 'Target match'; + await fs.writeFile( + path.join(tempRootDir, 'context.txt'), + lines.join('\n'), + ); + + const params: GrepToolParams = { pattern: 'Target match' }; + const invocation = grepTool.build(params); + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain( + 'Found 1 match for pattern "Target match"', + ); + // Verify context before + expect(result.llmContent).toContain('L40- Line 40'); + // Verify match line + expect(result.llmContent).toContain('L51: Target match'); + // Verify context after + expect(result.llmContent).toContain('L60- Line 60'); }); }); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index b1fdb9474c..92fe58288d 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -27,6 +27,7 @@ import { GREP_TOOL_NAME } from './tool-names.js'; import { debugLogger } from '../utils/debugLogger.js'; import { GREP_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { type GrepMatch, formatGrepResults } from './grep-utils.js'; // --- Interfaces --- @@ -70,15 +71,6 @@ export interface GrepToolParams { total_max_matches?: number; } -/** - * Result object for a single grep match - */ -interface GrepMatch { - filePath: string; - lineNumber: number; - line: string; -} - class GrepToolInvocation extends BaseToolInvocation< GrepToolParams, ToolResult @@ -130,6 +122,7 @@ class GrepToolInvocation extends BaseToolInvocation< return { filePath: relativeFilePath || path.basename(absoluteFilePath), + absolutePath: absoluteFilePath, lineNumber, line: lineContent, }; @@ -267,62 +260,12 @@ class GrepToolInvocation extends BaseToolInvocation< searchLocationDescription = `in path "${searchDirDisplay}"`; } - if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; - return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; - } - - const wasTruncated = allMatches.length >= totalMaxMatches; - - // Group matches by file - const matchesByFile = allMatches.reduce( - (acc, match) => { - const fileKey = match.filePath; - if (!acc[fileKey]) { - acc[fileKey] = []; - } - acc[fileKey].push(match); - acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); - return acc; - }, - {} as Record, + return await formatGrepResults( + allMatches, + this.params, + searchLocationDescription, + totalMaxMatches, ); - - const matchCount = allMatches.length; - const matchTerm = matchCount === 1 ? 'match' : 'matches'; - - if (this.params.names_only) { - const filePaths = Object.keys(matchesByFile).sort(); - let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; - llmContent += filePaths.join('\n'); - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, - }; - } - - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}`; - - if (wasTruncated) { - llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; - } - - llmContent += `:\n---\n`; - - for (const filePath in matchesByFile) { - llmContent += `File: ${filePath} -`; - matchesByFile[filePath].forEach((match) => { - const trimmedLine = match.line.trim(); - llmContent += `L${match.lineNumber}: ${trimmedLine}\n`; - }); - llmContent += '---\n'; - } - - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${matchCount} ${matchTerm}${wasTruncated ? ' (limited)' : ''}`, - }; } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); @@ -569,6 +512,7 @@ class GrepToolInvocation extends BaseToolInvocation< filePath: path.relative(absolutePath, fileAbsolutePath) || path.basename(fileAbsolutePath), + absolutePath: fileAbsolutePath, lineNumber: index + 1, line, }); diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 09f8b5f00c..58842e9b22 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -265,6 +265,7 @@ describe('RipGrepTool', () => { downloadRipGrepMock.mockReset(); downloadRipGrepMock.mockResolvedValue(undefined); mockSpawn.mockReset(); + mockSpawn.mockImplementation(createMockSpawn()); tempBinRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ripgrep-bin-')); binDir = path.join(tempBinRoot, 'bin'); await fs.mkdir(binDir, { recursive: true }); @@ -396,7 +397,7 @@ describe('RipGrepTool', () => { describe('execute', () => { it('should find matches for a simple pattern in all files', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -447,7 +448,7 @@ describe('RipGrepTool', () => { }); it('should ignore matches that escape the base path', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -482,7 +483,7 @@ describe('RipGrepTool', () => { it('should find matches in a specific path', async () => { // Setup specific mock for this test - searching in 'sub' should only return matches from that directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -510,7 +511,7 @@ describe('RipGrepTool', () => { it('should find matches with an include glob', async () => { // Setup specific mock for this test - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -545,7 +546,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -577,7 +578,7 @@ describe('RipGrepTool', () => { it('should return "No matches found" when pattern does not exist', async () => { // Setup specific mock for no matches - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ exitCode: 1, // No matches found }), @@ -699,7 +700,7 @@ describe('RipGrepTool', () => { ); // Mock ripgrep returning both an ignored file and an allowed file - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -738,7 +739,7 @@ describe('RipGrepTool', () => { it('should handle regex special characters correctly', async () => { // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";' - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -765,7 +766,7 @@ describe('RipGrepTool', () => { it('should be case-insensitive by default (JS fallback)', async () => { // Setup specific mock for this test - case insensitive search for 'HELLO' - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -878,7 +879,7 @@ describe('RipGrepTool', () => { // Setup specific mock for this test - multi-directory search for 'world' // Mock will be called twice - once for each directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -989,7 +990,7 @@ describe('RipGrepTool', () => { } as unknown as Config; // Setup specific mock for this test - searching in 'sub' should only return matches from that directory - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1042,7 +1043,7 @@ describe('RipGrepTool', () => { it('should abort streaming search when signal is triggered', async () => { // Setup specific mock for this test - simulate process being killed due to abort - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ exitCode: null, signal: 'SIGTERM', @@ -1087,7 +1088,7 @@ describe('RipGrepTool', () => { }, }, ])('should handle $name gracefully', async ({ setup }) => { - mockSpawn.mockImplementationOnce(createMockSpawn({ exitCode: 1 })); + mockSpawn.mockImplementation(createMockSpawn({ exitCode: 1 })); const params = await setup(); const invocation = grepTool.build(params); @@ -1104,7 +1105,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'world' should find the file with special characters - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1135,7 +1136,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'deep' should find the deeply nested file - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1166,7 +1167,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - regex pattern should match function declarations - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1180,7 +1181,10 @@ describe('RipGrepTool', () => { }), ); - const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' }; + const params: RipGrepToolParams = { + pattern: 'function\\s+\\w+\\s*\\(', + context: 0, + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1195,7 +1199,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - case insensitive search should match all variants - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1244,7 +1248,7 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - escaped regex pattern should match price format - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1258,7 +1262,10 @@ describe('RipGrepTool', () => { }), ); - const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' }; + const params: RipGrepToolParams = { + pattern: '\\$\\d+\\.\\d+', + context: 0, + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1281,7 +1288,7 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content'); // Setup specific mock for this test - include pattern should filter to only ts/tsx files - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1327,7 +1334,7 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code'); // Setup specific mock for this test - include pattern should filter to only src/** files - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1356,7 +1363,7 @@ describe('RipGrepTool', () => { describe('advanced search options', () => { it('should handle case_sensitive parameter', async () => { // Case-insensitive search (default) - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1370,7 +1377,7 @@ describe('RipGrepTool', () => { exitCode: 0, }), ); - let params: RipGrepToolParams = { pattern: 'HELLO' }; + let params: RipGrepToolParams = { pattern: 'HELLO', context: 0 }; let invocation = grepTool.build(params); let result = await invocation.execute(abortSignal); expect(mockSpawn).toHaveBeenLastCalledWith( @@ -1382,7 +1389,7 @@ describe('RipGrepTool', () => { expect(result.llmContent).toContain('L1: hello world'); // Case-sensitive search - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1396,7 +1403,7 @@ describe('RipGrepTool', () => { exitCode: 0, }), ); - params = { pattern: 'HELLO', case_sensitive: true }; + params = { pattern: 'HELLO', case_sensitive: true, context: 0 }; invocation = grepTool.build(params); result = await invocation.execute(abortSignal); expect(mockSpawn).toHaveBeenLastCalledWith( @@ -1409,7 +1416,7 @@ describe('RipGrepTool', () => { }); it('should handle fixed_strings parameter', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1453,7 +1460,7 @@ describe('RipGrepTool', () => { }); it('should handle no_ignore parameter', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1526,7 +1533,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1592,7 +1599,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1658,7 +1665,7 @@ describe('RipGrepTool', () => { createMockMessageBus(), ); - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1685,7 +1692,7 @@ describe('RipGrepTool', () => { }); it('should handle context parameters', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1855,7 +1862,7 @@ describe('RipGrepTool', () => { describe('new parameters', () => { it('should pass --max-count when max_matches_per_file is provided', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1884,7 +1891,7 @@ describe('RipGrepTool', () => { it('should respect total_max_matches and truncate results', async () => { // Return 3 matches, but set total_max_matches to 2 - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1921,6 +1928,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'match', total_max_matches: 2, + context: 0, }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1936,7 +1944,7 @@ describe('RipGrepTool', () => { }); it('should return only file paths when names_only is true', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -1976,7 +1984,7 @@ describe('RipGrepTool', () => { }); it('should filter out matches based on exclude_pattern', async () => { - mockSpawn.mockImplementationOnce( + mockSpawn.mockImplementation( createMockSpawn({ outputData: JSON.stringify({ @@ -2004,6 +2012,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'Copyright .* Google LLC', exclude_pattern: '2026', + context: 0, }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 7f49774c05..9ad929f256 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -31,6 +31,7 @@ import { } from './constants.js'; import { RIP_GREP_DEFINITION } from './definitions/coreTools.js'; import { resolveToolDeclaration } from './definitions/resolver.js'; +import { type GrepMatch, formatGrepResults } from './grep-utils.js'; function getRgCandidateFilenames(): readonly string[] { return process.platform === 'win32' ? ['rg.exe', 'rg'] : ['rg']; @@ -155,16 +156,6 @@ export interface RipGrepToolParams { total_max_matches?: number; } -/** - * Result object for a single grep match - */ -interface GrepMatch { - filePath: string; - lineNumber: number; - line: string; - isContext?: boolean; -} - class GrepToolInvocation extends BaseToolInvocation< RipGrepToolParams, ToolResult @@ -287,58 +278,23 @@ class GrepToolInvocation extends BaseToolInvocation< ); } - const searchLocationDescription = `in path "${searchDirDisplay}"`; - if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}.`; - return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; - } - - const matchesByFile = allMatches.reduce( - (acc, match) => { - const fileKey = match.filePath; - if (!acc[fileKey]) { - acc[fileKey] = []; - } - acc[fileKey].push(match); - acc[fileKey].sort((a, b) => a.lineNumber - b.lineNumber); - return acc; - }, - {} as Record, + const matchCount = allMatches.filter((m) => !m.isContext).length; + allMatches = await this.enrichWithRipgrepAutoContext( + allMatches, + matchCount, + totalMaxMatches, + searchDirAbs, + timeoutController.signal, ); - const matchesOnly = allMatches.filter((m) => !m.isContext); - const matchCount = matchesOnly.length; - const matchTerm = matchCount === 1 ? 'match' : 'matches'; + const searchLocationDescription = `in path "${searchDirDisplay}"`; - const wasTruncated = matchCount >= totalMaxMatches; - - if (this.params.names_only) { - const filePaths = Object.keys(matchesByFile).sort(); - let llmContent = `Found ${filePaths.length} files with matches for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n`; - llmContent += filePaths.join('\n'); - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${filePaths.length} files${wasTruncated ? ' (limited)' : ''}`, - }; - } - - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${this.params.pattern}" ${searchLocationDescription}${this.params.include ? ` (filter: "${this.params.include}")` : ''}${wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` : ''}:\n---\n`; - - for (const filePath in matchesByFile) { - llmContent += `File: ${filePath}\n`; - matchesByFile[filePath].forEach((match) => { - const separator = match.isContext ? '-' : ':'; - llmContent += `L${match.lineNumber}${separator} ${match.line}\n`; - }); - llmContent += '---\n'; - } - - return { - llmContent: llmContent.trim(), - returnDisplay: `Found ${matchCount} ${matchTerm}${ - wasTruncated ? ' (limited)' : '' - }`, - }; + return await formatGrepResults( + allMatches, + this.params, + searchLocationDescription, + totalMaxMatches, + ); } catch (error) { debugLogger.warn(`Error during GrepLogic execution: ${error}`); const errorMessage = getErrorMessage(error); @@ -349,9 +305,61 @@ class GrepToolInvocation extends BaseToolInvocation< } } + private async enrichWithRipgrepAutoContext( + allMatches: GrepMatch[], + matchCount: number, + totalMaxMatches: number, + searchDirAbs: string, + signal: AbortSignal, + ): Promise { + if ( + matchCount >= 1 && + matchCount <= 3 && + !this.params.names_only && + this.params.context === undefined && + this.params.before === undefined && + this.params.after === undefined + ) { + const contextLines = matchCount === 1 ? 50 : 15; + const uniqueFiles = Array.from( + new Set(allMatches.map((m) => m.absolutePath)), + ); + + let enrichedMatches = await this.performRipgrepSearch({ + pattern: this.params.pattern, + path: uniqueFiles, + basePath: searchDirAbs, + include: this.params.include, + exclude_pattern: this.params.exclude_pattern, + case_sensitive: this.params.case_sensitive, + fixed_strings: this.params.fixed_strings, + context: contextLines, + no_ignore: this.params.no_ignore, + maxMatches: totalMaxMatches, + max_matches_per_file: this.params.max_matches_per_file, + signal, + }); + + if (!this.params.no_ignore) { + const allowedFiles = this.fileDiscoveryService.filterFiles(uniqueFiles); + const allowedSet = new Set(allowedFiles); + enrichedMatches = enrichedMatches.filter((m) => + allowedSet.has(m.absolutePath), + ); + } + + // Set context to prevent grep-utils from doing the JS fallback auto-context + this.params.context = contextLines; + return enrichedMatches; + } + + return allMatches; + } + private async performRipgrepSearch(options: { pattern: string; - path: string; + path: string | string[]; + basePath?: string; include?: string; exclude_pattern?: string; case_sensitive?: boolean; @@ -366,7 +374,8 @@ class GrepToolInvocation extends BaseToolInvocation< }): Promise { const { pattern, - path: absolutePath, + path, + basePath, include, exclude_pattern, case_sensitive, @@ -379,6 +388,8 @@ class GrepToolInvocation extends BaseToolInvocation< max_matches_per_file, } = options; + const searchPaths = Array.isArray(path) ? path : [path]; + const rgArgs = ['--json']; if (!case_sensitive) { @@ -436,7 +447,7 @@ class GrepToolInvocation extends BaseToolInvocation< } rgArgs.push('--threads', '4'); - rgArgs.push(absolutePath); + rgArgs.push(...searchPaths); const results: GrepMatch[] = []; try { @@ -452,8 +463,10 @@ class GrepToolInvocation extends BaseToolInvocation< excludeRegex = new RegExp(exclude_pattern, case_sensitive ? '' : 'i'); } + const parseBasePath = basePath || searchPaths[0]; + for await (const line of generator) { - const match = this.parseRipgrepJsonLine(line, absolutePath); + const match = this.parseRipgrepJsonLine(line, parseBasePath); if (match) { if (excludeRegex && excludeRegex.test(match.line)) { continue; @@ -501,6 +514,7 @@ class GrepToolInvocation extends BaseToolInvocation< const relativeFilePath = path.relative(basePath, absoluteFilePath); return { + absolutePath: absoluteFilePath, filePath: relativeFilePath || path.basename(absoluteFilePath), // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment lineNumber: data.line_number, diff --git a/packages/core/src/tools/write-file.test.ts b/packages/core/src/tools/write-file.test.ts index 3545affe3f..3a0c8487b8 100644 --- a/packages/core/src/tools/write-file.test.ts +++ b/packages/core/src/tools/write-file.test.ts @@ -831,6 +831,63 @@ describe('WriteFileTool', () => { } }, ); + + it('should include the file content in llmContent', async () => { + const filePath = path.join(rootDir, 'content_check.txt'); + const content = 'This is the content that should be returned.'; + mockEnsureCorrectFileContent.mockResolvedValue(content); + + const params = { file_path: filePath, content }; + const invocation = tool.build(params); + + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Here is the updated code:'); + expect(result.llmContent).toContain(content); + }); + + it('should return only changed lines plus context for large updates', async () => { + const filePath = path.join(rootDir, 'large_update.txt'); + const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`); + const originalContent = lines.join('\n'); + fs.writeFileSync(filePath, originalContent, 'utf8'); + + const newLines = [...lines]; + newLines[50] = 'Line 51 Modified'; // Modify one line in the middle + + const newContent = newLines.join('\n'); + mockEnsureCorrectEdit.mockResolvedValue({ + params: { + file_path: filePath, + old_string: originalContent, + new_string: newContent, + }, + occurrences: 1, + }); + + const params = { file_path: filePath, content: newContent }; + const invocation = tool.build(params); + + // Confirm execution first + const confirmDetails = await invocation.shouldConfirmExecute(abortSignal); + if (confirmDetails && 'onConfirm' in confirmDetails) { + await confirmDetails.onConfirm(ToolConfirmationOutcome.ProceedOnce); + } + + const result = await invocation.execute(abortSignal); + + expect(result.llmContent).toContain('Here is the updated code:'); + // Should contain the modified line + expect(result.llmContent).toContain('Line 51 Modified'); + // Should contain context lines (e.g. Line 46, Line 56) + expect(result.llmContent).toContain('Line 46'); + expect(result.llmContent).toContain('Line 56'); + // Should NOT contain far away lines (e.g. Line 1, Line 100) + expect(result.llmContent).not.toContain('Line 1\n'); + expect(result.llmContent).not.toContain('Line 100'); + // Should indicate truncation + expect(result.llmContent).toContain('...'); + }); }); describe('workspace boundary validation', () => { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index 4d521db33c..afdb3587ac 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -36,6 +36,7 @@ import { } from '../utils/editCorrector.js'; import { detectLineEnding } from '../utils/textUtils.js'; import { DEFAULT_DIFF_OPTIONS, getDiffStat } from './diffOptions.js'; +import { getDiffContextSnippet } from './diff-utils.js'; import type { ModifiableDeclarativeTool, ModifyContext, @@ -351,6 +352,15 @@ class WriteFileToolInvocation extends BaseToolInvocation< ); } + // Return a diff of the file before and after the write so that the agent + // can avoid the need to spend a turn doing a verification read. + const snippet = getDiffContextSnippet( + isNewFile ? '' : originalContent, + finalContent, + 5, + ); + llmSuccessMessageParts.push(`Here is the updated code:\n${snippet}`); + // Log file operation for telemetry (without diff_stat to avoid double-counting) const mimetype = getSpecificMimeType(this.resolvedPath); const programmingLanguage = getLanguageFromFilePath(this.resolvedPath); From 09218572d0474be6d714f57af42ad33fdce7185e Mon Sep 17 00:00:00 2001 From: matt korwel Date: Fri, 20 Feb 2026 19:00:57 -0600 Subject: [PATCH 47/52] refactor(core): remove unsafe type assertions in error utils (Phase 1.1) (#19750) --- packages/core/src/utils/errors.ts | 72 +++++++++++++++++++++---- packages/core/src/utils/googleErrors.ts | 30 +++++++++-- 2 files changed, 86 insertions(+), 16 deletions(-) diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index dc7d5b33c5..5465977ff2 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -10,6 +10,16 @@ interface GaxiosError { }; } +function isGaxiosError(error: unknown): error is GaxiosError { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof (error as { response: unknown }).response === 'object' && + (error as { response: unknown }).response !== null + ); +} + export function isNodeError(error: unknown): error is NodeJS.ErrnoException { return error instanceof Error && 'code' in error; } @@ -105,11 +115,41 @@ interface ResponseData { }; } +function isResponseData(data: unknown): data is ResponseData { + if (typeof data !== 'object' || data === null) { + return false; + } + const candidate = data as ResponseData; + if (!('error' in candidate)) { + return false; + } + const error = candidate.error; + if (typeof error !== 'object' || error === null) { + return false; // error property exists but is not an object (could be undefined, but we checked 'in') + } + + // Optional properties check + if ( + 'code' in error && + typeof error.code !== 'number' && + error.code !== undefined + ) { + return false; + } + if ( + 'message' in error && + typeof error.message !== 'string' && + error.message !== undefined + ) { + return false; + } + + return true; +} + export function toFriendlyError(error: unknown): unknown { - if (error && typeof error === 'object' && 'response' in error) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const gaxiosError = error as GaxiosError; - const data = parseResponseData(gaxiosError); + if (isGaxiosError(error)) { + const data = parseResponseData(error); if (data && data.error && data.error.message && data.error.code) { switch (data.error.code) { case 400: @@ -129,17 +169,20 @@ export function toFriendlyError(error: unknown): unknown { } function parseResponseData(error: GaxiosError): ResponseData | undefined { + let data = error.response?.data; // Inexplicably, Gaxios sometimes doesn't JSONify the response data. - if (typeof error.response?.data === 'string') { + if (typeof data === 'string') { try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(error.response?.data) as ResponseData; + data = JSON.parse(data); } catch { return undefined; } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return error.response?.data as ResponseData | undefined; + + if (isResponseData(data)) { + return data; + } + return undefined; } /** @@ -152,8 +195,15 @@ function parseResponseData(error: GaxiosError): ResponseData | undefined { export function isAuthenticationError(error: unknown): boolean { // Check for MCP SDK errors with code property // (SseError and StreamableHTTPError both have numeric 'code' property) - if (error && typeof error === 'object' && 'code' in error) { - const errorCode = (error as { code: unknown }).code; + if ( + error && + typeof error === 'object' && + 'code' in error && + typeof (error as { code: unknown }).code === 'number' + ) { + // Safe access after check + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const errorCode = (error as { code: number }).code; if (errorCode === 401) { return true; } diff --git a/packages/core/src/utils/googleErrors.ts b/packages/core/src/utils/googleErrors.ts index 3ae74f0de8..88f78e35d6 100644 --- a/packages/core/src/utils/googleErrors.ts +++ b/packages/core/src/utils/googleErrors.ts @@ -207,9 +207,13 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { detailObj['@type'] = detailObj[typeKey]; delete detailObj[typeKey]; } - // We can just cast it; the consumer will have to switch on @type - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - details.push(detailObj as unknown as GoogleApiErrorDetail); + // Basic structural check before casting. + // Since the proto definitions are loose, we primarily rely on @type presence. + if (typeof detailObj['@type'] === 'string') { + // We can just cast it; the consumer will have to switch on @type + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + details.push(detailObj as unknown as GoogleApiErrorDetail); + } } } } @@ -225,6 +229,16 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null { return null; } +function isErrorShape(obj: unknown): obj is ErrorShape { + return ( + typeof obj === 'object' && + obj !== null && + (('message' in obj && + typeof (obj as { message: unknown }).message === 'string') || + ('code' in obj && typeof (obj as { code: unknown }).code === 'number')) + ); +} + function fromGaxiosError(errorObj: object): ErrorShape | undefined { const gaxiosError = errorObj as { response?: { @@ -260,7 +274,10 @@ function fromGaxiosError(errorObj: object): ErrorShape | undefined { if (typeof data === 'object' && data !== null) { if ('error' in data) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - outerError = (data as { error: ErrorShape }).error; + const potentialError = (data as { error: unknown }).error; + if (isErrorShape(potentialError)) { + outerError = potentialError; + } } } } @@ -320,7 +337,10 @@ function fromApiError(errorObj: object): ErrorShape | undefined { if (typeof data === 'object' && data !== null) { if ('error' in data) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - outerError = (data as { error: ErrorShape }).error; + const potentialError = (data as { error: unknown }).error; + if (isErrorShape(potentialError)) { + outerError = potentialError; + } } } } From dfd7721e69f196ba574a4e9e9aa95c2e17b7e5c9 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Sat, 21 Feb 2026 01:12:56 +0000 Subject: [PATCH 48/52] Disallow unsafe returns. (#19767) --- eslint.config.js | 1 + packages/a2a-server/src/config/settings.ts | 2 +- packages/cli/src/commands/extensions/utils.ts | 1 + packages/cli/src/config/extensionRegistryClient.ts | 1 + packages/cli/src/config/extensions/extensionEnablement.ts | 1 + packages/cli/src/config/extensions/extensionSettings.ts | 1 + packages/cli/src/config/extensions/variables.ts | 1 + packages/cli/src/test-utils/mockCommandContext.ts | 1 + packages/cli/src/ui/hooks/useAtCompletion.ts | 2 ++ packages/cli/src/ui/utils/TableRenderer.tsx | 6 +++++- packages/cli/src/utils/activityLogger.ts | 2 +- packages/cli/src/utils/envVarResolver.ts | 1 + packages/cli/src/utils/gitUtils.ts | 1 + packages/cli/src/utils/jsonoutput.ts | 1 + packages/cli/src/utils/settingsUtils.ts | 1 + packages/cli/src/zed-integration/fileSystemService.ts | 1 + packages/core/src/code_assist/oauth2.ts | 1 + packages/core/src/config/projectRegistry.ts | 1 + packages/core/src/core/baseLlmClient.ts | 7 +++---- packages/core/src/core/fakeContentGenerator.ts | 2 ++ packages/core/src/ide/ide-client.ts | 1 + packages/core/src/ide/ide-connection-utils.ts | 5 +++++ packages/core/src/services/chatRecordingService.ts | 1 + packages/core/src/utils/filesearch/fileSearch.ts | 1 + packages/core/src/utils/safeJsonStringify.ts | 2 ++ packages/core/src/utils/stdio.ts | 4 ++++ 26 files changed, 42 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index e2a7fa3e8a..12dc29a238 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -201,6 +201,7 @@ export default tseslint.config( rules: { '@typescript-eslint/no-unsafe-type-assertion': 'error', '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-return': 'error', }, }, { diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index 3f4528c3b2..a2b11d0886 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -147,7 +147,7 @@ function resolveEnvVarsInObject(obj: T): T { } if (Array.isArray(obj)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; } diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 26e47b912b..78bad54502 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -47,6 +47,7 @@ const defaultRequestConfirmation: RequestConfirmationCallback = async ( message, initial: false, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.confirm; }; diff --git a/packages/cli/src/config/extensionRegistryClient.ts b/packages/cli/src/config/extensionRegistryClient.ts index 073b78ad79..bf09aabe77 100644 --- a/packages/cli/src/config/extensionRegistryClient.ts +++ b/packages/cli/src/config/extensionRegistryClient.ts @@ -83,6 +83,7 @@ export class ExtensionRegistryClient { }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const results = await fzf.find(query); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: RegistryExtension }) => r.item); } diff --git a/packages/cli/src/config/extensions/extensionEnablement.ts b/packages/cli/src/config/extensions/extensionEnablement.ts index a619587342..7ae2431ee9 100644 --- a/packages/cli/src/config/extensions/extensionEnablement.ts +++ b/packages/cli/src/config/extensions/extensionEnablement.ts @@ -179,6 +179,7 @@ export class ExtensionEnablementManager { readConfig(): AllExtensionsEnablementConfig { try { const content = fs.readFileSync(this.configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (error) { if ( diff --git a/packages/cli/src/config/extensions/extensionSettings.ts b/packages/cli/src/config/extensions/extensionSettings.ts index 23df066db1..06e4f49db4 100644 --- a/packages/cli/src/config/extensions/extensionSettings.ts +++ b/packages/cli/src/config/extensions/extensionSettings.ts @@ -156,6 +156,7 @@ export async function promptForSetting( name: 'value', message: `${setting.name}\n${setting.description}`, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.value; } diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 5a2e0ca457..3a79fc705f 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -58,6 +58,7 @@ export function recursivelyHydrateStrings( if (Array.isArray(obj)) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return obj.map((item) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return recursivelyHydrateStrings(item, values), ) as unknown as T; } diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 6bf7aafdbc..8dc5b9930a 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -121,5 +121,6 @@ export const createMockCommandContext = ( return output; }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return merge(defaultMocks, overrides); }; diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts index af827f1b12..8d860bb6ce 100644 --- a/packages/cli/src/ui/hooks/useAtCompletion.ts +++ b/packages/cli/src/ui/hooks/useAtCompletion.ts @@ -170,6 +170,7 @@ async function searchResourceCandidates( const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW * 3, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map( (result: { item: ResourceSuggestionCandidate }) => result.item.suggestion, ); @@ -193,6 +194,7 @@ async function searchAgentCandidates( const results = await fzf.find(normalizedPattern, { limit: MAX_SUGGESTIONS_TO_SHOW, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return results.map((r: { item: Suggestion }) => r.item); } diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index e57e2f7faa..ab1981762c 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -193,7 +193,11 @@ export const TableRenderer: React.FC = ({ const wrappedRows = styledRows.map((row) => wrapAndProcessRow(row)); // Use the TIGHTEST widths that fit the wrapped content + padding - const adjustedWidths = actualColumnWidths.map((w) => w + COLUMN_PADDING); + const adjustedWidths = actualColumnWidths.map( + (w) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + w + COLUMN_PADDING, + ); return { wrappedHeaders, wrappedRows, adjustedWidths }; }, [styledHeaders, styledRows, terminalWidth]); diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts index cf852257a9..9f1d268a91 100644 --- a/packages/cli/src/utils/activityLogger.ts +++ b/packages/cli/src/utils/activityLogger.ts @@ -472,7 +472,7 @@ export class ActivityLogger extends EventEmitter { body, pending: true, }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return return (oldEnd as any).apply(this, [chunk, ...etc]); }; diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts index 4bd1d6f82f..6e01f67ac7 100644 --- a/packages/cli/src/utils/envVarResolver.ts +++ b/packages/cli/src/utils/envVarResolver.ts @@ -98,6 +98,7 @@ function resolveEnvVarsInObjectInternal( visited.add(obj); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = obj.map((item) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return resolveEnvVarsInObjectInternal(item, visited, customEnv), ) as unknown as T; visited.delete(obj); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index c25366f72a..e27673f0fe 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -83,6 +83,7 @@ export const getLatestGitHubRelease = async ( if (!releaseTag) { throw new Error(`Response did not include tag_name field`); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return releaseTag; } catch (_error) { debugLogger.debug( diff --git a/packages/cli/src/utils/jsonoutput.ts b/packages/cli/src/utils/jsonoutput.ts index f600b7e165..7f60c34104 100644 --- a/packages/cli/src/utils/jsonoutput.ts +++ b/packages/cli/src/utils/jsonoutput.ts @@ -40,6 +40,7 @@ export function tryParseJSON(input: string): object | null { if (!Array.isArray(parsed) && Object.keys(parsed).length === 0) return null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return parsed; } catch (_err) { return null; diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index 8d313cf688..3fa1d8bd5d 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -374,6 +374,7 @@ export function setPendingSettingValue( // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const newSettings = JSON.parse(JSON.stringify(pendingSettings)); setNestedValue(newSettings, path, value); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return newSettings; } diff --git a/packages/cli/src/zed-integration/fileSystemService.ts b/packages/cli/src/zed-integration/fileSystemService.ts index fc4672b4b5..1d3c8ad0b8 100644 --- a/packages/cli/src/zed-integration/fileSystemService.ts +++ b/packages/cli/src/zed-integration/fileSystemService.ts @@ -29,6 +29,7 @@ export class AcpFileSystemService implements FileSystemService { sessionId: this.sessionId, }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return response.content; } diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index fd2f5ea178..bfa50599c7 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -636,6 +636,7 @@ async function fetchCachedCredentials(): Promise< for (const keyFile of pathsToTry) { try { const keyFileString = await fs.readFile(keyFile, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(keyFileString); } catch (error) { // Log specific error for debugging, but continue trying other paths diff --git a/packages/core/src/config/projectRegistry.ts b/packages/core/src/config/projectRegistry.ts index 225faedf9b..725ea081f9 100644 --- a/packages/core/src/config/projectRegistry.ts +++ b/packages/core/src/config/projectRegistry.ts @@ -59,6 +59,7 @@ export class ProjectRegistry { try { const content = await fs.promises.readFile(this.registryPath, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (e) { debugLogger.debug('Failed to load registry: ', e); diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index 64730ff74c..64442ac86e 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -13,21 +13,19 @@ import type { GenerateContentConfig, } from '@google/genai'; import type { Config } from '../config/config.js'; -import type { ContentGenerator } from './contentGenerator.js'; -import type { AuthType } from './contentGenerator.js'; +import type { ContentGenerator, AuthType } from './contentGenerator.js'; import { handleFallback } from '../fallback/handler.js'; import { getResponseText } from '../utils/partUtils.js'; import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage } from '../utils/errors.js'; import { logMalformedJsonResponse } from '../telemetry/loggers.js'; -import { MalformedJsonResponseEvent } from '../telemetry/types.js'; +import { MalformedJsonResponseEvent, LlmRole } from '../telemetry/types.js'; import { retryWithBackoff } from '../utils/retry.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { applyModelSelection, createAvailabilityContextProvider, } from '../availability/policyHelpers.js'; -import { LlmRole } from '../telemetry/types.js'; const DEFAULT_MAX_ATTEMPTS = 5; @@ -164,6 +162,7 @@ export class BaseLlmClient { ); // If we are here, the content is valid (not empty and parsable). + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse( this.cleanJsonResponse(getResponseText(result)!.trim(), model), ); diff --git a/packages/core/src/core/fakeContentGenerator.ts b/packages/core/src/core/fakeContentGenerator.ts index 5bedc2d187..c765bde087 100644 --- a/packages/core/src/core/fakeContentGenerator.ts +++ b/packages/core/src/core/fakeContentGenerator.ts @@ -83,6 +83,7 @@ export class FakeContentGenerator implements ContentGenerator { // eslint-disable-next-line @typescript-eslint/no-unused-vars role: LlmRole, ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Object.setPrototypeOf( this.getNextResponse('generateContent', request), GenerateContentResponse.prototype, @@ -116,6 +117,7 @@ export class FakeContentGenerator implements ContentGenerator { async embedContent( request: EmbedContentParameters, ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Object.setPrototypeOf( this.getNextResponse('embedContent', request), EmbedContentResponse.prototype, diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index 2b3b29ac3c..373df31f5f 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -348,6 +348,7 @@ export class IdeClient { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const parsedJson = JSON.parse(textPart.text); if (parsedJson && typeof parsedJson.content === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return parsedJson.content; } if (parsedJson && parsedJson.content === null) { diff --git a/packages/core/src/ide/ide-connection-utils.ts b/packages/core/src/ide/ide-connection-utils.ts index 81a9740327..c9776e1509 100644 --- a/packages/core/src/ide/ide-connection-utils.ts +++ b/packages/core/src/ide/ide-connection-utils.ts @@ -123,6 +123,7 @@ export async function getConnectionConfigFromFile( `gemini-ide-server-${pid}.json`, ); const portFileContents = await fs.promises.readFile(portFile, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(portFileContents); } catch (_) { // For newer extension versions, the file name matches the pattern @@ -167,6 +168,7 @@ export async function getConnectionConfigFromFile( } const parsedContents = fileContents.map((content) => { try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(content); } catch (e) { logger.debug('Failed to parse JSON from config file: ', e); @@ -196,6 +198,7 @@ export async function getConnectionConfigFromFile( if (fileIndex !== -1) { logger.debug(`Selected IDE connection file: ${matchingFiles[fileIndex]}`); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } @@ -213,6 +216,7 @@ export async function getConnectionConfigFromFile( `Selected IDE connection file (matched port from env): ${matchingFiles[fileIndex]}`, ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } } @@ -225,6 +229,7 @@ export async function getConnectionConfigFromFile( `Selected first valid IDE connection file: ${matchingFiles[fileIndex]}`, ); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return selected; } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 0b94825353..6d94b9a3bf 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -419,6 +419,7 @@ export class ChatRecordingService { private readConversation(): ConversationRecord { try { this.cachedLastConvData = fs.readFileSync(this.conversationFile!, 'utf8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return JSON.parse(this.cachedLastConvData); } catch (error) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion diff --git a/packages/core/src/utils/filesearch/fileSearch.ts b/packages/core/src/utils/filesearch/fileSearch.ts index 21d1e19168..97560f7070 100644 --- a/packages/core/src/utils/filesearch/fileSearch.ts +++ b/packages/core/src/utils/filesearch/fileSearch.ts @@ -149,6 +149,7 @@ class RecursiveFileSearch implements FileSearch { filteredCandidates = await this.fzf .find(pattern) .then((results: Array>) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-return results.map((entry: FzfResultItem) => entry.item), ) .catch(() => { diff --git a/packages/core/src/utils/safeJsonStringify.ts b/packages/core/src/utils/safeJsonStringify.ts index 611519d09b..b32a09df27 100644 --- a/packages/core/src/utils/safeJsonStringify.ts +++ b/packages/core/src/utils/safeJsonStringify.ts @@ -27,6 +27,7 @@ export function safeJsonStringify( } seen.add(value); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, space, @@ -60,6 +61,7 @@ export function safeJsonStringifyBooleanValuesOnly(obj: any): string { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion if ((value as Config) !== null && !configSeen) { configSeen = true; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } if (typeof value === 'boolean') { diff --git a/packages/core/src/utils/stdio.ts b/packages/core/src/utils/stdio.ts index 22fd2e8447..66abbe6ade 100644 --- a/packages/core/src/utils/stdio.ts +++ b/packages/core/src/utils/stdio.ts @@ -91,8 +91,10 @@ export function createWorkingStdio() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value.bind(target); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, }); @@ -105,8 +107,10 @@ export function createWorkingStdio() { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value.bind(target); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; }, }); From d2d345f41acea432760696fa9645e4155940f1a2 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sat, 21 Feb 2026 12:41:27 -0500 Subject: [PATCH 49/52] fix(cli): filter subagent sessions from resume history (#19698) --- packages/cli/src/utils/sessionUtils.test.ts | 67 +++++++++++++++++++ packages/cli/src/utils/sessionUtils.ts | 6 ++ .../core/src/agents/local-executor.test.ts | 7 +- packages/core/src/agents/local-executor.ts | 12 ++-- packages/core/src/core/geminiChat.ts | 3 +- .../src/services/chatRecordingService.test.ts | 15 +++++ .../core/src/services/chatRecordingService.ts | 15 ++++- 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/utils/sessionUtils.test.ts b/packages/cli/src/utils/sessionUtils.test.ts index 29fc5bdff9..8491f748bd 100644 --- a/packages/cli/src/utils/sessionUtils.test.ts +++ b/packages/cli/src/utils/sessionUtils.test.ts @@ -445,9 +445,76 @@ describe('SessionSelector', () => { const sessionSelector = new SessionSelector(config); const sessions = await sessionSelector.listSessions(); + // Should list the session with gemini message expect(sessions.length).toBe(1); expect(sessions[0].id).toBe(sessionIdGeminiOnly); }); + + it('should not list sessions marked as subagent', async () => { + const mainSessionId = randomUUID(); + const subagentSessionId = randomUUID(); + + // Create test session files + const chatsDir = path.join(tmpDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Main session - should be listed + const mainSession = { + sessionId: mainSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T10:00:00.000Z', + lastUpdated: '2024-01-01T10:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Hello world', + id: 'msg1', + timestamp: '2024-01-01T10:00:00.000Z', + }, + ], + kind: 'main', + }; + + // Subagent session - should NOT be listed + const subagentSession = { + sessionId: subagentSessionId, + projectHash: 'test-hash', + startTime: '2024-01-01T11:00:00.000Z', + lastUpdated: '2024-01-01T11:30:00.000Z', + messages: [ + { + type: 'user', + content: 'Internal subagent task', + id: 'msg1', + timestamp: '2024-01-01T11:00:00.000Z', + }, + ], + kind: 'subagent', + }; + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-${mainSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(mainSession, null, 2), + ); + + await fs.writeFile( + path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T11-00-${subagentSessionId.slice(0, 8)}.json`, + ), + JSON.stringify(subagentSession, null, 2), + ); + + const sessionSelector = new SessionSelector(config); + const sessions = await sessionSelector.listSessions(); + + // Should only list the main session + expect(sessions.length).toBe(1); + expect(sessions[0].id).toBe(mainSessionId); + }); }); describe('extractFirstUserMessage', () => { diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 559a04dccf..039c1232a2 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -276,6 +276,12 @@ export const getAllSessionFiles = async ( return { fileName: file, sessionInfo: null }; } + // Skip subagent sessions - these are implementation details of a tool call + // and shouldn't be surfaced for resumption in the main agent history. + if (content.kind === 'subagent') { + return { fileName: file, sessionInfo: null }; + } + const firstUserMessage = extractFirstUserMessage(content.messages); const isCurrentSession = currentSessionId ? file.includes(currentSessionId.slice(0, 8)) diff --git a/packages/core/src/agents/local-executor.test.ts b/packages/core/src/agents/local-executor.test.ts index a9a0697bce..8f7269b784 100644 --- a/packages/core/src/agents/local-executor.test.ts +++ b/packages/core/src/agents/local-executor.test.ts @@ -41,14 +41,15 @@ import type { Config } from '../config/config.js'; import { MockTool } from '../test-utils/mock-tool.js'; import { getDirectoryContextString } from '../utils/environmentContext.js'; import { z } from 'zod'; +import { getErrorMessage } from '../utils/errors.js'; import { promptIdContext } from '../utils/promptIdContext.js'; import { logAgentStart, logAgentFinish, logRecoveryAttempt, } from '../telemetry/loggers.js'; -import { LlmRole } from '../telemetry/types.js'; import { + LlmRole, AgentStartEvent, AgentFinishEvent, RecoveryAttemptEvent, @@ -1250,7 +1251,7 @@ describe('LocalAgentExecutor', () => { ); await expect(executor.run({ goal: 'test' }, signal)).rejects.toThrow( - `Failed to create chat object: ${initError}`, + `Failed to create chat object: ${getErrorMessage(initError)}`, ); // Ensure the error was reported via the activity callback @@ -1258,7 +1259,7 @@ describe('LocalAgentExecutor', () => { expect.objectContaining({ type: 'ERROR', data: expect.objectContaining({ - error: `Error: Failed to create chat object: ${initError}`, + error: `Error: Failed to create chat object: ${getErrorMessage(initError)}`, }), }), ); diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index e6557785db..513424ad32 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -33,8 +33,8 @@ import { import { AgentStartEvent, AgentFinishEvent, - RecoveryAttemptEvent, LlmRole, + RecoveryAttemptEvent, } from '../telemetry/types.js'; import type { LocalAgentDefinition, @@ -48,6 +48,7 @@ import { DEFAULT_MAX_TURNS, DEFAULT_MAX_TIME_MINUTES, } from './types.js'; +import { getErrorMessage } from '../utils/errors.js'; import { templateString } from './utils.js'; import { DEFAULT_GEMINI_MODEL, isAutoModel } from '../config/models.js'; import type { RoutingContext } from '../routing/routingStrategy.js'; @@ -826,16 +827,19 @@ export class LocalAgentExecutor { systemInstruction, [{ functionDeclarations: tools }], startHistory, + undefined, + undefined, + 'subagent', ); - } catch (error) { + } catch (e: unknown) { await reportError( - error, + e, `Error initializing Gemini chat for agent ${this.definition.name}.`, startHistory, 'startChat', ); // Re-throw as a more specific error after reporting. - throw new Error(`Failed to create chat object: ${error}`); + throw new Error(`Failed to create chat object: ${getErrorMessage(e)}`); } } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 14f90cea9d..c9cb6cf8f2 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -249,10 +249,11 @@ export class GeminiChat { private history: Content[] = [], resumedSessionData?: ResumedSessionData, private readonly onModelChanged?: (modelId: string) => Promise, + kind: 'main' | 'subagent' = 'main', ) { validateHistory(history); this.chatRecordingService = new ChatRecordingService(config); - this.chatRecordingService.initialize(resumedSessionData); + this.chatRecordingService.initialize(resumedSessionData, kind); this.lastPromptTokenCount = estimateTokenCountSync( this.history.flatMap((c) => c.parts || []), ); diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 61ba3d32a3..086a7b6ff5 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -86,6 +86,21 @@ describe('ChatRecordingService', () => { expect(files[0]).toMatch(/^session-.*-test-ses\.json$/); }); + it('should include the conversation kind when specified', () => { + chatRecordingService.initialize(undefined, 'subagent'); + chatRecordingService.recordMessage({ + type: 'user', + content: 'ping', + model: 'm', + }); + + const sessionFile = chatRecordingService.getConversationFilePath()!; + const conversation = JSON.parse( + fs.readFileSync(sessionFile, 'utf8'), + ) as ConversationRecord; + expect(conversation.kind).toBe('subagent'); + }); + it('should resume from an existing session if provided', () => { const chatsDir = path.join(testTempDir, 'chats'); fs.mkdirSync(chatsDir, { recursive: true }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 6d94b9a3bf..2afbd16657 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -102,6 +102,8 @@ export interface ConversationRecord { summary?: string; /** Workspace directories added during the session via /dir add */ directories?: string[]; + /** The kind of conversation (main agent or subagent) */ + kind?: 'main' | 'subagent'; } /** @@ -128,6 +130,7 @@ export class ChatRecordingService { private cachedLastConvData: string | null = null; private sessionId: string; private projectHash: string; + private kind?: 'main' | 'subagent'; private queuedThoughts: Array = []; private queuedTokens: TokensSummary | null = null; private config: Config; @@ -141,13 +144,21 @@ export class ChatRecordingService { /** * Initializes the chat recording service: creates a new conversation file and associates it with * this service instance, or resumes from an existing session if resumedSessionData is provided. + * + * @param resumedSessionData Data from a previous session to resume from. + * @param kind The kind of conversation (main or subagent). */ - initialize(resumedSessionData?: ResumedSessionData): void { + initialize( + resumedSessionData?: ResumedSessionData, + kind?: 'main' | 'subagent', + ): void { try { + this.kind = kind; if (resumedSessionData) { // Resume from existing session this.conversationFile = resumedSessionData.filePath; this.sessionId = resumedSessionData.conversation.sessionId; + this.kind = resumedSessionData.conversation.kind; // Update the session ID in the existing file this.updateConversation((conversation) => { @@ -180,6 +191,7 @@ export class ChatRecordingService { startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], + kind: this.kind, }); } @@ -435,6 +447,7 @@ export class ChatRecordingService { startTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), messages: [], + kind: this.kind, }; } } From acb7f577de419b0ff7cd299b25531e2995a3b1b1 Mon Sep 17 00:00:00 2001 From: Abhi <43648792+abhipatel12@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:33:25 -0500 Subject: [PATCH 50/52] chore(lint): fix lint errors seen when running npm run lint (#19844) --- packages/a2a-server/src/agent/task.test.ts | 12 +++++++----- packages/a2a-server/src/agent/task.ts | 3 +-- packages/a2a-server/src/config/config.ts | 8 +++++--- packages/a2a-server/src/http/app.test.ts | 9 ++++----- .../src/agents/a2a-client-manager.test.ts | 5 +++-- packages/core/src/agents/agentLoader.ts | 4 ++-- .../core/src/agents/local-invocation.test.ts | 7 +++++-- packages/core/src/agents/registry.test.ts | 7 +++++-- packages/core/src/agents/remote-invocation.ts | 10 +++++----- .../core/src/code_assist/converter.test.ts | 2 +- packages/core/src/config/config.test.ts | 8 +++++--- packages/core/src/config/config.ts | 19 +++++++++++-------- packages/core/src/core/baseLlmClient.test.ts | 10 ++++++---- packages/core/src/core/client.ts | 3 +-- .../core/src/core/coreToolHookTriggers.ts | 7 +++++-- packages/core/src/hooks/hookAggregator.ts | 2 +- .../core/src/hooks/hookEventHandler.test.ts | 12 ++++++------ packages/core/src/hooks/hookPlanner.ts | 4 ++-- packages/core/src/hooks/hookRunner.test.ts | 3 +-- packages/core/src/ide/ide-installer.test.ts | 3 +-- packages/core/src/scheduler/state-manager.ts | 3 +-- .../core/src/scheduler/tool-executor.test.ts | 3 +-- .../src/services/chatCompressionService.ts | 3 +-- .../core/src/services/loopDetectionService.ts | 2 +- packages/core/src/skills/skillLoader.ts | 4 ++-- .../clearcut-logger/clearcut-logger.test.ts | 1 - packages/core/src/telemetry/gcp-exporters.ts | 3 +-- packages/core/src/telemetry/loggers.test.ts | 5 ++--- .../core/src/telemetry/uiTelemetry.test.ts | 2 +- packages/core/src/telemetry/uiTelemetry.ts | 3 +-- packages/core/src/tools/edit.ts | 9 +++++---- packages/core/src/tools/mcp-tool.ts | 2 +- packages/core/src/tools/shell.test.ts | 3 +-- packages/core/src/tools/shell.ts | 2 +- packages/core/src/tools/web-fetch.ts | 8 ++------ packages/core/src/tools/write-file.ts | 8 ++------ packages/core/src/tools/write-todos.ts | 10 ++-------- packages/core/src/utils/editCorrector.test.ts | 4 ++-- .../core/src/utils/getFolderStructure.test.ts | 9 ++++----- .../core/src/utils/memoryDiscovery.test.ts | 4 +--- 40 files changed, 109 insertions(+), 117 deletions(-) diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts index 9b5bca8c5c..39cfe5eb74 100644 --- a/packages/a2a-server/src/agent/task.test.ts +++ b/packages/a2a-server/src/agent/task.test.ts @@ -14,19 +14,21 @@ import { type Mock, } from 'vitest'; import { Task } from './task.js'; +import type { + ToolCall, + Config, + ToolCallRequestInfo, + GitService, + CompletedToolCall, +} from '@google/gemini-cli-core'; import { GeminiEventType, - type Config, - type ToolCallRequestInfo, - type GitService, - type CompletedToolCall, ApprovalMode, ToolConfirmationOutcome, } from '@google/gemini-cli-core'; import { createMockConfig } from '../utils/testing_utils.js'; import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; import { CoderAgentEvent } from '../types.js'; -import type { ToolCall } from '@google/gemini-cli-core'; const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn()); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 890bc85b11..b74381714d 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -30,8 +30,7 @@ import { EDIT_TOOL_NAMES, processRestorableToolCalls, } from '@google/gemini-cli-core'; -import type { RequestContext } from '@a2a-js/sdk/server'; -import { type ExecutionEventBus } from '@a2a-js/sdk/server'; +import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server'; import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 48daffbe42..eb92e55f36 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -8,17 +8,19 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as dotenv from 'dotenv'; -import type { TelemetryTarget } from '@google/gemini-cli-core'; +import type { + TelemetryTarget, + ConfigParameters, + ExtensionLoader, +} from '@google/gemini-cli-core'; import { AuthType, Config, - type ConfigParameters, FileDiscoveryService, ApprovalMode, loadServerHierarchicalMemory, GEMINI_DIR, DEFAULT_GEMINI_EMBEDDING_MODEL, - type ExtensionLoader, startupProfiler, PREVIEW_GEMINI_MODEL, homedir, diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index 4eb6b522b2..c863fb1472 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -4,12 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config } from '@google/gemini-cli-core'; -import { - GeminiEventType, - ApprovalMode, - type ToolCallConfirmationDetails, +import type { + Config, + ToolCallConfirmationDetails, } from '@google/gemini-cli-core'; +import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core'; import type { TaskStatusUpdateEvent, SendStreamingMessageSuccessResponse, diff --git a/packages/core/src/agents/a2a-client-manager.test.ts b/packages/core/src/agents/a2a-client-manager.test.ts index 2f653ba176..42e31d2405 100644 --- a/packages/core/src/agents/a2a-client-manager.test.ts +++ b/packages/core/src/agents/a2a-client-manager.test.ts @@ -11,12 +11,13 @@ import { } from './a2a-client-manager.js'; import type { AgentCard, Task } from '@a2a-js/sdk'; import type { AuthenticationHandler, Client } from '@a2a-js/sdk/client'; -import { ClientFactory, DefaultAgentCardResolver } from '@a2a-js/sdk/client'; -import { debugLogger } from '../utils/debugLogger.js'; import { + ClientFactory, + DefaultAgentCardResolver, createAuthenticatingFetchWithRetry, ClientFactoryOptions, } from '@a2a-js/sdk/client'; +import { debugLogger } from '../utils/debugLogger.js'; vi.mock('../utils/debugLogger.js', () => ({ debugLogger: { diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index ed648c6191..bdc59de746 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yaml from 'js-yaml'; +import { load } from 'js-yaml'; import * as fs from 'node:fs/promises'; import { type Dirent } from 'node:fs'; import * as path from 'node:path'; @@ -262,7 +262,7 @@ export async function parseAgentMarkdown( let rawFrontmatter: unknown; try { - rawFrontmatter = yaml.load(frontmatterStr); + rawFrontmatter = load(frontmatterStr); } catch (error) { throw new AgentLoadError( filePath, diff --git a/packages/core/src/agents/local-invocation.test.ts b/packages/core/src/agents/local-invocation.test.ts index cdaa46fd76..91efcd399f 100644 --- a/packages/core/src/agents/local-invocation.test.ts +++ b/packages/core/src/agents/local-invocation.test.ts @@ -5,10 +5,13 @@ */ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest'; -import type { LocalAgentDefinition } from './types.js'; +import type { + LocalAgentDefinition, + SubagentActivityEvent, + AgentInputs, +} from './types.js'; import { LocalSubagentInvocation } from './local-invocation.js'; import { LocalAgentExecutor } from './local-executor.js'; -import type { SubagentActivityEvent, AgentInputs } from './types.js'; import { AgentTerminateMode } from './types.js'; import { makeFakeConfig } from '../test-utils/config.js'; import { ToolErrorType } from '../tools/tool-error.js'; diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 2068968428..8cc45a9a5a 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AgentRegistry, getModelConfigAlias } from './registry.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { AgentDefinition, LocalAgentDefinition } from './types.js'; -import type { Config, GeminiCLIExtension } from '../config/config.js'; +import type { + Config, + GeminiCLIExtension, + ConfigParameters, +} from '../config/config.js'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents, CoreEvent } from '../utils/events.js'; import { A2AClientManager } from './a2a-client-manager.js'; @@ -22,7 +26,6 @@ import { } from '../config/models.js'; import * as tomlLoader from './agentLoader.js'; import { SimpleExtensionLoader } from '../utils/extensionLoader.js'; -import type { ConfigParameters } from '../config/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import { ThinkingLevel } from '@google/genai'; import type { AcknowledgedAgentsService } from './acknowledgedAgents.js'; diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index 41564944ec..ea43c901a2 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -4,12 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ToolConfirmationOutcome } from '../tools/tools.js'; -import { - BaseToolInvocation, - type ToolResult, - type ToolCallConfirmationDetails, +import type { + ToolConfirmationOutcome, + ToolResult, + ToolCallConfirmationDetails, } from '../tools/tools.js'; +import { BaseToolInvocation } from '../tools/tools.js'; import { DEFAULT_QUERY_STRING } from './types.js'; import type { RemoteAgentInputs, diff --git a/packages/core/src/code_assist/converter.test.ts b/packages/core/src/code_assist/converter.test.ts index 31e66bcd17..21fecec547 100644 --- a/packages/core/src/code_assist/converter.test.ts +++ b/packages/core/src/code_assist/converter.test.ts @@ -14,12 +14,12 @@ import { import type { ContentListUnion, GenerateContentParameters, + Part, } from '@google/genai'; import { GenerateContentResponse, FinishReason, BlockedReason, - type Part, } from '@google/genai'; describe('converter', () => { diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index a899ee045f..d4a9197124 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -20,6 +20,7 @@ import { setGeminiMdFilename as mockSetGeminiMdFilename } from '../tools/memoryT import { DEFAULT_TELEMETRY_TARGET, DEFAULT_OTLP_ENDPOINT, + uiTelemetryService, } from '../telemetry/index.js'; import type { ContentGeneratorConfig } from '../core/contentGenerator.js'; import { @@ -201,14 +202,15 @@ vi.mock('../services/contextManager.js', () => ({ import { BaseLlmClient } from '../core/baseLlmClient.js'; import { tokenLimit } from '../core/tokenLimits.js'; -import { uiTelemetryService } from '../telemetry/index.js'; import { getCodeAssistServer } from '../code_assist/codeAssist.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; import type { CodeAssistServer } from '../code_assist/server.js'; import { ContextManager } from '../services/contextManager.js'; import { UserTierId } from '../code_assist/types.js'; -import type { ModelConfigService } from '../services/modelConfigService.js'; -import type { ModelConfigServiceConfig } from '../services/modelConfigService.js'; +import type { + ModelConfigService, + ModelConfigServiceConfig, +} from '../services/modelConfigService.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index fc4f7c2ff7..2b80ea13f0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -68,7 +68,12 @@ import { ideContextStore } from '../ide/ideContext.js'; import { WriteTodosTool } from '../tools/write-todos.js'; import type { FileSystemService } from '../services/fileSystemService.js'; import { StandardFileSystemService } from '../services/fileSystemService.js'; -import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js'; +import { + logRipgrepFallback, + logFlashFallback, + logApprovalModeSwitch, + logApprovalModeDuration, +} from '../telemetry/loggers.js'; import { RipgrepFallbackEvent, FlashFallbackEvent, @@ -103,9 +108,11 @@ import type { EventEmitter } from 'node:events'; import { PolicyEngine } from '../policy/policy-engine.js'; import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js'; import { HookSystem } from '../hooks/index.js'; -import type { UserTierId } from '../code_assist/types.js'; -import type { RetrieveUserQuotaResponse } from '../code_assist/types.js'; -import type { AdminControlsSettings } from '../code_assist/types.js'; +import type { + UserTierId, + RetrieveUserQuotaResponse, + AdminControlsSettings, +} from '../code_assist/types.js'; import type { HierarchicalMemory } from './memory.js'; import { getCodeAssistServer } from '../code_assist/codeAssist.js'; import type { Experiments } from '../code_assist/experiments/experiments.js'; @@ -119,10 +126,6 @@ import { debugLogger } from '../utils/debugLogger.js'; import { SkillManager, type SkillDefinition } from '../skills/skillManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; import type { AgentDefinition } from '../agents/types.js'; -import { - logApprovalModeSwitch, - logApprovalModeDuration, -} from '../telemetry/loggers.js'; import { fetchAdminControls } from '../code_assist/admin/admin_controls.js'; import { isSubpath } from '../utils/paths.js'; import { UserHintService } from './userHintService.js'; diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index 4d09a1edd9..d067ec49ef 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -15,22 +15,24 @@ import { type Mock, } from 'vitest'; -import { BaseLlmClient, type GenerateJsonOptions } from './baseLlmClient.js'; +import type { + GenerateContentOptions, + GenerateJsonOptions, +} from './baseLlmClient.js'; +import { BaseLlmClient } from './baseLlmClient.js'; import type { ContentGenerator } from './contentGenerator.js'; import type { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { createAvailabilityServiceMock } from '../availability/testUtils.js'; -import type { GenerateContentOptions } from './baseLlmClient.js'; import type { GenerateContentResponse } from '@google/genai'; import type { Config } from '../config/config.js'; import { AuthType } from './contentGenerator.js'; import { reportError } from '../utils/errorReporting.js'; import { logMalformedJsonResponse } from '../telemetry/loggers.js'; import { retryWithBackoff } from '../utils/retry.js'; -import { MalformedJsonResponseEvent } from '../telemetry/types.js'; +import { MalformedJsonResponseEvent, LlmRole } from '../telemetry/types.js'; import { getErrorMessage } from '../utils/errors.js'; import type { ModelConfigService } from '../services/modelConfigService.js'; import { makeResolvedModelConfig } from '../services/modelConfigServiceTestUtils.js'; -import { LlmRole } from '../telemetry/types.js'; vi.mock('../utils/errorReporting.js'); vi.mock('../telemetry/loggers.js'); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index efa35a868b..56447468bd 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -17,8 +17,7 @@ import { getInitialChatHistory, } from '../utils/environmentContext.js'; import type { ServerGeminiStreamEvent, ChatCompressionInfo } from './turn.js'; -import { CompressionStatus } from './turn.js'; -import { Turn, GeminiEventType } from './turn.js'; +import { CompressionStatus, Turn, GeminiEventType } from './turn.js'; import type { Config } from '../config/config.js'; import { getCoreSystemPrompt } from './prompts.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; diff --git a/packages/core/src/core/coreToolHookTriggers.ts b/packages/core/src/core/coreToolHookTriggers.ts index 0ed947623c..cb98d3af20 100644 --- a/packages/core/src/core/coreToolHookTriggers.ts +++ b/packages/core/src/core/coreToolHookTriggers.ts @@ -6,11 +6,14 @@ import { type McpToolContext, BeforeToolHookOutput } from '../hooks/types.js'; import type { Config } from '../config/config.js'; -import type { ToolResult, AnyDeclarativeTool } from '../tools/tools.js'; +import type { + ToolResult, + AnyDeclarativeTool, + AnyToolInvocation, +} from '../tools/tools.js'; import { ToolErrorType } from '../tools/tool-error.js'; import { debugLogger } from '../utils/debugLogger.js'; import type { AnsiOutput, ShellExecutionConfig } from '../index.js'; -import type { AnyToolInvocation } from '../tools/tools.js'; import { ShellToolInvocation } from '../tools/shell.js'; import { DiscoveredMCPToolInvocation } from '../tools/mcp-tool.js'; diff --git a/packages/core/src/hooks/hookAggregator.ts b/packages/core/src/hooks/hookAggregator.ts index b8a280cca1..5cd53e8c6e 100644 --- a/packages/core/src/hooks/hookAggregator.ts +++ b/packages/core/src/hooks/hookAggregator.ts @@ -17,8 +17,8 @@ import { BeforeToolSelectionHookOutput, AfterModelHookOutput, AfterAgentHookOutput, + HookEventName, } from './types.js'; -import { HookEventName } from './types.js'; /** * Aggregated hook result diff --git a/packages/core/src/hooks/hookEventHandler.test.ts b/packages/core/src/hooks/hookEventHandler.test.ts index b9ae878e76..9a07d39672 100644 --- a/packages/core/src/hooks/hookEventHandler.test.ts +++ b/packages/core/src/hooks/hookEventHandler.test.ts @@ -11,16 +11,16 @@ import type { import { describe, it, expect, vi, beforeEach } from 'vitest'; import { HookEventHandler } from './hookEventHandler.js'; import type { Config } from '../config/config.js'; -import type { HookConfig } from './types.js'; -import type { HookPlanner } from './hookPlanner.js'; -import type { HookRunner } from './hookRunner.js'; -import type { HookAggregator } from './hookAggregator.js'; -import { HookEventName, HookType } from './types.js'; +import type { HookConfig, HookExecutionResult } from './types.js'; import { NotificationType, SessionStartSource, - type HookExecutionResult, + HookEventName, + HookType, } from './types.js'; +import type { HookPlanner } from './hookPlanner.js'; +import type { HookRunner } from './hookRunner.js'; +import type { HookAggregator } from './hookAggregator.js'; // Mock debugLogger const mockDebugLogger = vi.hoisted(() => ({ diff --git a/packages/core/src/hooks/hookPlanner.ts b/packages/core/src/hooks/hookPlanner.ts index 92701c4a42..3e016efe23 100644 --- a/packages/core/src/hooks/hookPlanner.ts +++ b/packages/core/src/hooks/hookPlanner.ts @@ -5,8 +5,8 @@ */ import type { HookRegistry, HookRegistryEntry } from './hookRegistry.js'; -import type { HookExecutionPlan } from './types.js'; -import { getHookKey, type HookEventName } from './types.js'; +import type { HookExecutionPlan, HookEventName } from './types.js'; +import { getHookKey } from './types.js'; import { debugLogger } from '../utils/debugLogger.js'; /** diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 5bc671b088..ca88b9411e 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -8,8 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { HookRunner } from './hookRunner.js'; import { HookEventName, HookType, ConfigSource } from './types.js'; -import type { HookConfig } from './types.js'; -import type { HookInput } from './types.js'; +import type { HookConfig, HookInput } from './types.js'; import type { Readable, Writable } from 'node:stream'; import type { Config } from '../config/config.js'; diff --git a/packages/core/src/ide/ide-installer.test.ts b/packages/core/src/ide/ide-installer.test.ts index e35cb3280f..0347fd892f 100644 --- a/packages/core/src/ide/ide-installer.test.ts +++ b/packages/core/src/ide/ide-installer.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; vi.mock('node:child_process', async (importOriginal) => { const actual = await importOriginal(); @@ -24,7 +24,6 @@ vi.mock('../utils/paths.js', async (importOriginal) => { }; }); -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { getIdeInstaller } from './ide-installer.js'; import * as child_process from 'node:child_process'; import * as fs from 'node:fs'; diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index 42da1d78ac..fb16125340 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -17,8 +17,7 @@ import type { ExecutingToolCall, ToolCallResponseInfo, } from './types.js'; -import { CoreToolCallStatus } from './types.js'; -import { ROOT_SCHEDULER_ID } from './types.js'; +import { CoreToolCallStatus, ROOT_SCHEDULER_ID } from './types.js'; import type { ToolConfirmationOutcome, ToolResultDisplay, diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index 53b244031d..1cbee019c6 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -6,13 +6,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { ToolExecutor } from './tool-executor.js'; -import type { Config } from '../index.js'; +import type { Config, AnyToolInvocation } from '../index.js'; import type { ToolResult } from '../tools/tools.js'; import { makeFakeConfig } from '../test-utils/config.js'; import { MockTool } from '../test-utils/mock-tool.js'; import type { ScheduledToolCall } from './types.js'; import { CoreToolCallStatus } from './types.js'; -import type { AnyToolInvocation } from '../index.js'; import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; import * as fileUtils from '../utils/fileUtils.js'; import * as coreToolHookTriggers from '../core/coreToolHookTriggers.js'; diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 432c08dd1e..5303a1a82a 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -12,7 +12,7 @@ import { tokenLimit } from '../core/tokenLimits.js'; import { getCompressionPrompt } from '../core/prompts.js'; import { getResponseText } from '../utils/partUtils.js'; import { logChatCompression } from '../telemetry/loggers.js'; -import { makeChatCompressionEvent } from '../telemetry/types.js'; +import { makeChatCompressionEvent, LlmRole } from '../telemetry/types.js'; import { saveTruncatedToolOutput, formatTruncatedToolOutput, @@ -32,7 +32,6 @@ import { PREVIEW_GEMINI_3_1_MODEL, } from '../config/models.js'; import { PreCompressTrigger } from '../hooks/types.js'; -import { LlmRole } from '../telemetry/types.js'; /** * Default threshold for compression token count as a fraction of the model's diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 8ae2b77898..247b1dacf4 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -18,6 +18,7 @@ import { LoopDetectionDisabledEvent, LoopType, LlmLoopCheckEvent, + LlmRole, } from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { @@ -25,7 +26,6 @@ import { isFunctionResponse, } from '../utils/messageInspectors.js'; import { debugLogger } from '../utils/debugLogger.js'; -import { LlmRole } from '../telemetry/types.js'; const TOOL_CALL_LOOP_THRESHOLD = 5; const CONTENT_LOOP_THRESHOLD = 10; diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts index 08374ec93a..e746caa179 100644 --- a/packages/core/src/skills/skillLoader.ts +++ b/packages/core/src/skills/skillLoader.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { glob } from 'glob'; -import yaml from 'js-yaml'; +import { load } from 'js-yaml'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents } from '../utils/events.js'; @@ -40,7 +40,7 @@ function parseFrontmatter( content: string, ): { name: string; description: string } | null { try { - const parsed = yaml.load(content); + const parsed = load(content); if (parsed && typeof parsed === 'object') { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const { name, description } = parsed as Record; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index c5a00bc11d..ed87bb34fc 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import 'vitest'; import { vi, describe, diff --git a/packages/core/src/telemetry/gcp-exporters.ts b/packages/core/src/telemetry/gcp-exporters.ts index 528b15b22e..c7429383eb 100644 --- a/packages/core/src/telemetry/gcp-exporters.ts +++ b/packages/core/src/telemetry/gcp-exporters.ts @@ -9,9 +9,8 @@ import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter' import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter'; import { Logging } from '@google-cloud/logging'; import type { Log } from '@google-cloud/logging'; -import { hrTimeToMilliseconds } from '@opentelemetry/core'; +import { hrTimeToMilliseconds, ExportResultCode } from '@opentelemetry/core'; import type { ExportResult } from '@opentelemetry/core'; -import { ExportResultCode } from '@opentelemetry/core'; import type { ReadableLogRecord, LogRecordExporter, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 316cf0b33f..db0e44be25 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -10,6 +10,7 @@ import type { CompletedToolCall, ContentGeneratorConfig, ErroredToolCall, + MessageBus, } from '../index.js'; import { CoreToolCallStatus, @@ -19,11 +20,10 @@ import { ToolConfirmationOutcome, ToolErrorType, ToolRegistry, - type MessageBus, } from '../index.js'; import { OutputFormat } from '../output/types.js'; import { logs } from '@opentelemetry/api-logs'; -import type { Config } from '../config/config.js'; +import type { Config, GeminiCLIExtension } from '../config/config.js'; import { logApiError, logApiRequest, @@ -100,7 +100,6 @@ import { FileOperation } from './metrics.js'; import * as sdk from './sdk.js'; import { createMockMessageBus } from '../test-utils/mock-message-bus.js'; import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; -import { type GeminiCLIExtension } from '../config/config.js'; import { FinishReason, type CallableTool, diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 52f0911730..d1a3b1a9a6 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -8,8 +8,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { UiTelemetryService } from './uiTelemetry.js'; import { ToolCallDecision } from './tool-call-decision.js'; import type { ApiErrorEvent, ApiResponseEvent } from './types.js'; -import { ToolCallEvent } from './types.js'; import { + ToolCallEvent, EVENT_API_ERROR, EVENT_API_RESPONSE, EVENT_TOOL_CALL, diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 8c9f2adb83..669b6a8c68 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -16,10 +16,9 @@ import type { ApiErrorEvent, ApiResponseEvent, ToolCallEvent, + LlmRole, } from './types.js'; -import type { LlmRole } from './types.js'; - export type UiEvent = | (ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }) | (ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }) diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 3df9c21b5e..d758e03229 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -38,10 +38,11 @@ import { import { IdeClient } from '../ide/ide-client.js'; import { FixLLMEditWithInstruction } from '../utils/llm-edit-fixer.js'; import { safeLiteralReplace, detectLineEnding } from '../utils/textUtils.js'; -import { EditStrategyEvent } from '../telemetry/types.js'; -import { logEditStrategy } from '../telemetry/loggers.js'; -import { EditCorrectionEvent } from '../telemetry/types.js'; -import { logEditCorrectionEvent } from '../telemetry/loggers.js'; +import { EditStrategyEvent, EditCorrectionEvent } from '../telemetry/types.js'; +import { + logEditStrategy, + logEditCorrectionEvent, +} from '../telemetry/loggers.js'; import { correctPath } from '../utils/pathCorrector.js'; import { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 1279d0f705..6faa30c673 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,13 +10,13 @@ import type { ToolInvocation, ToolMcpConfirmationDetails, ToolResult, + PolicyUpdateOptions, } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind, ToolConfirmationOutcome, - type PolicyUpdateOptions, } from './tools.js'; import type { CallableTool, FunctionCall, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 5fc3ca7f25..907d117439 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -42,7 +42,7 @@ vi.mock('crypto'); vi.mock('../utils/summarizer.js'); import { initializeShellParsers } from '../utils/shell-utils.js'; -import { ShellTool } from './shell.js'; +import { ShellTool, OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { debugLogger } from '../index.js'; import { type Config } from '../config/config.js'; import { @@ -58,7 +58,6 @@ import * as crypto from 'node:crypto'; import * as summarizer from '../utils/summarizer.js'; import { ToolErrorType } from './tool-error.js'; import { ToolConfirmationOutcome } from './tools.js'; -import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js'; import { SHELL_TOOL_NAME } from './tool-names.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 76db302f42..741272f555 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -16,13 +16,13 @@ import type { ToolResult, ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, + PolicyUpdateOptions, } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, ToolConfirmationOutcome, Kind, - type PolicyUpdateOptions, } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 9b6f832971..3521ad935b 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -8,13 +8,9 @@ import type { ToolCallConfirmationDetails, ToolInvocation, ToolResult, + ToolConfirmationOutcome, } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolConfirmationOutcome, -} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { ToolErrorType } from './tool-error.js'; import { getErrorMessage } from '../utils/errors.js'; diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index afdb3587ac..3ad5838c95 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -20,13 +20,9 @@ import type { ToolInvocation, ToolLocation, ToolResult, + ToolConfirmationOutcome, } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type ToolConfirmationOutcome, -} from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; import { getErrorMessage, isNodeError } from '../utils/errors.js'; diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index 38aef4f309..5eb42c73f4 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -4,14 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ToolInvocation } from './tools.js'; -import { - BaseDeclarativeTool, - BaseToolInvocation, - Kind, - type Todo, - type ToolResult, -} from './tools.js'; +import type { ToolInvocation, Todo, ToolResult } from './tools.js'; +import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { WRITE_TODOS_TOOL_NAME } from './tool-names.js'; import { WRITE_TODOS_DEFINITION } from './definitions/coreTools.js'; diff --git a/packages/core/src/utils/editCorrector.test.ts b/packages/core/src/utils/editCorrector.test.ts index 86e7c61d0f..35b126a5ea 100644 --- a/packages/core/src/utils/editCorrector.test.ts +++ b/packages/core/src/utils/editCorrector.test.ts @@ -5,8 +5,8 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { Mock } from 'vitest'; -import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; +import type { Mock, Mocked } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; import * as fs from 'node:fs'; import { EDIT_TOOL_NAME } from '../tools/tool-names.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; diff --git a/packages/core/src/utils/getFolderStructure.test.ts b/packages/core/src/utils/getFolderStructure.test.ts index e6c0c88cdc..5a9a077e91 100644 --- a/packages/core/src/utils/getFolderStructure.test.ts +++ b/packages/core/src/utils/getFolderStructure.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fsPromises from 'node:fs/promises'; -import * as nodePath from 'node:path'; import * as os from 'node:os'; import { getFolderStructure } from './getFolderStructure.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; @@ -251,7 +250,7 @@ ${testRootDir}${path.sep} it('should ignore files and folders specified in .gitignore', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.gitignore'), + path.join(testRootDir, '.gitignore'), 'ignored.txt\nnode_modules/\n.gemini/*\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -274,7 +273,7 @@ ${testRootDir}${path.sep} it('should not ignore files if respectGitIgnore is false', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, '.gitignore'), + path.join(testRootDir, '.gitignore'), 'ignored.txt', ); await createTestFile('file1.txt'); @@ -298,7 +297,7 @@ ${testRootDir}${path.sep} describe('with geminiignore', () => { it('should ignore geminiignore files by default', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); @@ -318,7 +317,7 @@ ${testRootDir}${path.sep} it('should not ignore files if respectGeminiIgnore is false', async () => { await fsPromises.writeFile( - nodePath.join(testRootDir, GEMINI_IGNORE_FILE_NAME), + path.join(testRootDir, GEMINI_IGNORE_FILE_NAME), 'ignored.txt\nnode_modules/\n.gemini/\n!/.gemini/config.yaml', ); await createTestFile('file1.txt'); diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 32cf8cabc4..3df110d678 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -22,7 +22,7 @@ import { } from '../tools/memoryTool.js'; import { flattenMemory } from '../config/memory.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; -import { GEMINI_DIR, normalizePath } from './paths.js'; +import { GEMINI_DIR, normalizePath, homedir as pathsHomedir } from './paths.js'; import type { HierarchicalMemory } from '../config/memory.js'; function flattenResult(result: { @@ -62,8 +62,6 @@ vi.mock('../utils/paths.js', async (importOriginal) => { }; }); -import { homedir as pathsHomedir } from './paths.js'; - describe('memoryDiscovery', () => { const DEFAULT_FOLDER_TRUST = true; let testRootDir: string; From a7d851146a5348163e4e6fa3248b3f86d6a4e8b3 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Sat, 21 Feb 2026 13:55:11 -0800 Subject: [PATCH 51/52] feat(core): remove unnecessary login verbiage from Code Assist auth (#19861) --- packages/core/src/code_assist/oauth2.ts | 5 ++--- packages/core/src/utils/authConsent.test.ts | 19 +++++++++++++++++++ packages/core/src/utils/authConsent.ts | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index bfa50599c7..7ee3fbe02e 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -271,7 +271,7 @@ async function initOauthClient( await triggerPostAuthCallbacks(client.credentials); } else { - const userConsent = await getConsentForOauth('Code Assist login required.'); + const userConsent = await getConsentForOauth(''); if (!userConsent) { throw new FatalCancellationError('Authentication cancelled by user.'); } @@ -281,8 +281,7 @@ async function initOauthClient( coreEvents.emit(CoreEvent.UserFeedback, { severity: 'info', message: - `\n\nCode Assist login required.\n` + - `Attempting to open authentication page in your browser.\n` + + `\n\nAttempting to open authentication page in your browser.\n` + `Otherwise navigate to:\n\n${webLogin.authUrl}\n\n\n`, }); try { diff --git a/packages/core/src/utils/authConsent.test.ts b/packages/core/src/utils/authConsent.test.ts index c46df2d250..7fc05b2a03 100644 --- a/packages/core/src/utils/authConsent.test.ts +++ b/packages/core/src/utils/authConsent.test.ts @@ -56,6 +56,25 @@ describe('getConsentForOauth', () => { ); }); + it('should handle empty prompt correctly', async () => { + const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); + + mockEmitConsentRequest.mockImplementation((payload) => { + payload.onConfirm(true); + }); + + await getConsentForOauth(''); + + expect(mockEmitConsentRequest).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringMatching( + /^Opening authentication page in your browser\./, + ), + }), + ); + }); + it('should return false when user declines via UI', async () => { const mockEmitConsentRequest = vi.spyOn(coreEvents, 'emitConsentRequest'); vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(1); diff --git a/packages/core/src/utils/authConsent.ts b/packages/core/src/utils/authConsent.ts index ef8b52b02e..589c922f57 100644 --- a/packages/core/src/utils/authConsent.ts +++ b/packages/core/src/utils/authConsent.ts @@ -15,7 +15,9 @@ import { isHeadlessMode } from './headless.js'; * Handles both interactive and non-interactive (headless) modes. */ export async function getConsentForOauth(prompt: string): Promise { - const finalPrompt = prompt + ' Opening authentication page in your browser. '; + const finalPrompt = + (prompt ? prompt + ' ' : '') + + 'Opening authentication page in your browser. '; if (isHeadlessMode()) { return getOauthConsentNonInteractive(finalPrompt); From 84666e1bbc35276eff31d8b83e9608f14ac8958a Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:32:57 -0500 Subject: [PATCH 52/52] fix(plan): time share by approval mode dashboard reporting negative time shares (#19847) --- packages/core/src/config/config.test.ts | 84 ++++++++++++++++++++++++- packages/core/src/config/config.ts | 18 +++--- 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index d4a9197124..bb4123d0db 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -34,7 +34,10 @@ import { ShellTool } from '../tools/shell.js'; import { ReadFileTool } from '../tools/read-file.js'; import { GrepTool } from '../tools/grep.js'; import { RipGrepTool, canUseRipgrep } from '../tools/ripGrep.js'; -import { logRipgrepFallback } from '../telemetry/loggers.js'; +import { + logRipgrepFallback, + logApprovalModeDuration, +} from '../telemetry/loggers.js'; import { RipgrepFallbackEvent } from '../telemetry/types.js'; import { ToolRegistry } from '../tools/tool-registry.js'; import { ACTIVATE_SKILL_TOOL_NAME } from '../tools/tool-names.js'; @@ -131,6 +134,7 @@ vi.mock('../telemetry/loggers.js', async (importOriginal) => { return { ...actual, logRipgrepFallback: vi.fn(), + logApprovalModeDuration: vi.fn(), }; }); @@ -1419,6 +1423,84 @@ describe('setApprovalMode with folder trust', () => { expect(updateSpy).not.toHaveBeenCalled(); }); + describe('approval mode duration logging', () => { + beforeEach(() => { + vi.mocked(logApprovalModeDuration).mockClear(); + }); + + it('should initialize lastModeSwitchTime with performance.now() and log positive duration', () => { + const startTime = 1000; + const endTime = 5000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(startTime); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(endTime); + config.setApprovalMode(ApprovalMode.PLAN); + + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.DEFAULT, + duration_ms: endTime - startTime, + }), + ); + performanceSpy.mockRestore(); + }); + + it('should skip logging if duration is zero or negative', () => { + const startTime = 5000; + const endTime = 4000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(startTime); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(endTime); + config.setApprovalMode(ApprovalMode.PLAN); + + expect(logApprovalModeDuration).not.toHaveBeenCalled(); + performanceSpy.mockRestore(); + }); + + it('should update lastModeSwitchTime after logging to prevent double counting', () => { + const time1 = 1000; + const time2 = 3000; + const time3 = 6000; + const performanceSpy = vi.spyOn(performance, 'now'); + + performanceSpy.mockReturnValueOnce(time1); + const config = new Config(baseParams); + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + + performanceSpy.mockReturnValueOnce(time2); + config.setApprovalMode(ApprovalMode.PLAN); + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.DEFAULT, + duration_ms: time2 - time1, + }), + ); + + vi.mocked(logApprovalModeDuration).mockClear(); + + performanceSpy.mockReturnValueOnce(time3); + config.setApprovalMode(ApprovalMode.YOLO); + expect(logApprovalModeDuration).toHaveBeenCalledWith( + config, + expect.objectContaining({ + mode: ApprovalMode.PLAN, + duration_ms: time3 - time2, + }), + ); + performanceSpy.mockRestore(); + }); + }); + describe('registerCoreTools', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2b80ea13f0..4836e77fc8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -699,7 +699,7 @@ export class Config { private terminalBackground: string | undefined = undefined; private remoteAdminSettings: AdminControlsSettings | undefined; private latestApiRequest: GenerateContentParameters | undefined; - private lastModeSwitchTime: number = Date.now(); + private lastModeSwitchTime: number = performance.now(); readonly userHintService: UserHintService; private approvedPlanPath: string | undefined; @@ -1798,12 +1798,11 @@ export class Config { const currentMode = this.getApprovalMode(); if (currentMode !== mode) { - this.logCurrentModeDuration(this.getApprovalMode()); + this.logCurrentModeDuration(currentMode); logApprovalModeSwitch( this, new ApprovalModeSwitchEvent(currentMode, mode), ); - this.lastModeSwitchTime = Date.now(); } this.policyEngine.setApprovalMode(mode); @@ -1866,12 +1865,15 @@ export class Config { * Logs the duration of the current approval mode. */ logCurrentModeDuration(mode: ApprovalMode): void { - const now = Date.now(); + const now = performance.now(); const duration = now - this.lastModeSwitchTime; - logApprovalModeDuration( - this, - new ApprovalModeDurationEvent(mode, duration), - ); + if (duration > 0) { + logApprovalModeDuration( + this, + new ApprovalModeDurationEvent(mode, duration), + ); + } + this.lastModeSwitchTime = now; } isYoloModeDisabled(): boolean {