Compare commits

...

12 Commits

Author SHA1 Message Date
gemini-cli-robot 443148ea1e chore(release): v0.3.3 2025-09-06 01:12:31 +00:00
Sandy Tao ddd4659d10 Fix(core): Fix stream validation logic (#7832) 2025-09-05 18:01:04 -07:00
Victor May a2a3c66e28 Handle cleaning up the response text in the UI when a response stream retry occurs (#7416) 2025-09-05 18:00:12 -07:00
Victor May dfa9dda1dd Refine stream validation to prevent unnecessary retries (#7278) 2025-09-05 17:32:29 -07:00
gemini-cli-robot 4c9746561c chore(release): v0.3.2 2025-09-04 23:55:40 +00:00
Shreya Keshive ad3bc17e45 fix(process-utils): fix bug that prevented start-up when running process walking command fails (#7757) 2025-09-04 16:25:09 -07:00
gemini-cli-robot 9e574773c9 chore(release): v0.3.1 2025-09-04 19:50:25 +00:00
Shreya Keshive 2fb14ead1f Hotfix for issue #7730 (#7739)
Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
Co-authored-by: Arya Gummadi <aryagummadi@google.com>
2025-09-04 12:46:02 -07:00
gemini-cli-robot 76553622f6 chore(release): v0.3.0 2025-09-04 17:49:18 +00:00
matt korwel 7404949eff Patch 0.3.0 preview.4 (#7713)
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
Co-authored-by: christine betts <chrstn@uw.edu>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: anthony bushong <agmsb@users.noreply.github.com>
2025-09-04 17:00:46 +00:00
Gal Zahavi 52cc0f6feb Fix setting migration nosiness and merging (#7571) 2025-09-02 01:23:35 +00:00
gemini-cli-robot 0c0309abdc chore(release): v0.3.0-preview.1 2025-08-28 04:33:56 +00:00
35 changed files with 979 additions and 243 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js';
describe('read_many_files', () => {
it('should be able to read multiple files', async () => {
it.skip('should be able to read multiple files', async () => {
const rig = new TestRig();
await rig.setup('should be able to read multiple files');
rig.createFile('file1.txt', 'file 1 content');
+14 -12
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.2.1",
"version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.2.1",
"version": "0.3.3",
"workspaces": [
"packages/*"
],
@@ -8085,9 +8085,9 @@
}
},
"node_modules/ink": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.2.tgz",
"integrity": "sha512-LN1f+/D8KKqMqRux08fIfA9wsEAJ9Bu9CiI3L6ih7bnqNSDUXT/JVJ0rUIc4NkjPiPaeI3BVNREcLYLz9ePSEg==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz",
"integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.0",
@@ -8104,7 +8104,6 @@
"is-in-ci": "^2.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.32.0",
"scheduler": "^0.26.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^7.1.0",
"stack-utils": "^2.0.6",
@@ -14236,7 +14235,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.1.0",
"version": "0.3.3",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -14507,7 +14506,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.2.1",
"version": "0.3.3",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.13.0",
@@ -14517,9 +14516,10 @@
"command-exists": "^1.2.9",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fzf": "^0.5.2",
"glob": "^10.4.1",
"highlight.js": "^11.11.1",
"ink": "^6.1.1",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"lodash-es": "^4.17.21",
@@ -14529,6 +14529,7 @@
"react": "^19.1.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
@@ -14689,9 +14690,10 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.2.1",
"version": "0.3.3",
"dependencies": {
"@google/genai": "1.13.0",
"@lvce-editor/ripgrep": "^1.6.0",
"@modelcontextprotocol/sdk": "^1.11.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
@@ -14810,7 +14812,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.2.1",
"version": "0.3.3",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -14821,7 +14823,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.2.1",
"version": "0.3.3",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.2.1",
"version": "0.3.3",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.2.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.3"
},
"scripts": {
"start": "node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.1.0",
"version": "0.3.3",
"private": true,
"description": "Gemini CLI A2A Server",
"repository": {
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.2.1",
"version": "0.3.3",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.2.1"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.3.3"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -36,9 +36,10 @@
"command-exists": "^1.2.9",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"fzf": "^0.5.2",
"glob": "^10.4.1",
"highlight.js": "^11.11.1",
"ink": "^6.1.1",
"ink": "^6.2.3",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"lodash-es": "^4.17.21",
+1 -1
View File
@@ -83,7 +83,7 @@ vi.mock('@google/gemini-cli-core', async () => {
return {
...actualServer,
IdeClient: {
getInstance: vi.fn().mockReturnValue({
getInstance: vi.fn().mockResolvedValue({
getConnectionStatus: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
+3 -3
View File
@@ -482,10 +482,10 @@ export async function loadCliConfig(
}
const sandboxConfig = await loadSandboxConfig(settings, argv);
// The screen reader argument takes precedence over the accessibility setting.
const screenReader =
argv.screenReader ?? settings.ui?.accessibility?.screenReader ?? false;
argv.screenReader !== undefined
? argv.screenReader
: (settings.ui?.accessibility?.screenReader ?? false);
return new Config({
sessionId,
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
+54 -9
View File
@@ -142,6 +142,11 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
telemetry: {},
tools: {},
ide: {},
});
expect(settings.errors.length).toBe(0);
});
@@ -197,6 +202,13 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
telemetry: {},
tools: {
sandbox: false,
},
ide: {},
});
});
@@ -253,6 +265,11 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
telemetry: {},
tools: {},
ide: {},
});
});
@@ -308,6 +325,10 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
telemetry: {},
ide: {},
});
});
@@ -374,6 +395,10 @@ describe('Settings Loading and Merging', () => {
chatCompression: {},
},
security: {},
general: {},
privacy: {},
telemetry: {},
ide: {},
});
});
@@ -439,6 +464,7 @@ describe('Settings Loading and Merging', () => {
},
tools: {
sandbox: false,
core: ['tool1'],
},
telemetry: { enabled: false },
context: {
@@ -460,6 +486,9 @@ describe('Settings Loading and Merging', () => {
chatCompression: {},
},
security: {},
general: {},
privacy: {},
ide: {},
});
});
@@ -538,6 +567,10 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
privacy: {},
telemetry: {},
tools: {},
ide: {},
});
});
@@ -668,7 +701,7 @@ describe('Settings Loading and Merging', () => {
chatCompression: {},
},
security: {},
telemetry: false,
telemetry: {},
tools: {
sandbox: false,
},
@@ -676,6 +709,9 @@ describe('Settings Loading and Merging', () => {
customThemes: {},
theme: 'system-theme',
},
general: {},
privacy: {},
ide: {},
});
});
@@ -901,7 +937,7 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
);
const userSettingsContent = { telemetry: true };
const userSettingsContent = { telemetry: { enabled: true } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
@@ -910,14 +946,14 @@ describe('Settings Loading and Merging', () => {
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(true);
expect(settings.merged.telemetry?.enabled).toBe(true);
});
it('should load telemetry setting from workspace settings', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
);
const workspaceSettingsContent = { telemetry: false };
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
@@ -926,13 +962,13 @@ describe('Settings Loading and Merging', () => {
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(false);
expect(settings.merged.telemetry?.enabled).toBe(false);
});
it('should prioritize workspace telemetry setting over user setting', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettingsContent = { telemetry: true };
const workspaceSettingsContent = { telemetry: false };
const userSettingsContent = { telemetry: { enabled: true } };
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
@@ -943,14 +979,14 @@ describe('Settings Loading and Merging', () => {
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBe(false);
expect(settings.merged.telemetry?.enabled).toBe(false);
});
it('should have telemetry as undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockReturnValue(false); // No settings files exist
(fs.readFileSync as Mock).mockReturnValue('{}');
const settings = loadSettings(MOCK_WORKSPACE_DIR);
expect(settings.merged.telemetry).toBeUndefined();
expect(settings.merged.telemetry).toEqual({});
expect(settings.merged.ui?.customThemes).toEqual({});
expect(settings.merged.mcpServers).toEqual({});
});
@@ -1400,6 +1436,11 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
tools: {},
telemetry: {},
ide: {},
});
// Check that error objects are populated in settings.errors
@@ -1828,6 +1869,10 @@ describe('Settings Loading and Merging', () => {
workspacesWithMigrationNudge: [],
},
security: {},
general: {},
privacy: {},
telemetry: {},
ide: {},
});
});
});
+31 -8
View File
@@ -30,7 +30,6 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
const MIGRATE_V2_OVERWRITE = false;
// As defined in spec.md
const MIGRATION_MAP: Record<string, string> = {
preferredEditor: 'general.preferredEditor',
vimMode: 'general.vimMode',
@@ -63,6 +62,7 @@ const MIGRATION_MAP: Record<string, string> = {
fileFiltering: 'context.fileFiltering',
sandbox: 'tools.sandbox',
shouldUseNodePtyShell: 'tools.usePty',
allowedTools: 'tools.allowed',
coreTools: 'tools.core',
excludeTools: 'tools.exclude',
toolDiscoveryCommand: 'tools.discoveryCommand',
@@ -299,6 +299,12 @@ function mergeSettings(
...user,
...safeWorkspaceWithoutFolderTrust,
...system,
general: {
...(systemDefaults.general || {}),
...(user.general || {}),
...(safeWorkspaceWithoutFolderTrust.general || {}),
...(system.general || {}),
},
ui: {
...(systemDefaults.ui || {}),
...(user.ui || {}),
@@ -311,6 +317,24 @@ function mergeSettings(
...(system.ui?.customThemes || {}),
},
},
ide: {
...(systemDefaults.ide || {}),
...(user.ide || {}),
...(safeWorkspaceWithoutFolderTrust.ide || {}),
...(system.ide || {}),
},
privacy: {
...(systemDefaults.privacy || {}),
...(user.privacy || {}),
...(safeWorkspaceWithoutFolderTrust.privacy || {}),
...(system.privacy || {}),
},
telemetry: {
...(systemDefaults.telemetry || {}),
...(user.telemetry || {}),
...(safeWorkspaceWithoutFolderTrust.telemetry || {}),
...(system.telemetry || {}),
},
security: {
...(systemDefaults.security || {}),
...(user.security || {}),
@@ -329,6 +353,12 @@ function mergeSettings(
...(safeWorkspaceWithoutFolderTrust.mcpServers || {}),
...(system.mcpServers || {}),
},
tools: {
...(systemDefaults.tools || {}),
...(user.tools || {}),
...(safeWorkspaceWithoutFolderTrust.tools || {}),
...(system.tools || {}),
},
context: {
...(systemDefaults.context || {}),
...(user.context || {}),
@@ -667,7 +697,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
let settingsObject = rawSettings as Record<string, unknown>;
if (needsMigration(settingsObject)) {
console.error(`Legacy settings file detected at: ${filePath}`);
const migratedSettings = migrateSettingsToV2(settingsObject);
if (migratedSettings) {
if (MIGRATE_V2_OVERWRITE) {
@@ -678,9 +707,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
JSON.stringify(migratedSettings, null, 2),
'utf-8',
);
console.log(
`Successfully migrated and saved settings file: ${filePath}`,
);
} catch (e) {
console.error(
`Error migrating settings file on disk: ${getErrorMessage(
@@ -689,9 +715,6 @@ export function loadSettings(workspaceDir: string): LoadedSettings {
);
}
} else {
console.log(
`Successfully migrated settings for ${filePath} in-memory for the current session.`,
);
migratedInMemorScopes.add(scope);
}
settingsObject = migratedSettings;
+1 -1
View File
@@ -242,7 +242,7 @@ export const SETTINGS_SCHEMA = {
label: 'Screen Reader Mode',
category: 'UI',
requiresRestart: true,
default: false,
default: undefined as boolean | undefined,
description:
'Render output in plain-text to be more screen reader accessible',
showInDialog: true,
@@ -5,11 +5,15 @@
*/
import type React from 'react';
import { Text } from 'ink';
import { Text, useIsScreenReaderEnabled } from 'ink';
import Spinner from 'ink-spinner';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
interface GeminiRespondingSpinnerProps {
/**
@@ -24,11 +28,19 @@ export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (streamingState === StreamingState.Responding) {
return <Spinner type={spinnerType} />;
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_RESPONDING}</Text>
) : (
<Spinner type={spinnerType} />
);
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text>{nonRespondingDisplay}</Text>
);
}
return null;
};
@@ -29,7 +29,7 @@ import {
cleanupOldClipboardImages,
} from '../utils/clipboardUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../constants.js';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
export interface InputPromptProps {
buffer: TextBuffer;
@@ -9,7 +9,7 @@ import { Box, Text } from 'ink';
import type { CompressionProps } from '../../types.js';
import Spinner from 'ink-spinner';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
export interface CompressionDisplayProps {
compression: CompressionProps;
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Colors } from '../../colors.js';
import crypto from 'node:crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
@@ -107,6 +107,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
terminalWidth,
theme,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
return <Text color={Colors.AccentYellow}>No diff content.</Text>;
}
@@ -120,6 +121,17 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
</Box>
);
}
if (screenReaderEnabled) {
return (
<Box flexDirection="column">
{parsedLines.map((line, index) => (
<Text key={index}>
{line.type}: {line.content}
</Text>
))}
</Box>
);
}
// Check if the diff represents a new file (only additions and header lines)
const isNewFile = parsedLines.every(
@@ -8,7 +8,7 @@ import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { Colors } from '../../colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../constants.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
@@ -129,18 +129,22 @@ const ToolStatusIndicator: React.FC<ToolStatusIndicatorProps> = ({
/>
)}
{status === ToolCallStatus.Success && (
<Text color={Colors.AccentGreen}>{TOOL_STATUS.SUCCESS}</Text>
<Text color={Colors.AccentGreen} aria-label={'Success:'}>
{TOOL_STATUS.SUCCESS}
</Text>
)}
{status === ToolCallStatus.Confirming && (
<Text color={Colors.AccentYellow}>{TOOL_STATUS.CONFIRMING}</Text>
<Text color={Colors.AccentYellow} aria-label={'Confirming:'}>
{TOOL_STATUS.CONFIRMING}
</Text>
)}
{status === ToolCallStatus.Canceled && (
<Text color={Colors.AccentYellow} bold>
<Text color={Colors.AccentYellow} aria-label={'Canceled:'} bold>
{TOOL_STATUS.CANCELED}
</Text>
)}
{status === ToolCallStatus.Error && (
<Text color={Colors.AccentRed} bold>
<Text color={Colors.AccentRed} aria-label={'Error:'} bold>
{TOOL_STATUS.ERROR}
</Text>
)}
@@ -7,7 +7,7 @@
import type React from 'react';
import { Text, Box } from 'ink';
import { Colors } from '../../colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../constants.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
@@ -65,7 +65,6 @@ export function RadioButtonSelect<T>({
const [scrollOffset, setScrollOffset] = useState(0);
const [numberInput, setNumberInput] = useState('');
const numberInputTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const newScrollOffset = Math.max(
0,
@@ -195,7 +194,10 @@ export function RadioButtonSelect<T>({
return (
<Box key={item.label} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? Colors.AccentGreen : Colors.Foreground}>
<Text
color={isSelected ? Colors.AccentGreen : Colors.Foreground}
aria-hidden
>
{isSelected ? '●' : ' '}
</Text>
</Box>
@@ -203,6 +205,7 @@ export function RadioButtonSelect<T>({
marginRight={1}
flexShrink={0}
minWidth={itemNumberText.length}
aria-state={{ checked: isSelected }}
>
<Text color={numberColor}>{itemNumberText}</Text>
</Box>
-4
View File
@@ -16,10 +16,6 @@ export const STREAM_DEBOUNCE_MS = 100;
export const SHELL_COMMAND_NAME = 'Shell Command';
export const SCREEN_READER_USER_PREFIX = 'User: ';
export const SCREEN_READER_MODEL_PREFIX = 'Model: ';
// Tool status symbols used in ToolMessage component
export const TOOL_STATUS = {
SUCCESS: '✓',
@@ -616,6 +616,9 @@ export const useGeminiStream = (
// before we add loop detected message to history
loopDetectedRef.current = true;
break;
case ServerGeminiEventType.Retry:
// Will add the missing logic later
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
+13
View File
@@ -0,0 +1,13 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const SCREEN_READER_USER_PREFIX = 'User: ';
export const SCREEN_READER_MODEL_PREFIX = 'Model: ';
export const SCREEN_READER_LOADING = 'loading';
export const SCREEN_READER_RESPONDING = 'responding';
@@ -24,6 +24,7 @@ import {
getErrorStatus,
MCPServerConfig,
DiscoveredMCPTool,
StreamEventType,
} from '@google/gemini-cli-core';
import * as acp from './acp.js';
import { AcpFileSystemService } from './fileSystemService.js';
@@ -269,8 +270,12 @@ class Session {
return { stopReason: 'cancelled' };
}
if (resp.candidates && resp.candidates.length > 0) {
const candidate = resp.candidates[0];
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
continue;
@@ -290,8 +295,8 @@ class Session {
}
}
if (resp.functionCalls) {
functionCalls.push(...resp.functionCalls);
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
}
} catch (error) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.2.1",
"version": "0.3.3",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+10
View File
@@ -100,6 +100,16 @@ vi.mock('../services/gitService.js', () => {
return { GitService: GitServiceMock };
});
vi.mock('../ide/ide-client.js', () => ({
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getConnectionStatus: vi.fn(),
initialize: vi.fn(),
shutdown: vi.fn(),
}),
},
}));
describe('Server Config (config.ts)', () => {
const MODEL = 'gemini-pro';
const SANDBOX: SandboxConfig = {
+337 -13
View File
@@ -12,7 +12,12 @@ import type {
Part,
GenerateContentResponse,
} from '@google/genai';
import { GeminiChat, EmptyStreamError } from './geminiChat.js';
import {
GeminiChat,
EmptyStreamError,
StreamEventType,
type StreamEvent,
} from './geminiChat.js';
import type { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
@@ -73,6 +78,92 @@ describe('GeminiChat', () => {
});
describe('sendMessage', () => {
it('should retain the initial user message when an automatic function call occurs', async () => {
// 1. Define the user's initial text message. This is the turn that gets dropped by the buggy logic.
const userInitialMessage: Content = {
role: 'user',
parts: [{ text: 'How is the weather in Boston?' }],
};
// 2. Mock the full API response, including the automaticFunctionCallingHistory.
// This history represents the full turn: user asks, model calls tool, tool responds, model answers.
const mockAfcResponse = {
candidates: [
{
content: {
role: 'model',
parts: [
{ text: 'The weather in Boston is 72 degrees and sunny.' },
],
},
},
],
automaticFunctionCallingHistory: [
userInitialMessage, // The user's turn
{
// The model's first response: a tool call
role: 'model',
parts: [
{
functionCall: {
name: 'get_weather',
args: { location: 'Boston' },
},
},
],
},
{
// The tool's response, which has a 'user' role
role: 'user',
parts: [
{
functionResponse: {
name: 'get_weather',
response: { temperature: 72, condition: 'sunny' },
},
},
],
},
],
} as unknown as GenerateContentResponse;
vi.mocked(mockModelsModule.generateContent).mockResolvedValue(
mockAfcResponse,
);
// 3. Action: Send the initial message.
await chat.sendMessage(
{ message: 'How is the weather in Boston?' },
'prompt-id-afc-bug',
);
// 4. Assert: Check the final state of the history.
const history = chat.getHistory();
// With the bug, history.length will be 3, because the first user message is dropped.
// The correct behavior is for the history to contain all 4 turns.
expect(history.length).toBe(4);
// Crucially, assert that the very first turn in the history matches the user's initial message.
// This is the assertion that will fail.
const firstTurn = history[0]!;
expect(firstTurn.role).toBe('user');
expect(firstTurn?.parts![0]!.text).toBe('How is the weather in Boston?');
// Verify the rest of the history is also correct.
const secondTurn = history[1]!;
expect(secondTurn.role).toBe('model');
expect(secondTurn?.parts![0]!.functionCall).toBeDefined();
const thirdTurn = history[2]!;
expect(thirdTurn.role).toBe('user');
expect(thirdTurn?.parts![0]!.functionResponse).toBeDefined();
const fourthTurn = history[3]!;
expect(fourthTurn.role).toBe('model');
expect(fourthTurn?.parts![0]!.text).toContain('72 degrees and sunny');
});
it('should throw an error when attempting to add a user turn after another user turn', async () => {
// 1. Setup: Create a history that already ends with a user turn (a functionResponse).
const initialHistory: Content[] = [
@@ -240,6 +331,101 @@ describe('GeminiChat', () => {
});
describe('sendMessageStream', () => {
it('should succeed if a tool call is followed by an empty part', async () => {
// 1. Mock a stream that contains a tool call, then an invalid (empty) part.
const streamWithToolCall = (async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ functionCall: { name: 'test_tool', args: {} } }],
},
},
],
} as unknown as GenerateContentResponse;
// This second chunk is invalid according to isValidResponse
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ text: '' }],
},
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
streamWithToolCall,
);
// 2. Action & Assert: The stream processing should complete without throwing an error
// because the presence of a tool call makes the empty final chunk acceptable.
const stream = await chat.sendMessageStream(
{ message: 'test message' },
'prompt-id-tool-call-empty-end',
);
await expect(
(async () => {
for await (const _ of stream) {
/* consume stream */
}
})(),
).resolves.not.toThrow();
// 3. Verify history was recorded correctly
const history = chat.getHistory();
expect(history.length).toBe(2); // user turn + model turn
const modelTurn = history[1]!;
expect(modelTurn?.parts?.length).toBe(1); // The empty part is discarded
expect(modelTurn?.parts![0]!.functionCall).toBeDefined();
});
it('should fail if the stream ends with an empty part and has no finishReason', async () => {
// 1. Mock a stream that ends with an invalid part and has no finish reason.
const streamWithNoFinish = (async function* () {
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ text: 'Initial content...' }],
},
},
],
} as unknown as GenerateContentResponse;
// This second chunk is invalid and has no finishReason, so it should fail.
yield {
candidates: [
{
content: {
role: 'model',
parts: [{ text: '' }],
},
},
],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockModelsModule.generateContentStream).mockResolvedValue(
streamWithNoFinish,
);
// 2. Action & Assert: The stream should fail because there's no finish reason.
const stream = await chat.sendMessageStream(
{ message: 'test message' },
'prompt-id-no-finish-empty-end',
);
await expect(
(async () => {
for await (const _ of stream) {
/* consume stream */
}
})(),
).rejects.toThrow(EmptyStreamError);
});
it('should not consolidate text into a part that also contains a functionCall', async () => {
// 1. Mock the API to stream a malformed part followed by a valid text part.
const multiChunkStream = (async function* () {
@@ -314,7 +500,10 @@ describe('GeminiChat', () => {
// as the important part is consolidating what comes after.
yield {
candidates: [
{ content: { role: 'model', parts: [{ text: ' World!' }] } },
{
content: { role: 'model', parts: [{ text: ' World!' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
@@ -417,6 +606,7 @@ describe('GeminiChat', () => {
{ text: 'This is the visible text that should not be lost.' },
],
},
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
@@ -477,7 +667,10 @@ describe('GeminiChat', () => {
const emptyStreamResponse = (async function* () {
yield {
candidates: [
{ content: { role: 'model', parts: [{ thought: true }] } },
{
content: { role: 'model', parts: [{ thought: true }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
@@ -732,6 +925,47 @@ describe('GeminiChat', () => {
});
describe('sendMessageStream with retries', () => {
it('should yield a RETRY event when an invalid stream is encountered', async () => {
// ARRANGE: Mock the stream to fail once, then succeed.
vi.mocked(mockModelsModule.generateContentStream)
.mockImplementationOnce(async () =>
// First attempt: An invalid stream with an empty text part.
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '' }] } }],
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
// Second attempt (the retry): A minimal valid stream.
(async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Success' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
// ACT: Send a message and collect all events from the stream.
const stream = await chat.sendMessageStream(
{ message: 'test' },
'prompt-id-yield-retry',
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// ASSERT: Check that a RETRY event was present in the stream's output.
const retryEvent = events.find((e) => e.type === StreamEventType.RETRY);
expect(retryEvent).toBeDefined();
expect(retryEvent?.type).toBe(StreamEventType.RETRY);
});
it('should retry on invalid content, succeed, and report metrics', async () => {
// Use mockImplementationOnce to provide a fresh, promise-wrapped generator for each attempt.
vi.mocked(mockModelsModule.generateContentStream)
@@ -748,7 +982,10 @@ describe('GeminiChat', () => {
(async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Successful response' }] } },
{
content: { parts: [{ text: 'Successful response' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
@@ -758,7 +995,7 @@ describe('GeminiChat', () => {
{ message: 'test' },
'prompt-id-retry-success',
);
const chunks = [];
const chunks: StreamEvent[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
@@ -768,11 +1005,17 @@ describe('GeminiChat', () => {
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetryFailure).not.toHaveBeenCalled();
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
// Check for a retry event
expect(chunks.some((c) => c.type === StreamEventType.RETRY)).toBe(true);
// Check for the successful content chunk
expect(
chunks.some(
(c) =>
c.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response',
c.type === StreamEventType.CHUNK &&
c.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response',
),
).toBe(true);
@@ -853,7 +1096,12 @@ describe('GeminiChat', () => {
// Second attempt succeeds
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Second answer' }] } }],
candidates: [
{
content: { parts: [{ text: 'Second answer' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
@@ -1002,6 +1250,7 @@ describe('GeminiChat', () => {
content: {
parts: [{ text: 'Successful response after empty' }],
},
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
@@ -1013,7 +1262,7 @@ describe('GeminiChat', () => {
{ message: 'test empty stream' },
'prompt-id-empty-stream',
);
const chunks = [];
const chunks: StreamEvent[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
@@ -1023,8 +1272,9 @@ describe('GeminiChat', () => {
expect(
chunks.some(
(c) =>
c.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response after empty',
c.type === StreamEventType.CHUNK &&
c.value.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response after empty',
),
).toBe(true);
@@ -1062,13 +1312,23 @@ describe('GeminiChat', () => {
} as unknown as GenerateContentResponse;
await firstStreamContinuePromise; // Pause the stream
yield {
candidates: [{ content: { parts: [{ text: ' part 2' }] } }],
candidates: [
{
content: { parts: [{ text: ' part 2' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
const secondStreamGenerator = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'second response' }] } }],
candidates: [
{
content: { parts: [{ text: 'second response' }] },
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})();
@@ -1123,4 +1383,68 @@ describe('GeminiChat', () => {
}
expect(turn4.parts[0].text).toBe('second response');
});
it('should discard valid partial content from a failed attempt upon retry', async () => {
// ARRANGE: Mock the stream to fail on the first attempt after yielding some valid content.
vi.mocked(mockModelsModule.generateContentStream)
.mockImplementationOnce(async () =>
// First attempt: yields one valid chunk, then one invalid chunk
(async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'This valid part should be discarded' }],
},
},
],
} as unknown as GenerateContentResponse;
yield {
candidates: [{ content: { parts: [{ text: '' }] } }], // Invalid chunk triggers retry
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
// Second attempt (the retry): succeeds
(async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'Successful final response' }],
},
finishReason: 'STOP',
},
],
} as unknown as GenerateContentResponse;
})(),
);
// ACT: Send a message and consume the stream
const stream = await chat.sendMessageStream(
{ message: 'test' },
'prompt-id-discard-test',
);
const events: StreamEvent[] = [];
for await (const event of stream) {
events.push(event);
}
// ASSERT
// Check that a retry happened
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
expect(events.some((e) => e.type === StreamEventType.RETRY)).toBe(true);
// Check the final recorded history
const history = chat.getHistory();
expect(history.length).toBe(2); // user turn + final model turn
const modelTurn = history[1]!;
// The model turn should only contain the text from the successful attempt
expect(modelTurn!.parts![0]!.text).toBe('Successful final response');
// It should NOT contain any text from the failed attempt
expect(modelTurn!.parts![0]!.text).not.toContain(
'This valid part should be discarded',
);
});
});
+46 -6
View File
@@ -34,6 +34,18 @@ import {
InvalidChunkEvent,
} from '../telemetry/types.js';
export enum StreamEventType {
/** A regular content chunk from the API. */
CHUNK = 'chunk',
/** A signal that a retry is about to happen. The UI should discard any partial
* content from the attempt that just failed. */
RETRY = 'retry',
}
export type StreamEvent =
| { type: StreamEventType.CHUNK; value: GenerateContentResponse }
| { type: StreamEventType.RETRY };
/**
* Options for retrying due to invalid content from the model.
*/
@@ -340,7 +352,7 @@ export class GeminiChat {
async sendMessageStream(
params: SendMessageParameters,
prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
): Promise<AsyncGenerator<StreamEvent>> {
await this.sendPromise;
let streamDoneResolver: () => void;
@@ -367,6 +379,10 @@ export class GeminiChat {
attempt++
) {
try {
if (attempt > 0) {
yield { type: StreamEventType.RETRY };
}
const stream = await self.makeApiCallAndProcessStream(
requestContents,
params,
@@ -375,7 +391,7 @@ export class GeminiChat {
);
for await (const chunk of stream) {
yield chunk;
yield { type: StreamEventType.CHUNK, value: chunk };
}
lastError = null;
@@ -562,29 +578,53 @@ export class GeminiChat {
userInput: Content,
): AsyncGenerator<GenerateContentResponse> {
const modelResponseParts: Part[] = [];
let isStreamInvalid = false;
let hasReceivedAnyChunk = false;
let hasToolCall = false;
let lastChunk: GenerateContentResponse | null = null;
let lastChunkIsInvalid = false;
for await (const chunk of streamResponse) {
hasReceivedAnyChunk = true;
lastChunk = chunk;
if (isValidResponse(chunk)) {
lastChunkIsInvalid = false;
const content = chunk.candidates?.[0]?.content;
if (content?.parts) {
modelResponseParts.push(...content.parts);
if (content.parts.some((part) => part.functionCall)) {
hasToolCall = true;
}
}
} else {
logInvalidChunk(
this.config,
new InvalidChunkEvent('Invalid chunk received from stream.'),
);
isStreamInvalid = true;
lastChunkIsInvalid = true;
}
yield chunk;
}
if (isStreamInvalid || !hasReceivedAnyChunk) {
if (!hasReceivedAnyChunk) {
throw new EmptyStreamError('Model stream completed without any chunks.');
}
const hasFinishReason = lastChunk?.candidates?.some(
(candidate) => candidate.finishReason,
);
// --- FIX: The entire validation block was restructured for clarity and correctness ---
// Stream validation logic: A stream is considered successful if:
// 1. There's a tool call (tool calls can end without explicit finish reasons), OR
// 2. Both conditions are met: last chunk is valid AND any candidate has a finish reason
//
// We throw an error only when there's no tool call AND either:
// - The last chunk is invalid, OR
// - No candidate in the last chunk has a finish reason
if (!hasToolCall && (lastChunkIsInvalid || !hasFinishReason)) {
throw new EmptyStreamError(
'Model stream was invalid or completed without valid content.',
'Model stream ended with an invalid chunk or missing finish reason.',
);
}
+24 -8
View File
@@ -21,7 +21,7 @@ import type {
} from './subagent.js';
import { Config } from '../config/config.js';
import type { ConfigParameters } from '../config/config.js';
import { GeminiChat } from './geminiChat.js';
import { GeminiChat, StreamEventType } from './geminiChat.js';
import { createContentGenerator } from './contentGenerator.js';
import { getEnvironmentContext } from '../utils/environmentContext.js';
import { executeToolCall } from './nonInteractiveToolExecutor.js';
@@ -33,6 +33,7 @@ import type {
FunctionCall,
FunctionDeclaration,
GenerateContentConfig,
GenerateContentResponse,
} from '@google/genai';
import { ToolErrorType } from '../tools/tool-error.js';
@@ -73,18 +74,33 @@ const createMockStream = (
functionCallsList: Array<FunctionCall[] | 'stop'>,
) => {
let index = 0;
return vi.fn().mockImplementation(() => {
// This mock now returns a Promise that resolves to the async generator,
// matching the new signature for sendMessageStream.
return vi.fn().mockImplementation(async () => {
const response = functionCallsList[index] || 'stop';
index++;
return (async function* () {
if (response === 'stop') {
// When stopping, the model might return text, but the subagent logic primarily cares about the absence of functionCalls.
yield { text: 'Done.' };
} else if (response.length > 0) {
yield { functionCalls: response };
let mockResponseValue: Partial<GenerateContentResponse>;
if (response === 'stop' || response.length === 0) {
// Simulate a text response for stop/empty conditions.
mockResponseValue = {
candidates: [{ content: { parts: [{ text: 'Done.' }] } }],
};
} else {
yield { text: 'Done.' }; // Handle empty array also as stop
// Simulate a tool call response.
mockResponseValue = {
candidates: [], // Good practice to include for safety.
functionCalls: response,
};
}
// The stream must now yield a StreamEvent object of type CHUNK.
yield {
type: StreamEventType.CHUNK,
value: mockResponseValue as GenerateContentResponse,
};
})();
});
};
+5 -6
View File
@@ -20,7 +20,7 @@ import type {
FunctionDeclaration,
} from '@google/genai';
import { Type } from '@google/genai';
import { GeminiChat } from './geminiChat.js';
import { GeminiChat, StreamEventType } from './geminiChat.js';
/**
* @fileoverview Defines the configuration interfaces for a subagent.
@@ -439,12 +439,11 @@ export class SubAgentScope {
let textResponse = '';
for await (const resp of responseStream) {
if (abortController.signal.aborted) return;
if (resp.functionCalls) {
functionCalls.push(...resp.functionCalls);
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
}
const text = resp.text;
if (text) {
textResponse += text;
if (resp.type === StreamEventType.CHUNK && resp.value.text) {
textResponse += resp.value.text;
}
}
+171 -110
View File
@@ -13,6 +13,7 @@ import { Turn, GeminiEventType } from './turn.js';
import type { GenerateContentResponse, Part, Content } from '@google/genai';
import { reportError } from '../utils/errorReporting.js';
import type { GeminiChat } from './geminiChat.js';
import { StreamEventType } from './geminiChat.js';
const mockSendMessageStream = vi.fn();
const mockGetHistory = vi.fn();
@@ -35,6 +36,7 @@ vi.mock('../utils/errorReporting', () => ({
reportError: vi.fn(),
}));
// Use the actual implementation from partUtils now that it's provided.
vi.mock('../utils/generateContentResponseUtilities', () => ({
getResponseText: (resp: GenerateContentResponse) =>
resp.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
@@ -78,11 +80,17 @@ describe('Turn', () => {
it('should yield content events for text parts', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'Hello' }] } }],
} as GenerateContentResponse,
};
yield {
candidates: [{ content: { parts: [{ text: ' world' }] } }],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: ' world' }] } }],
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -113,16 +121,23 @@ describe('Turn', () => {
it('should yield tool_call_request events for function calls', async () => {
const mockResponseStream = (async function* () {
yield {
functionCalls: [
{
id: 'fc1',
name: 'tool1',
args: { arg1: 'val1' },
isClientInitiated: false,
},
{ name: 'tool2', args: { arg2: 'val2' }, isClientInitiated: false }, // No ID
],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
functionCalls: [
{
id: 'fc1',
name: 'tool1',
args: { arg1: 'val1' },
isClientInitiated: false,
},
{
name: 'tool2',
args: { arg2: 'val2' },
isClientInitiated: false,
}, // No ID
],
} as unknown as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -168,18 +183,24 @@ describe('Turn', () => {
const abortController = new AbortController();
const mockResponseStream = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'First part' }] } }],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'First part' }] } }],
} as GenerateContentResponse,
};
abortController.abort();
yield {
candidates: [
{
content: {
parts: [{ text: 'Second part - should not be processed' }],
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [{ text: 'Second part - should not be processed' }],
},
},
},
],
} as unknown as GenerateContentResponse;
],
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -230,79 +251,79 @@ describe('Turn', () => {
it('should handle function calls with undefined name or args', async () => {
const mockResponseStream = (async function* () {
yield {
functionCalls: [
{ id: 'fc1', name: undefined, args: { arg1: 'val1' } },
{ id: 'fc2', name: 'tool2', args: undefined },
{ id: 'fc3', name: undefined, args: undefined },
],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [],
functionCalls: [
// Add `id` back to the mock to match what the code expects
{ id: 'fc1', name: undefined, args: { arg1: 'val1' } },
{ id: 'fc2', name: 'tool2', args: undefined },
{ id: 'fc3', name: undefined, args: undefined },
],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
const reqParts: Part[] = [{ text: 'Test undefined tool parts' }];
for await (const event of turn.run(
reqParts,
[{ text: 'Test undefined tool parts' }],
new AbortController().signal,
)) {
events.push(event);
}
expect(events.length).toBe(3);
// Assertions for each specific tool call event
const event1 = events[0] as ServerGeminiToolCallRequestEvent;
expect(event1.type).toBe(GeminiEventType.ToolCallRequest);
expect(event1.value).toEqual(
expect.objectContaining({
callId: 'fc1',
name: 'undefined_tool_name',
args: { arg1: 'val1' },
isClientInitiated: false,
}),
);
expect(turn.pendingToolCalls[0]).toEqual(event1.value);
expect(event1.value).toMatchObject({
callId: 'fc1',
name: 'undefined_tool_name',
args: { arg1: 'val1' },
});
const event2 = events[1] as ServerGeminiToolCallRequestEvent;
expect(event2.type).toBe(GeminiEventType.ToolCallRequest);
expect(event2.value).toEqual(
expect.objectContaining({
callId: 'fc2',
name: 'tool2',
args: {},
isClientInitiated: false,
}),
);
expect(turn.pendingToolCalls[1]).toEqual(event2.value);
expect(event2.value).toMatchObject({
callId: 'fc2',
name: 'tool2',
args: {},
});
const event3 = events[2] as ServerGeminiToolCallRequestEvent;
expect(event3.type).toBe(GeminiEventType.ToolCallRequest);
expect(event3.value).toEqual(
expect.objectContaining({
callId: 'fc3',
name: 'undefined_tool_name',
args: {},
isClientInitiated: false,
}),
);
expect(turn.pendingToolCalls[2]).toEqual(event3.value);
expect(turn.getDebugResponses().length).toBe(1);
expect(event3.value).toMatchObject({
callId: 'fc3',
name: 'undefined_tool_name',
args: {},
});
});
it('should yield finished event when response has finish reason', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Partial response' }] },
finishReason: 'STOP',
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Partial response' }] },
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 17,
candidatesTokenCount: 50,
cachedContentTokenCount: 10,
thoughtsTokenCount: 5,
toolUsePromptTokenCount: 2,
},
],
} as unknown as GenerateContentResponse;
} as GenerateContentResponse,
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
const reqParts: Part[] = [{ text: 'Test finish reason' }];
for await (const event of turn.run(
reqParts,
[{ text: 'Test finish reason' }],
new AbortController().signal,
)) {
events.push(event);
@@ -317,17 +338,20 @@ describe('Turn', () => {
it('should yield finished event for MAX_TOKENS finish reason', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [
{
content: {
parts: [
{ text: 'This is a long response that was cut off...' },
],
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [
{ text: 'This is a long response that was cut off...' },
],
},
finishReason: 'MAX_TOKENS',
},
finishReason: 'MAX_TOKENS',
},
],
} as unknown as GenerateContentResponse;
],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -352,13 +376,16 @@ describe('Turn', () => {
it('should yield finished event for SAFETY finish reason', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Content blocked' }] },
finishReason: 'SAFETY',
},
],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'Content blocked' }] },
finishReason: 'SAFETY',
},
],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -380,13 +407,18 @@ describe('Turn', () => {
it('should not yield finished event when there is no finish reason', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'Response without finish reason' }] },
// No finishReason property
},
],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: {
parts: [{ text: 'Response without finish reason' }],
},
// No finishReason property
},
],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -411,21 +443,27 @@ describe('Turn', () => {
it('should handle multiple responses with different finish reasons', async () => {
const mockResponseStream = (async function* () {
yield {
candidates: [
{
content: { parts: [{ text: 'First part' }] },
// No finish reason on first response
},
],
} as unknown as GenerateContentResponse;
type: StreamEventType.CHUNK,
value: {
candidates: [
{
content: { parts: [{ text: 'First part' }] },
// No finish reason on first response
},
],
},
};
yield {
candidates: [
{
content: { parts: [{ text: 'Second part' }] },
finishReason: 'OTHER',
},
],
} as unknown as GenerateContentResponse;
value: {
type: StreamEventType.CHUNK,
candidates: [
{
content: { parts: [{ text: 'Second part' }] },
finishReason: 'OTHER',
},
],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
@@ -470,6 +508,29 @@ describe('Turn', () => {
expect(reportError).not.toHaveBeenCalled();
});
it('should yield a Retry event when it receives one from the chat stream', async () => {
const mockResponseStream = (async function* () {
yield { type: StreamEventType.RETRY };
yield {
type: StreamEventType.CHUNK,
value: {
candidates: [{ content: { parts: [{ text: 'Success' }] } }],
},
};
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const events = [];
for await (const event of turn.run([], new AbortController().signal)) {
events.push(event);
}
expect(events).toEqual([
{ type: GeminiEventType.Retry },
{ type: GeminiEventType.Content, value: 'Success' },
]);
});
});
describe('getDebugResponses', () => {
@@ -481,8 +542,8 @@ describe('Turn', () => {
functionCalls: [{ name: 'debugTool' }],
} as unknown as GenerateContentResponse;
const mockResponseStream = (async function* () {
yield resp1;
yield resp2;
yield { type: StreamEventType.CHUNK, value: resp1 };
yield { type: StreamEventType.CHUNK, value: resp2 };
})();
mockSendMessageStream.mockResolvedValue(mockResponseStream);
const reqParts: Part[] = [{ text: 'Hi' }];
+23 -3
View File
@@ -54,8 +54,14 @@ export enum GeminiEventType {
MaxSessionTurns = 'max_session_turns',
Finished = 'finished',
LoopDetected = 'loop_detected',
Citation = 'citation',
Retry = 'retry',
}
export type ServerGeminiRetryEvent = {
type: GeminiEventType.Retry;
};
export interface StructuredError {
message: string;
status?: number;
@@ -175,7 +181,8 @@ export type ServerGeminiStreamEvent =
| ServerGeminiThoughtEvent
| ServerGeminiMaxSessionTurnsEvent
| ServerGeminiFinishedEvent
| ServerGeminiLoopDetectedEvent;
| ServerGeminiLoopDetectedEvent
| ServerGeminiRetryEvent;
// A turn manages the agentic loop turn within the server context.
export class Turn {
@@ -197,6 +204,8 @@ export class Turn {
signal: AbortSignal,
): AsyncGenerator<ServerGeminiStreamEvent> {
try {
// Note: This assumes `sendMessageStream` yields events like
// { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse }
const responseStream = await this.chat.sendMessageStream(
{
message: req,
@@ -207,12 +216,22 @@ export class Turn {
this.prompt_id,
);
for await (const resp of responseStream) {
for await (const streamEvent of responseStream) {
if (signal?.aborted) {
yield { type: GeminiEventType.UserCancelled };
// Do not add resp to debugResponses if aborted before processing
return;
}
// Handle the new RETRY event
if (streamEvent.type === 'retry') {
yield { type: GeminiEventType.Retry };
continue; // Skip to the next event in the stream
}
// Assuming other events are chunks with a `value` property
const resp = streamEvent.value as GenerateContentResponse;
if (!resp) continue; // Skip if there's no response body
this.debugResponses.push(resp);
const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
@@ -254,6 +273,7 @@ export class Turn {
// Check if response was truncated or stopped for various reasons
const finishReason = resp.candidates?.[0]?.finishReason;
// This is the key change: Only yield 'Finished' if there is a finishReason.
if (finishReason) {
this.finishReason = finishReason;
yield {
+110 -4
View File
@@ -66,13 +66,34 @@ describe('getIdeProcessInfo', () => {
it('should traverse up and find the great-grandchild of the root process', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[1000, { stdout: 'ParentProcessId=900\r\nCommandLine=node.exe\r\n' }],
[
1000,
{
stdout:
'{"Name":"node.exe","ParentProcessId":900,"CommandLine":"node.exe"}',
},
],
[
900,
{ stdout: 'ParentProcessId=800\r\nCommandLine=powershell.exe\r\n' },
{
stdout:
'{"Name":"powershell.exe","ParentProcessId":800,"CommandLine":"powershell.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"code.exe","ParentProcessId":700,"CommandLine":"code.exe"}',
},
],
[
700,
{
stdout:
'{"Name":"wininit.exe","ParentProcessId":0,"CommandLine":"wininit.exe"}',
},
],
[800, { stdout: 'ParentProcessId=700\r\nCommandLine=code.exe\r\n' }],
[700, { stdout: 'ParentProcessId=0\r\nCommandLine=wininit.exe\r\n' }],
]);
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
@@ -86,5 +107,90 @@ describe('getIdeProcessInfo', () => {
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 900, command: 'powershell.exe' });
});
it('should handle non-existent process gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '' }) // Non-existent PID returns empty due to -ErrorAction SilentlyContinue
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should handle malformed JSON output gracefully', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '{"invalid":json}' }) // Malformed JSON
.mockResolvedValueOnce({
stdout:
'{"Name":"fallback.exe","ParentProcessId":0,"CommandLine":"fallback.exe"}',
}); // Fallback call
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'fallback.exe' });
});
it('should handle PowerShell errors without crashing the process chain', async () => {
(os.platform as Mock).mockReturnValue('win32');
const processInfoMap = new Map([
[1000, { stdout: '' }], // First process doesn't exist (empty due to -ErrorAction)
[
1001,
{
stdout:
'{"Name":"parent.exe","ParentProcessId":800,"CommandLine":"parent.exe"}',
},
],
[
800,
{
stdout:
'{"Name":"ide.exe","ParentProcessId":0,"CommandLine":"ide.exe"}',
},
],
]);
// Mock the process.pid to test traversal with missing processes
Object.defineProperty(process, 'pid', {
value: 1001,
configurable: true,
});
mockedExec.mockImplementation((command: string) => {
const pidMatch = command.match(/ProcessId=(\d+)/);
if (pidMatch) {
const pid = parseInt(pidMatch[1], 10);
return Promise.resolve(processInfoMap.get(pid) || { stdout: '' });
}
return Promise.reject(new Error('Invalid command for mock'));
});
const result = await getIdeProcessInfo();
// Should return the current process command since traversal continues despite missing processes
expect(result).toEqual({ pid: 1001, command: 'parent.exe' });
// Reset process.pid
Object.defineProperty(process, 'pid', {
value: 1000,
configurable: true,
});
});
it('should handle partial JSON data with defaults', async () => {
(os.platform as Mock).mockReturnValue('win32');
mockedExec
.mockResolvedValueOnce({ stdout: '{"Name":"partial.exe"}' }) // Missing ParentProcessId, defaults to 0
.mockResolvedValueOnce({
stdout:
'{"Name":"root.exe","ParentProcessId":0,"CommandLine":"root.exe"}',
}); // Get grandparent info
const result = await getIdeProcessInfo();
expect(result).toEqual({ pid: 1000, command: 'root.exe' });
});
});
});
+38 -25
View File
@@ -24,30 +24,44 @@ async function getProcessInfo(pid: number): Promise<{
name: string;
command: string;
}> {
const platform = os.platform();
if (platform === 'win32') {
const command = `wmic process where "ProcessId=${pid}" get Name,ParentProcessId,CommandLine /value`;
const { stdout } = await execAsync(command);
const nameMatch = stdout.match(/Name=([^\n]*)/);
const processName = nameMatch ? nameMatch[1].trim() : '';
const ppidMatch = stdout.match(/ParentProcessId=(\d+)/);
const parentPid = ppidMatch ? parseInt(ppidMatch[1], 10) : 0;
const commandLineMatch = stdout.match(/CommandLine=([^\n]*)/);
const commandLine = commandLineMatch ? commandLineMatch[1].trim() : '';
return { parentPid, name: processName, command: commandLine };
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
try {
const platform = os.platform();
if (platform === 'win32') {
const powershellCommand = [
'$p = Get-CimInstance Win32_Process',
`-Filter 'ProcessId=${pid}'`,
'-ErrorAction SilentlyContinue;',
'if ($p) {',
'@{Name=$p.Name;ParentProcessId=$p.ParentProcessId;CommandLine=$p.CommandLine}',
'| ConvertTo-Json',
'}',
].join(' ');
const { stdout } = await execAsync(`powershell "${powershellCommand}"`);
const output = stdout.trim();
if (!output) return { parentPid: 0, name: '', command: '' };
const {
Name = '',
ParentProcessId = 0,
CommandLine = '',
} = JSON.parse(output);
return { parentPid: ParentProcessId, name: Name, command: CommandLine };
} else {
const command = `ps -o ppid=,command= -p ${pid}`;
const { stdout } = await execAsync(command);
const trimmedStdout = stdout.trim();
const ppidString = trimmedStdout.split(/\s+/)[0];
const parentPid = parseInt(ppidString, 10);
const fullCommand = trimmedStdout.substring(ppidString.length).trim();
const processName = path.basename(fullCommand.split(' ')[0]);
return {
parentPid: isNaN(parentPid) ? 1 : parentPid,
name: processName,
command: fullCommand,
};
}
} catch (_e) {
console.debug(`Failed to get process info for pid ${pid}:`, _e);
return { parentPid: 0, name: '', command: '' };
}
}
@@ -160,7 +174,6 @@ async function getIdeProcessInfoForWindows(): Promise<{
* top-level ancestor process ID and command as a fallback.
*
* @returns A promise that resolves to the PID and command of the IDE process.
* @throws Will throw an error if the underlying shell commands fail.
*/
export async function getIdeProcessInfo(): Promise<{
pid: number;
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import { createHash } from 'node:crypto';
import type { ServerGeminiStreamEvent } from '../core/turn.js';
import { GeminiEventType } from '../core/turn.js';
@@ -11,6 +12,10 @@ import { logLoopDetected } from '../telemetry/loggers.js';
import { LoopDetectedEvent, LoopType } from '../telemetry/types.js';
import type { Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js';
import {
isFunctionCall,
isFunctionResponse,
} from '../utils/messageInspectors.js';
const TOOL_CALL_LOOP_THRESHOLD = 5;
const CONTENT_LOOP_THRESHOLD = 10;
@@ -328,12 +333,35 @@ export class LoopDetectionService {
return originalChunk === currentChunk;
}
private trimRecentHistory(recentHistory: Content[]): Content[] {
// A function response must be preceded by a function call.
// Continuously removes dangling function calls from the end of the history
// until the last turn is not a function call.
while (
recentHistory.length > 0 &&
isFunctionCall(recentHistory[recentHistory.length - 1])
) {
recentHistory.pop();
}
// A function response should follow a function call.
// Continuously removes leading function responses from the beginning of history
// until the first turn is not a function response.
while (recentHistory.length > 0 && isFunctionResponse(recentHistory[0])) {
recentHistory.shift();
}
return recentHistory;
}
private async checkForLoopWithLLM(signal: AbortSignal) {
const recentHistory = this.config
.getGeminiClient()
.getHistory()
.slice(-LLM_LOOP_CHECK_HISTORY_COUNT);
const trimmedHistory = this.trimRecentHistory(recentHistory);
const prompt = `You are a sophisticated AI diagnostic agent specializing in identifying when a conversational AI is stuck in an unproductive state. Your task is to analyze the provided conversation history and determine if the assistant has ceased to make meaningful progress.
An unproductive state is characterized by one or more of the following patterns over the last 5 or more assistant turns:
@@ -347,7 +375,7 @@ For example, a series of 'tool_A' or 'tool_B' tool calls that make small, distin
Please analyze the conversation history to determine the possibility that the conversation is stuck in a repetitive, non-productive state.`;
const contents = [
...recentHistory,
...trimmedHistory,
{ role: 'user', parts: [{ text: prompt }] },
];
const schema: Record<string, unknown> = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.2.1",
"version": "0.3.3",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.2.1",
"version": "0.3.3",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {