Merge branch 'main' into fix/windows-preflight-resilience

This commit is contained in:
matt korwel
2026-03-11 14:09:54 -07:00
committed by GitHub
47 changed files with 373 additions and 139 deletions
+4 -2
View File
@@ -6,8 +6,10 @@
import type { IdeInfo } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './components/shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './components/shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './components/shared/RadioButtonSelect.js';
import { useKeypress } from './hooks/useKeypress.js';
import { theme } from './semantic-colors.js';
+4 -4
View File
@@ -9,11 +9,11 @@ import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
import type {
LoadableSettingScope,
LoadedSettings,
import {
SettingScope,
type LoadableSettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
AuthType,
clearCachedCredentialFile,
@@ -8,11 +8,11 @@ import type React from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type {
LoadableSettingScope,
LoadedSettings,
import {
SettingScope,
type LoadableSettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import type { AgentDefinition, AgentOverride } from '@google/gemini-cli-core';
import { getCachedStringWidth } from '../utils/textUtils.js';
import {
@@ -15,13 +15,12 @@ import {
} from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { Question } from '@google/gemini-cli-core';
import { checkExhaustive, type Question } from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
import { formatCommand } from '../key/keybindingUtils.js';
import {
+1 -1
View File
@@ -5,9 +5,9 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useMemo } from 'react';
import { ChecklistItem, type ChecklistItemData } from './ChecklistItem.js';
export interface ChecklistProps {
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useCallback } from 'react';
import type React from 'react';
import { useRef, useCallback } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
@@ -7,8 +7,7 @@
import { render } from '../../test-utils/render.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingScope } from '../../config/settings.js';
import type { LoadedSettings } from '../../config/settings.js';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { waitFor } from '../../test-utils/async.js';
@@ -13,18 +13,18 @@ import {
type EditorDisplay,
} from '../editors/editorSettingsManager.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import type {
LoadableSettingScope,
LoadedSettings,
import {
SettingScope,
type LoadableSettingScope,
type LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
type EditorType,
isEditorAvailable,
EDITOR_DISPLAY_NAMES,
coreEvents,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { coreEvents } from '@google/gemini-cli-core';
interface EditorDialogProps {
onSelect: (
@@ -9,8 +9,10 @@ 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 {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { Scrollable } from './shared/Scrollable.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -7,7 +7,7 @@
import { describe, it, expect, vi } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { Header } from './Header.js';
+1 -2
View File
@@ -7,8 +7,7 @@
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Help } from './Help.js';
import type { SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import { CommandKind, type SlashCommand } from '../commands/types.js';
const mockCommands: readonly SlashCommand[] = [
{
@@ -6,13 +6,12 @@
import { describe, it, expect, vi } from 'vitest';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { type HistoryItem } from '../types.js';
import { MessageType } from '../types.js';
import { MessageType, type HistoryItem } from '../types.js';
import { SessionStatsProvider } from '../contexts/SessionContext.js';
import {
CoreToolCallStatus,
type Config,
type ToolExecuteConfirmationDetails,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
@@ -8,31 +8,46 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt, tryTogglePasteExpansion } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import {
InputPrompt,
tryTogglePasteExpansion,
type InputPromptProps,
} from './InputPrompt.js';
import {
calculateTransformationsForLine,
calculateTransformedLine,
type TextBuffer,
} from './shared/text-buffer.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, debugLogger } from '@google/gemini-cli-core';
import {
ApprovalMode,
debugLogger,
type Config,
} from '@google/gemini-cli-core';
import * as path from 'node:path';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import { CommandKind } from '../commands/types.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from '../commands/types.js';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { Text } from 'ink';
import type { UseShellHistoryReturn } from '../hooks/useShellHistory.js';
import { useShellHistory } from '../hooks/useShellHistory.js';
import type { UseCommandCompletionReturn } from '../hooks/useCommandCompletion.js';
import {
useShellHistory,
type UseShellHistoryReturn,
} from '../hooks/useShellHistory.js';
import {
useCommandCompletion,
CompletionMode,
type UseCommandCompletionReturn,
} from '../hooks/useCommandCompletion.js';
import type { UseInputHistoryReturn } from '../hooks/useInputHistory.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { UseReverseSearchCompletionReturn } from '../hooks/useReverseSearchCompletion.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import {
useInputHistory,
type UseInputHistoryReturn,
} from '../hooks/useInputHistory.js';
import {
useReverseSearchCompletion,
type UseReverseSearchCompletionReturn,
} from '../hooks/useReverseSearchCompletion.js';
import clipboardy from 'clipboardy';
import * as clipboardUtils from '../utils/clipboardUtils.js';
import { useKittyKeyboardProtocol } from '../hooks/useKittyKeyboardProtocol.js';
@@ -5,8 +5,8 @@
*/
import type React from 'react';
import clipboardy from 'clipboardy';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import clipboardy from 'clipboardy';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@@ -34,13 +34,16 @@ import {
useCommandCompletion,
CompletionMode,
} from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
import {
ApprovalMode,
coreEvents,
debugLogger,
type Config,
} from '@google/gemini-cli-core';
import {
parseInputForHighlighting,
parseSegmentsFromTokens,
@@ -7,8 +7,10 @@
import { Box, Text } from 'ink';
import type React from 'react';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
export enum LogoutChoice {
@@ -5,8 +5,10 @@
*/
import { Box, Text } from 'ink';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -9,8 +9,8 @@ import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { ModelStatsDisplay } from './ModelStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import * as SettingsContext from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { type LoadedSettings } from '../../config/settings.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
@@ -8,14 +8,16 @@ import { Box, Text } from 'ink';
import type React from 'react';
import { useState } from 'react';
import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
import { expandHomeDir } from '../utils/directoryUtils.js';
import * as path from 'node:path';
import { MessageType, type HistoryItem } from '../types.js';
import type { Config } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
export enum MultiFolderTrustChoice {
YES,
@@ -4,8 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
@@ -5,16 +5,18 @@
*/
import { Box, Text } from 'ink';
import { useCallback, useRef } from 'react';
import type React from 'react';
import { useCallback, useRef } from 'react';
import {
PolicyIntegrityManager,
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 {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
@@ -8,8 +8,10 @@ import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import type React from 'react';
import { useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
@@ -8,10 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import type { Config } from '@google/gemini-cli-core';
import { SessionBrowser } from './SessionBrowser.js';
import type { SessionBrowserProps } from './SessionBrowser.js';
import type { SessionInfo } from '../../utils/sessionUtils.js';
import { type Config } from '@google/gemini-cli-core';
import { SessionBrowser, type SessionBrowserProps } from './SessionBrowser.js';
import { type SessionInfo } from '../../utils/sessionUtils.js';
// Collect key handlers registered via useKeypress so tests can
// simulate input without going through the full stdin pipeline.
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SessionSummaryDisplay } from './SessionSummaryDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
getShellConfiguration,
@@ -4,14 +4,17 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import type React from 'react';
import { Text } from 'ink';
import { AsyncFzf } from 'fzf';
import type { Key } from '../hooks/useKeypress.js';
import { type Key } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
import type { LoadableSettingScope, Settings } from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import {
SettingScope,
type LoadableSettingScope,
type Settings,
} from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import {
getDialogSettingKeys,
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback } from 'react';
import type React from 'react';
import { useCallback } from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../key/keyToAnsi.js';
@@ -8,7 +8,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import {
ToolCallDecision,
type RetrieveUserQuotaResponse,
@@ -9,8 +9,10 @@ import { Box, Text, useStdout } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
useSessionStats,
type ModelMetrics,
} from '../contexts/SessionContext.js';
import {
getStatusColor,
TOOL_SUCCESS_RATE_HIGH,
@@ -8,7 +8,7 @@ import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { ToolStatsDisplay } from './ToolStatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
@@ -5,10 +5,12 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import type { CompressionDisplayProps } from './CompressionMessage.js';
import { CompressionMessage } from './CompressionMessage.js';
import {
CompressionMessage,
type CompressionDisplayProps,
} from './CompressionMessage.js';
import { CompressionStatus } from '@google/gemini-cli-core';
import type { CompressionProps } from '../../types.js';
import { type CompressionProps } from '../../types.js';
import { describe, it, expect } from 'vitest';
describe('<CompressionMessage />', () => {
@@ -8,11 +8,9 @@ import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { Box } from 'ink';
import { TodoTray } from './Todo.js';
import type { Todo } from '@google/gemini-cli-core';
import type { UIState } from '../../contexts/UIStateContext.js';
import { UIStateContext } from '../../contexts/UIStateContext.js';
import type { HistoryItem } from '../../types.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { CoreToolCallStatus, type Todo } from '@google/gemini-cli-core';
import { UIStateContext, type UIState } from '../../contexts/UIStateContext.js';
import { type HistoryItem } from '../../types.js';
const createTodoHistoryItem = (todos: Todo[]): HistoryItem =>
({
@@ -5,9 +5,9 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { type TodoList } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
@@ -18,9 +18,11 @@ import {
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import {
sanitizeForDisplay,
@@ -5,8 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolMessage } from './ToolMessage.js';
import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { StreamingState } from '../../types.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
@@ -8,9 +8,10 @@ import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
import {
useSelectionList,
type SelectionListItem,
} from '../../hooks/useSelectionList.js';
export interface RenderItemContext {
isSelected: boolean;
@@ -4,8 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
@@ -6,9 +6,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import type { Text } from 'ink';
import { Box } from 'ink';
import type React from 'react';
import { Box, type Text } from 'ink';
import {
RadioButtonSelect,
type RadioSelectItem,
@@ -6,13 +6,11 @@
import type React from 'react';
import { useCallback } from 'react';
import type { Key } from '../../hooks/useKeypress.js';
import { Text, Box } from 'ink';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import chalk from 'chalk';
import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
import { expandPastePlaceholders } from './text-buffer.js';
import { expandPastePlaceholders, type TextBuffer } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
@@ -7,8 +7,12 @@
import { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import {
debugLogger,
spawnAsync,
LlmRole,
type Config,
} from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
@@ -7,8 +7,12 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import {
debugLogger,
spawnAsync,
LlmRole,
type Config,
} from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
@@ -8,7 +8,6 @@ import type React from 'react';
import { useMemo, useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import type { RegistryExtension } from '../../../config/extensionRegistryClient.js';
import {
SearchableList,
type GenericListItem,
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerConfig } from '@google/gemini-cli-core';
import { MCPServerStatus } from '@google/gemini-cli-core';
import { MCPServerStatus, type MCPServerConfig } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import type React from 'react';
import { MAX_MCP_RESOURCES_TO_SHOW } from '../../constants.js';
@@ -10,6 +10,7 @@ import {
AuthType,
createContentGeneratorConfig,
type ContentGenerator,
validateBaseUrl,
} from './contentGenerator.js';
import { createCodeAssistContentGenerator } from '../code_assist/codeAssist.js';
import { GoogleGenAI } from '@google/genai';
@@ -442,6 +443,116 @@ describe('createContentGenerator', () => {
);
});
it('should pass GOOGLE_GEMINI_BASE_URL as httpOptions.baseUrl for Gemini API', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'https://my-gemini-proxy.example.com');
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://my-gemini-proxy.example.com',
}),
}),
);
});
it('should pass GOOGLE_VERTEX_BASE_URL as httpOptions.baseUrl for Vertex AI', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
vi.stubEnv('GOOGLE_VERTEX_BASE_URL', 'https://my-vertex-proxy.example.com');
await createContentGenerator(
{
apiKey: 'test-api-key',
vertexai: true,
authType: AuthType.USE_VERTEX_AI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: 'https://my-vertex-proxy.example.com',
}),
}),
);
});
it('should not include baseUrl in httpOptions when GOOGLE_GEMINI_BASE_URL is not set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
const mockGenerator = {
models: {},
} as unknown as GoogleGenAI;
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
await createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
);
expect(GoogleGenAI).toHaveBeenCalledWith(
expect.not.objectContaining({
httpOptions: expect.objectContaining({
baseUrl: expect.any(String),
}),
}),
);
});
it('should reject an insecure GOOGLE_GEMINI_BASE_URL for non-local hosts', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
getProxy: vi.fn().mockReturnValue(undefined),
getUsageStatisticsEnabled: () => false,
} as unknown as Config;
vi.stubEnv('GOOGLE_GEMINI_BASE_URL', 'http://evil-proxy.example.com');
await expect(
createContentGenerator(
{
apiKey: 'test-api-key',
authType: AuthType.USE_GEMINI,
},
mockConfig,
),
).rejects.toThrow('Custom base URL must use HTTPS unless it is localhost.');
});
it('should pass apiVersion for Vertex AI when GOOGLE_GENAI_API_VERSION is set', async () => {
const mockConfig = {
getModel: vi.fn().mockReturnValue('gemini-pro'),
@@ -560,3 +671,33 @@ describe('createContentGeneratorConfig', () => {
expect(config.vertexai).toBeUndefined();
});
});
describe('validateBaseUrl', () => {
it('should accept a valid HTTPS URL', () => {
expect(() => validateBaseUrl('https://my-proxy.example.com')).not.toThrow();
});
it('should accept HTTP for localhost', () => {
expect(() => validateBaseUrl('http://localhost:8080')).not.toThrow();
});
it('should accept HTTP for 127.0.0.1', () => {
expect(() => validateBaseUrl('http://127.0.0.1:3000')).not.toThrow();
});
it('should accept HTTP for ::1', () => {
expect(() => validateBaseUrl('http://[::1]:8080')).not.toThrow();
});
it('should reject HTTP for non-local hosts', () => {
expect(() => validateBaseUrl('http://my-proxy.example.com')).toThrow(
'Custom base URL must use HTTPS unless it is localhost.',
);
});
it('should reject an invalid URL', () => {
expect(() => validateBaseUrl('not-a-url')).toThrow(
'Invalid custom base URL: not-a-url',
);
});
});
+28 -2
View File
@@ -225,13 +225,25 @@ export async function createContentGenerator(
'x-gemini-api-privileged-user-id': `${installationId}`,
};
}
let baseUrl = config.baseUrl;
if (!baseUrl) {
const envBaseUrl = config.vertexai
? process.env['GOOGLE_VERTEX_BASE_URL']
: process.env['GOOGLE_GEMINI_BASE_URL'];
if (envBaseUrl) {
validateBaseUrl(envBaseUrl);
baseUrl = envBaseUrl;
}
} else {
validateBaseUrl(baseUrl);
}
const httpOptions: {
baseUrl?: string;
headers: Record<string, string>;
} = { headers };
if (config.baseUrl) {
httpOptions.baseUrl = config.baseUrl;
if (baseUrl) {
httpOptions.baseUrl = baseUrl;
}
const googleGenAI = new GoogleGenAI({
@@ -253,3 +265,17 @@ export async function createContentGenerator(
return generator;
}
const LOCAL_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
export function validateBaseUrl(baseUrl: string): void {
let url: URL;
try {
url = new URL(baseUrl);
} catch {
throw new Error(`Invalid custom base URL: ${baseUrl}`);
}
if (url.protocol !== 'https:' && !LOCAL_HOSTNAMES.includes(url.hostname)) {
throw new Error('Custom base URL must use HTTPS unless it is localhost.');
}
}
+3 -3
View File
@@ -1380,11 +1380,11 @@ describe('GeminiChat', () => {
}
}).rejects.toThrow(InvalidStreamError);
// Should be called 2 times (initial + 1 retry)
// Should be called 4 times (initial + 3 retries)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
2,
4,
);
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetry).toHaveBeenCalledTimes(3);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should still contain the user message.
+23 -18
View File
@@ -79,17 +79,17 @@ export type StreamEvent =
| { type: StreamEventType.AGENT_EXECUTION_BLOCKED; reason: string };
/**
* Options for retrying due to invalid content from the model.
* Options for retrying mid-stream errors (e.g. invalid content or API disconnects).
*/
interface ContentRetryOptions {
interface MidStreamRetryOptions {
/** Total number of attempts to make (1 initial + N retries). */
maxAttempts: number;
/** The base delay in milliseconds for linear backoff. */
initialDelayMs: number;
}
const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
maxAttempts: 2, // 1 initial call + 1 retry
const MID_STREAM_RETRY_OPTIONS: MidStreamRetryOptions = {
maxAttempts: 4, // 1 initial call + 3 retries mid-stream
initialDelayMs: 500,
};
@@ -350,7 +350,7 @@ export class GeminiChat {
this: GeminiChat,
): AsyncGenerator<StreamEvent, void, void> {
try {
const maxAttempts = INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
const maxAttempts = this.config.getMaxAttempts();
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let isConnectionPhase = true;
@@ -402,21 +402,19 @@ export class GeminiChat {
return; // Stop the generator
}
if (isConnectionPhase) {
// Connection phase errors have already been retried by retryWithBackoff.
// If they bubble up here, they are exhausted or fatal.
throw error;
}
// Check if the error is retryable (e.g., transient SSL errors
// like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC)
// like ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC or ApiError)
const isRetryable = isRetryableError(
error,
this.config.getRetryFetchErrors(),
);
// For connection phase errors, only retryable errors should continue
if (isConnectionPhase) {
if (!isRetryable || signal.aborted) {
throw error;
}
// Fall through to retry logic for retryable connection errors
}
const isContentError = error instanceof InvalidStreamError;
const errorType = isContentError
? error.type
@@ -426,9 +424,16 @@ export class GeminiChat {
(isContentError && isGemini2Model(model)) ||
(isRetryable && !signal.aborted)
) {
// Check if we have more attempts left.
if (attempt < maxAttempts - 1) {
const delayMs = INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs;
// The issue requests exactly 3 retries (4 attempts) for API errors during stream iteration.
// Regardless of the global maxAttempts (e.g. 10), we only want to retry these mid-stream API errors
// up to 3 times before finally throwing the error to the user.
const maxMidStreamAttempts = MID_STREAM_RETRY_OPTIONS.maxAttempts;
if (
attempt < maxAttempts - 1 &&
attempt < maxMidStreamAttempts - 1
) {
const delayMs = MID_STREAM_RETRY_OPTIONS.initialDelayMs;
if (isContentError) {
logContentRetry(
@@ -449,7 +454,7 @@ export class GeminiChat {
}
coreEvents.emitRetryAttempt({
attempt: attempt + 1,
maxAttempts,
maxAttempts: Math.min(maxAttempts, maxMidStreamAttempts),
delayMs: delayMs * (attempt + 1),
error: errorType,
model,
@@ -292,6 +292,14 @@ describe('GeminiChat Network Retries', () => {
(sslError as NodeJS.ErrnoException).code =
'ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC';
// Instead of outer loop, connection retries are handled by retryWithBackoff.
// Simulate retryWithBackoff attempting it twice: first throws, second succeeds.
mockRetryWithBackoff.mockImplementation(
async (apiCall) =>
// Execute the apiCall to trigger mockContentGenerator
await apiCall(),
);
vi.mocked(mockContentGenerator.generateContentStream)
// First call: throw SSL error immediately (connection phase)
.mockRejectedValueOnce(sslError)
@@ -309,6 +317,15 @@ describe('GeminiChat Network Retries', () => {
})(),
);
// Because retryWithBackoff is mocked and we just want to test GeminiChat's integration,
// we need to actually execute the real retryWithBackoff logic for this test to see it work.
// So let's restore the real retryWithBackoff for this test.
const { retryWithBackoff } =
await vi.importActual<typeof import('../utils/retry.js')>(
'../utils/retry.js',
);
mockRetryWithBackoff.mockImplementation(retryWithBackoff);
const stream = await chat.sendMessageStream(
{ model: 'test-model' },
'test message',
@@ -322,10 +339,6 @@ describe('GeminiChat Network Retries', () => {
events.push(event);
}
// Should have retried and succeeded
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
@@ -342,6 +355,12 @@ describe('GeminiChat Network Retries', () => {
const connectionError = new Error('read ECONNRESET');
(connectionError as NodeJS.ErrnoException).code = 'ECONNRESET';
const { retryWithBackoff } =
await vi.importActual<typeof import('../utils/retry.js')>(
'../utils/retry.js',
);
mockRetryWithBackoff.mockImplementation(retryWithBackoff);
vi.mocked(mockContentGenerator.generateContentStream)
.mockRejectedValueOnce(connectionError)
.mockImplementationOnce(async () =>
@@ -372,9 +391,6 @@ describe('GeminiChat Network Retries', () => {
events.push(event);
}
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
const successChunk = events.find(
(e) =>
e.type === StreamEventType.CHUNK &&
@@ -382,6 +398,7 @@ describe('GeminiChat Network Retries', () => {
'Success after connection retry',
);
expect(successChunk).toBeDefined();
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(2);
});
it('should NOT retry on non-retryable error during connection phase', async () => {
+1
View File
@@ -116,6 +116,7 @@ export function runSensitiveKeywordLinter() {
console.log('\nRunning sensitive keyword linter...');
const SENSITIVE_PATTERN = /gemini-\d+(\.\d+)?/g;
const ALLOWED_KEYWORDS = new Set([
'gemini-3.1',
'gemini-3',
'gemini-3.0',
'gemini-2.5',