mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 08:10:57 -07:00
Compare commits
12 Commits
buffer-toggle
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a8ec2d83c | |||
| 9dd36d6310 | |||
| 0910b35812 | |||
| dc21a06b54 | |||
| 8e4b9da54f | |||
| 4811eef801 | |||
| d354dc1450 | |||
| daea8f3c56 | |||
| 86fd6419a3 | |||
| dda33bb360 | |||
| 07b7df5c7b | |||
| b06e8dbaef |
@@ -4,11 +4,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release'
|
||||
- 'release/**'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release'
|
||||
- 'release/**'
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -40,3 +40,6 @@ packages/cli/src/generated/
|
||||
packages/core/src/generated/
|
||||
.integration-tests/
|
||||
packages/vscode-ide-companion/*.vsix
|
||||
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
||||
Generated
+6
-6
@@ -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
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.1.21",
|
||||
"version": "0.2.0",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 !!(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user