mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5161610ff | |||
| 44b3c97486 | |||
| b1bbef433d | |||
| 8a72585992 | |||
| 73b3211ea7 | |||
| ce40a6534f | |||
| 5be5575df0 | |||
| 847c6e7fd0 | |||
| f4f3727936 | |||
| f36dec6ad4 |
@@ -473,21 +473,6 @@ a few things you can try in order of recommendation:
|
||||
"loadMemoryFromIncludeDirectories": true
|
||||
```
|
||||
|
||||
- **`chatCompression`** (object):
|
||||
- **Description:** Controls the settings for chat history compression, both
|
||||
automatic and when manually invoked through the /compress command.
|
||||
- **Properties:**
|
||||
- **`contextPercentageThreshold`** (number): A value between 0 and 1 that
|
||||
specifies the token threshold for compression as a percentage of the
|
||||
model's total token limit. For example, a value of `0.6` will trigger
|
||||
compression when the chat history exceeds 60% of the token limit.
|
||||
- **Example:**
|
||||
```json
|
||||
"chatCompression": {
|
||||
"contextPercentageThreshold": 0.6
|
||||
}
|
||||
```
|
||||
|
||||
- **`showLineNumbers`** (boolean):
|
||||
- **Description:** Controls whether line numbers are displayed in code blocks
|
||||
in the CLI output.
|
||||
|
||||
@@ -244,13 +244,13 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
example `{"run_shell_command": {"tokenBudget": 2000}}`
|
||||
- **Default:** `undefined`
|
||||
|
||||
- **`model.chatCompression.contextPercentageThreshold`** (number):
|
||||
- **`model.compressionThreshold`** (number):
|
||||
- **Description:** Sets the threshold for chat history compression as a
|
||||
percentage of the model's total token limit. This is a value between 0 and 1
|
||||
fraction of the model's total token limit. This is a value between 0 and 1
|
||||
that applies to both automatic compression and the manual `/compress`
|
||||
command. For example, a value of `0.6` will trigger compression when the
|
||||
chat history exceeds 60% of the token limit.
|
||||
- **Default:** `0.7`
|
||||
- **Default:** `0.2`
|
||||
|
||||
- **`model.skipNextSpeakerCheck`** (boolean):
|
||||
- **Description:** Skip the next speaker check.
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17669,7 +17669,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "^0.3.2",
|
||||
"@google-cloud/storage": "^7.16.0",
|
||||
@@ -17943,7 +17943,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
"@google/genai": "1.16.0",
|
||||
@@ -18056,7 +18056,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"dependencies": {
|
||||
"@google-cloud/logging": "^11.2.1",
|
||||
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0",
|
||||
@@ -18197,7 +18197,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3"
|
||||
@@ -18208,7 +18208,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.11.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.11.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"description": "Gemini CLI",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -25,7 +25,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.11.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.11.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
|
||||
@@ -1572,7 +1572,7 @@ describe('loadCliConfig with includeDirectories', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig chatCompression', () => {
|
||||
describe('loadCliConfig compressionThreshold', () => {
|
||||
const originalArgv = process.argv;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1587,28 +1587,24 @@ describe('loadCliConfig chatCompression', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should pass chatCompression settings to the core config', async () => {
|
||||
it('should pass settings to the core config', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {
|
||||
model: {
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
compressionThreshold: 0.5,
|
||||
},
|
||||
};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getChatCompression()).toEqual({
|
||||
contextPercentageThreshold: 0.5,
|
||||
});
|
||||
expect(config.getCompressionThreshold()).toEqual(0.5);
|
||||
});
|
||||
|
||||
it('should have undefined chatCompression if not in settings', async () => {
|
||||
it('should have undefined compressionThreshold if not in settings', async () => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments({} as Settings);
|
||||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||
expect(config.getChatCompression()).toBeUndefined();
|
||||
expect(config.getCompressionThreshold()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@ export async function loadCliConfig(
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
compressionThreshold: settings.model?.compressionThreshold,
|
||||
folderTrust,
|
||||
interactive,
|
||||
trustedFolder,
|
||||
|
||||
@@ -1051,15 +1051,15 @@ describe('Settings Loading and Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge chatCompression settings, with workspace taking precedence', () => {
|
||||
it('should merge compressionThreshold settings, with workspace taking precedence', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
|
||||
model: { compressionThreshold: 0.5 },
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: { contextPercentageThreshold: 0.8 } },
|
||||
model: { compressionThreshold: 0.8 },
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
@@ -1074,15 +1074,11 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.user.settings.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 0.5,
|
||||
});
|
||||
expect(settings.workspace.settings.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 0.8,
|
||||
});
|
||||
expect(settings.merged.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 0.8,
|
||||
});
|
||||
expect(settings.user.settings.model?.compressionThreshold).toEqual(0.5);
|
||||
expect(settings.workspace.settings.model?.compressionThreshold).toEqual(
|
||||
0.8,
|
||||
);
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(0.8);
|
||||
});
|
||||
|
||||
it('should merge output format settings, with workspace taking precedence', () => {
|
||||
@@ -1109,13 +1105,13 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.output?.format).toBe('json');
|
||||
});
|
||||
|
||||
it('should handle chatCompression when only in user settings', () => {
|
||||
it('should handle compressionThreshold when only in user settings', () => {
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
|
||||
model: { compressionThreshold: 0.5 },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
@@ -1126,9 +1122,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 0.5,
|
||||
});
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
|
||||
});
|
||||
|
||||
it('should have model as undefined if not in any settings file', () => {
|
||||
@@ -1138,39 +1132,15 @@ describe('Settings Loading and Merging', () => {
|
||||
expect(settings.merged.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore chatCompression if contextPercentageThreshold is invalid', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
(mockFsExistsSync as Mock).mockImplementation(
|
||||
(p: fs.PathLike) => p === USER_SETTINGS_PATH,
|
||||
);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: { contextPercentageThreshold: 1.5 } },
|
||||
};
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === USER_SETTINGS_PATH)
|
||||
return JSON.stringify(userSettingsContent);
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
expect(settings.merged.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 1.5,
|
||||
});
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should deep merge chatCompression settings', () => {
|
||||
it('should use user compressionThreshold if workspace does not define it', () => {
|
||||
(mockFsExistsSync as Mock).mockReturnValue(true);
|
||||
const userSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: { contextPercentageThreshold: 0.5 } },
|
||||
model: { compressionThreshold: 0.5 },
|
||||
};
|
||||
const workspaceSettingsContent = {
|
||||
general: {},
|
||||
model: { chatCompression: {} },
|
||||
model: {},
|
||||
};
|
||||
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
@@ -1185,9 +1155,7 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
expect(settings.merged.model?.chatCompression).toEqual({
|
||||
contextPercentageThreshold: 0.5,
|
||||
});
|
||||
expect(settings.merged.model?.compressionThreshold).toEqual(0.5);
|
||||
});
|
||||
|
||||
it('should merge includeDirectories from all scopes', () => {
|
||||
@@ -1972,9 +1940,6 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
@@ -1993,9 +1958,6 @@ describe('Settings Loading and Merging', () => {
|
||||
myTheme: {},
|
||||
},
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.5,
|
||||
},
|
||||
mcpServers: {
|
||||
'server-1': {
|
||||
command: 'node server.js',
|
||||
@@ -2035,9 +1997,6 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
model: {
|
||||
name: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
fileName: 'CONTEXT.md',
|
||||
@@ -2077,9 +2036,6 @@ describe('Settings Loading and Merging', () => {
|
||||
theme: 'dark',
|
||||
usageStatisticsEnabled: false,
|
||||
model: 'gemini-pro',
|
||||
chatCompression: {
|
||||
contextPercentageThreshold: 0.8,
|
||||
},
|
||||
contextFileName: 'CONTEXT.md',
|
||||
includeDirectories: ['/src'],
|
||||
sandbox: true,
|
||||
|
||||
@@ -64,7 +64,7 @@ const MIGRATION_MAP: Record<string, string> = {
|
||||
autoAccept: 'tools.autoAccept',
|
||||
autoConfigureMaxOldSpaceSize: 'advanced.autoConfigureMemory',
|
||||
bugCommand: 'advanced.bugCommand',
|
||||
chatCompression: 'model.chatCompression',
|
||||
chatCompression: 'model.compressionThreshold',
|
||||
checkpointing: 'general.checkpointing',
|
||||
coreTools: 'tools.core',
|
||||
contextFileName: 'context.fileName',
|
||||
|
||||
@@ -159,6 +159,10 @@ describe('SettingsSchema', () => {
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.showMemoryUsage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().ui.properties.footer.properties
|
||||
.hideContextPercentage.showInDialog,
|
||||
).toBe(true);
|
||||
expect(getSettingsSchema().general.properties.vimMode.showInDialog).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,6 @@ import type {
|
||||
BugCommandSettings,
|
||||
TelemetrySettings,
|
||||
AuthType,
|
||||
ChatCompressionSettings,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
@@ -389,6 +388,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Hide the model name and context usage in the footer.',
|
||||
showInDialog: true,
|
||||
},
|
||||
hideContextPercentage: {
|
||||
type: 'boolean',
|
||||
label: 'Hide Context Window Percentage',
|
||||
category: 'UI',
|
||||
requiresRestart: false,
|
||||
default: true,
|
||||
description: 'Hides the context window remaining percentage.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
hideFooter: {
|
||||
@@ -578,14 +586,15 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'Settings for summarizing tool output.',
|
||||
showInDialog: false,
|
||||
},
|
||||
chatCompression: {
|
||||
type: 'object',
|
||||
label: 'Chat Compression',
|
||||
compressionThreshold: {
|
||||
type: 'number',
|
||||
label: 'Compression Threshold',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as ChatCompressionSettings | undefined,
|
||||
description: 'Chat compression settings.',
|
||||
showInDialog: false,
|
||||
requiresRestart: true,
|
||||
default: 0.2 as number,
|
||||
description:
|
||||
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
|
||||
showInDialog: true,
|
||||
},
|
||||
skipNextSpeakerCheck: {
|
||||
type: 'boolean',
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
renderWithProviders,
|
||||
createMockSettings,
|
||||
@@ -120,6 +121,13 @@ describe('<Footer />', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
@@ -129,6 +137,13 @@ describe('<Footer />', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 99,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+%\)/);
|
||||
@@ -192,6 +207,13 @@ describe('<Footer />', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-wide');
|
||||
});
|
||||
@@ -247,10 +269,49 @@ describe('<Footer />', () => {
|
||||
expect(lastFrame()).toMatchSnapshot('footer-only-sandbox');
|
||||
});
|
||||
|
||||
it('hides the context percentage when hideContextPercentage is true', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
|
||||
});
|
||||
|
||||
it('shows the context percentage when hideContextPercentage is false', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 120,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toContain(defaultProps.model);
|
||||
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
|
||||
});
|
||||
|
||||
it('renders complete footer in narrow terminal (baseline narrow)', () => {
|
||||
const { lastFrame } = renderWithProviders(<Footer />, {
|
||||
width: 79,
|
||||
uiState: { sessionStats: mockSessionStats },
|
||||
settings: createMockSettings({
|
||||
ui: {
|
||||
footer: {
|
||||
hideContextPercentage: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(lastFrame()).toMatchSnapshot('complete-footer-narrow');
|
||||
});
|
||||
|
||||
@@ -61,6 +61,8 @@ export const Footer: React.FC = () => {
|
||||
const hideSandboxStatus =
|
||||
settings.merged.ui?.footer?.hideSandboxStatus || false;
|
||||
const hideModelInfo = settings.merged.ui?.footer?.hideModelInfo || false;
|
||||
const hideContextPercentage =
|
||||
settings.merged.ui?.footer?.hideContextPercentage ?? true;
|
||||
|
||||
const pathLength = Math.max(20, Math.floor(mainAreaWidth * 0.25));
|
||||
const displayPath = shortenPath(tildeifyPath(targetDir), pathLength);
|
||||
@@ -146,12 +148,17 @@ export const Footer: React.FC = () => {
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{model}{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
{model}
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
{' '}
|
||||
<ContextUsageDisplay
|
||||
promptTokenCount={promptTokenCount}
|
||||
model={model}
|
||||
terminalWidth={mainAreaWidth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
{showMemoryUsage && <MemoryUsageDisplay />}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"description": "Gemini CLI Core",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -581,7 +581,7 @@ describe('Server Config (config.ts)', () => {
|
||||
useModelRouter: true,
|
||||
});
|
||||
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
|
||||
expect(config.getUseModelRouter()).toBe(false);
|
||||
expect(config.getUseModelRouter()).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable model router by default for other auth types', async () => {
|
||||
|
||||
@@ -96,10 +96,6 @@ export interface BugCommandSettings {
|
||||
urlTemplate: string;
|
||||
}
|
||||
|
||||
export interface ChatCompressionSettings {
|
||||
contextPercentageThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
@@ -261,7 +257,7 @@ export interface ConfigParameters {
|
||||
folderTrust?: boolean;
|
||||
ideMode?: boolean;
|
||||
loadMemoryFromIncludeDirectories?: boolean;
|
||||
chatCompression?: ChatCompressionSettings;
|
||||
compressionThreshold?: number;
|
||||
interactive?: boolean;
|
||||
trustedFolder?: boolean;
|
||||
useRipgrep?: boolean;
|
||||
@@ -354,7 +350,7 @@ export class Config {
|
||||
| undefined;
|
||||
private readonly experimentalZedIntegration: boolean = false;
|
||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||
private readonly chatCompression: ChatCompressionSettings | undefined;
|
||||
private readonly compressionThreshold: number | undefined;
|
||||
private readonly interactive: boolean;
|
||||
private readonly ptyInfo: string;
|
||||
private readonly trustedFolder: boolean | undefined;
|
||||
@@ -453,7 +449,7 @@ export class Config {
|
||||
this.ideMode = params.ideMode ?? false;
|
||||
this.loadMemoryFromIncludeDirectories =
|
||||
params.loadMemoryFromIncludeDirectories ?? false;
|
||||
this.chatCompression = params.chatCompression;
|
||||
this.compressionThreshold = params.compressionThreshold;
|
||||
this.interactive = params.interactive ?? false;
|
||||
this.ptyInfo = params.ptyInfo ?? 'child_process';
|
||||
this.trustedFolder = params.trustedFolder;
|
||||
@@ -476,9 +472,7 @@ export class Config {
|
||||
this.useWriteTodos = params.useWriteTodos ?? false;
|
||||
this.initialUseModelRouter = params.useModelRouter ?? false;
|
||||
this.useModelRouter = this.initialUseModelRouter;
|
||||
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
];
|
||||
this.disableModelRouterForAuth = params.disableModelRouterForAuth ?? [];
|
||||
this.enableMessageBusIntegration =
|
||||
params.enableMessageBusIntegration ?? false;
|
||||
this.codebaseInvestigatorSettings = {
|
||||
@@ -979,8 +973,8 @@ export class Config {
|
||||
this.fileSystemService = fileSystemService;
|
||||
}
|
||||
|
||||
getChatCompression(): ChatCompressionSettings | undefined {
|
||||
return this.chatCompression;
|
||||
getCompressionThreshold(): number | undefined {
|
||||
return this.compressionThreshold;
|
||||
}
|
||||
|
||||
isInteractiveShellEnabled(): boolean {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
|
||||
import type { Content, GenerateContentResponse, Part } from '@google/genai';
|
||||
import {
|
||||
findCompressSplitPoint,
|
||||
isThinkingDefault,
|
||||
isThinkingSupported,
|
||||
GeminiClient,
|
||||
@@ -40,9 +39,11 @@ import { FileDiscoveryService } from '../services/fileDiscoveryService.js';
|
||||
import { setSimulate429 } from '../utils/testUtils.js';
|
||||
import { tokenLimit } from './tokenLimits.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
|
||||
import type { ModelRouterService } from '../routing/modelRouterService.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
|
||||
vi.mock('../services/chatCompressionService.js');
|
||||
|
||||
// Mock fs module to prevent actual file system operations during tests
|
||||
const mockFileSystem = new Map<string, string>();
|
||||
@@ -132,83 +133,6 @@ async function fromAsync<T>(promise: AsyncGenerator<T>): Promise<readonly T[]> {
|
||||
return results;
|
||||
}
|
||||
|
||||
describe('findCompressSplitPoint', () => {
|
||||
it('should throw an error for non-positive numbers', () => {
|
||||
expect(() => findCompressSplitPoint([], 0)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for a fraction greater than or equal to 1', () => {
|
||||
expect(() => findCompressSplitPoint([], 1)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty history', () => {
|
||||
expect(findCompressSplitPoint([], 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle a fraction in the middle', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.5)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.9)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of after last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
|
||||
});
|
||||
|
||||
it('should return earlier splitpoint if no valid ones are after threshhold', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] },
|
||||
{ role: 'model', parts: [{ functionCall: {} }] },
|
||||
];
|
||||
// Can't return 4 because the previous item has a function call.
|
||||
expect(findCompressSplitPoint(history, 0.99)).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle a history with only one item', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle history with weird parts', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
{ role: 'model', parts: [{ fileData: { fileUri: 'derp' } }] },
|
||||
{ role: 'user', parts: [{ text: 'Message 2' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isThinkingSupported', () => {
|
||||
it('should return true for gemini-2.5', () => {
|
||||
expect(isThinkingSupported('gemini-2.5')).toBe(true);
|
||||
@@ -252,6 +176,15 @@ describe('Gemini Client (client.ts)', () => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(uiTelemetryService.setLastPromptTokenCount).mockClear();
|
||||
|
||||
vi.mocked(ChatCompressionService.prototype.compress).mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
|
||||
mockGenerateContentFn = vi.fn().mockResolvedValue({
|
||||
candidates: [{ content: { parts: [{ text: '{"key": "value"}' }] } }],
|
||||
});
|
||||
@@ -404,7 +337,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
{ role: 'model', parts: [{ text: 'Long response' }] },
|
||||
] as Content[],
|
||||
originalTokenCount = 1000,
|
||||
summaryText = 'This is a summary.',
|
||||
newTokenCount = 500,
|
||||
compressionStatus = CompressionStatus.COMPRESSED,
|
||||
} = {}) {
|
||||
const mockOriginalChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn((_curated?: boolean) => chatHistory),
|
||||
@@ -416,47 +350,25 @@ describe('Gemini Client (client.ts)', () => {
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
// Calculate what the new history will be
|
||||
const splitPoint = findCompressSplitPoint(chatHistory, 0.7); // 1 - 0.3
|
||||
const historyToKeep = chatHistory.slice(splitPoint);
|
||||
|
||||
// This is the history that the new chat will have.
|
||||
// It includes the default startChat history + the extra history from tryCompressChat
|
||||
const newCompressedHistory: Content[] = [
|
||||
// Mocked envParts + canned response from startChat
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Mocked env context' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the context!' }],
|
||||
},
|
||||
// extraHistory from tryCompressChat
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
const newHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Summary' }] },
|
||||
{ role: 'model', parts: [{ text: 'Got it' }] },
|
||||
];
|
||||
|
||||
vi.mocked(ChatCompressionService.prototype.compress).mockResolvedValue({
|
||||
newHistory:
|
||||
compressionStatus === CompressionStatus.COMPRESSED
|
||||
? newHistory
|
||||
: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
compressionStatus,
|
||||
},
|
||||
});
|
||||
|
||||
const mockNewChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn().mockReturnValue(newCompressedHistory),
|
||||
getHistory: vi.fn().mockReturnValue(newHistory),
|
||||
setHistory: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -464,39 +376,32 @@ describe('Gemini Client (client.ts)', () => {
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const estimatedNewTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
return {
|
||||
client,
|
||||
mockOriginalChat,
|
||||
mockNewChat,
|
||||
estimatedNewTokenCount,
|
||||
estimatedNewTokenCount: newTokenCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe('when compression inflates the token count', () => {
|
||||
it('allows compression to be forced/manual after a failure', async () => {
|
||||
// Call 1 (Fails): Setup with a long summary to inflate tokens
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount: inflatedTokenCount } = setup({
|
||||
// Call 1 (Fails): Setup with inflated tokens
|
||||
setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
newTokenCount: 200,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
expect(inflatedTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
await client.tryCompressChat('prompt-id-4', false); // Fails
|
||||
|
||||
// Call 2 (Forced): Re-setup with a short summary
|
||||
const shortSummary = 'short';
|
||||
// Call 2 (Forced): Re-setup with compressed tokens
|
||||
const { estimatedNewTokenCount: compressedTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: shortSummary,
|
||||
newTokenCount: 50,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
});
|
||||
expect(compressedTokenCount).toBeLessThanOrEqual(100); // Ensure setup is correct
|
||||
|
||||
const result = await client.tryCompressChat('prompt-id-4', true); // Forced
|
||||
|
||||
@@ -508,12 +413,12 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
it('yields the result even if the compression inflated the tokens', async () => {
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
newTokenCount: 200,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
const result = await client.tryCompressChat('prompt-id-4', false);
|
||||
|
||||
@@ -530,12 +435,12 @@ describe('Gemini Client (client.ts)', () => {
|
||||
});
|
||||
|
||||
it('does not manipulate the source chat', async () => {
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, mockOriginalChat, estimatedNewTokenCount } = setup({
|
||||
const { client, mockOriginalChat } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
newTokenCount: 200,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
await client.tryCompressChat('prompt-id-4', false);
|
||||
|
||||
@@ -543,45 +448,65 @@ describe('Gemini Client (client.ts)', () => {
|
||||
expect(client['chat']).toBe(mockOriginalChat);
|
||||
});
|
||||
|
||||
it('will not attempt to compress context after a failure', async () => {
|
||||
const longSummary = 'long summary '.repeat(100);
|
||||
const { client, estimatedNewTokenCount } = setup({
|
||||
it.skip('will not attempt to compress context after a failure', async () => {
|
||||
const { client } = setup({
|
||||
originalTokenCount: 100,
|
||||
summaryText: longSummary,
|
||||
newTokenCount: 200,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
});
|
||||
expect(estimatedNewTokenCount).toBeGreaterThan(100); // Ensure setup is correct
|
||||
|
||||
await client.tryCompressChat('prompt-id-4', false); // This fails and sets hasFailedCompressionAttempt = true
|
||||
|
||||
// Mock the next call to return NOOP
|
||||
vi.mocked(
|
||||
ChatCompressionService.prototype.compress,
|
||||
).mockResolvedValueOnce({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
|
||||
// This call should now be a NOOP
|
||||
const result = await client.tryCompressChat('prompt-id-5', false);
|
||||
|
||||
// generateContent (for summary) should only have been called once
|
||||
expect(mockGenerateContentFn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
newTokenCount: 0,
|
||||
originalTokenCount: 0,
|
||||
});
|
||||
expect(result.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||
expect(ChatCompressionService.prototype.compress).toHaveBeenCalledTimes(
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
ChatCompressionService.prototype.compress,
|
||||
).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
'prompt-id-5',
|
||||
false,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
true, // hasFailedCompressionAttempt
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not trigger summarization if token count is below threshold', async () => {
|
||||
const MOCKED_TOKEN_LIMIT = 1000;
|
||||
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
|
||||
mockGetHistory.mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
]);
|
||||
const originalTokenCount = MOCKED_TOKEN_LIMIT * 0.699;
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
vi.mocked(ChatCompressionService.prototype.compress).mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
});
|
||||
|
||||
const initialChat = client.getChat();
|
||||
const result = await client.tryCompressChat('prompt-id-2', false);
|
||||
const newChat = client.getChat();
|
||||
|
||||
expect(tokenLimit).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
newTokenCount: originalTokenCount,
|
||||
@@ -594,6 +519,8 @@ describe('Gemini Client (client.ts)', () => {
|
||||
const { client } = setup({
|
||||
chatHistory: [{ role: 'user', parts: [{ text: 'hi' }] }],
|
||||
originalTokenCount: 50,
|
||||
newTokenCount: 50,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
});
|
||||
|
||||
const result = await client.tryCompressChat('prompt-id-noop', false);
|
||||
@@ -603,337 +530,6 @@ describe('Gemini Client (client.ts)', () => {
|
||||
originalTokenCount: 50,
|
||||
newTokenCount: 50,
|
||||
});
|
||||
expect(mockGenerateContentFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a telemetry event when compressing', async () => {
|
||||
vi.spyOn(ClearcutLogger.prototype, 'logChatCompressionEvent');
|
||||
const MOCKED_TOKEN_LIMIT = 1000;
|
||||
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
||||
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
||||
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
||||
});
|
||||
const history = [
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
|
||||
const originalTokenCount =
|
||||
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
|
||||
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
// We need to control the estimated new token count.
|
||||
// We mock startChat to return a chat with a known history.
|
||||
const summaryText = 'This is a summary.';
|
||||
const splitPoint = findCompressSplitPoint(history, 0.7);
|
||||
const historyToKeep = history.slice(splitPoint);
|
||||
const newCompressedHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Mocked env context' }] },
|
||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
||||
{ role: 'user', parts: [{ text: summaryText }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
];
|
||||
const mockNewChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn().mockReturnValue(newCompressedHistory),
|
||||
};
|
||||
client['startChat'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
await client.tryCompressChat('prompt-id-3', false);
|
||||
|
||||
expect(
|
||||
ClearcutLogger.prototype.logChatCompressionEvent,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tokens_before: originalTokenCount,
|
||||
tokens_after: newTokenCount,
|
||||
}),
|
||||
);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(
|
||||
newTokenCount,
|
||||
);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should trigger summarization if token count is at threshold with contextPercentageThreshold setting', async () => {
|
||||
const MOCKED_TOKEN_LIMIT = 1000;
|
||||
const MOCKED_CONTEXT_PERCENTAGE_THRESHOLD = 0.5;
|
||||
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
|
||||
vi.spyOn(client['config'], 'getChatCompression').mockReturnValue({
|
||||
contextPercentageThreshold: MOCKED_CONTEXT_PERCENTAGE_THRESHOLD,
|
||||
});
|
||||
const history = [
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
|
||||
const originalTokenCount =
|
||||
MOCKED_TOKEN_LIMIT * MOCKED_CONTEXT_PERCENTAGE_THRESHOLD;
|
||||
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
// Mock summary and new chat
|
||||
const summaryText = 'This is a summary.';
|
||||
const splitPoint = findCompressSplitPoint(history, 0.7);
|
||||
const historyToKeep = history.slice(splitPoint);
|
||||
const newCompressedHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Mocked env context' }] },
|
||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
||||
{ role: 'user', parts: [{ text: summaryText }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
];
|
||||
const mockNewChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn().mockReturnValue(newCompressedHistory),
|
||||
};
|
||||
client['startChat'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
const result = await client.tryCompressChat('prompt-id-3', false);
|
||||
const newChat = client.getChat();
|
||||
|
||||
expect(tokenLimit).toHaveBeenCalled();
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
// Assert that summarization happened and returned the correct stats
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
});
|
||||
|
||||
it('should not compress across a function call response', async () => {
|
||||
const MOCKED_TOKEN_LIMIT = 1000;
|
||||
vi.mocked(tokenLimit).mockReturnValue(MOCKED_TOKEN_LIMIT);
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: '...history 1...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history 2...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history 3...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history 4...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history 5...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history 6...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history 7...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history 8...' }] },
|
||||
// Normally we would break here, but we have a function response.
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ functionResponse: { name: '...history 8...' } }],
|
||||
},
|
||||
{ role: 'model', parts: [{ text: '...history 10...' }] },
|
||||
// Instead we will break here.
|
||||
{ role: 'user', parts: [{ text: '...history 10...' }] },
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
|
||||
const originalTokenCount = 1000 * 0.7;
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
// Mock summary and new chat
|
||||
const summaryText = 'This is a summary.';
|
||||
const splitPoint = findCompressSplitPoint(history, 0.7); // This should be 10
|
||||
expect(splitPoint).toBe(10); // Verify split point logic
|
||||
const historyToKeep = history.slice(splitPoint); // Should keep last user message
|
||||
expect(historyToKeep).toEqual([
|
||||
{ role: 'user', parts: [{ text: '...history 10...' }] },
|
||||
]);
|
||||
|
||||
const newCompressedHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Mocked env context' }] },
|
||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
||||
{ role: 'user', parts: [{ text: summaryText }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
];
|
||||
const mockNewChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn().mockReturnValue(newCompressedHistory),
|
||||
};
|
||||
client['startChat'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
const result = await client.tryCompressChat('prompt-id-3', false);
|
||||
const newChat = client.getChat();
|
||||
|
||||
expect(tokenLimit).toHaveBeenCalled();
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
// Assert that summarization happened and returned the correct stats
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
|
||||
// 1. standard start context message (env)
|
||||
// 2. standard canned model response
|
||||
// 3. compressed summary message (user)
|
||||
// 4. standard canned model response
|
||||
// 5. The last user message (historyToKeep)
|
||||
expect(newChat.getHistory().length).toEqual(5);
|
||||
});
|
||||
|
||||
it('should always trigger summarization when force is true, regardless of token count', async () => {
|
||||
const history = [
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
{ role: 'user', parts: [{ text: '...history...' }] },
|
||||
{ role: 'model', parts: [{ text: '...history...' }] },
|
||||
];
|
||||
mockGetHistory.mockReturnValue(history);
|
||||
|
||||
const originalTokenCount = 100; // Well below threshold, but > estimated new count
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
originalTokenCount,
|
||||
);
|
||||
|
||||
// Mock summary and new chat
|
||||
const summaryText = 'This is a summary.';
|
||||
const splitPoint = findCompressSplitPoint(history, 0.7);
|
||||
const historyToKeep = history.slice(splitPoint);
|
||||
const newCompressedHistory: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Mocked env context' }] },
|
||||
{ role: 'model', parts: [{ text: 'Got it. Thanks for the context!' }] },
|
||||
{ role: 'user', parts: [{ text: summaryText }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
];
|
||||
const mockNewChat: Partial<GeminiChat> = {
|
||||
getHistory: vi.fn().mockReturnValue(newCompressedHistory),
|
||||
};
|
||||
client['startChat'] = vi
|
||||
.fn()
|
||||
.mockResolvedValue(mockNewChat as GeminiChat);
|
||||
|
||||
const totalChars = newCompressedHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
);
|
||||
const newTokenCount = Math.floor(totalChars / 4);
|
||||
|
||||
// Mock the summary response from the chat
|
||||
mockGenerateContentFn.mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ text: summaryText }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
|
||||
const initialChat = client.getChat();
|
||||
const result = await client.tryCompressChat('prompt-id-1', true); // force = true
|
||||
const newChat = client.getChat();
|
||||
|
||||
expect(mockGenerateContentFn).toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
});
|
||||
|
||||
// Assert that the chat was reset
|
||||
expect(newChat).not.toBe(initialChat);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2073,7 +1669,11 @@ ${JSON.stringify(
|
||||
vi.mocked(ideContextStore.get).mockReturnValue({
|
||||
workspaceState: {
|
||||
openFiles: [
|
||||
{ ...currentActiveFile, isActive: true, timestamp: Date.now() },
|
||||
{
|
||||
...currentActiveFile,
|
||||
isActive: true,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,14 +13,13 @@ import type {
|
||||
} from '@google/genai';
|
||||
import {
|
||||
getDirectoryContextString,
|
||||
getEnvironmentContext,
|
||||
getInitialChatHistory,
|
||||
} from '../utils/environmentContext.js';
|
||||
import type { ServerGeminiStreamEvent, ChatCompressionInfo } from './turn.js';
|
||||
import { CompressionStatus } from './turn.js';
|
||||
import { Turn, GeminiEventType } from './turn.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { getCoreSystemPrompt } from './prompts.js';
|
||||
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
|
||||
import { reportError } from '../utils/errorReporting.js';
|
||||
import { GeminiChat } from './geminiChat.js';
|
||||
@@ -37,15 +36,14 @@ import {
|
||||
getEffectiveModel,
|
||||
} from '../config/models.js';
|
||||
import { LoopDetectionService } from '../services/loopDetectionService.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { ideContextStore } from '../ide/ideContext.js';
|
||||
import {
|
||||
logChatCompression,
|
||||
logContentRetryFailure,
|
||||
logNextSpeakerCheck,
|
||||
} from '../telemetry/loggers.js';
|
||||
import {
|
||||
ContentRetryFailureEvent,
|
||||
makeChatCompressionEvent,
|
||||
NextSpeakerCheckEvent,
|
||||
} from '../telemetry/types.js';
|
||||
import type { IdeContext, File } from '../ide/types.js';
|
||||
@@ -65,68 +63,8 @@ export function isThinkingDefault(model: string) {
|
||||
return model.startsWith('gemini-2.5') || model === DEFAULT_GEMINI_MODEL_AUTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the oldest item to keep when compressing. May return
|
||||
* contents.length which indicates that everything should be compressed.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function findCompressSplitPoint(
|
||||
contents: Content[],
|
||||
fraction: number,
|
||||
): number {
|
||||
if (fraction <= 0 || fraction >= 1) {
|
||||
throw new Error('Fraction must be between 0 and 1');
|
||||
}
|
||||
|
||||
const charCounts = contents.map((content) => JSON.stringify(content).length);
|
||||
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
||||
const targetCharCount = totalCharCount * fraction;
|
||||
|
||||
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
|
||||
let cumulativeCharCount = 0;
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
const content = contents[i];
|
||||
if (
|
||||
content.role === 'user' &&
|
||||
!content.parts?.some((part) => !!part.functionResponse)
|
||||
) {
|
||||
if (cumulativeCharCount >= targetCharCount) {
|
||||
return i;
|
||||
}
|
||||
lastSplitPoint = i;
|
||||
}
|
||||
cumulativeCharCount += charCounts[i];
|
||||
}
|
||||
|
||||
// We found no split points after targetCharCount.
|
||||
// Check if it's safe to compress everything.
|
||||
const lastContent = contents[contents.length - 1];
|
||||
if (
|
||||
lastContent?.role === 'model' &&
|
||||
!lastContent?.parts?.some((part) => part.functionCall)
|
||||
) {
|
||||
return contents.length;
|
||||
}
|
||||
|
||||
// Can't compress everything so just compress at last splitpoint.
|
||||
return lastSplitPoint;
|
||||
}
|
||||
|
||||
const MAX_TURNS = 100;
|
||||
|
||||
/**
|
||||
* Threshold for compression token count as a fraction of the model's token limit.
|
||||
* If the chat history exceeds this threshold, it will be compressed.
|
||||
*/
|
||||
const COMPRESSION_TOKEN_THRESHOLD = 0.7;
|
||||
|
||||
/**
|
||||
* The fraction of the latest chat history to keep. A value of 0.3
|
||||
* means that only the last 30% of the chat history will be kept after compression.
|
||||
*/
|
||||
const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
|
||||
|
||||
export class GeminiClient {
|
||||
private chat?: GeminiChat;
|
||||
private readonly generateContentConfig: GenerateContentConfig = {
|
||||
@@ -136,6 +74,7 @@ export class GeminiClient {
|
||||
private sessionTurnCount = 0;
|
||||
|
||||
private readonly loopDetector: LoopDetectionService;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private lastPromptId: string;
|
||||
private currentSequenceModel: string | null = null;
|
||||
private lastSentIdeContext: IdeContext | undefined;
|
||||
@@ -149,6 +88,7 @@ export class GeminiClient {
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
this.loopDetector = new LoopDetectionService(config);
|
||||
this.compressionService = new ChatCompressionService();
|
||||
this.lastPromptId = this.config.getSessionId();
|
||||
}
|
||||
|
||||
@@ -233,31 +173,7 @@ export class GeminiClient {
|
||||
const toolDeclarations = toolRegistry.getFunctionDeclarations();
|
||||
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
|
||||
|
||||
// 1. Get the environment context parts as an array
|
||||
const envParts = await getEnvironmentContext(this.config);
|
||||
|
||||
// 2. Convert the array of parts into a single string
|
||||
const envContextString = envParts
|
||||
.map((part) => part.text || '')
|
||||
.join('\n\n');
|
||||
|
||||
// 3. Combine the dynamic context with the static handshake instruction
|
||||
const allSetupText = `
|
||||
${envContextString}
|
||||
|
||||
Reminder: Do not return an empty response when a tool call is required.
|
||||
|
||||
My setup is complete. I will provide my first command in the next turn.
|
||||
`.trim();
|
||||
|
||||
// 4. Create the history with a single, comprehensive user turn
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: allSetupText }],
|
||||
},
|
||||
...(extraHistory ?? []),
|
||||
];
|
||||
const history = await getInitialChatHistory(this.config, extraHistory);
|
||||
|
||||
try {
|
||||
const userMemory = this.config.getUserMemory();
|
||||
@@ -738,129 +654,27 @@ My setup is complete. I will provide my first command in the next turn.
|
||||
// before the model is chosen would result in an error.
|
||||
const model = this._getEffectiveModelForCurrentTurn();
|
||||
|
||||
const curatedHistory = this.getChat().getHistory(true);
|
||||
const { newHistory, info } = await this.compressionService.compress(
|
||||
this.getChat(),
|
||||
prompt_id,
|
||||
force,
|
||||
model,
|
||||
this.config,
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
// Regardless of `force`, don't do anything if the history is empty.
|
||||
if (
|
||||
curatedHistory.length === 0 ||
|
||||
(this.hasFailedCompressionAttempt && !force)
|
||||
info.compressionStatus ===
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||
) {
|
||||
return {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
};
|
||||
}
|
||||
|
||||
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
|
||||
const contextPercentageThreshold =
|
||||
this.config.getChatCompression()?.contextPercentageThreshold;
|
||||
|
||||
// Don't compress if not forced and we are under the limit.
|
||||
if (!force) {
|
||||
const threshold =
|
||||
contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;
|
||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||
return {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
};
|
||||
this.hasFailedCompressionAttempt = !force && true;
|
||||
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
if (newHistory) {
|
||||
this.chat = await this.startChat(newHistory);
|
||||
this.forceFullIdeContext = true;
|
||||
}
|
||||
}
|
||||
|
||||
const splitPoint = findCompressSplitPoint(
|
||||
curatedHistory,
|
||||
1 - COMPRESSION_PRESERVE_THRESHOLD,
|
||||
);
|
||||
|
||||
const historyToCompress = curatedHistory.slice(0, splitPoint);
|
||||
const historyToKeep = curatedHistory.slice(splitPoint);
|
||||
|
||||
if (historyToCompress.length === 0) {
|
||||
return {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
};
|
||||
}
|
||||
|
||||
const summaryResponse = await this.config
|
||||
.getContentGenerator()
|
||||
.generateContent(
|
||||
{
|
||||
model,
|
||||
contents: [
|
||||
...historyToCompress,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
config: {
|
||||
systemInstruction: { text: getCompressionPrompt() },
|
||||
},
|
||||
},
|
||||
prompt_id,
|
||||
);
|
||||
const summary = getResponseText(summaryResponse) ?? '';
|
||||
|
||||
const chat = await this.startChat([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: summary }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
]);
|
||||
this.forceFullIdeContext = true;
|
||||
|
||||
// Estimate token count 1 token ≈ 4 characters
|
||||
const newTokenCount = Math.floor(
|
||||
chat
|
||||
.getHistory()
|
||||
.reduce((total, content) => total + JSON.stringify(content).length, 0) /
|
||||
4,
|
||||
);
|
||||
|
||||
logChatCompression(
|
||||
this.config,
|
||||
makeChatCompressionEvent({
|
||||
tokens_before: originalTokenCount,
|
||||
tokens_after: newTokenCount,
|
||||
}),
|
||||
);
|
||||
|
||||
if (newTokenCount > originalTokenCount) {
|
||||
this.hasFailedCompressionAttempt = !force && true;
|
||||
return {
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
};
|
||||
} else {
|
||||
this.chat = chat; // Chat compression successful, set new state.
|
||||
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
|
||||
}
|
||||
|
||||
return {
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
};
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
COMPRESSION_PRESERVE_THRESHOLD,
|
||||
COMPRESSION_TOKEN_THRESHOLD,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
ChatCompressionService,
|
||||
findCompressSplitPoint,
|
||||
} from './chatCompressionService.js';
|
||||
import type { Content, GenerateContentResponse } from '@google/genai';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import type { GeminiChat } from '../core/geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
|
||||
vi.mock('../telemetry/uiTelemetry.js');
|
||||
vi.mock('../core/tokenLimits.js');
|
||||
vi.mock('../telemetry/loggers.js');
|
||||
vi.mock('../utils/environmentContext.js');
|
||||
|
||||
describe('findCompressSplitPoint', () => {
|
||||
it('should throw an error for non-positive numbers', () => {
|
||||
expect(() => findCompressSplitPoint([], 0)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for a fraction greater than or equal to 1', () => {
|
||||
expect(() => findCompressSplitPoint([], 1)).toThrow(
|
||||
'Fraction must be between 0 and 1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle an empty history', () => {
|
||||
expect(findCompressSplitPoint([], 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle a fraction in the middle', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.5)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (19%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (40%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (60%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (80%)
|
||||
{ role: 'user', parts: [{ text: 'This is the fifth message.' }] }, // JSON length: 65 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.9)).toBe(4);
|
||||
});
|
||||
|
||||
it('should handle a fraction of after last index', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] }, // JSON length: 66 (24%)
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] }, // JSON length: 68 (50%)
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] }, // JSON length: 66 (74%)
|
||||
{ role: 'model', parts: [{ text: 'This is the fourth message.' }] }, // JSON length: 68 (100%)
|
||||
];
|
||||
expect(findCompressSplitPoint(history, 0.8)).toBe(4);
|
||||
});
|
||||
|
||||
it('should return earlier splitpoint if no valid ones are after threshold', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'This is the first message.' }] },
|
||||
{ role: 'model', parts: [{ text: 'This is the second message.' }] },
|
||||
{ role: 'user', parts: [{ text: 'This is the third message.' }] },
|
||||
{ role: 'model', parts: [{ functionCall: { name: 'foo', args: {} } }] },
|
||||
];
|
||||
// Can't return 4 because the previous item has a function call.
|
||||
expect(findCompressSplitPoint(history, 0.99)).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle a history with only one item', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle history with weird parts', () => {
|
||||
const historyWithEmptyParts: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Message 1' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ fileData: { fileUri: 'derp', mimeType: 'text/plain' } }],
|
||||
},
|
||||
{ role: 'user', parts: [{ text: 'Message 2' }] },
|
||||
];
|
||||
expect(findCompressSplitPoint(historyWithEmptyParts, 0.5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompressionService', () => {
|
||||
let service: ChatCompressionService;
|
||||
let mockChat: GeminiChat;
|
||||
let mockConfig: Config;
|
||||
const mockModel = 'gemini-pro';
|
||||
const mockPromptId = 'test-prompt-id';
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ChatCompressionService();
|
||||
mockChat = {
|
||||
getHistory: vi.fn(),
|
||||
} as unknown as GeminiChat;
|
||||
mockConfig = {
|
||||
getCompressionThreshold: vi.fn(),
|
||||
getContentGenerator: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(500);
|
||||
vi.mocked(getInitialChatHistory).mockImplementation(
|
||||
async (_config, extraHistory) => extraHistory || [],
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return NOOP if history is empty', async () => {
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue([]);
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return NOOP if previously failed and not forced', async () => {
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'hi' }] },
|
||||
]);
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
true,
|
||||
);
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should return NOOP if under token threshold and not forced', async () => {
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'hi' }] },
|
||||
]);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(600);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
// Threshold is 0.7 * 1000 = 700. 600 < 700, so NOOP.
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
|
||||
it('should compress if over token threshold', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.newHistory).not.toBeNull();
|
||||
expect(result.newHistory![0].parts![0].text).toBe('Summary');
|
||||
expect(mockGenerateContent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should force compress even if under threshold', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(100);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true, // forced
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.newHistory).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return FAILED if new token count is inflated', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(10);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const longSummary = 'a'.repeat(1000); // Long summary to inflate token count
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: longSummary }],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
);
|
||||
expect(result.newHistory).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { GeminiChat } from '../core/geminiChat.js';
|
||||
import { type ChatCompressionInfo, CompressionStatus } from '../core/turn.js';
|
||||
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
|
||||
import { tokenLimit } from '../core/tokenLimits.js';
|
||||
import { getCompressionPrompt } from '../core/prompts.js';
|
||||
import { getResponseText } from '../utils/partUtils.js';
|
||||
import { logChatCompression } from '../telemetry/loggers.js';
|
||||
import { makeChatCompressionEvent } from '../telemetry/types.js';
|
||||
import { getInitialChatHistory } from '../utils/environmentContext.js';
|
||||
|
||||
/**
|
||||
* Default threshold for compression token count as a fraction of the model's
|
||||
* token limit. If the chat history exceeds this threshold, it will be compressed.
|
||||
*/
|
||||
export const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.2;
|
||||
|
||||
/**
|
||||
* The fraction of the latest chat history to keep. A value of 0.3
|
||||
* means that only the last 30% of the chat history will be kept after compression.
|
||||
*/
|
||||
export const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
|
||||
|
||||
/**
|
||||
* Returns the index of the oldest item to keep when compressing. May return
|
||||
* contents.length which indicates that everything should be compressed.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function findCompressSplitPoint(
|
||||
contents: Content[],
|
||||
fraction: number,
|
||||
): number {
|
||||
if (fraction <= 0 || fraction >= 1) {
|
||||
throw new Error('Fraction must be between 0 and 1');
|
||||
}
|
||||
|
||||
const charCounts = contents.map((content) => JSON.stringify(content).length);
|
||||
const totalCharCount = charCounts.reduce((a, b) => a + b, 0);
|
||||
const targetCharCount = totalCharCount * fraction;
|
||||
|
||||
let lastSplitPoint = 0; // 0 is always valid (compress nothing)
|
||||
let cumulativeCharCount = 0;
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
const content = contents[i];
|
||||
if (
|
||||
content.role === 'user' &&
|
||||
!content.parts?.some((part) => !!part.functionResponse)
|
||||
) {
|
||||
if (cumulativeCharCount >= targetCharCount) {
|
||||
return i;
|
||||
}
|
||||
lastSplitPoint = i;
|
||||
}
|
||||
cumulativeCharCount += charCounts[i];
|
||||
}
|
||||
|
||||
// We found no split points after targetCharCount.
|
||||
// Check if it's safe to compress everything.
|
||||
const lastContent = contents[contents.length - 1];
|
||||
if (
|
||||
lastContent?.role === 'model' &&
|
||||
!lastContent?.parts?.some((part) => part.functionCall)
|
||||
) {
|
||||
return contents.length;
|
||||
}
|
||||
|
||||
// Can't compress everything so just compress at last splitpoint.
|
||||
return lastSplitPoint;
|
||||
}
|
||||
|
||||
export class ChatCompressionService {
|
||||
async compress(
|
||||
chat: GeminiChat,
|
||||
promptId: string,
|
||||
force: boolean,
|
||||
model: string,
|
||||
config: Config,
|
||||
hasFailedCompressionAttempt: boolean,
|
||||
): Promise<{ newHistory: Content[] | null; info: ChatCompressionInfo }> {
|
||||
const curatedHistory = chat.getHistory(true);
|
||||
|
||||
// Regardless of `force`, don't do anything if the history is empty.
|
||||
if (
|
||||
curatedHistory.length === 0 ||
|
||||
(hasFailedCompressionAttempt && !force)
|
||||
) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount: 0,
|
||||
newTokenCount: 0,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const originalTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
|
||||
// Don't compress if not forced and we are under the limit.
|
||||
if (!force) {
|
||||
const threshold =
|
||||
config.getCompressionThreshold() ?? DEFAULT_COMPRESSION_TOKEN_THRESHOLD;
|
||||
if (originalTokenCount < threshold * tokenLimit(model)) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const splitPoint = findCompressSplitPoint(
|
||||
curatedHistory,
|
||||
1 - COMPRESSION_PRESERVE_THRESHOLD,
|
||||
);
|
||||
|
||||
const historyToCompress = curatedHistory.slice(0, splitPoint);
|
||||
const historyToKeep = curatedHistory.slice(splitPoint);
|
||||
|
||||
if (historyToCompress.length === 0) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount: originalTokenCount,
|
||||
compressionStatus: CompressionStatus.NOOP,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const summaryResponse = await config.getContentGenerator().generateContent(
|
||||
{
|
||||
model,
|
||||
contents: [
|
||||
...historyToCompress,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'First, reason in your scratchpad. Then, generate the <state_snapshot>.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
config: {
|
||||
systemInstruction: { text: getCompressionPrompt() },
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
const summary = getResponseText(summaryResponse) ?? '';
|
||||
|
||||
const extraHistory: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: summary }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Got it. Thanks for the additional context!' }],
|
||||
},
|
||||
...historyToKeep,
|
||||
];
|
||||
|
||||
// Use a shared utility to construct the initial history for an accurate token count.
|
||||
const fullNewHistory = await getInitialChatHistory(config, extraHistory);
|
||||
|
||||
// Estimate token count 1 token ≈ 4 characters
|
||||
const newTokenCount = Math.floor(
|
||||
fullNewHistory.reduce(
|
||||
(total, content) => total + JSON.stringify(content).length,
|
||||
0,
|
||||
) / 4,
|
||||
);
|
||||
|
||||
logChatCompression(
|
||||
config,
|
||||
makeChatCompressionEvent({
|
||||
tokens_before: originalTokenCount,
|
||||
tokens_after: newTokenCount,
|
||||
}),
|
||||
);
|
||||
|
||||
if (newTokenCount > originalTokenCount) {
|
||||
return {
|
||||
newHistory: null,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
compressionStatus:
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
uiTelemetryService.setLastPromptTokenCount(newTokenCount);
|
||||
return {
|
||||
newHistory: extraHistory,
|
||||
info: {
|
||||
originalTokenCount,
|
||||
newTokenCount,
|
||||
compressionStatus: CompressionStatus.COMPRESSED,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,19 @@ describe('LoopDetectionService', () => {
|
||||
}
|
||||
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should stop reporting a loop if disabled after detection', () => {
|
||||
const event = createToolCallRequestEvent('testTool', { param: 'value' });
|
||||
for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) {
|
||||
service.addAndCheck(event);
|
||||
}
|
||||
expect(service.addAndCheck(event)).toBe(true);
|
||||
|
||||
service.disableForSession();
|
||||
|
||||
// Should now return false even though a loop was previously detected
|
||||
expect(service.addAndCheck(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Loop Detection', () => {
|
||||
|
||||
@@ -123,7 +123,11 @@ export class LoopDetectionService {
|
||||
* @returns true if a loop is detected, false otherwise
|
||||
*/
|
||||
addAndCheck(event: ServerGeminiStreamEvent): boolean {
|
||||
if (this.loopDetected || this.disabledForSession) {
|
||||
if (this.disabledForSession) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.loopDetected) {
|
||||
return this.loopDetected;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Part } from '@google/genai';
|
||||
import type { Part, Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getFolderStructure } from './getFolderStructure.js';
|
||||
|
||||
@@ -71,3 +71,27 @@ ${directoryContext}
|
||||
|
||||
return initialParts;
|
||||
}
|
||||
|
||||
export async function getInitialChatHistory(
|
||||
config: Config,
|
||||
extraHistory?: Content[],
|
||||
): Promise<Content[]> {
|
||||
const envParts = await getEnvironmentContext(config);
|
||||
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||
|
||||
const allSetupText = `
|
||||
${envContextString}
|
||||
|
||||
Reminder: Do not return an empty response when a tool call is required.
|
||||
|
||||
My setup is complete. I will provide my first command in the next turn.
|
||||
`.trim();
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: allSetupText }],
|
||||
},
|
||||
...(extraHistory ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
const quotaId = violation.quotaId ?? '';
|
||||
if (quotaId.includes('PerDay') || quotaId.includes('Daily')) {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}\nExpected quota reset within 24h.`,
|
||||
`You have exhausted your daily quota on this model.`,
|
||||
googleApiError,
|
||||
);
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
const quotaLimit = errorInfo.metadata?.['quota_limit'] ?? '';
|
||||
if (quotaLimit.includes('PerDay') || quotaLimit.includes('Daily')) {
|
||||
return new TerminalQuotaError(
|
||||
`${googleApiError.message}\nExpected quota reset within 24h.`,
|
||||
`You have exhausted your daily quota on this model.`,
|
||||
googleApiError,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.11.1",
|
||||
"version": "0.11.3",
|
||||
"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.11.1",
|
||||
"version": "0.11.3",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user