Compare commits

...

12 Commits

Author SHA1 Message Date
gemini-cli-robot 3a8ec2d83c chore(release): v0.2.0 2025-08-27 01:16:04 +00:00
Shreya Keshive 9dd36d6310 Fix sandbox npm command (#7176) 2025-08-27 00:39:26 +00:00
Silvio Junior 0910b35812 Hotfix/metrics stream error (#7156)
Co-authored-by: Victor May <mayvic@google.com>
2025-08-26 15:28:04 -07:00
Silvio Junior dc21a06b54 [cherrypic][hotfix] Do not call nextSpeakerCheck in case of API error (#7140) 2025-08-26 16:28:13 -04:00
N. Taylor Mullen 8e4b9da54f fix(ci): allow release branches to run (#7060) (#7061) 2025-08-25 17:09:03 -07:00
Abhi 4811eef801 fix(keyboard): Implement Tab and Backspace handling for Kitty Protocol (#7006) (Cherry-pick) (#7051) 2025-08-25 15:29:57 -07:00
Jerop Kipruto d354dc1450 cherrypick workflow fixes into preview release branch (#7052) 2025-08-25 15:29:36 -07:00
Conrad Irwin daea8f3c56 Zed preview patches (#7036)
Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Antonio Scandurra <me@as-cii.com>
2025-08-25 14:17:06 -07:00
Silvio Junior 86fd6419a3 Hotfix/retry stream 6777 (#6881)
Co-authored-by: Victor May <mayvic@google.com>
2025-08-22 17:37:13 -04:00
Shreya Keshive dda33bb360 chore(release): v0.2.0-preview.2 (#6868)
Co-authored-by: gemini-cli-robot <gemini-cli-robot@google.com>
2025-08-22 16:04:12 -04:00
Shreya Keshive 07b7df5c7b Merge #6677 into release branch for hot fix. (#6850) 2025-08-22 14:49:37 -04:00
gemini-cli-robot b06e8dbaef chore(release): v0.2.0-preview.0 2025-08-20 02:17:59 +00:00
32 changed files with 2036 additions and 388 deletions
+2 -2
View File
@@ -4,11 +4,11 @@ on:
push:
branches:
- 'main'
- 'release'
- 'release/**'
pull_request:
branches:
- 'main'
- 'release'
- 'release/**'
merge_group:
concurrency:
+3
View File
@@ -40,3 +40,6 @@ packages/cli/src/generated/
packages/core/src/generated/
.integration-tests/
packages/vscode-ide-companion/*.vsix
# GHA credentials
gha-creds-*.json
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.1.21",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.1.21",
"version": "0.2.0",
"workspaces": [
"packages/*"
],
@@ -12474,7 +12474,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.1.21",
"version": "0.2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@google/genai": "1.13.0",
@@ -12657,7 +12657,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.1.21",
"version": "0.2.0",
"dependencies": {
"@google/genai": "1.13.0",
"@modelcontextprotocol/sdk": "^1.11.0",
@@ -12776,7 +12776,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.1.21",
"version": "0.2.0",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -12787,7 +12787,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.1.21",
"version": "0.2.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.1.21",
"version": "0.2.0",
"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.1.21"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.2.0"
},
"scripts": {
"start": "node scripts/start.js",
@@ -36,7 +36,7 @@
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
"test:integration:sandbox:none": "GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "GEMINI_SANDBOX=docker npm run build:sandbox && GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
"lint": "eslint . --ext .ts,.tsx && eslint integration-tests",
"lint:fix": "eslint . --fix && eslint integration-tests --fix",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.1.21",
"version": "0.2.0",
"description": "Gemini CLI",
"repository": {
"type": "git",
@@ -25,7 +25,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.1.21"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.2.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -220,7 +220,7 @@ describe('ideCommand', () => {
}),
expect.any(Number),
);
});
}, 10000);
it('should show an error if installation fails', async () => {
mockInstall.mockResolvedValue({
+33 -4
View File
@@ -187,10 +187,6 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
);
const result = await installer.install();
if (result.success) {
config.setIdeMode(true);
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
}
context.ui.addItem(
{
type: result.success ? 'info' : 'error',
@@ -198,6 +194,39 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
},
Date.now(),
);
if (result.success) {
context.services.settings.setValue(SettingScope.User, 'ideMode', true);
// Poll for up to 5 seconds for the extension to activate.
for (let i = 0; i < 10; i++) {
await config.setIdeModeAndSyncConnection(true);
if (
ideClient.getConnectionStatus().status ===
IDEConnectionStatus.Connected
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
const { messageType, content } = getIdeStatusMessage(ideClient);
if (messageType === 'error') {
context.ui.addItem(
{
type: messageType,
text: `Failed to automatically enable IDE integration. To fix this, run the CLI in a new terminal window.`,
},
Date.now(),
);
} else {
context.ui.addItem(
{
type: messageType,
text: content,
},
Date.now(),
);
}
}
},
};
@@ -10,7 +10,11 @@ import fs from 'node:fs/promises';
import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest';
import * as gitUtils from '../../utils/gitUtils.js';
import { setupGithubCommand } from './setupGithubCommand.js';
import {
setupGithubCommand,
updateGitignore,
GITHUB_WORKFLOW_PATHS,
} from './setupGithubCommand.js';
import { CommandContext, ToolActionReturn } from './types.js';
import * as commandUtils from '../utils/commandUtils.js';
@@ -51,12 +55,7 @@ describe('setupGithubCommand', async () => {
const fakeRepoRoot = scratchDir;
const fakeReleaseVersion = 'v1.2.3';
const workflows = [
'gemini-cli.yml',
'gemini-issue-automated-triage.yml',
'gemini-issue-scheduled-triage.yml',
'gemini-pr-review.yml',
];
const workflows = GITHUB_WORKFLOW_PATHS.map((p) => path.basename(p));
for (const workflow of workflows) {
vi.mocked(global.fetch).mockReturnValueOnce(
Promise.resolve(new Response(workflow)),
@@ -102,5 +101,138 @@ describe('setupGithubCommand', async () => {
const contents = await fs.readFile(workflowFile, 'utf8');
expect(contents).toContain(workflow);
}
// Verify that .gitignore was created with the expected entries
const gitignorePath = path.join(scratchDir, '.gitignore');
const gitignoreExists = await fs
.access(gitignorePath)
.then(() => true)
.catch(() => false);
expect(gitignoreExists).toBe(true);
if (gitignoreExists) {
const gitignoreContent = await fs.readFile(gitignorePath, 'utf8');
expect(gitignoreContent).toContain('.gemini/');
expect(gitignoreContent).toContain('gha-creds-*.json');
}
});
});
describe('updateGitignore', () => {
let scratchDir = '';
beforeEach(async () => {
scratchDir = await fs.mkdtemp(path.join(os.tmpdir(), 'update-gitignore-'));
});
afterEach(async () => {
if (scratchDir) await fs.rm(scratchDir, { recursive: true });
});
it('creates a new .gitignore file when none exists', async () => {
await updateGitignore(scratchDir);
const gitignorePath = path.join(scratchDir, '.gitignore');
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe('.gemini/\ngha-creds-*.json\n');
});
it('appends entries to existing .gitignore file', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '# Existing content\nnode_modules/\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(
'# Existing content\nnode_modules/\n\n.gemini/\ngha-creds-*.json\n',
);
});
it('does not add duplicate entries', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\ngha-creds-*.json\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
const content = await fs.readFile(gitignorePath, 'utf8');
expect(content).toBe(existingContent);
});
it('adds only missing entries when some already exist', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = '.gemini/\nsome-other-file\n';
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add only the missing gha-creds-*.json entry
expect(content).toBe('.gemini/\nsome-other-file\n\ngha-creds-*.json\n');
expect(content).toContain('gha-creds-*.json');
// Should not duplicate .gemini/ entry
expect((content.match(/\.gemini\//g) || []).length).toBe(1);
});
it('does not get confused by entries in comments or as substrings', async () => {
const gitignorePath = path.join(scratchDir, '.gitignore');
const existingContent = [
'# This is a comment mentioning .gemini/ folder',
'my-app.gemini/config',
'# Another comment with gha-creds-*.json pattern',
'some-other-gha-creds-file.json',
'',
].join('\n');
await fs.writeFile(gitignorePath, existingContent);
await updateGitignore(scratchDir);
const content = await fs.readFile(gitignorePath, 'utf8');
// Should add both entries since they don't actually exist as gitignore rules
expect(content).toContain('.gemini/');
expect(content).toContain('gha-creds-*.json');
// Verify the entries were added (not just mentioned in comments)
const lines = content
.split('\n')
.map((line) => line.split('#')[0].trim())
.filter((line) => line);
expect(lines).toContain('.gemini/');
expect(lines).toContain('gha-creds-*.json');
expect(lines).toContain('my-app.gemini/config');
expect(lines).toContain('some-other-gha-creds-file.json');
});
it('handles file system errors gracefully', async () => {
// Try to update gitignore in a non-existent directory
const nonExistentDir = path.join(scratchDir, 'non-existent');
// This should not throw an error
await expect(updateGitignore(nonExistentDir)).resolves.toBeUndefined();
});
it('handles permission errors gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const fsModule = await import('node:fs');
const writeFileSpy = vi
.spyOn(fsModule.promises, 'writeFile')
.mockRejectedValue(new Error('Permission denied'));
await expect(updateGitignore(scratchDir)).resolves.toBeUndefined();
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to update .gitignore:',
expect.any(Error),
);
writeFileSpy.mockRestore();
consoleSpy.mockRestore();
});
});
@@ -24,6 +24,14 @@ import {
} from './types.js';
import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-dispatch/gemini-dispatch.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
];
// Generate OS-specific commands to open the GitHub pages needed for setup.
function getOpenUrlsCommands(readmeUrl: string): string[] {
// Determine the OS-specific command to open URLs, ex: 'open', 'xdg-open', etc
@@ -44,6 +52,46 @@ function getOpenUrlsCommands(readmeUrl: string): string[] {
return commands;
}
// Add Gemini CLI specific entries to .gitignore file
export async function updateGitignore(gitRepoRoot: string): Promise<void> {
const gitignoreEntries = ['.gemini/', 'gha-creds-*.json'];
const gitignorePath = path.join(gitRepoRoot, '.gitignore');
try {
// Check if .gitignore exists and read its content
let existingContent = '';
let fileExists = true;
try {
existingContent = await fs.promises.readFile(gitignorePath, 'utf8');
} catch (_error) {
// File doesn't exist
fileExists = false;
}
if (!fileExists) {
// Create new .gitignore file with the entries
const contentToWrite = gitignoreEntries.join('\n') + '\n';
await fs.promises.writeFile(gitignorePath, contentToWrite);
} else {
// Check which entries are missing
const missingEntries = gitignoreEntries.filter(
(entry) =>
!existingContent
.split(/\r?\n/)
.some((line) => line.split('#')[0].trim() === entry),
);
if (missingEntries.length > 0) {
const contentToAdd = '\n' + missingEntries.join('\n') + '\n';
await fs.promises.appendFile(gitignorePath, contentToAdd);
}
}
} catch (error) {
console.debug('Failed to update .gitignore:', error);
// Continue without failing the whole command
}
}
export const setupGithubCommand: SlashCommand = {
name: 'setup-github',
description: 'Set up GitHub Actions',
@@ -91,15 +139,8 @@ export const setupGithubCommand: SlashCommand = {
// Download each workflow in parallel - there aren't enough files to warrant
// a full workerpool model here.
const workflows = [
'gemini-cli/gemini-cli.yml',
'issue-triage/gemini-issue-automated-triage.yml',
'issue-triage/gemini-issue-scheduled-triage.yml',
'pr-review/gemini-pr-review.yml',
];
const downloads = [];
for (const workflow of workflows) {
for (const workflow of GITHUB_WORKFLOW_PATHS) {
downloads.push(
(async () => {
const endpoint = `https://raw.githubusercontent.com/google-github-actions/run-gemini-cli/refs/tags/${releaseTag}/examples/workflows/${workflow}`;
@@ -146,11 +187,14 @@ export const setupGithubCommand: SlashCommand = {
abortController.abort();
});
// Add entries to .gitignore file
await updateGitignore(gitRepoRoot);
// Print out a message
const commands = [];
commands.push('set -eEuo pipefail');
commands.push(
`echo "Successfully downloaded ${workflows.length} workflows. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
`echo "Successfully downloaded ${GITHUB_WORKFLOW_PATHS.length} workflows and updated .gitignore. Follow the steps in ${readmeUrl} (skipping the /setup-github step) to complete setup."`,
);
commands.push(...getOpenUrlsCommands(readmeUrl));
@@ -17,6 +17,8 @@ import { EventEmitter } from 'events';
import {
KITTY_KEYCODE_ENTER,
KITTY_KEYCODE_NUMPAD_ENTER,
KITTY_KEYCODE_TAB,
KITTY_KEYCODE_BACKSPACE,
} from '../utils/platformConstants.js';
// Mock the 'ink' module to control stdin
@@ -278,10 +280,86 @@ describe('KeypressContext - Kitty Protocol', () => {
});
});
describe('Tab and Backspace handling', () => {
it('should recognize Tab key in kitty protocol', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.sendKittySequence(`\x1b[${KITTY_KEYCODE_TAB}u`);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tab',
kittyProtocol: true,
shift: false,
}),
);
});
it('should recognize Shift+Tab in kitty protocol', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
// Modifier 2 is Shift
act(() => {
stdin.sendKittySequence(`\x1b[${KITTY_KEYCODE_TAB};2u`);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'tab',
kittyProtocol: true,
shift: true,
}),
);
});
it('should recognize Backspace key in kitty protocol', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => {
stdin.sendKittySequence(`\x1b[${KITTY_KEYCODE_BACKSPACE}u`);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
kittyProtocol: true,
meta: false,
}),
);
});
it('should recognize Option+Backspace in kitty protocol', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
// Modifier 3 is Alt/Option
act(() => {
stdin.sendKittySequence(`\x1b[${KITTY_KEYCODE_BACKSPACE};3u`);
});
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'backspace',
kittyProtocol: true,
meta: true,
}),
);
});
});
describe('paste mode', () => {
it('should handle multiline paste as a single event', async () => {
const keyHandler = vi.fn();
const pastedText = 'This \nis \na \nmultiline \npaste.';
const pastedText = 'This \n is \n a \n multiline \n paste.';
const { result } = renderHook(() => useKeypressContext(), {
wrapper,
@@ -22,8 +22,10 @@ import { PassThrough } from 'stream';
import {
BACKSLASH_ENTER_DETECTION_WINDOW_MS,
KITTY_CTRL_C,
KITTY_KEYCODE_BACKSPACE,
KITTY_KEYCODE_ENTER,
KITTY_KEYCODE_NUMPAD_ENTER,
KITTY_KEYCODE_TAB,
MAX_KITTY_SEQUENCE_LENGTH,
} from '../utils/platformConstants.js';
@@ -134,6 +136,30 @@ export function KeypressProvider({
};
}
if (keyCode === KITTY_KEYCODE_TAB) {
return {
name: 'tab',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (keyCode === KITTY_KEYCODE_BACKSPACE) {
return {
name: 'backspace',
ctrl,
meta: alt,
shift,
paste: false,
sequence,
kittyProtocol: true,
};
}
if (
keyCode === KITTY_KEYCODE_ENTER ||
keyCode === KITTY_KEYCODE_NUMPAD_ENTER
@@ -98,10 +98,6 @@ describe('handleAtCommand', () => {
processedQuery: [{ text: query }],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: query },
123,
);
});
it('should pass through original query if only a lone @ symbol is present', async () => {
@@ -120,10 +116,6 @@ describe('handleAtCommand', () => {
processedQuery: [{ text: queryWithSpaces }],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: queryWithSpaces },
124,
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
'Lone @ detected, will be treated as text in the modified query.',
);
@@ -156,10 +148,6 @@ describe('handleAtCommand', () => {
],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: query },
125,
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
@@ -198,10 +186,6 @@ describe('handleAtCommand', () => {
],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: query },
126,
);
expect(mockOnDebugMessage).toHaveBeenCalledWith(
`Path ${dirPath} resolved to directory, using glob: ${resolvedGlob}`,
);
@@ -236,10 +220,6 @@ describe('handleAtCommand', () => {
],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: query },
128,
);
});
it('should correctly unescape paths with escaped spaces', async () => {
@@ -270,10 +250,6 @@ describe('handleAtCommand', () => {
],
shouldProceed: true,
});
expect(mockAddItem).toHaveBeenCalledWith(
{ type: 'user', text: query },
125,
);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'tool_group',
@@ -1090,4 +1066,37 @@ describe('handleAtCommand', () => {
});
});
});
it("should not add the user's turn to history, as that is the caller's responsibility", async () => {
// Arrange
const fileContent = 'This is the file content.';
const filePath = await createTestFile(
path.join(testRootDir, 'path', 'to', 'another-file.txt'),
fileContent,
);
const query = `A query with @${filePath}`;
// Act
await handleAtCommand({
query,
config: mockConfig,
addItem: mockAddItem,
onDebugMessage: mockOnDebugMessage,
messageId: 999,
signal: abortController.signal,
});
// Assert
// It SHOULD be called for the tool_group
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'tool_group' }),
999,
);
// It should NOT have been called for the user turn
const userTurnCalls = mockAddItem.mock.calls.filter(
(call) => call[0].type === 'user',
);
expect(userTurnCalls).toHaveLength(0);
});
});
@@ -137,12 +137,9 @@ export async function handleAtCommand({
);
if (atPathCommandParts.length === 0) {
addItem({ type: 'user', text: query }, userMessageTimestamp);
return { processedQuery: [{ text: query }], shouldProceed: true };
}
addItem({ type: 'user', text: query }, userMessageTimestamp);
// Get centralized file discovery service
const fileDiscovery = config.getFileService();
@@ -5,10 +5,19 @@
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
Mock,
MockInstance,
} from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useGeminiStream, mergePartListUnions } from './useGeminiStream.js';
import { useKeypress } from './useKeypress.js';
import * as atCommandProcessor from './atCommandProcessor.js';
import {
useReactToolScheduler,
TrackedToolCall,
@@ -20,8 +29,10 @@ import {
Config,
EditorType,
AuthType,
GeminiClient,
GeminiEventType as ServerGeminiEventType,
AnyToolInvocation,
ToolErrorType, // <-- Import ToolErrorType
} from '@google/gemini-cli-core';
import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -83,11 +94,7 @@ vi.mock('./shellCommandProcessor.js', () => ({
}),
}));
vi.mock('./atCommandProcessor.js', () => ({
handleAtCommand: vi
.fn()
.mockResolvedValue({ shouldProceed: true, processedQuery: 'mocked' }),
}));
vi.mock('./atCommandProcessor.js');
vi.mock('../utils/markdownUtilities.js', () => ({
findLastSafeSplitPoint: vi.fn((s: string) => s.length),
@@ -259,6 +266,7 @@ describe('useGeminiStream', () => {
let mockScheduleToolCalls: Mock;
let mockCancelAllToolCalls: Mock;
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
beforeEach(() => {
vi.clearAllMocks(); // Clear mocks before each test
@@ -342,6 +350,7 @@ describe('useGeminiStream', () => {
mockSendMessageStream
.mockClear()
.mockReturnValue((async function* () {})());
handleAtCommandSpy = vi.spyOn(atCommandProcessor, 'handleAtCommand');
});
const mockLoadedSettings: LoadedSettings = {
@@ -447,6 +456,7 @@ describe('useGeminiStream', () => {
callId: 'call1',
responseParts: [{ text: 'tool 1 response' }],
error: undefined,
errorType: undefined, // FIX: Added missing property
resultDisplay: 'Tool 1 success display',
},
tool: {
@@ -512,7 +522,11 @@ describe('useGeminiStream', () => {
},
status: 'success',
responseSubmittedToGemini: false,
response: { callId: 'call1', responseParts: toolCall1ResponseParts },
response: {
callId: 'call1',
responseParts: toolCall1ResponseParts,
errorType: undefined, // FIX: Added missing property
},
tool: {
displayName: 'MockTool',
},
@@ -530,7 +544,11 @@ describe('useGeminiStream', () => {
},
status: 'error',
responseSubmittedToGemini: false,
response: { callId: 'call2', responseParts: toolCall2ResponseParts },
response: {
callId: 'call2',
responseParts: toolCall2ResponseParts,
errorType: ToolErrorType.UNHANDLED_EXCEPTION, // FIX: Added missing property
},
} as TrackedCompletedToolCall, // Treat error as a form of completion for submission
];
@@ -597,7 +615,11 @@ describe('useGeminiStream', () => {
prompt_id: 'prompt-id-3',
},
status: 'cancelled',
response: { callId: '1', responseParts: [{ text: 'cancelled' }] },
response: {
callId: '1',
responseParts: [{ text: 'cancelled' }],
errorType: undefined, // FIX: Added missing property
},
responseSubmittedToGemini: false,
tool: {
displayName: 'mock tool',
@@ -682,6 +704,7 @@ describe('useGeminiStream', () => {
],
resultDisplay: undefined,
error: undefined,
errorType: undefined, // FIX: Added missing property
},
responseSubmittedToGemini: false,
};
@@ -710,6 +733,7 @@ describe('useGeminiStream', () => {
],
resultDisplay: undefined,
error: undefined,
errorType: undefined, // FIX: Added missing property
},
responseSubmittedToGemini: false,
};
@@ -812,6 +836,7 @@ describe('useGeminiStream', () => {
callId: 'call1',
responseParts: toolCallResponseParts,
error: undefined,
errorType: undefined, // FIX: Added missing property
resultDisplay: 'Tool 1 success display',
},
endTime: Date.now(),
@@ -1214,6 +1239,7 @@ describe('useGeminiStream', () => {
responseParts: [{ text: 'Memory saved' }],
resultDisplay: 'Success: Memory saved',
error: undefined,
errorType: undefined, // FIX: Added missing property
},
tool: {
name: 'save_memory',
@@ -1757,4 +1783,301 @@ describe('useGeminiStream', () => {
);
});
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
const rawQuery = '@include file.txt Summarize this.';
const processedQueryParts = [
{ text: 'Summarize this with content from @file.txt' },
{ text: 'File content...' },
];
const userMessageTimestamp = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp);
handleAtCommandSpy.mockResolvedValue({
processedQuery: processedQueryParts,
shouldProceed: true,
});
const { result } = renderHook(() =>
useGeminiStream(
mockConfig.getGeminiClient() as GeminiClient,
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false, // shellModeActive
vi.fn(), // getPreferredEditor
vi.fn(), // onAuthError
vi.fn(), // performMemoryRefresh
false, // modelSwitched
vi.fn(), // setModelSwitched
vi.fn(), // onEditorClose
vi.fn(), // onCancelSubmit
),
);
await act(async () => {
await result.current.submitQuery(rawQuery);
});
expect(handleAtCommandSpy).toHaveBeenCalledWith(
expect.objectContaining({
query: rawQuery,
}),
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.USER,
text: rawQuery,
},
userMessageTimestamp,
);
// FIX: The expectation now matches the actual call signature.
expect(mockSendMessageStream).toHaveBeenCalledWith(
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
);
});
describe('Thought Reset', () => {
it('should reset thought to null when starting a new prompt', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: {
subject: 'Previous thought',
description: 'Old description',
},
};
yield {
type: ServerGeminiEventType.Content,
value: 'Some response content',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
),
);
await act(async () => {
await result.current.submitQuery('First query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'Some response content',
}),
expect.any(Number),
);
});
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'New response content',
};
yield { type: ServerGeminiEventType.Finished, value: 'STOP' };
})(),
);
await act(async () => {
await result.current.submitQuery('Second query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'New response content',
}),
expect.any(Number),
);
});
});
it('should reset thought to null when user cancels', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { subject: 'Some thought', description: 'Description' },
};
yield { type: ServerGeminiEventType.UserCancelled };
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
),
);
await act(async () => {
await result.current.submitQuery('Test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'User cancelled the request.',
}),
expect.any(Number),
);
});
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should reset thought to null when there is an error', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Thought,
value: { subject: 'Some thought', description: 'Description' },
};
yield {
type: ServerGeminiEventType.Error,
value: { error: { message: 'Test error' } },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
),
);
await act(async () => {
await result.current.submitQuery('Test query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
expect.any(Number),
);
});
expect(mockParseAndFormatApiError).toHaveBeenCalledWith(
{ message: 'Test error' },
expect.any(String),
undefined,
'gemini-2.5-pro',
'gemini-2.5-flash',
);
});
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
const rawQuery = '@include file.txt Summarize this.';
const processedQueryParts = [
{ text: 'Summarize this with content from @file.txt' },
{ text: 'File content...' },
];
const userMessageTimestamp = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(userMessageTimestamp);
handleAtCommandSpy.mockResolvedValue({
processedQuery: processedQueryParts,
shouldProceed: true,
});
const { result } = renderHook(() =>
useGeminiStream(
mockConfig.getGeminiClient() as GeminiClient,
[],
mockAddItem,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
vi.fn(),
vi.fn(),
vi.fn(),
false,
vi.fn(),
vi.fn(),
vi.fn(),
),
);
await act(async () => {
await result.current.submitQuery(rawQuery);
});
expect(handleAtCommandSpy).toHaveBeenCalledWith(
expect.objectContaining({
query: rawQuery,
}),
);
expect(mockAddItem).toHaveBeenCalledWith(
{
type: MessageType.USER,
text: rawQuery,
},
userMessageTimestamp,
);
// FIX: This expectation now correctly matches the actual function call signature.
expect(mockSendMessageStream).toHaveBeenCalledWith(
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
);
});
});
@@ -306,6 +306,13 @@ export const useGeminiStream = (
messageId: userMessageTimestamp,
signal: abortSignal,
});
// Add user's turn after @ command processing is done.
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
if (!atCommandResult.shouldProceed) {
return { queryToSend: null, shouldProceed: false };
}
@@ -22,6 +22,8 @@ export const KITTY_CTRL_C = '[99;5u';
*/
export const KITTY_KEYCODE_ENTER = 13;
export const KITTY_KEYCODE_NUMPAD_ENTER = 57414;
export const KITTY_KEYCODE_TAB = 9;
export const KITTY_KEYCODE_BACKSPACE = 127;
/**
* Timing constants for terminal interactions
+10 -1
View File
@@ -84,6 +84,8 @@ export type AgentCapabilities = z.infer<typeof agentCapabilitiesSchema>;
export type AuthMethod = z.infer<typeof authMethodSchema>;
export type PromptCapabilities = z.infer<typeof promptCapabilitiesSchema>;
export type ClientResponse = z.infer<typeof clientResponseSchema>;
export type ClientNotification = z.infer<typeof clientNotificationSchema>;
@@ -270,8 +272,15 @@ export const mcpServerSchema = z.object({
name: z.string(),
});
export const promptCapabilitiesSchema = z.object({
audio: z.boolean().optional(),
embeddedContext: z.boolean().optional(),
image: z.boolean().optional(),
});
export const agentCapabilitiesSchema = z.object({
loadSession: z.boolean(),
loadSession: z.boolean().optional(),
promptCapabilities: promptCapabilitiesSchema.optional(),
});
export const authMethodSchema = z.object({
+210 -156
View File
@@ -99,6 +99,11 @@ class GeminiAgent {
authMethods,
agentCapabilities: {
loadSession: false,
promptCapabilities: {
image: true,
audio: true,
embeddedContext: true,
},
},
};
}
@@ -374,74 +379,75 @@ class Session {
);
}
const invocation = tool.build(args);
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
try {
const invocation = tool.build(args);
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
if (confirmationDetails) {
const content: acp.ToolCallContent[] = [];
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: tool.kind,
},
};
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: tool.kind,
});
}
const params: acp.RequestPermissionRequest = {
sessionId: this.id,
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
content,
locations: invocation.toolLocations(),
kind: tool.kind,
},
};
const output = await this.client.requestPermission(params);
const outcome =
output.outcome.outcome === 'cancelled'
? ToolConfirmationOutcome.Cancel
: z
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await confirmationDetails.onConfirm(outcome);
switch (outcome) {
case ToolConfirmationOutcome.Cancel:
return errorResponse(
new Error(`Tool "${fc.name}" was canceled by the user.`),
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
break;
default: {
const resultOutcome: never = outcome;
throw new Error(`Unexpected: ${resultOutcome}`);
}
}
} else {
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: tool.kind,
});
}
try {
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
@@ -488,45 +494,59 @@ class Session {
message: acp.ContentBlock[],
abortSignal: AbortSignal,
): Promise<Part[]> {
const FILE_URI_SCHEME = 'file://';
const embeddedContext: acp.EmbeddedResourceResource[] = [];
const parts = message.map((part) => {
switch (part.type) {
case 'text':
return { text: part.text };
case 'resource_link':
case 'image':
case 'audio':
return {
fileData: {
mimeData: part.mimeType,
name: part.name,
fileUri: part.uri,
inlineData: {
mimeType: part.mimeType,
data: part.data,
},
};
case 'resource_link': {
if (part.uri.startsWith(FILE_URI_SCHEME)) {
return {
fileData: {
mimeData: part.mimeType,
name: part.name,
fileUri: part.uri.slice(FILE_URI_SCHEME.length),
},
};
} else {
return { text: `@${part.uri}` };
}
}
case 'resource': {
return {
fileData: {
mimeData: part.resource.mimeType,
name: part.resource.uri,
fileUri: part.resource.uri,
},
};
embeddedContext.push(part.resource);
return { text: `@${part.resource.uri}` };
}
default: {
throw new Error(`Unexpected chunk type: '${part.type}'`);
const unreachable: never = part;
throw new Error(`Unexpected chunk type: '${unreachable}'`);
}
}
});
const atPathCommandParts = parts.filter((part) => 'fileData' in part);
if (atPathCommandParts.length === 0) {
if (atPathCommandParts.length === 0 && embeddedContext.length === 0) {
return parts;
}
const atPathToResolvedSpecMap = new Map<string, string>();
// Get centralized file discovery service
const fileDiscovery = this.config.getFileService();
const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore();
const pathSpecsToRead: string[] = [];
const atPathToResolvedSpecMap = new Map<string, string>();
const contentLabelsForDisplay: string[] = [];
const ignoredPaths: string[] = [];
@@ -634,6 +654,7 @@ class Session {
contentLabelsForDisplay.push(pathName);
}
}
// Construct the initial part of the query for the LLM
let initialQueryText = '';
for (let i = 0; i < parts.length; i++) {
@@ -687,94 +708,123 @@ class Session {
`Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`,
);
}
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
if (pathSpecsToRead.length === 0) {
const processedQueryParts: Part[] = [{ text: initialQueryText }];
if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) {
// Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText
console.warn('No valid file paths found in @ commands to read.');
return [{ text: initialQueryText }];
}
const processedQueryParts: Part[] = [{ text: initialQueryText }];
const toolArgs = {
paths: pathSpecsToRead,
respectGitIgnore, // Use configuration setting
};
const callId = `${readManyFilesTool.name}-${Date.now()}`;
try {
const invocation = readManyFilesTool.build(toolArgs);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: readManyFilesTool.kind,
});
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'content',
content: {
type: 'text',
text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
},
if (pathSpecsToRead.length > 0) {
const toolArgs = {
paths: pathSpecsToRead,
respectGitIgnore, // Use configuration setting
};
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
const callId = `${readManyFilesTool.name}-${Date.now()}`;
try {
const invocation = readManyFilesTool.build(toolArgs);
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
locations: invocation.toolLocations(),
kind: readManyFilesTool.kind,
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
processedQueryParts.push({ text: '\n--- End of content ---' });
} else {
console.warn(
'read_many_files tool returned no content or empty content.',
);
}
return processedQueryParts;
} catch (error: unknown) {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{
type: 'content',
content: {
type: 'text',
text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
const result = await invocation.execute(abortSignal);
const content = toToolCallContent(result) || {
type: 'content',
content: {
type: 'text',
text: `Successfully read: ${contentLabelsForDisplay.join(', ')}`,
},
],
};
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
content: content ? [content] : [],
});
if (Array.isArray(result.llmContent)) {
const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/;
processedQueryParts.push({
text: '\n--- Content from referenced files ---',
});
for (const part of result.llmContent) {
if (typeof part === 'string') {
const match = fileContentRegex.exec(part);
if (match) {
const filePathSpecInContent = match[1]; // This is a resolved pathSpec
const fileActualContent = match[2].trim();
processedQueryParts.push({
text: `\nContent from @${filePathSpecInContent}:\n`,
});
processedQueryParts.push({ text: fileActualContent });
} else {
processedQueryParts.push({ text: part });
}
} else {
// part is a Part object.
processedQueryParts.push(part);
}
}
} else {
console.warn(
'read_many_files tool returned no content or empty content.',
);
}
} catch (error: unknown) {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'failed',
content: [
{
type: 'content',
content: {
type: 'text',
text: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`,
},
},
],
});
throw error;
}
}
if (embeddedContext.length > 0) {
processedQueryParts.push({
text: '\n--- Content from referenced context ---',
});
throw error;
for (const contextPart of embeddedContext) {
processedQueryParts.push({
text: `\nContent from @${contextPart.uri}:\n`,
});
if ('text' in contextPart) {
processedQueryParts.push({
text: contextPart.text,
});
} else {
processedQueryParts.push({
inlineData: {
mimeType: contextPart.mimeType ?? 'application/octet-stream',
data: contextPart.blob,
},
});
}
}
}
return processedQueryParts;
}
debug(msg: string) {
@@ -785,6 +835,10 @@ class Session {
}
function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null {
if (toolResult.error?.message) {
throw new Error(toolResult.error.message);
}
if (toolResult.returnDisplay) {
if (typeof toolResult.returnDisplay === 'string') {
return {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.1.21",
"version": "0.2.0",
"description": "Gemini CLI Core",
"repository": {
"type": "git",
+85
View File
@@ -50,6 +50,8 @@ vi.mock('./turn', () => {
GeminiEventType: {
MaxSessionTurns: 'MaxSessionTurns',
ChatCompressed: 'ChatCompressed',
Error: 'error',
Content: 'content',
},
};
});
@@ -1863,6 +1865,89 @@ ${JSON.stringify(
expect(JSON.stringify(finalCall)).toContain('fileC.ts');
});
});
it('should not call checkNextSpeaker when turn.run() yields an error', async () => {
// Arrange
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
const mockStream = (async function* () {
yield {
type: GeminiEventType.Error,
value: { error: { message: 'test error' } },
};
})();
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = mockGenerator as ContentGenerator;
// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-error',
);
for await (const _ of stream) {
// consume stream
}
// Assert
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
});
it('should not call checkNextSpeaker when turn.run() yields a value then an error', async () => {
// Arrange
const { checkNextSpeaker } = await import(
'../utils/nextSpeakerChecker.js'
);
const mockCheckNextSpeaker = vi.mocked(checkNextSpeaker);
const mockStream = (async function* () {
yield { type: GeminiEventType.Content, value: 'some content' };
yield {
type: GeminiEventType.Error,
value: { error: { message: 'test error' } },
};
})();
mockTurnRunFn.mockReturnValue(mockStream);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
};
client['chat'] = mockChat as GeminiChat;
const mockGenerator: Partial<ContentGenerator> = {
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
generateContent: mockGenerateContentFn,
};
client['contentGenerator'] = mockGenerator as ContentGenerator;
// Act
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-error',
);
for await (const _ of stream) {
// consume stream
}
// Assert
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
});
});
describe('generateContent', () => {
+3
View File
@@ -517,6 +517,9 @@ export class GeminiClient {
return turn;
}
yield event;
if (event.type === GeminiEventType.Error) {
return turn;
}
}
if (!turn.pendingToolCalls.length && signal && !signal.aborted) {
// Check if model was switched during the call (likely due to quota error)
+414 -2
View File
@@ -12,7 +12,7 @@ import {
Part,
GenerateContentResponse,
} from '@google/genai';
import { GeminiChat } from './geminiChat.js';
import { GeminiChat, EmptyStreamError } from './geminiChat.js';
import { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
@@ -25,6 +25,19 @@ const mockModelsModule = {
batchEmbedContents: vi.fn(),
} as unknown as Models;
const { mockLogInvalidChunk, mockLogContentRetry, mockLogContentRetryFailure } =
vi.hoisted(() => ({
mockLogInvalidChunk: vi.fn(),
mockLogContentRetry: vi.fn(),
mockLogContentRetryFailure: vi.fn(),
}));
vi.mock('../telemetry/loggers.js', () => ({
logInvalidChunk: mockLogInvalidChunk,
logContentRetry: mockLogContentRetry,
logContentRetryFailure: mockLogContentRetryFailure,
}));
describe('GeminiChat', () => {
let chat: GeminiChat;
let mockConfig: Config;
@@ -112,7 +125,13 @@ describe('GeminiChat', () => {
response,
);
await chat.sendMessageStream({ message: 'hello' }, 'prompt-id-1');
const stream = await chat.sendMessageStream(
{ message: 'hello' },
'prompt-id-1',
);
for await (const _ of stream) {
// consume stream to trigger internal logic
}
expect(mockModelsModule.generateContentStream).toHaveBeenCalledWith(
{
@@ -475,4 +494,397 @@ describe('GeminiChat', () => {
expect(history[1]).toEqual(content2);
});
});
describe('sendMessageStream with retries', () => {
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)
.mockImplementationOnce(async () =>
// First call returns an invalid stream
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '' }] } }], // Invalid empty text part
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
// Second call returns a valid stream
(async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'Successful response' }] } },
],
} as unknown as GenerateContentResponse;
})(),
);
const stream = await chat.sendMessageStream(
{ message: 'test' },
'prompt-id-retry-success',
);
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
// Assertions
expect(mockLogInvalidChunk).toHaveBeenCalledTimes(1);
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetryFailure).not.toHaveBeenCalled();
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
expect(
chunks.some(
(c) =>
c.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response',
),
).toBe(true);
// Check that history was recorded correctly once, with no duplicates.
const history = chat.getHistory();
expect(history.length).toBe(2);
expect(history[0]).toEqual({
role: 'user',
parts: [{ text: 'test' }],
});
expect(history[1]).toEqual({
role: 'model',
parts: [{ text: 'Successful response' }],
});
});
it('should fail after all retries on persistent invalid content and report metrics', async () => {
vi.mocked(mockModelsModule.generateContentStream).mockImplementation(
async () =>
(async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: '' }],
role: 'model',
},
},
],
} as unknown as GenerateContentResponse;
})(),
);
// This helper function consumes the stream and allows us to test for rejection.
async function consumeStreamAndExpectError() {
const stream = await chat.sendMessageStream(
{ message: 'test' },
'prompt-id-retry-fail',
);
for await (const _ of stream) {
// Must loop to trigger the internal logic that throws.
}
}
await expect(consumeStreamAndExpectError()).rejects.toThrow(
EmptyStreamError,
);
// Should be called 3 times (initial + 2 retries)
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(3);
expect(mockLogInvalidChunk).toHaveBeenCalledTimes(3);
expect(mockLogContentRetry).toHaveBeenCalledTimes(2);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should be clean, as if the failed turn never happened.
const history = chat.getHistory();
expect(history.length).toBe(0);
});
});
it('should correctly retry and append to an existing history mid-conversation', async () => {
// 1. Setup
const initialHistory: Content[] = [
{ role: 'user', parts: [{ text: 'First question' }] },
{ role: 'model', parts: [{ text: 'First answer' }] },
];
chat.setHistory(initialHistory);
// 2. Mock the API to fail once with an empty stream, then succeed.
vi.mocked(mockModelsModule.generateContentStream)
.mockImplementationOnce(async () =>
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: '' }] } }],
} as unknown as GenerateContentResponse;
})(),
)
.mockImplementationOnce(async () =>
// Second attempt succeeds
(async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'Second answer' }] } }],
} as unknown as GenerateContentResponse;
})(),
);
// 3. Send a new message
const stream = await chat.sendMessageStream(
{ message: 'Second question' },
'prompt-id-retry-existing',
);
for await (const _ of stream) {
// consume stream
}
// 4. Assert the final history and metrics
const history = chat.getHistory();
expect(history.length).toBe(4);
// Assert that the correct metrics were reported for one empty-stream retry
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
// Explicitly verify the structure of each part to satisfy TypeScript
const turn1 = history[0];
if (!turn1?.parts?.[0] || !('text' in turn1.parts[0])) {
throw new Error('Test setup error: First turn is not a valid text part.');
}
expect(turn1.parts[0].text).toBe('First question');
const turn2 = history[1];
if (!turn2?.parts?.[0] || !('text' in turn2.parts[0])) {
throw new Error(
'Test setup error: Second turn is not a valid text part.',
);
}
expect(turn2.parts[0].text).toBe('First answer');
const turn3 = history[2];
if (!turn3?.parts?.[0] || !('text' in turn3.parts[0])) {
throw new Error('Test setup error: Third turn is not a valid text part.');
}
expect(turn3.parts[0].text).toBe('Second question');
const turn4 = history[3];
if (!turn4?.parts?.[0] || !('text' in turn4.parts[0])) {
throw new Error(
'Test setup error: Fourth turn is not a valid text part.',
);
}
expect(turn4.parts[0].text).toBe('Second answer');
});
describe('concurrency control', () => {
it('should queue a subsequent sendMessage call until the first one completes', async () => {
// 1. Create promises to manually control when the API calls resolve
let firstCallResolver: (value: GenerateContentResponse) => void;
const firstCallPromise = new Promise<GenerateContentResponse>(
(resolve) => {
firstCallResolver = resolve;
},
);
let secondCallResolver: (value: GenerateContentResponse) => void;
const secondCallPromise = new Promise<GenerateContentResponse>(
(resolve) => {
secondCallResolver = resolve;
},
);
// A standard response body for the mock
const mockResponse = {
candidates: [
{
content: { parts: [{ text: 'response' }], role: 'model' },
},
],
} as unknown as GenerateContentResponse;
// 2. Mock the API to return our controllable promises in order
vi.mocked(mockModelsModule.generateContent)
.mockReturnValueOnce(firstCallPromise)
.mockReturnValueOnce(secondCallPromise);
// 3. Start the first message call. Do not await it yet.
const firstMessagePromise = chat.sendMessage(
{ message: 'first' },
'prompt-1',
);
// Give the event loop a chance to run the async call up to the `await`
await new Promise(process.nextTick);
// 4. While the first call is "in-flight", start the second message call.
const secondMessagePromise = chat.sendMessage(
{ message: 'second' },
'prompt-2',
);
// 5. CRUCIAL CHECK: At this point, only the first API call should have been made.
// The second call should be waiting on `sendPromise`.
expect(mockModelsModule.generateContent).toHaveBeenCalledTimes(1);
expect(mockModelsModule.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({ parts: [{ text: 'first' }] }),
]),
}),
'prompt-1',
);
// 6. Unblock the first API call and wait for the first message to fully complete.
firstCallResolver!(mockResponse);
await firstMessagePromise;
// Give the event loop a chance to unblock and run the second call.
await new Promise(process.nextTick);
// 7. CRUCIAL CHECK: Now, the second API call should have been made.
expect(mockModelsModule.generateContent).toHaveBeenCalledTimes(2);
expect(mockModelsModule.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({ parts: [{ text: 'second' }] }),
]),
}),
'prompt-2',
);
// 8. Clean up by resolving the second call.
secondCallResolver!(mockResponse);
await secondMessagePromise;
});
});
it('should retry if the model returns a completely empty stream (no chunks)', async () => {
// 1. Mock the API to return an empty stream first, then a valid one.
vi.mocked(mockModelsModule.generateContentStream)
.mockImplementationOnce(
// First call resolves to an async generator that yields nothing.
async () => (async function* () {})(),
)
.mockImplementationOnce(
// Second call returns a valid stream.
async () =>
(async function* () {
yield {
candidates: [
{
content: {
parts: [{ text: 'Successful response after empty' }],
},
},
],
} as unknown as GenerateContentResponse;
})(),
);
// 2. Call the method and consume the stream.
const stream = await chat.sendMessageStream(
{ message: 'test empty stream' },
'prompt-id-empty-stream',
);
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
// 3. Assert the results.
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
expect(
chunks.some(
(c) =>
c.candidates?.[0]?.content?.parts?.[0]?.text ===
'Successful response after empty',
),
).toBe(true);
const history = chat.getHistory();
expect(history.length).toBe(2);
// Explicitly verify the structure of each part to satisfy TypeScript
const turn1 = history[0];
if (!turn1?.parts?.[0] || !('text' in turn1.parts[0])) {
throw new Error('Test setup error: First turn is not a valid text part.');
}
expect(turn1.parts[0].text).toBe('test empty stream');
const turn2 = history[1];
if (!turn2?.parts?.[0] || !('text' in turn2.parts[0])) {
throw new Error(
'Test setup error: Second turn is not a valid text part.',
);
}
expect(turn2.parts[0].text).toBe('Successful response after empty');
});
it('should queue a subsequent sendMessageStream call until the first stream is fully consumed', async () => {
// 1. Create a promise to manually control the stream's lifecycle
let continueFirstStream: () => void;
const firstStreamContinuePromise = new Promise<void>((resolve) => {
continueFirstStream = resolve;
});
// 2. Mock the API to return controllable async generators
const firstStreamGenerator = (async function* () {
yield {
candidates: [
{ content: { parts: [{ text: 'first response part 1' }] } },
],
} as unknown as GenerateContentResponse;
await firstStreamContinuePromise; // Pause the stream
yield {
candidates: [{ content: { parts: [{ text: ' part 2' }] } }],
} as unknown as GenerateContentResponse;
})();
const secondStreamGenerator = (async function* () {
yield {
candidates: [{ content: { parts: [{ text: 'second response' }] } }],
} as unknown as GenerateContentResponse;
})();
vi.mocked(mockModelsModule.generateContentStream)
.mockResolvedValueOnce(firstStreamGenerator)
.mockResolvedValueOnce(secondStreamGenerator);
// 3. Start the first stream and consume only the first chunk to pause it
const firstStream = await chat.sendMessageStream(
{ message: 'first' },
'prompt-1',
);
const firstStreamIterator = firstStream[Symbol.asyncIterator]();
await firstStreamIterator.next();
// 4. While the first stream is paused, start the second call. It will block.
const secondStreamPromise = chat.sendMessageStream(
{ message: 'second' },
'prompt-2',
);
// 5. Assert that only one API call has been made so far.
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(1);
// 6. Unblock and fully consume the first stream to completion.
continueFirstStream!();
await firstStreamIterator.next(); // Consume the rest of the stream
await firstStreamIterator.next(); // Finish the iterator
// 7. Now that the first stream is done, await the second promise to get its generator.
const secondStream = await secondStreamPromise;
// 8. Start consuming the second stream, which triggers its internal API call.
const secondStreamIterator = secondStream[Symbol.asyncIterator]();
await secondStreamIterator.next();
// 9. The second API call should now have been made.
expect(mockModelsModule.generateContentStream).toHaveBeenCalledTimes(2);
// 10. FIX: Fully consume the second stream to ensure recordHistory is called.
await secondStreamIterator.next(); // This finishes the iterator.
// 11. Final check on history.
const history = chat.getHistory();
expect(history.length).toBe(4);
const turn4 = history[3];
if (!turn4?.parts?.[0] || !('text' in turn4.parts[0])) {
throw new Error(
'Test setup error: Fourth turn is not a valid text part.',
);
}
expect(turn4.parts[0].text).toBe('second response');
});
});
+232 -148
View File
@@ -23,7 +23,31 @@ import { Config } from '../config/config.js';
import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { hasCycleInSchema } from '../tools/tools.js';
import { StructuredError } from './turn.js';
import {
logContentRetry,
logContentRetryFailure,
logInvalidChunk,
} from '../telemetry/loggers.js';
import {
ContentRetryEvent,
ContentRetryFailureEvent,
InvalidChunkEvent,
} from '../telemetry/types.js';
/**
* Options for retrying due to invalid content from the model.
*/
interface ContentRetryOptions {
/** 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: 3, // 1 initial call + 2 retries
initialDelayMs: 500,
};
/**
* Returns true if the response is valid, false otherwise.
*/
@@ -98,15 +122,23 @@ function extractCuratedHistory(comprehensiveHistory: Content[]): Content[] {
}
if (isValid) {
curatedHistory.push(...modelOutput);
} else {
// Remove the last user input when model content is invalid.
curatedHistory.pop();
}
}
}
return curatedHistory;
}
/**
* Custom error to signal that a stream completed without valid content,
* which should trigger a retry.
*/
export class EmptyStreamError extends Error {
constructor(message: string) {
super(message);
this.name = 'EmptyStreamError';
}
}
/**
* Chat session that enables sending messages to the model with previous
* conversation context.
@@ -305,65 +337,138 @@ export class GeminiChat {
prompt_id: string,
): Promise<AsyncGenerator<GenerateContentResponse>> {
await this.sendPromise;
let streamDoneResolver: () => void;
const streamDonePromise = new Promise<void>((resolve) => {
streamDoneResolver = resolve;
});
this.sendPromise = streamDonePromise;
const userContent = createUserContent(params.message);
const requestContents = this.getHistory(true).concat(userContent);
try {
const apiCall = () => {
const modelToUse = this.config.getModel();
// Add user content to history ONCE before any attempts.
this.history.push(userContent);
const requestContents = this.getHistory(true);
// Prevent Flash model calls immediately after quota error
if (
this.config.getQuotaErrorOccurred() &&
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
return (async function* () {
try {
let lastError: unknown = new Error('Request failed after all retries.');
for (
let attempt = 0;
attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts;
attempt++
) {
throw new Error(
'Please submit a new query to continue with the Flash model.',
);
try {
const stream = await self.makeApiCallAndProcessStream(
requestContents,
params,
prompt_id,
userContent,
);
for await (const chunk of stream) {
yield chunk;
}
lastError = null;
break;
} catch (error) {
lastError = error;
const isContentError = error instanceof EmptyStreamError;
if (isContentError) {
// Check if we have more attempts left.
if (attempt < INVALID_CONTENT_RETRY_OPTIONS.maxAttempts - 1) {
logContentRetry(
self.config,
new ContentRetryEvent(
attempt,
'EmptyStreamError',
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs,
),
);
await new Promise((res) =>
setTimeout(
res,
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs *
(attempt + 1),
),
);
continue;
}
}
break;
}
}
return this.contentGenerator.generateContentStream(
{
model: modelToUse,
contents: requestContents,
config: { ...this.generationConfig, ...params.config },
},
prompt_id,
);
};
// Note: Retrying streams can be complex. If generateContentStream itself doesn't handle retries
// for transient issues internally before yielding the async generator, this retry will re-initiate
// the stream. For simple 429/500 errors on initial call, this is fine.
// If errors occur mid-stream, this setup won't resume the stream; it will restart it.
const streamResponse = await retryWithBackoff(apiCall, {
shouldRetry: (error: unknown) => {
// Check for known error messages and codes.
if (error instanceof Error && error.message) {
if (isSchemaDepthError(error.message)) return false;
if (error.message.includes('429')) return true;
if (error.message.match(/5\d{2}/)) return true;
if (lastError) {
if (lastError instanceof EmptyStreamError) {
logContentRetryFailure(
self.config,
new ContentRetryFailureEvent(
INVALID_CONTENT_RETRY_OPTIONS.maxAttempts,
'EmptyStreamError',
),
);
}
return false; // Don't retry other errors by default
// If the stream fails, remove the user message that was added.
if (self.history[self.history.length - 1] === userContent) {
self.history.pop();
}
throw lastError;
}
} finally {
streamDoneResolver!();
}
})();
}
private async makeApiCallAndProcessStream(
requestContents: Content[],
params: SendMessageParameters,
prompt_id: string,
userContent: Content,
): Promise<AsyncGenerator<GenerateContentResponse>> {
const apiCall = () => {
const modelToUse = this.config.getModel();
if (
this.config.getQuotaErrorOccurred() &&
modelToUse === DEFAULT_GEMINI_FLASH_MODEL
) {
throw new Error(
'Please submit a new query to continue with the Flash model.',
);
}
return this.contentGenerator.generateContentStream(
{
model: modelToUse,
contents: requestContents,
config: { ...this.generationConfig, ...params.config },
},
onPersistent429: async (authType?: string, error?: unknown) =>
await this.handleFlashFallback(authType, error),
authType: this.config.getContentGeneratorConfig()?.authType,
});
prompt_id,
);
};
// Resolve the internal tracking of send completion promise - `sendPromise`
// for both success and failure response. The actual failure is still
// propagated by the `await streamResponse`.
this.sendPromise = Promise.resolve(streamResponse)
.then(() => undefined)
.catch(() => undefined);
const streamResponse = await retryWithBackoff(apiCall, {
shouldRetry: (error: unknown) => {
if (error instanceof Error && error.message) {
if (isSchemaDepthError(error.message)) return false;
if (error.message.includes('429')) return true;
if (error.message.match(/5\d{2}/)) return true;
}
return false;
},
onPersistent429: async (authType?: string, error?: unknown) =>
await this.handleFlashFallback(authType, error),
authType: this.config.getContentGeneratorConfig()?.authType,
});
const result = this.processStreamResponse(streamResponse, userContent);
return result;
} catch (error) {
this.sendPromise = Promise.resolve();
throw error;
}
return this.processStreamResponse(streamResponse, userContent);
}
/**
@@ -407,8 +512,6 @@ export class GeminiChat {
/**
* Adds a new entry to the chat history.
*
* @param content - The content to add to the history.
*/
addHistory(content: Content): void {
this.history.push(content);
@@ -451,41 +554,45 @@ export class GeminiChat {
private async *processStreamResponse(
streamResponse: AsyncGenerator<GenerateContentResponse>,
inputContent: Content,
) {
const outputContent: Content[] = [];
const chunks: GenerateContentResponse[] = [];
let errorOccurred = false;
userInput: Content,
): AsyncGenerator<GenerateContentResponse> {
const modelResponseParts: Part[] = [];
let isStreamInvalid = false;
let hasReceivedAnyChunk = false;
try {
for await (const chunk of streamResponse) {
if (isValidResponse(chunk)) {
chunks.push(chunk);
const content = chunk.candidates?.[0]?.content;
if (content !== undefined) {
if (this.isThoughtContent(content)) {
yield chunk;
continue;
}
outputContent.push(content);
for await (const chunk of streamResponse) {
hasReceivedAnyChunk = true;
if (isValidResponse(chunk)) {
const content = chunk.candidates?.[0]?.content;
if (content) {
// Filter out thought parts from being added to history.
if (!this.isThoughtContent(content) && content.parts) {
modelResponseParts.push(...content.parts);
}
}
yield chunk;
} else {
logInvalidChunk(
this.config,
new InvalidChunkEvent('Invalid chunk received from stream.'),
);
isStreamInvalid = true;
}
} catch (error) {
errorOccurred = true;
throw error;
yield chunk; // Yield every chunk to the UI immediately.
}
if (!errorOccurred) {
const allParts: Part[] = [];
for (const content of outputContent) {
if (content.parts) {
allParts.push(...content.parts);
}
}
// Now that the stream is finished, make a decision.
// Throw an error if the stream was invalid OR if it was completely empty.
if (isStreamInvalid || !hasReceivedAnyChunk) {
throw new EmptyStreamError(
'Model stream was invalid or completed without valid content.',
);
}
this.recordHistory(inputContent, outputContent);
// Use recordHistory to correctly save the conversation turn.
const modelOutput: Content[] = [
{ role: 'model', parts: modelResponseParts },
];
this.recordHistory(userInput, modelOutput);
}
private recordHistory(
@@ -493,88 +600,65 @@ export class GeminiChat {
modelOutput: Content[],
automaticFunctionCallingHistory?: Content[],
) {
const newHistoryEntries: Content[] = [];
// Part 1: Handle the user's part of the turn.
if (
automaticFunctionCallingHistory &&
automaticFunctionCallingHistory.length > 0
) {
newHistoryEntries.push(
...extractCuratedHistory(automaticFunctionCallingHistory),
);
} else {
// Guard for streaming calls where the user input might already be in the history.
if (
this.history.length === 0 ||
this.history[this.history.length - 1] !== userInput
) {
newHistoryEntries.push(userInput);
}
}
// Part 2: Handle the model's part of the turn, filtering out thoughts.
const nonThoughtModelOutput = modelOutput.filter(
(content) => !this.isThoughtContent(content),
);
let outputContents: Content[] = [];
if (
nonThoughtModelOutput.length > 0 &&
nonThoughtModelOutput.every((content) => content.role !== undefined)
) {
if (nonThoughtModelOutput.length > 0) {
outputContents = nonThoughtModelOutput;
} else if (nonThoughtModelOutput.length === 0 && modelOutput.length > 0) {
// This case handles when the model returns only a thought.
// We don't want to add an empty model response in this case.
} else {
// When not a function response appends an empty content when model returns empty response, so that the
// history is always alternating between user and model.
// Workaround for: https://b.corp.google.com/issues/420354090
if (!isFunctionResponse(userInput)) {
outputContents.push({
role: 'model',
parts: [],
} as Content);
}
}
if (
automaticFunctionCallingHistory &&
automaticFunctionCallingHistory.length > 0
} else if (
modelOutput.length === 0 &&
!isFunctionResponse(userInput) &&
!automaticFunctionCallingHistory
) {
this.history.push(
...extractCuratedHistory(automaticFunctionCallingHistory),
);
} else {
this.history.push(userInput);
// Add an empty model response if the model truly returned nothing.
outputContents.push({ role: 'model', parts: [] } as Content);
}
// Consolidate adjacent model roles in outputContents
// Part 3: Consolidate the parts of this turn's model response.
const consolidatedOutputContents: Content[] = [];
for (const content of outputContents) {
if (this.isThoughtContent(content)) {
continue;
}
const lastContent =
consolidatedOutputContents[consolidatedOutputContents.length - 1];
if (this.isTextContent(lastContent) && this.isTextContent(content)) {
// If both current and last are text, combine their text into the lastContent's first part
// and append any other parts from the current content.
lastContent.parts[0].text += content.parts[0].text || '';
if (content.parts.length > 1) {
lastContent.parts.push(...content.parts.slice(1));
if (outputContents.length > 0) {
for (const content of outputContents) {
const lastContent =
consolidatedOutputContents[consolidatedOutputContents.length - 1];
if (this.hasTextContent(lastContent) && this.hasTextContent(content)) {
lastContent.parts[0].text += content.parts[0].text || '';
if (content.parts.length > 1) {
lastContent.parts.push(...content.parts.slice(1));
}
} else {
consolidatedOutputContents.push(content);
}
} else {
consolidatedOutputContents.push(content);
}
}
if (consolidatedOutputContents.length > 0) {
const lastHistoryEntry = this.history[this.history.length - 1];
const canMergeWithLastHistory =
!automaticFunctionCallingHistory ||
automaticFunctionCallingHistory.length === 0;
if (
canMergeWithLastHistory &&
this.isTextContent(lastHistoryEntry) &&
this.isTextContent(consolidatedOutputContents[0])
) {
// If both current and last are text, combine their text into the lastHistoryEntry's first part
// and append any other parts from the current content.
lastHistoryEntry.parts[0].text +=
consolidatedOutputContents[0].parts[0].text || '';
if (consolidatedOutputContents[0].parts.length > 1) {
lastHistoryEntry.parts.push(
...consolidatedOutputContents[0].parts.slice(1),
);
}
consolidatedOutputContents.shift(); // Remove the first element as it's merged
}
this.history.push(...consolidatedOutputContents);
}
// Part 4: Add the new turn (user and model parts) to the main history.
this.history.push(...newHistoryEntries, ...consolidatedOutputContents);
}
private isTextContent(
private hasTextContent(
content: Content | undefined,
): content is Content & { parts: [{ text: string }, ...Part[]] } {
return !!(
+17 -6
View File
@@ -104,8 +104,13 @@ export class IdeClient {
this.setState(IDEConnectionStatus.Connecting);
const ideInfoFromFile = await this.getIdeInfoFromFile();
const workspacePath =
ideInfoFromFile.workspacePath ??
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
const { isValid, error } = IdeClient.validateWorkspacePath(
process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'],
workspacePath,
this.currentIdeDisplayName,
process.cwd(),
);
@@ -115,7 +120,7 @@ export class IdeClient {
return;
}
const portFromFile = await this.getPortFromFile();
const portFromFile = ideInfoFromFile.port;
if (portFromFile) {
const connected = await this.establishConnection(portFromFile);
if (connected) {
@@ -311,7 +316,10 @@ export class IdeClient {
return port;
}
private async getPortFromFile(): Promise<string | undefined> {
private async getIdeInfoFromFile(): Promise<{
port?: string;
workspacePath?: string;
}> {
try {
const ideProcessId = await getIdeProcessId();
const portFile = path.join(
@@ -319,10 +327,13 @@ export class IdeClient {
`gemini-ide-server-${ideProcessId}.json`,
);
const portFileContents = await fs.promises.readFile(portFile, 'utf8');
const port = JSON.parse(portFileContents).port;
return port.toString();
const ideInfo = JSON.parse(portFileContents);
return {
port: ideInfo?.port?.toString(),
workspacePath: ideInfo?.workspacePath,
};
} catch (_) {
return undefined;
return {};
}
}
@@ -19,6 +19,9 @@ import {
IdeConnectionEvent,
KittySequenceOverflowEvent,
ChatCompressionEvent,
InvalidChunkEvent,
ContentRetryEvent,
ContentRetryFailureEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import { Config } from '../../config/config.js';
@@ -48,6 +51,9 @@ export enum EventNames {
IDE_CONNECTION = 'ide_connection',
KITTY_SEQUENCE_OVERFLOW = 'kitty_sequence_overflow',
CHAT_COMPRESSION = 'chat_compression',
INVALID_CHUNK = 'invalid_chunk',
CONTENT_RETRY = 'content_retry',
CONTENT_RETRY_FAILURE = 'content_retry_failure',
}
export interface LogResponse {
@@ -675,6 +681,69 @@ export class ClearcutLogger {
});
}
logInvalidChunkEvent(event: InvalidChunkEvent): void {
const data: EventValue[] = [];
if (event.error_message) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE,
value: event.error_message,
});
}
this.enqueueLogEvent(this.createLogEvent(EventNames.INVALID_CHUNK, data));
this.flushIfNeeded();
}
logContentRetryEvent(event: ContentRetryEvent): void {
const data: EventValue[] = [
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER,
value: String(event.attempt_number),
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE,
value: event.error_type,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_DELAY_MS,
value: String(event.retry_delay_ms),
},
];
this.enqueueLogEvent(this.createLogEvent(EventNames.CONTENT_RETRY, data));
this.flushIfNeeded();
}
logContentRetryFailureEvent(event: ContentRetryFailureEvent): void {
const data: EventValue[] = [
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS,
value: String(event.total_attempts),
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE,
value: event.final_error_type,
},
];
if (event.total_duration_ms) {
data.push({
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS,
value: String(event.total_duration_ms),
});
}
this.enqueueLogEvent(
this.createLogEvent(EventNames.CONTENT_RETRY_FAILURE, data),
);
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.
@@ -229,6 +229,16 @@ export enum EventMetadataKey {
// Logs the length of the kitty sequence that overflowed.
GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53,
// ==========================================================================
// Conversation Finished Event Keys
// ===========================================================================
// Logs the approval mode of the session.
GEMINI_CLI_APPROVAL_MODE = 58,
// Logs the number of turns
GEMINI_CLI_CONVERSATION_TURN_COUNT = 59,
// Logs the number of tokens before context window compression.
GEMINI_CLI_COMPRESSION_TOKENS_BEFORE = 60,
@@ -237,4 +247,72 @@ export enum EventMetadataKey {
// Logs tool type whether it is mcp or native.
GEMINI_CLI_TOOL_TYPE = 62,
// Logs name of MCP tools as comma separated string
GEMINI_CLI_START_SESSION_MCP_TOOLS = 65,
// ==========================================================================
// Research Event Keys
// ===========================================================================
// Logs the research opt-in status (true/false)
GEMINI_CLI_RESEARCH_OPT_IN_STATUS = 66,
// Logs the contact email for research participation
GEMINI_CLI_RESEARCH_CONTACT_EMAIL = 67,
// Logs the user ID for research events
GEMINI_CLI_RESEARCH_USER_ID = 68,
// Logs the type of research feedback
GEMINI_CLI_RESEARCH_FEEDBACK_TYPE = 69,
// Logs the content of research feedback
GEMINI_CLI_RESEARCH_FEEDBACK_CONTENT = 70,
// Logs survey responses for research feedback (JSON stringified)
GEMINI_CLI_RESEARCH_SURVEY_RESPONSES = 71,
// ==========================================================================
// File Operation Event Keys
// ===========================================================================
// Logs the programming language of the project.
GEMINI_CLI_PROGRAMMING_LANGUAGE = 56,
// Logs the operation type of the file operation.
GEMINI_CLI_FILE_OPERATION_TYPE = 57,
// Logs the number of lines in the file operation.
GEMINI_CLI_FILE_OPERATION_LINES = 72,
// Logs the mimetype of the file in the file operation.
GEMINI_CLI_FILE_OPERATION_MIMETYPE = 73,
// Logs the extension of the file in the file operation.
GEMINI_CLI_FILE_OPERATION_EXTENSION = 74,
// ==========================================================================
// Content Streaming Event Keys
// ===========================================================================
// Logs the error message for an invalid chunk.
GEMINI_CLI_INVALID_CHUNK_ERROR_MESSAGE = 75,
// Logs the attempt number for a content retry.
GEMINI_CLI_CONTENT_RETRY_ATTEMPT_NUMBER = 76,
// Logs the error type for a content retry.
GEMINI_CLI_CONTENT_RETRY_ERROR_TYPE = 77,
// Logs the delay in milliseconds for a content retry.
GEMINI_CLI_CONTENT_RETRY_DELAY_MS = 78,
// Logs the total number of attempts for a content retry failure.
GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_ATTEMPTS = 79,
// Logs the final error type for a content retry failure.
GEMINI_CLI_CONTENT_RETRY_FAILURE_FINAL_ERROR_TYPE = 80,
// Logs the total duration in milliseconds for a content retry failure.
GEMINI_CLI_CONTENT_RETRY_FAILURE_TOTAL_DURATION_MS = 81,
}
+9
View File
@@ -17,6 +17,11 @@ export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check';
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection';
export const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression';
export const EVENT_INVALID_CHUNK = 'gemini_cli.chat.invalid_chunk';
export const EVENT_CONTENT_RETRY = 'gemini_cli.chat.content_retry';
export const EVENT_CONTENT_RETRY_FAILURE =
'gemini_cli.chat.content_retry_failure';
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
export const METRIC_API_REQUEST_COUNT = 'gemini_cli.api.request.count';
@@ -24,3 +29,7 @@ export const METRIC_API_REQUEST_LATENCY = 'gemini_cli.api.request.latency';
export const METRIC_TOKEN_USAGE = 'gemini_cli.token.usage';
export const METRIC_SESSION_COUNT = 'gemini_cli.session.count';
export const METRIC_FILE_OPERATION_COUNT = 'gemini_cli.file.operation.count';
export const METRIC_INVALID_CHUNK_COUNT = 'gemini_cli.chat.invalid_chunk.count';
export const METRIC_CONTENT_RETRY_COUNT = 'gemini_cli.chat.content_retry.count';
export const METRIC_CONTENT_RETRY_FAILURE_COUNT =
'gemini_cli.chat.content_retry_failure.count';
+78
View File
@@ -20,6 +20,9 @@ import {
SERVICE_NAME,
EVENT_SLASH_COMMAND,
EVENT_CHAT_COMPRESSION,
EVENT_INVALID_CHUNK,
EVENT_CONTENT_RETRY,
EVENT_CONTENT_RETRY_FAILURE,
} from './constants.js';
import {
ApiErrorEvent,
@@ -35,6 +38,9 @@ import {
SlashCommandEvent,
KittySequenceOverflowEvent,
ChatCompressionEvent,
InvalidChunkEvent,
ContentRetryEvent,
ContentRetryFailureEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -42,6 +48,9 @@ import {
recordApiResponseMetrics,
recordToolCallMetrics,
recordChatCompressionMetrics,
recordInvalidChunk,
recordContentRetry,
recordContentRetryFailure,
} from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js';
import { uiTelemetryService, UiEvent } from './uiTelemetry.js';
@@ -426,3 +435,72 @@ export function logKittySequenceOverflow(
};
logger.emit(logRecord);
}
export function logInvalidChunk(
config: Config,
event: InvalidChunkEvent,
): void {
ClearcutLogger.getInstance(config)?.logInvalidChunkEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
'event.name': EVENT_INVALID_CHUNK,
'event.timestamp': event['event.timestamp'],
};
if (event.error_message) {
attributes['error.message'] = event.error_message;
}
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Invalid chunk received from stream.`,
attributes,
};
logger.emit(logRecord);
recordInvalidChunk(config);
}
export function logContentRetry(
config: Config,
event: ContentRetryEvent,
): void {
ClearcutLogger.getInstance(config)?.logContentRetryEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_CONTENT_RETRY,
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `Content retry attempt ${event.attempt_number} due to ${event.error_type}.`,
attributes,
};
logger.emit(logRecord);
recordContentRetry(config);
}
export function logContentRetryFailure(
config: Config,
event: ContentRetryFailureEvent,
): void {
ClearcutLogger.getInstance(config)?.logContentRetryFailureEvent(event);
if (!isTelemetrySdkInitialized()) return;
const attributes: LogAttributes = {
...getCommonAttributes(config),
...event,
'event.name': EVENT_CONTENT_RETRY_FAILURE,
};
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: `All content retries failed after ${event.total_attempts} attempts.`,
attributes,
};
logger.emit(logRecord);
recordContentRetryFailure(config);
}
+50
View File
@@ -22,6 +22,9 @@ import {
METRIC_SESSION_COUNT,
METRIC_FILE_OPERATION_COUNT,
EVENT_CHAT_COMPRESSION,
METRIC_INVALID_CHUNK_COUNT,
METRIC_CONTENT_RETRY_COUNT,
METRIC_CONTENT_RETRY_FAILURE_COUNT,
} from './constants.js';
import { Config } from '../config/config.js';
import { DiffStat } from '../tools/tools.js';
@@ -40,6 +43,9 @@ let apiRequestLatencyHistogram: Histogram | undefined;
let tokenUsageCounter: Counter | undefined;
let fileOperationCounter: Counter | undefined;
let chatCompressionCounter: Counter | undefined;
let invalidChunkCounter: Counter | undefined;
let contentRetryCounter: Counter | undefined;
let contentRetryFailureCounter: Counter | undefined;
let isMetricsInitialized = false;
function getCommonAttributes(config: Config): Attributes {
@@ -94,6 +100,24 @@ export function initializeMetrics(config: Config): void {
description: 'Counts chat compression events.',
valueType: ValueType.INT,
});
// New counters for content errors
invalidChunkCounter = meter.createCounter(METRIC_INVALID_CHUNK_COUNT, {
description: 'Counts invalid chunks received from a stream.',
valueType: ValueType.INT,
});
contentRetryCounter = meter.createCounter(METRIC_CONTENT_RETRY_COUNT, {
description: 'Counts retries due to content errors (e.g., empty stream).',
valueType: ValueType.INT,
});
contentRetryFailureCounter = meter.createCounter(
METRIC_CONTENT_RETRY_FAILURE_COUNT,
{
description: 'Counts occurrences of all content retries failing.',
valueType: ValueType.INT,
},
);
const sessionCounter = meter.createCounter(METRIC_SESSION_COUNT, {
description: 'Count of CLI sessions started.',
valueType: ValueType.INT,
@@ -227,3 +251,29 @@ export function recordFileOperationMetric(
}
fileOperationCounter.add(1, attributes);
}
// --- New Metric Recording Functions ---
/**
* Records a metric for when an invalid chunk is received from a stream.
*/
export function recordInvalidChunk(config: Config): void {
if (!invalidChunkCounter || !isMetricsInitialized) return;
invalidChunkCounter.add(1, getCommonAttributes(config));
}
/**
* Records a metric for when a retry is triggered due to a content error.
*/
export function recordContentRetry(config: Config): void {
if (!contentRetryCounter || !isMetricsInitialized) return;
contentRetryCounter.add(1, getCommonAttributes(config));
}
/**
* Records a metric for when all content error retries have failed for a request.
*/
export function recordContentRetryFailure(config: Config): void {
if (!contentRetryFailureCounter || !isMetricsInitialized) return;
contentRetryFailureCounter.add(1, getCommonAttributes(config));
}
+57 -1
View File
@@ -385,6 +385,59 @@ export class KittySequenceOverflowEvent {
}
}
// Add these new event interfaces
export class InvalidChunkEvent implements BaseTelemetryEvent {
'event.name': 'invalid_chunk';
'event.timestamp': string;
error_message?: string; // Optional: validation error details
constructor(error_message?: string) {
this['event.name'] = 'invalid_chunk';
this['event.timestamp'] = new Date().toISOString();
this.error_message = error_message;
}
}
export class ContentRetryEvent implements BaseTelemetryEvent {
'event.name': 'content_retry';
'event.timestamp': string;
attempt_number: number;
error_type: string; // e.g., 'EmptyStreamError'
retry_delay_ms: number;
constructor(
attempt_number: number,
error_type: string,
retry_delay_ms: number,
) {
this['event.name'] = 'content_retry';
this['event.timestamp'] = new Date().toISOString();
this.attempt_number = attempt_number;
this.error_type = error_type;
this.retry_delay_ms = retry_delay_ms;
}
}
export class ContentRetryFailureEvent implements BaseTelemetryEvent {
'event.name': 'content_retry_failure';
'event.timestamp': string;
total_attempts: number;
final_error_type: string;
total_duration_ms?: number; // Optional: total time spent retrying
constructor(
total_attempts: number,
final_error_type: string,
total_duration_ms?: number,
) {
this['event.name'] = 'content_retry_failure';
this['event.timestamp'] = new Date().toISOString();
this.total_attempts = total_attempts;
this.final_error_type = final_error_type;
this.total_duration_ms = total_duration_ms;
}
}
export type TelemetryEvent =
| StartSessionEvent
| EndSessionEvent
@@ -399,4 +452,7 @@ export type TelemetryEvent =
| KittySequenceOverflowEvent
| MalformedJsonResponseEvent
| IdeConnectionEvent
| SlashCommandEvent;
| SlashCommandEvent
| InvalidChunkEvent
| ContentRetryEvent
| ContentRetryFailureEvent;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.1.21",
"version": "0.2.0",
"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.1.21",
"version": "0.2.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {