mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 13:41:05 -07:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd5d18c210 | |||
| 4f9293e8e9 | |||
| 9b51ccf82a | |||
| c47bca8374 | |||
| 2c83cf9603 | |||
| 84432ddaa8 | |||
| 05d29d68aa | |||
| 99ddc299ad | |||
| cd4c8821cb | |||
| 8d0c2a3acb | |||
| 184a9abb59 | |||
| 4b2c117af3 | |||
| 511cbc54c9 | |||
| 692c34c834 | |||
| 3be1de915f | |||
| 16485afa96 | |||
| 375afc7199 | |||
| fd8d218911 | |||
| 1de0367faa | |||
| b6231f561b | |||
| 252dbeb39a | |||
| 39fb7b11a8 | |||
| eb3e540f3f |
+34
-32
@@ -46,38 +46,40 @@ they appear in the UI.
|
|||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
| UI Label | Setting | Description | Default |
|
| UI Label | Setting | Description | Default |
|
||||||
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
| ------------------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
|
||||||
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
|
||||||
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
|
||||||
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
|
||||||
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
|
||||||
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
|
||||||
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
|
||||||
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
|
||||||
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
|
||||||
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
|
||||||
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
|
||||||
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
|
||||||
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
|
||||||
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
|
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `true` |
|
||||||
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
| Show Context Window Warning | `ui.showContextWindowWarning` | Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached. | `false` |
|
||||||
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
| Show Context Compression Messages | `ui.showContextCompression` | Show a message in the chat history when it is compressed. | `false` |
|
||||||
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
|
||||||
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
|
||||||
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
|
||||||
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
|
||||||
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
|
||||||
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
|
||||||
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
|
||||||
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
|
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
|
||||||
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
|
||||||
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
|
||||||
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
|
||||||
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
|
||||||
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
|
||||||
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
|
||||||
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
|
||||||
|
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
|
||||||
|
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
|
||||||
|
|
||||||
### IDE
|
### IDE
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,16 @@ their corresponding top-level category object in your `settings.json` file.
|
|||||||
- **`ui.hideContextSummary`** (boolean):
|
- **`ui.hideContextSummary`** (boolean):
|
||||||
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
|
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
|
||||||
input.
|
input.
|
||||||
|
- **Default:** `true`
|
||||||
|
|
||||||
|
- **`ui.showContextWindowWarning`** (boolean):
|
||||||
|
- **Description:** Show a warning message when the context window limit is
|
||||||
|
nearly reached. If disabled, the CLI will attempt to automatically compress
|
||||||
|
the history when the limit is reached.
|
||||||
|
- **Default:** `false`
|
||||||
|
|
||||||
|
- **`ui.showContextCompression`** (boolean):
|
||||||
|
- **Description:** Show a message in the chat history when it is compressed.
|
||||||
- **Default:** `false`
|
- **Default:** `false`
|
||||||
|
|
||||||
- **`ui.footer.items`** (array):
|
- **`ui.footer.items`** (array):
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ describe('Interactive Mode', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await run.expectText('Chat history compressed', 5000);
|
await run.expectText('Context compressed', 5000);
|
||||||
|
await run.expectText('Adjust threshold', 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Context compression is broken and doesn't include the system
|
// TODO: Context compression is broken and doesn't include the system
|
||||||
|
|||||||
@@ -2103,6 +2103,52 @@ describe('loadCliConfig compressionThreshold', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig showContextWindowWarning', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
|
||||||
|
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass showContextWindowWarning from settings to config (true)', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
|
const settings = createTestMergedSettings({
|
||||||
|
ui: {
|
||||||
|
showContextWindowWarning: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||||
|
expect(config.getShowContextWindowWarning()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass showContextWindowWarning from settings to config (false)', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
|
const settings = createTestMergedSettings({
|
||||||
|
ui: {
|
||||||
|
showContextWindowWarning: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||||
|
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to false if not in settings', async () => {
|
||||||
|
process.argv = ['node', 'script.js'];
|
||||||
|
const argv = await parseArguments(createTestMergedSettings());
|
||||||
|
const settings = createTestMergedSettings();
|
||||||
|
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||||
|
expect(config.getShowContextWindowWarning()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('loadCliConfig useRipgrep', () => {
|
describe('loadCliConfig useRipgrep', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
|||||||
@@ -955,6 +955,8 @@ export async function loadCliConfig(
|
|||||||
bugCommand: settings.advanced?.bugCommand,
|
bugCommand: settings.advanced?.bugCommand,
|
||||||
model: resolvedModel,
|
model: resolvedModel,
|
||||||
maxSessionTurns: settings.model?.maxSessionTurns,
|
maxSessionTurns: settings.model?.maxSessionTurns,
|
||||||
|
showContextWindowWarning: settings.ui?.showContextWindowWarning,
|
||||||
|
showContextCompression: settings.ui?.showContextCompression,
|
||||||
|
|
||||||
listExtensions: argv.listExtensions || false,
|
listExtensions: argv.listExtensions || false,
|
||||||
listSessions: argv.listSessions || false,
|
listSessions: argv.listSessions || false,
|
||||||
|
|||||||
@@ -575,11 +575,31 @@ const SETTINGS_SCHEMA = {
|
|||||||
label: 'Hide Context Summary',
|
label: 'Hide Context Summary',
|
||||||
category: 'UI',
|
category: 'UI',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
default: false,
|
default: true,
|
||||||
description:
|
description:
|
||||||
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
|
showContextWindowWarning: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Context Window Warning',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
|
showContextCompression: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Context Compression Messages',
|
||||||
|
category: 'UI',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description:
|
||||||
|
'Show a message in the chat history when it is compressed.',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
footer: {
|
footer: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Footer',
|
label: 'Footer',
|
||||||
|
|||||||
@@ -4,28 +4,42 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import * as Core from '@google/gemini-cli-core';
|
||||||
CompressionStatus,
|
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
type ChatCompressionInfo,
|
|
||||||
type GeminiClient,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { compressCommand } from './compressCommand.js';
|
import { compressCommand } from './compressCommand.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const actual = (await importOriginal()) as any;
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
tokenLimit: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('compressCommand', () => {
|
describe('compressCommand', () => {
|
||||||
let context: ReturnType<typeof createMockCommandContext>;
|
let context: ReturnType<typeof createMockCommandContext>;
|
||||||
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
let mockTryCompressChat: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockTryCompressChat = vi.fn();
|
mockTryCompressChat = vi.fn();
|
||||||
|
vi.mocked(Core.tokenLimit).mockReturnValue(1000);
|
||||||
context = createMockCommandContext({
|
context = createMockCommandContext({
|
||||||
services: {
|
services: {
|
||||||
agentContext: {
|
agentContext: {
|
||||||
|
config: {
|
||||||
|
getModel: () => 'test-model',
|
||||||
|
getContextWindowCompressionThreshold: () => 0.2,
|
||||||
|
},
|
||||||
geminiClient: {
|
geminiClient: {
|
||||||
tryCompressChat: mockTryCompressChat,
|
tryCompressChat: mockTryCompressChat,
|
||||||
} as unknown as GeminiClient,
|
} as unknown as Core.GeminiClient,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -36,9 +50,10 @@ describe('compressCommand', () => {
|
|||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
compression: {
|
compression: {
|
||||||
isPending: true,
|
isPending: true,
|
||||||
originalTokenCount: null,
|
beforePercentage: null,
|
||||||
newTokenCount: null,
|
afterPercentage: null,
|
||||||
compressionStatus: null,
|
compressionStatus: null,
|
||||||
|
isManual: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await compressCommand.action!(context, '');
|
await compressCommand.action!(context, '');
|
||||||
@@ -54,9 +69,9 @@ describe('compressCommand', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set pending item, call tryCompressChat, and add result on success', async () => {
|
it('should set pending item, call tryCompressChat, and add result on success', async () => {
|
||||||
const compressedResult: ChatCompressionInfo = {
|
const compressedResult: Core.ChatCompressionInfo = {
|
||||||
originalTokenCount: 200,
|
originalTokenCount: 200,
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
compressionStatus: Core.CompressionStatus.COMPRESSED,
|
||||||
newTokenCount: 100,
|
newTokenCount: 100,
|
||||||
};
|
};
|
||||||
mockTryCompressChat.mockResolvedValue(compressedResult);
|
mockTryCompressChat.mockResolvedValue(compressedResult);
|
||||||
@@ -68,8 +83,9 @@ describe('compressCommand', () => {
|
|||||||
compression: {
|
compression: {
|
||||||
isPending: true,
|
isPending: true,
|
||||||
compressionStatus: null,
|
compressionStatus: null,
|
||||||
originalTokenCount: null,
|
beforePercentage: null,
|
||||||
newTokenCount: null,
|
afterPercentage: null,
|
||||||
|
isManual: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,9 +99,11 @@ describe('compressCommand', () => {
|
|||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
compression: {
|
compression: {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
compressionStatus: Core.CompressionStatus.COMPRESSED,
|
||||||
originalTokenCount: 200,
|
beforePercentage: 20,
|
||||||
newTokenCount: 100,
|
afterPercentage: 10,
|
||||||
|
isManual: true,
|
||||||
|
thresholdPercentage: 20,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { MessageType, type HistoryItemCompression } from '../types.js';
|
import { MessageType, type HistoryItemCompression } from '../types.js';
|
||||||
import { CommandKind, type SlashCommand } from './types.js';
|
import { CommandKind, type SlashCommand } from './types.js';
|
||||||
|
import { tokenLimit, type CompressionStatus } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
export const compressCommand: SlashCommand = {
|
export const compressCommand: SlashCommand = {
|
||||||
name: 'compress',
|
name: 'compress',
|
||||||
@@ -14,7 +15,21 @@ export const compressCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
autoExecute: true,
|
autoExecute: true,
|
||||||
action: async (context) => {
|
action: async (context) => {
|
||||||
const { ui } = context;
|
const { ui, services } = context;
|
||||||
|
const agentContext = services.agentContext;
|
||||||
|
if (!agentContext) {
|
||||||
|
ui.addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.ERROR,
|
||||||
|
text: 'Agent context not found.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = agentContext.config;
|
||||||
|
|
||||||
if (ui.pendingItem) {
|
if (ui.pendingItem) {
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
@@ -30,29 +45,43 @@ export const compressCommand: SlashCommand = {
|
|||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
compression: {
|
compression: {
|
||||||
isPending: true,
|
isPending: true,
|
||||||
originalTokenCount: null,
|
beforePercentage: null,
|
||||||
newTokenCount: null,
|
afterPercentage: null,
|
||||||
compressionStatus: null,
|
compressionStatus: null,
|
||||||
|
isManual: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ui.setPendingItem(pendingMessage);
|
ui.setPendingItem(pendingMessage);
|
||||||
const promptId = `compress-${Date.now()}`;
|
const promptId = `compress-${Date.now()}`;
|
||||||
const compressed =
|
const compressed = await agentContext.geminiClient.tryCompressChat(
|
||||||
await context.services.agentContext?.geminiClient?.tryCompressChat(
|
promptId,
|
||||||
promptId,
|
true,
|
||||||
true,
|
);
|
||||||
);
|
|
||||||
if (compressed) {
|
if (compressed) {
|
||||||
|
const limit = tokenLimit(config.getModel());
|
||||||
|
const threshold = config.getContextWindowCompressionThreshold();
|
||||||
|
const beforePercentage = Math.round(
|
||||||
|
(compressed.originalTokenCount / limit) * 100,
|
||||||
|
);
|
||||||
|
const afterPercentage = Math.round(
|
||||||
|
(compressed.newTokenCount / limit) * 100,
|
||||||
|
);
|
||||||
|
|
||||||
ui.addItem(
|
ui.addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.COMPRESSION,
|
type: MessageType.COMPRESSION,
|
||||||
compression: {
|
compression: {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
originalTokenCount: compressed.originalTokenCount,
|
beforePercentage,
|
||||||
newTokenCount: compressed.newTokenCount,
|
afterPercentage,
|
||||||
compressionStatus: compressed.compressionStatus,
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
compressionStatus: Number(
|
||||||
|
compressed.compressionStatus,
|
||||||
|
) as unknown as CompressionStatus,
|
||||||
|
isManual: true,
|
||||||
|
thresholdPercentage: Math.round(threshold * 100),
|
||||||
},
|
},
|
||||||
} as HistoryItemCompression,
|
} as HistoryItemCompression,
|
||||||
Date.now(),
|
Date.now(),
|
||||||
|
|||||||
@@ -17,11 +17,7 @@ import {
|
|||||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||||
import { SettingsContext } from '../contexts/SettingsContext.js';
|
import { SettingsContext } from '../contexts/SettingsContext.js';
|
||||||
import { createMockSettings } from '../../test-utils/settings.js';
|
import { createMockSettings } from '../../test-utils/settings.js';
|
||||||
import {
|
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
|
||||||
ApprovalMode,
|
|
||||||
tokenLimit,
|
|
||||||
CoreToolCallStatus,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
import type { Config } from '@google/gemini-cli-core';
|
import type { Config } from '@google/gemini-cli-core';
|
||||||
import { StreamingState } from '../types.js';
|
import { StreamingState } from '../types.js';
|
||||||
import { TransientMessageType } from '../../utils/events.js';
|
import { TransientMessageType } from '../../utils/events.js';
|
||||||
@@ -733,37 +729,6 @@ describe('Composer', () => {
|
|||||||
expect(output).toContain('Press Esc again to rewind.');
|
expect(output).toContain('Press Esc again to rewind.');
|
||||||
expect(output).not.toContain('ContextSummaryDisplay');
|
expect(output).not.toContain('ContextSummaryDisplay');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows context usage bleed-through when over 60%', async () => {
|
|
||||||
const model = 'gemini-2.5-pro';
|
|
||||||
const uiState = createMockUIState({
|
|
||||||
cleanUiDetailsVisible: false,
|
|
||||||
currentModel: model,
|
|
||||||
sessionStats: {
|
|
||||||
sessionId: 'test-session',
|
|
||||||
sessionStartTime: new Date(),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
metrics: {} as any,
|
|
||||||
lastPromptTokenCount: Math.floor(tokenLimit(model) * 0.7),
|
|
||||||
promptCount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const settings = createMockSettings({
|
|
||||||
ui: {
|
|
||||||
footer: { hideContextPercentage: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { lastFrame } = await renderComposer(uiState, settings);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
|
||||||
});
|
|
||||||
|
|
||||||
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
|
|
||||||
expect(lastFrame()).toContain('StatusDisplay');
|
|
||||||
expect(lastFrame()).toContain('70% used');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error Details Display', () => {
|
describe('Error Details Display', () => {
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ import { useVimMode } from '../contexts/VimModeContext.js';
|
|||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||||
import { isContextUsageHigh } from '../utils/contextUsage.js';
|
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
|
||||||
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||||
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
|
||||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||||
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
|
|
||||||
import { StatusDisplay } from './StatusDisplay.js';
|
import { StatusDisplay } from './StatusDisplay.js';
|
||||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||||
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
|
||||||
@@ -257,11 +255,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
|
||||||
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
|
||||||
const miniMode_ShowTip = showTipLine;
|
const miniMode_ShowTip = showTipLine;
|
||||||
const miniMode_ShowContext = isContextUsageHigh(
|
|
||||||
uiState.sessionStats.lastPromptTokenCount,
|
|
||||||
uiState.currentModel,
|
|
||||||
settings.merged.model?.compressionThreshold,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Composite Mini Mode Triggers
|
// Composite Mini Mode Triggers
|
||||||
const showRow1_MiniMode =
|
const showRow1_MiniMode =
|
||||||
@@ -270,7 +263,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
miniMode_ShowShortcuts ||
|
miniMode_ShowShortcuts ||
|
||||||
miniMode_ShowTip;
|
miniMode_ShowTip;
|
||||||
|
|
||||||
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
|
const showRow2_MiniMode = miniMode_ShowApprovalMode;
|
||||||
|
|
||||||
// Final Display Rules (Stable Footer Architecture)
|
// Final Display Rules (Stable Footer Architecture)
|
||||||
const showRow1 = showUiDetails || showRow1_MiniMode;
|
const showRow1 = showUiDetails || showRow1_MiniMode;
|
||||||
@@ -488,22 +481,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
marginLeft={isNarrow ? 1 : 0}
|
marginLeft={isNarrow ? 1 : 0}
|
||||||
>
|
>
|
||||||
{(showUiDetails || miniMode_ShowContext) && (
|
{showUiDetails && (
|
||||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||||
)}
|
)}
|
||||||
{miniMode_ShowContext && !showUiDetails && (
|
|
||||||
<Box marginLeft={1}>
|
|
||||||
<ContextUsageDisplay
|
|
||||||
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
|
|
||||||
model={
|
|
||||||
typeof uiState.currentModel === 'string'
|
|
||||||
? uiState.currentModel
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
terminalWidth={uiState.terminalWidth}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ const createMockConfig = (overrides = {}) => ({
|
|||||||
const renderStatusDisplay = async (
|
const renderStatusDisplay = async (
|
||||||
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
props: { hideContextSummary: boolean } = { hideContextSummary: false },
|
||||||
uiState: UIState = createMockUIState(),
|
uiState: UIState = createMockUIState(),
|
||||||
settings = createMockSettings(),
|
settings = createMockSettings({
|
||||||
|
ui: { hideContextSummary: true },
|
||||||
|
}),
|
||||||
config = createMockConfig(),
|
config = createMockConfig(),
|
||||||
) => {
|
) => {
|
||||||
const result = await render(
|
const result = await render(
|
||||||
@@ -97,16 +99,21 @@ describe('StatusDisplay', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders nothing by default if context summary is hidden via props', async () => {
|
it('renders nothing by default', async () => {
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay({
|
const { lastFrame, unmount } = await renderStatusDisplay();
|
||||||
hideContextSummary: true,
|
|
||||||
});
|
|
||||||
expect(lastFrame({ allowEmpty: true })).toBe('');
|
expect(lastFrame({ allowEmpty: true })).toBe('');
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders ContextSummaryDisplay by default', async () => {
|
it('renders ContextSummaryDisplay when hideContextSummary is false', async () => {
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay();
|
const settings = createMockSettings({
|
||||||
|
ui: { hideContextSummary: false },
|
||||||
|
});
|
||||||
|
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||||
|
{ hideContextSummary: false },
|
||||||
|
undefined,
|
||||||
|
settings,
|
||||||
|
);
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
@@ -118,34 +125,6 @@ describe('StatusDisplay', () => {
|
|||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders HookStatusDisplay when hooks are active', async () => {
|
|
||||||
const uiState = createMockUIState({
|
|
||||||
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
|
||||||
{ hideContextSummary: false },
|
|
||||||
uiState,
|
|
||||||
);
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT render HookStatusDisplay if notifications are disabled in settings', async () => {
|
|
||||||
const uiState = createMockUIState({
|
|
||||||
activeHooks: [{ name: 'hook', eventName: 'event' }],
|
|
||||||
});
|
|
||||||
const settings = createMockSettings({
|
|
||||||
hooksConfig: { notifications: false },
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
|
||||||
{ hideContextSummary: false },
|
|
||||||
uiState,
|
|
||||||
settings,
|
|
||||||
);
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides ContextSummaryDisplay if configured in settings', async () => {
|
it('hides ContextSummaryDisplay if configured in settings', async () => {
|
||||||
const settings = createMockSettings({
|
const settings = createMockSettings({
|
||||||
ui: { hideContextSummary: true },
|
ui: { hideContextSummary: true },
|
||||||
@@ -163,9 +142,13 @@ describe('StatusDisplay', () => {
|
|||||||
const uiState = createMockUIState({
|
const uiState = createMockUIState({
|
||||||
backgroundShellCount: 3,
|
backgroundShellCount: 3,
|
||||||
});
|
});
|
||||||
|
const settings = createMockSettings({
|
||||||
|
ui: { hideContextSummary: false },
|
||||||
|
});
|
||||||
const { lastFrame, unmount } = await renderStatusDisplay(
|
const { lastFrame, unmount } = await renderStatusDisplay(
|
||||||
{ hideContextSummary: false },
|
{ hideContextSummary: false },
|
||||||
uiState,
|
uiState,
|
||||||
|
settings,
|
||||||
);
|
);
|
||||||
expect(lastFrame()).toContain('Shells: 3');
|
expect(lastFrame()).toContain('Shells: 3');
|
||||||
unmount();
|
unmount();
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
|
exports[`StatusDisplay > renders ContextSummaryDisplay when hideContextSummary is false 1`] = `
|
||||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
|
||||||
"
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
|
|
||||||
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
"Mock Context Summary Display (Skills: 2, Shells: 0)
|
||||||
"
|
"
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -11,17 +11,22 @@ import {
|
|||||||
} from './CompressionMessage.js';
|
} from './CompressionMessage.js';
|
||||||
import { CompressionStatus } from '@google/gemini-cli-core';
|
import { CompressionStatus } from '@google/gemini-cli-core';
|
||||||
import { type CompressionProps } from '../../types.js';
|
import { type CompressionProps } from '../../types.js';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
describe('<CompressionMessage />', () => {
|
describe('<CompressionMessage />', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
const createCompressionProps = (
|
const createCompressionProps = (
|
||||||
overrides: Partial<CompressionProps> = {},
|
overrides: Partial<CompressionProps> = {},
|
||||||
): CompressionDisplayProps => ({
|
): CompressionDisplayProps => ({
|
||||||
compression: {
|
compression: {
|
||||||
isPending: false,
|
isPending: false,
|
||||||
originalTokenCount: null,
|
beforePercentage: null,
|
||||||
newTokenCount: null,
|
afterPercentage: null,
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
isManual: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -29,9 +34,10 @@ describe('<CompressionMessage />', () => {
|
|||||||
describe('pending state', () => {
|
describe('pending state', () => {
|
||||||
it('renders pending message when compression is in progress', async () => {
|
it('renders pending message when compression is in progress', async () => {
|
||||||
const props = createCompressionProps({ isPending: true });
|
const props = createCompressionProps({ isPending: true });
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
|
||||||
<CompressionMessage {...props} />,
|
<CompressionMessage {...props} />,
|
||||||
);
|
);
|
||||||
|
await waitUntilReady();
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('Compressing chat history');
|
expect(output).toContain('Compressing chat history');
|
||||||
@@ -43,56 +49,28 @@ describe('<CompressionMessage />', () => {
|
|||||||
it('renders success message when tokens are reduced', async () => {
|
it('renders success message when tokens are reduced', async () => {
|
||||||
const props = createCompressionProps({
|
const props = createCompressionProps({
|
||||||
isPending: false,
|
isPending: false,
|
||||||
originalTokenCount: 100,
|
beforePercentage: 22,
|
||||||
newTokenCount: 50,
|
afterPercentage: 6,
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
thresholdPercentage: 50,
|
||||||
});
|
});
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
const { lastFrame, unmount } = await renderWithProviders(
|
||||||
<CompressionMessage {...props} />,
|
<CompressionMessage {...props} />,
|
||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('✦');
|
expect(output).not.toContain('✦');
|
||||||
expect(output).toContain(
|
expect(output).toContain(
|
||||||
'Chat history compressed from 100 to 50 tokens.',
|
'Context compressed (22% → 6%). Adjust threshold (50%) in /settings.',
|
||||||
);
|
);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ original: 50000, newTokens: 25000 }, // Large compression
|
|
||||||
{ original: 700000, newTokens: 350000 }, // Very large compression
|
|
||||||
])(
|
|
||||||
'renders success message for large successful compression (from $original to $newTokens)',
|
|
||||||
async ({ original, newTokens }) => {
|
|
||||||
const props = createCompressionProps({
|
|
||||||
isPending: false,
|
|
||||||
originalTokenCount: original,
|
|
||||||
newTokenCount: newTokens,
|
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<CompressionMessage {...props} />,
|
|
||||||
);
|
|
||||||
const output = lastFrame();
|
|
||||||
|
|
||||||
expect(output).toContain('✦');
|
|
||||||
expect(output).toContain(
|
|
||||||
`compressed from ${original} to ${newTokens} tokens`,
|
|
||||||
);
|
|
||||||
expect(output).not.toContain('Skipping compression');
|
|
||||||
expect(output).not.toContain('did not reduce size');
|
|
||||||
unmount();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('skipped compression (tokens increased or same)', () => {
|
describe('skipped compression (tokens increased or same)', () => {
|
||||||
it('renders skip message when compression would increase token count', async () => {
|
it('renders skip message when compression would increase token count', async () => {
|
||||||
const props = createCompressionProps({
|
const props = createCompressionProps({
|
||||||
isPending: false,
|
isPending: false,
|
||||||
originalTokenCount: 50,
|
|
||||||
newTokenCount: 75,
|
|
||||||
compressionStatus:
|
compressionStatus:
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
||||||
});
|
});
|
||||||
@@ -101,121 +79,12 @@ describe('<CompressionMessage />', () => {
|
|||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('✦');
|
expect(output).not.toContain('✦');
|
||||||
expect(output).toContain(
|
expect(output).toContain(
|
||||||
'Compression was not beneficial for this history size.',
|
'Compression was not beneficial for this history size.',
|
||||||
);
|
);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders skip message when token counts are equal', async () => {
|
|
||||||
const props = createCompressionProps({
|
|
||||||
isPending: false,
|
|
||||||
originalTokenCount: 50,
|
|
||||||
newTokenCount: 50,
|
|
||||||
compressionStatus:
|
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<CompressionMessage {...props} />,
|
|
||||||
);
|
|
||||||
const output = lastFrame();
|
|
||||||
|
|
||||||
expect(output).toContain(
|
|
||||||
'Compression was not beneficial for this history size.',
|
|
||||||
);
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('message content validation', () => {
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
original: 200,
|
|
||||||
newTokens: 80,
|
|
||||||
expected: 'compressed from 200 to 80 tokens',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
original: 500,
|
|
||||||
newTokens: 150,
|
|
||||||
expected: 'compressed from 500 to 150 tokens',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
original: 1500,
|
|
||||||
newTokens: 400,
|
|
||||||
expected: 'compressed from 1500 to 400 tokens',
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'displays correct compression statistics (from $original to $newTokens)',
|
|
||||||
async ({ original, newTokens, expected }) => {
|
|
||||||
const props = createCompressionProps({
|
|
||||||
isPending: false,
|
|
||||||
originalTokenCount: original,
|
|
||||||
newTokenCount: newTokens,
|
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<CompressionMessage {...props} />,
|
|
||||||
);
|
|
||||||
const output = lastFrame();
|
|
||||||
|
|
||||||
expect(output).toContain(expected);
|
|
||||||
unmount();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ original: 50, newTokens: 60 }, // Increased
|
|
||||||
{ original: 100, newTokens: 100 }, // Same
|
|
||||||
{ original: 49999, newTokens: 50000 }, // Just under 50k threshold
|
|
||||||
])(
|
|
||||||
'shows skip message for small histories when new tokens >= original tokens ($original -> $newTokens)',
|
|
||||||
async ({ original, newTokens }) => {
|
|
||||||
const props = createCompressionProps({
|
|
||||||
isPending: false,
|
|
||||||
originalTokenCount: original,
|
|
||||||
newTokenCount: newTokens,
|
|
||||||
compressionStatus:
|
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<CompressionMessage {...props} />,
|
|
||||||
);
|
|
||||||
const output = lastFrame();
|
|
||||||
|
|
||||||
expect(output).toContain(
|
|
||||||
'Compression was not beneficial for this history size.',
|
|
||||||
);
|
|
||||||
expect(output).not.toContain('compressed from');
|
|
||||||
unmount();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ original: 50000, newTokens: 50100 }, // At 50k threshold
|
|
||||||
{ original: 700000, newTokens: 710000 }, // Large history case
|
|
||||||
{ original: 100000, newTokens: 100000 }, // Large history, same count
|
|
||||||
])(
|
|
||||||
'shows compression failure message for large histories when new tokens >= original tokens ($original -> $newTokens)',
|
|
||||||
async ({ original, newTokens }) => {
|
|
||||||
const props = createCompressionProps({
|
|
||||||
isPending: false,
|
|
||||||
originalTokenCount: original,
|
|
||||||
newTokenCount: newTokens,
|
|
||||||
compressionStatus:
|
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
|
||||||
});
|
|
||||||
const { lastFrame, unmount } = await renderWithProviders(
|
|
||||||
<CompressionMessage {...props} />,
|
|
||||||
);
|
|
||||||
const output = lastFrame();
|
|
||||||
|
|
||||||
expect(output).toContain('compression did not reduce size');
|
|
||||||
expect(output).not.toContain('compressed from');
|
|
||||||
expect(output).not.toContain('Compression was not beneficial');
|
|
||||||
unmount();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('failure states', () => {
|
describe('failure states', () => {
|
||||||
@@ -229,9 +98,9 @@ describe('<CompressionMessage />', () => {
|
|||||||
);
|
);
|
||||||
const output = lastFrame();
|
const output = lastFrame();
|
||||||
|
|
||||||
expect(output).toContain('✦');
|
expect(output).not.toContain('✦');
|
||||||
expect(output).toContain(
|
expect(output).toContain(
|
||||||
'Chat history compression failed: the model returned an empty summary.',
|
'Chat history compression failed: empty summary.',
|
||||||
);
|
);
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ export interface CompressionDisplayProps {
|
|||||||
export function CompressionMessage({
|
export function CompressionMessage({
|
||||||
compression,
|
compression,
|
||||||
}: CompressionDisplayProps): React.JSX.Element {
|
}: CompressionDisplayProps): React.JSX.Element {
|
||||||
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
|
const {
|
||||||
compression;
|
isPending,
|
||||||
|
beforePercentage,
|
||||||
const originalTokens = originalTokenCount ?? 0;
|
afterPercentage,
|
||||||
const newTokens = newTokenCount ?? 0;
|
compressionStatus,
|
||||||
|
thresholdPercentage,
|
||||||
|
} = compression;
|
||||||
|
|
||||||
const getCompressionText = () => {
|
const getCompressionText = () => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -34,20 +36,19 @@ export function CompressionMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (compressionStatus) {
|
switch (compressionStatus) {
|
||||||
case CompressionStatus.COMPRESSED:
|
case CompressionStatus.COMPRESSED: {
|
||||||
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
|
let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`;
|
||||||
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
|
if (thresholdPercentage != null) {
|
||||||
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
|
text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`;
|
||||||
if (originalTokens < 50000) {
|
|
||||||
return 'Compression was not beneficial for this history size.';
|
|
||||||
}
|
}
|
||||||
// For larger histories where compression should work but didn't,
|
return text;
|
||||||
// this suggests an issue with the compression process itself
|
}
|
||||||
return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.';
|
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
|
||||||
|
return 'Compression was not beneficial for this history size.';
|
||||||
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
|
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
|
||||||
return 'Could not compress chat history due to a token counting error.';
|
return 'Could not compress chat history due to a token counting error.';
|
||||||
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
|
case CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY:
|
||||||
return 'Chat history compression failed: the model returned an empty summary.';
|
return 'Chat history compression failed: empty summary.';
|
||||||
case CompressionStatus.NOOP:
|
case CompressionStatus.NOOP:
|
||||||
return 'Nothing to compress.';
|
return 'Nothing to compress.';
|
||||||
default:
|
default:
|
||||||
@@ -58,20 +59,13 @@ export function CompressionMessage({
|
|||||||
const text = getCompressionText();
|
const text = getCompressionText();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row" paddingLeft={1} marginBottom={1}>
|
||||||
<Box marginRight={1}>
|
<Box marginRight={1}>{isPending && <CliSpinner type="dots" />}</Box>
|
||||||
{isPending ? (
|
|
||||||
<CliSpinner type="dots" />
|
|
||||||
) : (
|
|
||||||
<Text color={theme.text.accent}>✦</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Text
|
<Text
|
||||||
color={
|
color={theme.text.secondary}
|
||||||
compression.isPending ? theme.text.accent : theme.status.success
|
|
||||||
}
|
|
||||||
aria-label={SCREEN_READER_MODEL_PREFIX}
|
aria-label={SCREEN_READER_MODEL_PREFIX}
|
||||||
|
italic
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any -- TODO: Refactor to remove any usage */
|
||||||
import {
|
import {
|
||||||
describe,
|
describe,
|
||||||
it,
|
it,
|
||||||
@@ -32,10 +32,7 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
EditorType,
|
EditorType,
|
||||||
AnyToolInvocation,
|
AnyToolInvocation,
|
||||||
AnyDeclarativeTool,
|
|
||||||
SpanMetadata,
|
SpanMetadata,
|
||||||
CompletedToolCall,
|
|
||||||
ToolCallRequestInfo,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
CoreToolCallStatus,
|
CoreToolCallStatus,
|
||||||
@@ -52,19 +49,15 @@ import {
|
|||||||
MCPDiscoveryState,
|
MCPDiscoveryState,
|
||||||
GeminiCliOperation,
|
GeminiCliOperation,
|
||||||
getPlanModeExitMessage,
|
getPlanModeExitMessage,
|
||||||
|
CompressionStatus,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import type { Part, PartListUnion } from '@google/genai';
|
import type { Part, PartListUnion } from '@google/genai';
|
||||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||||
import type {
|
import type { SlashCommandProcessorResult } from '../types.js';
|
||||||
SlashCommandProcessorResult,
|
|
||||||
HistoryItemWithoutId,
|
|
||||||
HistoryItem,
|
|
||||||
} from '../types.js';
|
|
||||||
import { MessageType, StreamingState } from '../types.js';
|
import { MessageType, StreamingState } from '../types.js';
|
||||||
|
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
|
|
||||||
// --- MOCKS ---
|
// --- MOCKS ---
|
||||||
const mockSendMessageStream = vi
|
const mockSendMessageStream = vi
|
||||||
@@ -145,6 +138,7 @@ const mockRunInDevTraceSpan = vi.hoisted(() =>
|
|||||||
};
|
};
|
||||||
return await fn({
|
return await fn({
|
||||||
metadata,
|
metadata,
|
||||||
|
endSpan: vi.fn(),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -249,10 +243,8 @@ describe('useGeminiStream', () => {
|
|||||||
let mockMarkToolsAsSubmitted: Mock;
|
let mockMarkToolsAsSubmitted: Mock;
|
||||||
let handleAtCommandSpy: MockInstance;
|
let handleAtCommandSpy: MockInstance;
|
||||||
|
|
||||||
const emptyHistory: HistoryItem[] = [];
|
const emptyHistory: any[] = [];
|
||||||
let capturedOnComplete:
|
let capturedOnComplete: any = null;
|
||||||
| ((tools: CompletedToolCall[]) => Promise<void>)
|
|
||||||
| null = null;
|
|
||||||
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
|
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
|
||||||
const mockOnAuthError = vi.fn();
|
const mockOnAuthError = vi.fn();
|
||||||
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
|
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
|
||||||
@@ -332,6 +324,9 @@ describe('useGeminiStream', () => {
|
|||||||
})),
|
})),
|
||||||
getIdeMode: vi.fn(() => false),
|
getIdeMode: vi.fn(() => false),
|
||||||
getEnableHooks: vi.fn(() => false),
|
getEnableHooks: vi.fn(() => false),
|
||||||
|
getShowContextWindowWarning: vi.fn(() => false),
|
||||||
|
getShowContextCompression: vi.fn(() => false),
|
||||||
|
getContextWindowCompressionThreshold: vi.fn(() => 0.2),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -411,17 +406,13 @@ describe('useGeminiStream', () => {
|
|||||||
lastToolCalls,
|
lastToolCalls,
|
||||||
mockScheduleToolCalls,
|
mockScheduleToolCalls,
|
||||||
mockMarkToolsAsSubmitted,
|
mockMarkToolsAsSubmitted,
|
||||||
(
|
(updater: any) => {
|
||||||
updater:
|
|
||||||
| TrackedToolCall[]
|
|
||||||
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
|
|
||||||
) => {
|
|
||||||
lastToolCalls =
|
lastToolCalls =
|
||||||
typeof updater === 'function' ? updater(lastToolCalls) : updater;
|
typeof updater === 'function' ? updater(lastToolCalls) : updater;
|
||||||
rerender({ ...initialProps, toolCalls: lastToolCalls });
|
rerender({ ...initialProps, toolCalls: lastToolCalls });
|
||||||
},
|
},
|
||||||
(signal: AbortSignal) => {
|
(...args: any[]) => {
|
||||||
mockCancelAllToolCalls(signal);
|
mockCancelAllToolCalls(...args);
|
||||||
lastToolCalls = lastToolCalls.map((tc) => {
|
lastToolCalls = lastToolCalls.map((tc) => {
|
||||||
if (
|
if (
|
||||||
tc.status === CoreToolCallStatus.AwaitingApproval ||
|
tc.status === CoreToolCallStatus.AwaitingApproval ||
|
||||||
@@ -888,7 +879,7 @@ describe('useGeminiStream', () => {
|
|||||||
const fn = spanArgs[1];
|
const fn = spanArgs[1];
|
||||||
const metadata = { attributes: {} };
|
const metadata = { attributes: {} };
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await fn({ metadata });
|
await fn({ metadata, endSpan: vi.fn() });
|
||||||
});
|
});
|
||||||
expect(metadata).toMatchObject({
|
expect(metadata).toMatchObject({
|
||||||
input: sentParts,
|
input: sentParts,
|
||||||
@@ -982,7 +973,7 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
|
||||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||||
{
|
{
|
||||||
request: {
|
request: {
|
||||||
callId: 'stop-call',
|
callId: 'stop-call',
|
||||||
@@ -1054,7 +1045,7 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
it('should add a compact suppressed-error note before STOP_EXECUTION terminal info in low verbosity mode', async () => {
|
||||||
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
|
const stopExecutionToolCalls: TrackedToolCall[] = [
|
||||||
{
|
{
|
||||||
request: {
|
request: {
|
||||||
callId: 'stop-call',
|
callId: 'stop-call',
|
||||||
@@ -1081,10 +1072,9 @@ describe('useGeminiStream', () => {
|
|||||||
} as unknown as TrackedCompletedToolCall,
|
} as unknown as TrackedCompletedToolCall,
|
||||||
];
|
];
|
||||||
const lowVerbositySettings = {
|
const lowVerbositySettings = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
...(mockLoadedSettings as any),
|
||||||
...mockLoadedSettings,
|
|
||||||
merged: {
|
merged: {
|
||||||
...mockLoadedSettings.merged,
|
...(mockLoadedSettings.merged as any),
|
||||||
ui: { errorVerbosity: 'low' },
|
ui: { errorVerbosity: 'low' },
|
||||||
},
|
},
|
||||||
} as LoadedSettings;
|
} as LoadedSettings;
|
||||||
@@ -1935,120 +1925,6 @@ describe('useGeminiStream', () => {
|
|||||||
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should record client-initiated tool calls in GeminiChat history', async () => {
|
|
||||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
|
||||||
|
|
||||||
mockHandleSlashCommand.mockResolvedValue({
|
|
||||||
type: 'schedule_tool',
|
|
||||||
toolName: 'activate_skill',
|
|
||||||
toolArgs: { name: 'test-skill' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('/test-skill');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate tool completion
|
|
||||||
const completedTool = {
|
|
||||||
request: {
|
|
||||||
callId: 'test-call-id',
|
|
||||||
name: 'activate_skill',
|
|
||||||
args: { name: 'test-skill' },
|
|
||||||
isClientInitiated: true,
|
|
||||||
},
|
|
||||||
status: CoreToolCallStatus.Success,
|
|
||||||
invocation: {
|
|
||||||
getDescription: () => 'Activating skill test-skill',
|
|
||||||
},
|
|
||||||
tool: {
|
|
||||||
isOutputMarkdown: true,
|
|
||||||
},
|
|
||||||
response: {
|
|
||||||
responseParts: [
|
|
||||||
{
|
|
||||||
functionResponse: {
|
|
||||||
name: 'activate_skill',
|
|
||||||
response: { content: 'skill instructions' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as unknown as TrackedCompletedToolCall;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
if (capturedOnComplete) {
|
|
||||||
await capturedOnComplete([completedTool]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that the tool call and response were added to GeminiChat history
|
|
||||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
|
||||||
role: 'model',
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
functionCall: {
|
|
||||||
name: 'activate_skill',
|
|
||||||
args: { name: 'test-skill' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(mockGeminiClient.addHistory).toHaveBeenCalledWith({
|
|
||||||
role: 'user',
|
|
||||||
parts: completedTool.response.responseParts,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT record other client-initiated tool calls (like save_memory) in history', async () => {
|
|
||||||
const { result, client: mockGeminiClient } = await renderTestHook();
|
|
||||||
|
|
||||||
mockHandleSlashCommand.mockResolvedValue({
|
|
||||||
type: 'schedule_tool',
|
|
||||||
toolName: 'save_memory',
|
|
||||||
toolArgs: { fact: 'test fact' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.submitQuery('/memory add "test fact"');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simulate tool completion
|
|
||||||
const completedTool = {
|
|
||||||
request: {
|
|
||||||
callId: 'test-call-id',
|
|
||||||
name: 'save_memory',
|
|
||||||
args: { fact: 'test fact' },
|
|
||||||
isClientInitiated: true,
|
|
||||||
},
|
|
||||||
status: CoreToolCallStatus.Success,
|
|
||||||
invocation: {
|
|
||||||
getDescription: () => 'Saving memory',
|
|
||||||
},
|
|
||||||
tool: {
|
|
||||||
isOutputMarkdown: true,
|
|
||||||
},
|
|
||||||
response: {
|
|
||||||
responseParts: [
|
|
||||||
{
|
|
||||||
functionResponse: {
|
|
||||||
name: 'save_memory',
|
|
||||||
response: { success: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
} as unknown as TrackedCompletedToolCall;
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
if (capturedOnComplete) {
|
|
||||||
await capturedOnComplete([completedTool]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify that addHistory was NOT called
|
|
||||||
expect(mockGeminiClient.addHistory).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Memory Refresh on save_memory', () => {
|
describe('Memory Refresh on save_memory', () => {
|
||||||
@@ -2076,7 +1952,7 @@ describe('useGeminiStream', () => {
|
|||||||
displayName: 'save_memory',
|
displayName: 'save_memory',
|
||||||
description: 'Saves memory',
|
description: 'Saves memory',
|
||||||
build: vi.fn(),
|
build: vi.fn(),
|
||||||
} as unknown as AnyDeclarativeTool,
|
} as any,
|
||||||
invocation: {
|
invocation: {
|
||||||
getDescription: () => `Mock description`,
|
getDescription: () => `Mock description`,
|
||||||
} as unknown as AnyToolInvocation,
|
} as unknown as AnyToolInvocation,
|
||||||
@@ -2150,8 +2026,7 @@ describe('useGeminiStream', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const testConfig = {
|
const testConfig = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
...(mockConfig as any),
|
||||||
...mockConfig,
|
|
||||||
getContentGenerator: vi.fn(),
|
getContentGenerator: vi.fn(),
|
||||||
getContentGeneratorConfig: vi.fn(() => ({
|
getContentGeneratorConfig: vi.fn(() => ({
|
||||||
authType: mockAuthType,
|
authType: mockAuthType,
|
||||||
@@ -2316,7 +2191,7 @@ describe('useGeminiStream', () => {
|
|||||||
displayName: 'replace',
|
displayName: 'replace',
|
||||||
description: 'Replace text',
|
description: 'Replace text',
|
||||||
build: vi.fn(),
|
build: vi.fn(),
|
||||||
} as unknown as AnyDeclarativeTool,
|
} as any,
|
||||||
invocation: {
|
invocation: {
|
||||||
getDescription: () => 'Mock description',
|
getDescription: () => 'Mock description',
|
||||||
} as unknown as AnyToolInvocation,
|
} as unknown as AnyToolInvocation,
|
||||||
@@ -2357,7 +2232,7 @@ describe('useGeminiStream', () => {
|
|||||||
displayName: 'write_file',
|
displayName: 'write_file',
|
||||||
description: 'Write file',
|
description: 'Write file',
|
||||||
build: vi.fn(),
|
build: vi.fn(),
|
||||||
} as unknown as AnyDeclarativeTool,
|
} as any,
|
||||||
invocation: {
|
invocation: {
|
||||||
getDescription: () => 'Mock description',
|
getDescription: () => 'Mock description',
|
||||||
} as unknown as AnyToolInvocation,
|
} as unknown as AnyToolInvocation,
|
||||||
@@ -2474,22 +2349,32 @@ describe('useGeminiStream', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{
|
{
|
||||||
name: 'without suggestion when remaining tokens are > 75% of limit',
|
name: 'add a message when remaining tokens overflow, regardless of showContextWindowWarning setting',
|
||||||
requestTokens: 20,
|
requestTokens: 20,
|
||||||
remainingTokens: 80,
|
remainingTokens: 80,
|
||||||
|
shouldShow: false, // The setting itself is false
|
||||||
expectedMessage:
|
expectedMessage:
|
||||||
'Sending this message (20 tokens) might exceed the context window limit (80 tokens left).',
|
'Context 20% full. Message may exceed window. Reduce size or /compress.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'with suggestion when remaining tokens are < 75% of limit',
|
name: 'add a message when showContextWindowWarning is true',
|
||||||
requestTokens: 30,
|
requestTokens: 30,
|
||||||
remainingTokens: 70,
|
remainingTokens: 70,
|
||||||
|
shouldShow: true,
|
||||||
expectedMessage:
|
expectedMessage:
|
||||||
'Sending this message (30 tokens) might exceed the context window limit (70 tokens left). Please try reducing the size of your message or use the `/compress` command to compress the chat history.',
|
'Context 30% full. Message may exceed window. Reduce size or /compress.',
|
||||||
},
|
},
|
||||||
])(
|
])(
|
||||||
'should add message $name',
|
'should $name',
|
||||||
async ({ requestTokens, remainingTokens, expectedMessage }) => {
|
async ({
|
||||||
|
requestTokens,
|
||||||
|
remainingTokens,
|
||||||
|
shouldShow,
|
||||||
|
expectedMessage,
|
||||||
|
}) => {
|
||||||
|
vi.mocked(mockConfig.getShowContextWindowWarning).mockReturnValue(
|
||||||
|
shouldShow,
|
||||||
|
);
|
||||||
mockSendMessageStream.mockReturnValue(
|
mockSendMessageStream.mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
yield {
|
yield {
|
||||||
@@ -2509,10 +2394,12 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockAddItem).toHaveBeenCalledWith({
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
type: 'info',
|
expect.objectContaining({
|
||||||
text: expectedMessage,
|
type: 'info',
|
||||||
});
|
text: expectedMessage,
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2566,8 +2453,12 @@ describe('useGeminiStream', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add informational messages when ChatCompressed event is received', async () => {
|
it('should add informational messages when ChatCompressed event is received and showContextCompression is true', async () => {
|
||||||
vi.mocked(tokenLimit).mockReturnValue(10000);
|
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||||
|
vi.mocked(
|
||||||
|
mockConfig.getContextWindowCompressionThreshold,
|
||||||
|
).mockReturnValue(0.2);
|
||||||
|
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(true);
|
||||||
// Setup mock to return a stream with ChatCompressed event
|
// Setup mock to return a stream with ChatCompressed event
|
||||||
mockSendMessageStream.mockReturnValue(
|
mockSendMessageStream.mockReturnValue(
|
||||||
(async function* () {
|
(async function* () {
|
||||||
@@ -2576,7 +2467,22 @@ describe('useGeminiStream', () => {
|
|||||||
value: {
|
value: {
|
||||||
originalTokenCount: 1000,
|
originalTokenCount: 1000,
|
||||||
newTokenCount: 500,
|
newTokenCount: 500,
|
||||||
compressionStatus: 'compressed',
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Content,
|
||||||
|
value: 'Response after compression',
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Finished,
|
||||||
|
value: {
|
||||||
|
finishReason: 'STOP',
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 20,
|
||||||
|
totalTokenCount: 30,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
@@ -2593,10 +2499,129 @@ describe('useGeminiStream', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockAddItem).toHaveBeenCalledWith(
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: MessageType.INFO,
|
type: 'compression',
|
||||||
text: 'Context compressed from 10% to 5%.',
|
compression: {
|
||||||
secondaryText: 'Change threshold in /settings.',
|
isPending: false,
|
||||||
color: theme.status.warning,
|
beforePercentage: 10,
|
||||||
|
afterPercentage: 5,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
isManual: false,
|
||||||
|
thresholdPercentage: 20,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Number),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT add informational messages when ChatCompressed event is received and showContextCompression is false', async () => {
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||||
|
vi.mocked(
|
||||||
|
mockConfig.getContextWindowCompressionThreshold,
|
||||||
|
).mockReturnValue(0.2);
|
||||||
|
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
|
||||||
|
// Setup mock to return a stream with ChatCompressed event
|
||||||
|
mockSendMessageStream.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.ChatCompressed,
|
||||||
|
value: {
|
||||||
|
originalTokenCount: 1000,
|
||||||
|
newTokenCount: 500,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Content,
|
||||||
|
value: 'Response after compression',
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Finished,
|
||||||
|
value: {
|
||||||
|
finishReason: 'STOP',
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 20,
|
||||||
|
totalTokenCount: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = await renderHookWithDefaults();
|
||||||
|
|
||||||
|
// Submit a query
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitQuery('Test compression');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that NO compression message was added
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddItem).not.toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'compression',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add informational messages when ChatCompressed event is received with a large prompt even if showContextCompression is false', async () => {
|
||||||
|
vi.mocked(tokenLimit).mockReturnValue(10000);
|
||||||
|
vi.mocked(
|
||||||
|
mockConfig.getContextWindowCompressionThreshold,
|
||||||
|
).mockReturnValue(0.2); // 20%
|
||||||
|
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(false);
|
||||||
|
|
||||||
|
// Setup mock to return a stream with ChatCompressed event and a large requestTokenCount (25%)
|
||||||
|
mockSendMessageStream.mockReturnValue(
|
||||||
|
(async function* () {
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.ChatCompressed,
|
||||||
|
value: {
|
||||||
|
originalTokenCount: 1000,
|
||||||
|
newTokenCount: 500,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
requestTokenCount: 2500, // 25% > 20%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Content,
|
||||||
|
value: 'Response after compression',
|
||||||
|
};
|
||||||
|
yield {
|
||||||
|
type: ServerGeminiEventType.Finished,
|
||||||
|
value: {
|
||||||
|
finishReason: 'STOP',
|
||||||
|
usageMetadata: {
|
||||||
|
promptTokenCount: 10,
|
||||||
|
candidatesTokenCount: 20,
|
||||||
|
totalTokenCount: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = await renderHookWithDefaults();
|
||||||
|
|
||||||
|
// Submit a query
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.submitQuery('Test large prompt compression');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that compression message WAS added despite the setting
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockAddItem).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'compression',
|
||||||
|
compression: expect.objectContaining({
|
||||||
|
beforePercentage: 10,
|
||||||
|
afterPercentage: 5,
|
||||||
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
isManual: false,
|
||||||
|
thresholdPercentage: 20,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
expect.any(Number),
|
expect.any(Number),
|
||||||
);
|
);
|
||||||
@@ -2702,14 +2727,14 @@ describe('useGeminiStream', () => {
|
|||||||
|
|
||||||
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
|
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
|
||||||
const addItemOrder: string[] = [];
|
const addItemOrder: string[] = [];
|
||||||
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
|
let capturedOnComplete: any;
|
||||||
|
|
||||||
const mockScheduleToolCalls = vi.fn(async (requests) => {
|
const mockScheduleToolCalls = vi.fn(async (requests) => {
|
||||||
addItemOrder.push('scheduleToolCalls_START');
|
addItemOrder.push('scheduleToolCalls_START');
|
||||||
// Simulate tools completing and triggering onComplete immediately.
|
// Simulate tools completing and triggering onComplete immediately.
|
||||||
// This mimics the behavior that caused the regression where tool results
|
// This mimics the behavior that caused the regression where tool results
|
||||||
// were added to history during the await scheduleToolCalls(...) block.
|
// were added to history during the await scheduleToolCalls(...) block.
|
||||||
const tools = requests.map((r: ToolCallRequestInfo) => ({
|
const tools = requests.map((r: any) => ({
|
||||||
request: r,
|
request: r,
|
||||||
status: CoreToolCallStatus.Success,
|
status: CoreToolCallStatus.Success,
|
||||||
tool: { displayName: r.name, name: r.name },
|
tool: { displayName: r.name, name: r.name },
|
||||||
@@ -2724,7 +2749,7 @@ describe('useGeminiStream', () => {
|
|||||||
addItemOrder.push('scheduleToolCalls_END');
|
addItemOrder.push('scheduleToolCalls_END');
|
||||||
});
|
});
|
||||||
|
|
||||||
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
|
mockAddItem.mockImplementation((item: any) => {
|
||||||
addItemOrder.push(`addItem:${item.type}`);
|
addItemOrder.push(`addItem:${item.type}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2954,10 +2979,9 @@ describe('useGeminiStream', () => {
|
|||||||
describe('Thought Reset', () => {
|
describe('Thought Reset', () => {
|
||||||
it('should keep full thinking entries in history when mode is full', async () => {
|
it('should keep full thinking entries in history when mode is full', async () => {
|
||||||
const fullThinkingSettings: LoadedSettings = {
|
const fullThinkingSettings: LoadedSettings = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
...(mockLoadedSettings as any),
|
||||||
...mockLoadedSettings,
|
|
||||||
merged: {
|
merged: {
|
||||||
...mockLoadedSettings.merged,
|
...(mockLoadedSettings.merged as any),
|
||||||
ui: { inlineThinkingMode: 'full' },
|
ui: { inlineThinkingMode: 'full' },
|
||||||
},
|
},
|
||||||
} as unknown as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
@@ -4036,7 +4060,7 @@ describe('useGeminiStream', () => {
|
|||||||
|
|
||||||
const spanMetadata = {} as SpanMetadata;
|
const spanMetadata = {} as SpanMetadata;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await userPromptCall;
|
await userPromptCall });
|
||||||
});
|
});
|
||||||
expect(spanMetadata.input).toBe('telemetry test query');
|
expect(spanMetadata.input).toBe('telemetry test query');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,22 +38,20 @@ import {
|
|||||||
GeminiCliOperation,
|
GeminiCliOperation,
|
||||||
getPlanModeExitMessage,
|
getPlanModeExitMessage,
|
||||||
isBackgroundExecutionData,
|
isBackgroundExecutionData,
|
||||||
|
type CompressionStatus,
|
||||||
Kind,
|
Kind,
|
||||||
ACTIVATE_SKILL_TOOL_NAME,
|
type Config,
|
||||||
} from '@google/gemini-cli-core';
|
type EditorType,
|
||||||
import type {
|
type GeminiClient,
|
||||||
Config,
|
type ServerGeminiChatCompressedEvent,
|
||||||
EditorType,
|
type ServerGeminiContentEvent as ContentEvent,
|
||||||
GeminiClient,
|
type ServerGeminiFinishedEvent,
|
||||||
ServerGeminiChatCompressedEvent,
|
type ServerGeminiStreamEvent as GeminiEvent,
|
||||||
ServerGeminiContentEvent as ContentEvent,
|
type ThoughtSummary,
|
||||||
ServerGeminiFinishedEvent,
|
type ToolCallRequestInfo,
|
||||||
ServerGeminiStreamEvent as GeminiEvent,
|
type ToolCallResponseInfo,
|
||||||
ThoughtSummary,
|
type GeminiErrorEventValue,
|
||||||
ToolCallRequestInfo,
|
type RetryAttemptPayload,
|
||||||
ToolCallResponseInfo,
|
|
||||||
GeminiErrorEventValue,
|
|
||||||
RetryAttemptPayload,
|
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||||
import type {
|
import type {
|
||||||
@@ -61,7 +59,6 @@ import type {
|
|||||||
HistoryItemThinking,
|
HistoryItemThinking,
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
HistoryItemToolGroup,
|
HistoryItemToolGroup,
|
||||||
HistoryItemInfo,
|
|
||||||
IndividualToolCallDisplay,
|
IndividualToolCallDisplay,
|
||||||
SlashCommandProcessorResult,
|
SlashCommandProcessorResult,
|
||||||
HistoryItemModel,
|
HistoryItemModel,
|
||||||
@@ -257,6 +254,8 @@ export const useGeminiStream = (
|
|||||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||||
useStateAndRef<boolean>(true);
|
useStateAndRef<boolean>(true);
|
||||||
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
||||||
|
const handleCompletedToolsRef =
|
||||||
|
useRef<(completedTools: TrackedToolCall[]) => Promise<void>>(undefined);
|
||||||
const { startNewPrompt, getPromptCount } = useSessionStats();
|
const { startNewPrompt, getPromptCount } = useSessionStats();
|
||||||
const storage = config.storage;
|
const storage = config.storage;
|
||||||
const logger = useLogger(storage);
|
const logger = useLogger(storage);
|
||||||
@@ -302,22 +301,23 @@ export const useGeminiStream = (
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the live-updating display now that the final state is in history.
|
// Clear the live-updating display now that the final state is in history.
|
||||||
setToolCallsForDisplay([]);
|
setToolCallsForDisplay([]);
|
||||||
|
|
||||||
// Record tool calls with full metadata before sending responses.
|
// Record tool calls with full metadata before sending responses.
|
||||||
try {
|
try {
|
||||||
const currentModel =
|
const currentModel =
|
||||||
config.getGeminiClient().getCurrentSequenceModel() ??
|
(typeof geminiClient.getCurrentSequenceModel === 'function'
|
||||||
config.getModel();
|
? geminiClient.getCurrentSequenceModel()
|
||||||
config
|
: undefined) ?? config.getModel();
|
||||||
.getGeminiClient()
|
const chat =
|
||||||
.getChat()
|
typeof geminiClient.getChat === 'function'
|
||||||
.recordCompletedToolCalls(
|
? geminiClient.getChat()
|
||||||
currentModel,
|
: undefined;
|
||||||
completedToolCallsFromScheduler,
|
chat?.recordCompletedToolCalls(
|
||||||
);
|
currentModel,
|
||||||
|
completedToolCallsFromScheduler,
|
||||||
|
);
|
||||||
|
|
||||||
await recordToolCallInteractions(
|
await recordToolCallInteractions(
|
||||||
config,
|
config,
|
||||||
@@ -330,7 +330,7 @@ export const useGeminiStream = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle tool response submission immediately when tools complete
|
// Handle tool response submission immediately when tools complete
|
||||||
await handleCompletedTools(
|
await handleCompletedToolsRef.current?.(
|
||||||
completedToolCallsFromScheduler as TrackedToolCall[],
|
completedToolCallsFromScheduler as TrackedToolCall[],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -549,9 +549,11 @@ export const useGeminiStream = (
|
|||||||
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// ToolGroupMessage now shows all non-canceled tools, so they are visible
|
return (
|
||||||
// in pending and we need to draw the closing border for them.
|
tc.status !== 'scheduled' &&
|
||||||
return true;
|
tc.status !== 'validating' &&
|
||||||
|
tc.status !== 'awaiting_approval'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1145,21 +1147,43 @@ export const useGeminiStream = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const limit = tokenLimit(config.getModel());
|
const limit = tokenLimit(config.getModel());
|
||||||
const originalPercentage = Math.round(
|
const beforePercentage =
|
||||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
eventValue?.originalTokenCount != null
|
||||||
);
|
? Math.round((eventValue.originalTokenCount / limit) * 100)
|
||||||
const newPercentage = Math.round(
|
: null;
|
||||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
const afterPercentage =
|
||||||
);
|
eventValue?.newTokenCount != null
|
||||||
|
? Math.round((eventValue.newTokenCount / limit) * 100)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const threshold = config.getContextWindowCompressionThreshold();
|
||||||
|
const isLargePrompt =
|
||||||
|
eventValue?.requestTokenCount != null &&
|
||||||
|
eventValue.requestTokenCount / limit > threshold;
|
||||||
|
|
||||||
|
if (!config.getShowContextCompression() && !isLargePrompt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
addItem(
|
addItem(
|
||||||
{
|
{
|
||||||
type: MessageType.INFO,
|
type: 'compression',
|
||||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
compression: {
|
||||||
secondaryText: `Change threshold in /settings.`,
|
isPending: false,
|
||||||
color: theme.status.warning,
|
beforePercentage,
|
||||||
marginBottom: 1,
|
afterPercentage,
|
||||||
} as HistoryItemInfo,
|
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
compressionStatus: eventValue
|
||||||
|
? (Number(
|
||||||
|
eventValue.compressionStatus,
|
||||||
|
) as unknown as CompressionStatus)
|
||||||
|
: null,
|
||||||
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
isManual: false,
|
||||||
|
thresholdPercentage: Math.round(threshold * 100),
|
||||||
|
},
|
||||||
|
timestamp: new Date(userMessageTimestamp),
|
||||||
|
} as HistoryItemWithoutId,
|
||||||
userMessageTimestamp,
|
userMessageTimestamp,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1182,16 +1206,11 @@ export const useGeminiStream = (
|
|||||||
onCancelSubmit(true);
|
onCancelSubmit(true);
|
||||||
|
|
||||||
const limit = tokenLimit(config.getModel());
|
const limit = tokenLimit(config.getModel());
|
||||||
|
const usedPercentage = Math.round(
|
||||||
|
((limit - remainingTokenCount) / limit) * 100,
|
||||||
|
);
|
||||||
|
|
||||||
const isMoreThan25PercentUsed =
|
const text = `Context ${usedPercentage}% full. Message may exceed window. Reduce size or /compress.`;
|
||||||
limit > 0 && remainingTokenCount < limit * 0.75;
|
|
||||||
|
|
||||||
let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;
|
|
||||||
|
|
||||||
if (isMoreThan25PercentUsed) {
|
|
||||||
text +=
|
|
||||||
' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
|
|
||||||
}
|
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
@@ -1538,8 +1557,7 @@ export const useGeminiStream = (
|
|||||||
setLoopDetectionConfirmationRequest(null);
|
setLoopDetectionConfirmationRequest(null);
|
||||||
|
|
||||||
if (result.userSelection === 'disable') {
|
if (result.userSelection === 'disable') {
|
||||||
config
|
geminiClient
|
||||||
.getGeminiClient()
|
|
||||||
.getLoopDetectionService()
|
.getLoopDetectionService()
|
||||||
.disableForSession();
|
.disableForSession();
|
||||||
addItem({
|
addItem({
|
||||||
@@ -1657,7 +1675,7 @@ export const useGeminiStream = (
|
|||||||
) {
|
) {
|
||||||
let awaitingApprovalCalls = toolCalls.filter(
|
let awaitingApprovalCalls = toolCalls.filter(
|
||||||
(call): call is TrackedWaitingToolCall =>
|
(call): call is TrackedWaitingToolCall =>
|
||||||
call.status === 'awaiting_approval' && !call.request.forcedAsk,
|
call.status === 'awaiting_approval',
|
||||||
);
|
);
|
||||||
|
|
||||||
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
// For AUTO_EDIT mode, only approve edit tools (replace, write_file)
|
||||||
@@ -1715,42 +1733,24 @@ export const useGeminiStream = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if all tools in the batch are in a terminal state
|
||||||
|
const allTerminal = completedToolCallsFromScheduler.every(
|
||||||
|
(tc) =>
|
||||||
|
tc.status === 'success' ||
|
||||||
|
tc.status === 'error' ||
|
||||||
|
tc.status === 'cancelled',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!allTerminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize any client-initiated tools as soon as they are done.
|
// Finalize any client-initiated tools as soon as they are done.
|
||||||
const clientTools = completedAndReadyToSubmitTools.filter(
|
const clientTools = completedAndReadyToSubmitTools.filter(
|
||||||
(t) => t.request.isClientInitiated,
|
(t) => t.request.isClientInitiated,
|
||||||
);
|
);
|
||||||
if (clientTools.length > 0) {
|
if (clientTools.length > 0) {
|
||||||
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
markToolsAsSubmitted(clientTools.map((t) => t.request.callId));
|
||||||
|
|
||||||
if (geminiClient) {
|
|
||||||
for (const tool of clientTools) {
|
|
||||||
// Only manually record skill activations in the chat history.
|
|
||||||
// Other client-initiated tools (like save_memory) update the system
|
|
||||||
// prompt/context and don't strictly need to be in the history.
|
|
||||||
if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add both the call (model turn) and the result (user turn) to history.
|
|
||||||
// Client-initiated calls are essentially "synthetic" turns that let
|
|
||||||
// subsequent model calls understand what just happened in the UI.
|
|
||||||
await geminiClient.addHistory({
|
|
||||||
role: 'model',
|
|
||||||
parts: [
|
|
||||||
{
|
|
||||||
functionCall: {
|
|
||||||
name: tool.request.name,
|
|
||||||
args: tool.request.args,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await geminiClient.addHistory({
|
|
||||||
role: 'user',
|
|
||||||
parts: tool.response.responseParts,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identify new, successful save_memory calls that we haven't processed yet.
|
// Identify new, successful save_memory calls that we haven't processed yet.
|
||||||
@@ -1906,6 +1906,7 @@ export const useGeminiStream = (
|
|||||||
setIsResponding,
|
setIsResponding,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
handleCompletedToolsRef.current = handleCompletedTools;
|
||||||
|
|
||||||
const pendingHistoryItems = useMemo(
|
const pendingHistoryItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -138,9 +138,11 @@ export interface IndividualToolCallDisplay {
|
|||||||
|
|
||||||
export interface CompressionProps {
|
export interface CompressionProps {
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
originalTokenCount: number | null;
|
beforePercentage: number | null;
|
||||||
newTokenCount: number | null;
|
afterPercentage: number | null;
|
||||||
compressionStatus: CompressionStatus | null;
|
compressionStatus: CompressionStatus | null;
|
||||||
|
isManual: boolean;
|
||||||
|
thresholdPercentage?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -673,6 +673,8 @@ export interface ConfigParameters {
|
|||||||
agents?: AgentSettings;
|
agents?: AgentSettings;
|
||||||
}>;
|
}>;
|
||||||
enableConseca?: boolean;
|
enableConseca?: boolean;
|
||||||
|
showContextWindowWarning?: boolean;
|
||||||
|
showContextCompression?: boolean;
|
||||||
billing?: {
|
billing?: {
|
||||||
overageStrategy?: OverageStrategy;
|
overageStrategy?: OverageStrategy;
|
||||||
};
|
};
|
||||||
@@ -708,6 +710,8 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
private readonly question: string | undefined;
|
private readonly question: string | undefined;
|
||||||
private readonly worktreeSettings: WorktreeSettings | undefined;
|
private readonly worktreeSettings: WorktreeSettings | undefined;
|
||||||
readonly enableConseca: boolean;
|
readonly enableConseca: boolean;
|
||||||
|
private readonly showContextWindowWarning: boolean;
|
||||||
|
private readonly showContextCompression: boolean;
|
||||||
|
|
||||||
private readonly coreTools: string[] | undefined;
|
private readonly coreTools: string[] | undefined;
|
||||||
private readonly mainAgentTools: string[] | undefined;
|
private readonly mainAgentTools: string[] | undefined;
|
||||||
@@ -1146,6 +1150,8 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
this.fileExclusions = new FileExclusions(this);
|
this.fileExclusions = new FileExclusions(this);
|
||||||
this.eventEmitter = params.eventEmitter;
|
this.eventEmitter = params.eventEmitter;
|
||||||
this.enableConseca = params.enableConseca ?? false;
|
this.enableConseca = params.enableConseca ?? false;
|
||||||
|
this.showContextWindowWarning = params.showContextWindowWarning ?? false;
|
||||||
|
this.showContextCompression = params.showContextCompression ?? false;
|
||||||
|
|
||||||
// Initialize Safety Infrastructure
|
// Initialize Safety Infrastructure
|
||||||
const contextBuilder = new ContextBuilder(this);
|
const contextBuilder = new ContextBuilder(this);
|
||||||
@@ -2053,6 +2059,18 @@ export class Config implements McpContext, AgentLoopContext {
|
|||||||
return this.mcpEnabled;
|
return this.mcpEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getShowContextWindowWarning(): boolean {
|
||||||
|
return this.showContextWindowWarning;
|
||||||
|
}
|
||||||
|
|
||||||
|
getShowContextCompression(): boolean {
|
||||||
|
return this.showContextCompression;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContextWindowCompressionThreshold(): number {
|
||||||
|
return this.compressionThreshold ?? 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||||
return this.mcpEnablementCallbacks;
|
return this.mcpEnablementCallbacks;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,6 +248,9 @@ describe('Gemini Client (client.ts)', () => {
|
|||||||
getEnableHooks: vi.fn().mockReturnValue(false),
|
getEnableHooks: vi.fn().mockReturnValue(false),
|
||||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||||
getCompressionThreshold: vi.fn().mockReturnValue(undefined),
|
getCompressionThreshold: vi.fn().mockReturnValue(undefined),
|
||||||
|
getShowContextWindowWarning: vi.fn().mockReturnValue(false),
|
||||||
|
getShowContextCompression: vi.fn().mockReturnValue(false),
|
||||||
|
getContextWindowCompressionThreshold: vi.fn().mockReturnValue(0.2),
|
||||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||||
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
|
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
|
||||||
getContinueOnFailedApiCall: vi.fn(),
|
getContinueOnFailedApiCall: vi.fn(),
|
||||||
@@ -1617,6 +1620,7 @@ ${JSON.stringify(
|
|||||||
originalTokenCount: initialTokenCount,
|
originalTokenCount: initialTokenCount,
|
||||||
newTokenCount: 400,
|
newTokenCount: 400,
|
||||||
compressionStatus: CompressionStatus.COMPRESSED,
|
compressionStatus: CompressionStatus.COMPRESSED,
|
||||||
|
requestTokenCount: 50, // Added to match updated interface
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1643,10 +1647,13 @@ ${JSON.stringify(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. Should contain compression event
|
// 2. Should contain compression event with requestTokenCount
|
||||||
expect(events).toContainEqual(
|
expect(events).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: GeminiEventType.ChatCompressed,
|
type: GeminiEventType.ChatCompressed,
|
||||||
|
value: expect.objectContaining({
|
||||||
|
requestTokenCount: expect.any(Number),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -608,17 +608,6 @@ export class GeminiClient {
|
|||||||
// Check for context window overflow
|
// Check for context window overflow
|
||||||
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
const modelForLimitCheck = this._getActiveModelForCurrentTurn();
|
||||||
|
|
||||||
const compressed = await this.tryCompressChat(prompt_id, false);
|
|
||||||
|
|
||||||
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
|
||||||
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
|
||||||
}
|
|
||||||
|
|
||||||
const remainingTokenCount =
|
|
||||||
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
|
|
||||||
|
|
||||||
await this.tryMaskToolOutputs(this.getHistory());
|
|
||||||
|
|
||||||
// Estimate tokens. For text-only requests, we estimate based on character length.
|
// Estimate tokens. For text-only requests, we estimate based on character length.
|
||||||
// For requests with non-text parts (like images, tools), we use the countTokens API.
|
// For requests with non-text parts (like images, tools), we use the countTokens API.
|
||||||
const estimatedRequestTokenCount = await calculateRequestTokenCount(
|
const estimatedRequestTokenCount = await calculateRequestTokenCount(
|
||||||
@@ -627,12 +616,48 @@ export class GeminiClient {
|
|||||||
modelForLimitCheck,
|
modelForLimitCheck,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const compressed = await this.tryCompressChat(
|
||||||
|
prompt_id,
|
||||||
|
false,
|
||||||
|
estimatedRequestTokenCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compressed.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||||
|
yield { type: GeminiEventType.ChatCompressed, value: compressed };
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingTokenCount =
|
||||||
|
tokenLimit(modelForLimitCheck) - this.getChat().getLastPromptTokenCount();
|
||||||
|
|
||||||
|
await this.tryMaskToolOutputs(this.getHistory());
|
||||||
|
|
||||||
if (estimatedRequestTokenCount > remainingTokenCount) {
|
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||||
yield {
|
if (!this.config.getShowContextWindowWarning()) {
|
||||||
type: GeminiEventType.ContextWindowWillOverflow,
|
const forcedCompressed = await this.tryCompressChat(
|
||||||
value: { estimatedRequestTokenCount, remainingTokenCount },
|
prompt_id,
|
||||||
};
|
true,
|
||||||
return turn;
|
estimatedRequestTokenCount,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
forcedCompressed.compressionStatus === CompressionStatus.COMPRESSED
|
||||||
|
) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.ChatCompressed,
|
||||||
|
value: forcedCompressed,
|
||||||
|
};
|
||||||
|
remainingTokenCount =
|
||||||
|
tokenLimit(modelForLimitCheck) -
|
||||||
|
this.getChat().getLastPromptTokenCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (estimatedRequestTokenCount > remainingTokenCount) {
|
||||||
|
yield {
|
||||||
|
type: GeminiEventType.ContextWindowWillOverflow,
|
||||||
|
value: { estimatedRequestTokenCount, remainingTokenCount },
|
||||||
|
};
|
||||||
|
return turn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent context updates from being sent while a tool call is
|
// Prevent context updates from being sent while a tool call is
|
||||||
@@ -1158,6 +1183,7 @@ export class GeminiClient {
|
|||||||
async tryCompressChat(
|
async tryCompressChat(
|
||||||
prompt_id: string,
|
prompt_id: string,
|
||||||
force: boolean = false,
|
force: boolean = false,
|
||||||
|
requestTokenCount?: number,
|
||||||
): Promise<ChatCompressionInfo> {
|
): Promise<ChatCompressionInfo> {
|
||||||
// If the model is 'auto', we will use a placeholder model to check.
|
// If the model is 'auto', we will use a placeholder model to check.
|
||||||
// Compression occurs before we choose a model, so calling `count_tokens`
|
// Compression occurs before we choose a model, so calling `count_tokens`
|
||||||
@@ -1173,6 +1199,11 @@ export class GeminiClient {
|
|||||||
this.hasFailedCompressionAttempt,
|
this.hasFailedCompressionAttempt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resultInfo = {
|
||||||
|
...info,
|
||||||
|
requestTokenCount,
|
||||||
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
info.compressionStatus ===
|
info.compressionStatus ===
|
||||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||||
@@ -1208,7 +1239,7 @@ export class GeminiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return info;
|
return resultInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export interface ChatCompressionInfo {
|
|||||||
originalTokenCount: number;
|
originalTokenCount: number;
|
||||||
newTokenCount: number;
|
newTokenCount: number;
|
||||||
compressionStatus: CompressionStatus;
|
compressionStatus: CompressionStatus;
|
||||||
|
requestTokenCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerGeminiChatCompressedEvent = {
|
export type ServerGeminiChatCompressedEvent = {
|
||||||
|
|||||||
@@ -327,7 +327,21 @@
|
|||||||
"hideContextSummary": {
|
"hideContextSummary": {
|
||||||
"title": "Hide Context Summary",
|
"title": "Hide Context Summary",
|
||||||
"description": "Hide the context summary (GEMINI.md, MCP servers) above the input.",
|
"description": "Hide the context summary (GEMINI.md, MCP servers) above the input.",
|
||||||
"markdownDescription": "Hide the context summary (GEMINI.md, MCP servers) above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
"markdownDescription": "Hide the context summary (GEMINI.md, MCP servers) above the input.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `true`",
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"showContextWindowWarning": {
|
||||||
|
"title": "Show Context Window Warning",
|
||||||
|
"description": "Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.",
|
||||||
|
"markdownDescription": "Show a warning message when the context window limit is nearly reached. If disabled, the CLI will attempt to automatically compress the history when the limit is reached.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"showContextCompression": {
|
||||||
|
"title": "Show Context Compression Messages",
|
||||||
|
"description": "Show a message in the chat history when it is compressed.",
|
||||||
|
"markdownDescription": "Show a message in the chat history when it is compressed.\n\n- Category: `UI`\n- Requires restart: `no`\n- Default: `false`",
|
||||||
"default": false,
|
"default": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user