Compare commits

..

25 Commits

Author SHA1 Message Date
Yuna Seol bd5d18c210 chore: remove unnecessary empty ignore files and plan.md 2026-04-10 13:42:51 -04:00
Yuna Seol 4f9293e8e9 fix(ui): always show context window warning on terminal overflow
This removes the early return in the `handleContextWindowWillOverflowEvent` hook. If the context window truly overflows and the message is blocked, we must tell the user why, regardless of their proactive warning settings. This prevents a silent block where the CLI appears to just swallow the user's input.
2026-04-10 13:32:18 -04:00
Keith Guerin 9b51ccf82a fix(cli): resolve CI test failures and stabilize mocks 2026-03-24 23:15:54 -07:00
Keith Guerin c47bca8374 fix(cli): address CI build and lint failures by using stable geminiClient reference 2026-03-24 17:20:13 -07:00
Keith Guerin 2c83cf9603 fix(cli): address CI build and lint failures 2026-03-24 15:53:55 -07:00
Keith Guerin 84432ddaa8 fix(ci): address final test and build issues after rebase 2026-03-24 14:02:02 -07:00
Keith Guerin 05d29d68aa fix(ci): address final test and build issues after rebase 2026-03-23 23:13:30 -07:00
Keith Guerin 99ddc299ad feat(ui): refine context messaging with window terminology and large prompt override 2026-03-23 22:24:11 -07:00
Keith Guerin cd4c8821cb fix(ci): address lint, build and test failures after merge 2026-03-23 22:24:11 -07:00
Keith Guerin 8d0c2a3acb fix(cli): resolve CI build and lint failures by fixing TS types and formatting 2026-03-23 22:24:11 -07:00
Keith Guerin 184a9abb59 docs(settings): regenerate schema and docs, and fix lint issue 2026-03-23 22:24:11 -07:00
Keith Guerin 4b2c117af3 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:24:11 -07:00
Keith Guerin 511cbc54c9 fix(cli): resolve merge conflicts and align with async test helpers 2026-03-23 22:23:26 -07:00
Keith Guerin 692c34c834 fix(cli): fix missing awaits in tests after merge 2026-03-23 22:22:02 -07:00
Keith Guerin 3be1de915f fix(cli): resolve compilation errors and update test mocks 2026-03-23 22:22:02 -07:00
Keith Guerin 16485afa96 feat(ui): finalize compression UX with threshold hint and large prompt override 2026-03-23 22:21:11 -07:00
Keith Guerin 375afc7199 feat(ui): restore threshold hint and thin arrow to compression message 2026-03-23 22:21:11 -07:00
Keith Guerin fd8d218911 docs(ui): refine documentation and test eslint-disable for context UI changes 2026-03-23 22:20:51 -07:00
Keith Guerin 1de0367faa docs(ui): update settings.md and settingsSchema descriptions for context UI changes 2026-03-23 22:20:15 -07:00
Keith Guerin b6231f561b docs(settings): rename context compression setting to use 'Messages' for clarity 2026-03-23 22:19:16 -07:00
Keith Guerin 252dbeb39a feat(ui): add showContextCompression setting and simplify message wording 2026-03-23 22:19:16 -07:00
Keith Guerin 39fb7b11a8 feat(ui): finalize context and compression messaging with symbolic arrow and refined wording 2026-03-23 22:17:47 -07:00
Keith Guerin eb3e540f3f feat(ui): redesign context and compression UI for a more seamless experience
- Added ui.showContextWindowWarning setting (default false).
- Implemented forced auto-compression on context overflow when warning is disabled.
- Redesigned compression messages to be subtle (gray, no icon, left border).
- Removed automatic context usage percentage from the minimal/focus UI.
- Changed ui.hideContextSummary default to true.
- Updated and verified all relevant tests.

Note: Includes a behavioral change where the CLI now attempts to force-compress
history when the context window is full rather than blocking by default.
2026-03-23 22:03:31 -07:00
David Pierce 37c8de3c06 Implementation of sandbox "Write-Protected" Governance Files (#23139)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2026-03-24 04:04:17 +00:00
Adam Weidman a833d350a4 docs: update /mcp refresh to /mcp reload (#23631) 2026-03-24 03:41:24 +00:00
33 changed files with 1077 additions and 1034 deletions
-113
View File
@@ -1,113 +0,0 @@
name: 'Evals: PR Impact'
on:
pull_request:
paths:
- 'packages/core/src/prompts/**'
- 'packages/core/src/tools/**'
- 'packages/core/src/agents/**'
- 'evals/**'
permissions:
contents: 'read'
checks: 'write'
pull-requests: 'write'
jobs:
eval-impact:
name: 'Eval Impact Analysis'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
- name: 'Run Evals (3 Attempts with Clean State)'
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
RUN_EVALS: 'true'
run: |
MODELS=("gemini-3.1-pro-preview-customtools" "gemini-3-flash-preview")
# Create a persistent logs dir outside the workspace that won't be git-cleaned
FINAL_LOGS_DIR="/tmp/eval-impact-logs"
mkdir -p "$FINAL_LOGS_DIR"
for model in "${MODELS[@]}"; do
for attempt in {1..3}; do
echo "::group::Running $model (Attempt $attempt)"
DIR_NAME="eval-logs-$model-$attempt"
# Run the tests
GEMINI_MODEL=$model npm run test:all_evals -- --outputFile.json="report.json" || true
# Move the report to the persistent location
mkdir -p "$FINAL_LOGS_DIR/$DIR_NAME"
if [ -f "report.json" ]; then
mv report.json "$FINAL_LOGS_DIR/$DIR_NAME/report.json"
fi
# FORCE CLEAN: Return to a perfectly pristine state
git clean -xfd
npm run build
echo "::endgroup::"
done
done
# Move all logs back into the workspace for the aggregation script
mkdir -p evals/logs
cp -r "$FINAL_LOGS_DIR"/* evals/logs/
- name: 'Generate Impact Report'
id: 'generate-report'
if: 'always()'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
if [ ! -d "evals/logs" ]; then
echo "No logs found, skipping report generation."
exit 0
fi
echo "<!-- eval-impact-report -->" > report.md
node scripts/aggregate_evals.js evals/logs --compare-main --pr-comment >> report.md
cat report.md >> "$GITHUB_STEP_SUMMARY"
# Check for blockers in the report
if grep -q "🔴" report.md; then
echo "BLOCKER_DETECTED=true" >> "$GITHUB_ENV"
fi
- name: 'Comment on PR'
if: 'always()'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
if [ ! -f "report.md" ]; then
exit 0
fi
PR_NUMBER=${{ github.event.pull_request.number }}
EXISTING_COMMENT=$(gh pr view $PR_NUMBER --json comments --jq '.comments[] | select(.body | contains("eval-impact-report")) | .id' | head -n 1)
if [ -n "$EXISTING_COMMENT" ]; then
gh pr comment $PR_NUMBER --body-file report.md
else
gh pr comment $PR_NUMBER --body-file report.md
fi
- name: 'Block PR on Stable Regression'
if: "env.BLOCKER_DETECTED == 'true'"
run: |
echo "Fatal regressions detected in ALWAYS_PASSES behavioral evaluations."
exit 1
+34 -32
View File
@@ -46,38 +46,40 @@ they appear in the UI.
### UI
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| 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` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| 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` |
| 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 Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| 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` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| 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 CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors 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` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| 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` |
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| 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` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| 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` |
| 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 Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| 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` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| 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. | `true` |
| 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` |
| Show Context Compression Messages | `ui.showContextCompression` | Show a message in the chat history when it is compressed. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors 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` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| 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
+2 -2
View File
@@ -250,8 +250,8 @@ Slash commands provide meta-level control over the CLI itself.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
- **`refresh`**:
- **Description:** Restarts all MCP servers and re-discovers their available
- **`reload`**:
- **Description:** Reloads all MCP servers and re-discovers their available
tools.
- **`schema`**:
- **Description:** List configured MCP servers and tools with descriptions
+10
View File
@@ -263,6 +263,16 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.hideContextSummary`** (boolean):
- **Description:** Hide the context summary (GEMINI.md, MCP servers) above the
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`
- **`ui.footer.items`** (array):
-20
View File
@@ -202,30 +202,10 @@ Results for evaluations are available on GitHub Actions:
- **CI Evals**: Included in the
[E2E (Chained)](https://github.com/google-gemini/gemini-cli/actions/workflows/chained_e2e.yml)
workflow. These must pass 100% for every PR.
- **PR Impact Analysis**: Run automatically on PRs that modify prompts, tools,
or agent logic via the
[Evals: PR Impact](https://github.com/google-gemini/gemini-cli/actions/workflows/eval-pr.yml)
workflow. This provides a "before and after" comparison of behavioral eval
stability and will fail the PR if regressions are detected.
- **Nightly Evals**: Run daily via the
[Evals: Nightly](https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml)
workflow. These track the long-term health and stability of model steering.
### PR Impact Report
When a PR triggers the impact analysis, a bot will post a table to the PR
commenting on the change in pass rates for critical models (e.g. Gemini 3.1 Pro
and Gemini 3 Flash).
- **Baseline**: The pass rate from the latest successful nightly run on `main`.
- **Current**: The pass rate on the PR branch (based on a "lite" run of 1
attempt).
- **Impact**: A delta showing if stability improved (🟢), regressed (🔴), or
remained stable (⚪).
A PR that introduces a regression (🔴) in any behavioral evaluation will fail
this check and require investigation before merging.
### Nightly Report Format
The nightly workflow executes the full evaluation suite multiple times
@@ -48,7 +48,8 @@ describe('Interactive Mode', () => {
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
+46
View File
@@ -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', () => {
beforeEach(() => {
vi.resetAllMocks();
+2
View File
@@ -955,6 +955,8 @@ export async function loadCliConfig(
bugCommand: settings.advanced?.bugCommand,
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
showContextWindowWarning: settings.ui?.showContextWindowWarning,
showContextCompression: settings.ui?.showContextCompression,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
+21 -1
View File
@@ -575,11 +575,31 @@ const SETTINGS_SCHEMA = {
label: 'Hide Context Summary',
category: 'UI',
requiresRestart: false,
default: false,
default: true,
description:
'Hide the context summary (GEMINI.md, MCP servers) above the input.',
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: {
type: 'object',
label: 'Footer',
@@ -4,28 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
CompressionStatus,
type ChatCompressionInfo,
type GeminiClient,
} from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach } from 'vitest';
import * as Core from '@google/gemini-cli-core';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { compressCommand } from './compressCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.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', () => {
let context: ReturnType<typeof createMockCommandContext>;
let mockTryCompressChat: ReturnType<typeof vi.fn>;
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
mockTryCompressChat = vi.fn();
vi.mocked(Core.tokenLimit).mockReturnValue(1000);
context = createMockCommandContext({
services: {
agentContext: {
config: {
getModel: () => 'test-model',
getContextWindowCompressionThreshold: () => 0.2,
},
geminiClient: {
tryCompressChat: mockTryCompressChat,
} as unknown as GeminiClient,
} as unknown as Core.GeminiClient,
},
},
});
@@ -36,9 +50,10 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
await compressCommand.action!(context, '');
@@ -54,9 +69,9 @@ describe('compressCommand', () => {
});
it('should set pending item, call tryCompressChat, and add result on success', async () => {
const compressedResult: ChatCompressionInfo = {
const compressedResult: Core.ChatCompressionInfo = {
originalTokenCount: 200,
compressionStatus: CompressionStatus.COMPRESSED,
compressionStatus: Core.CompressionStatus.COMPRESSED,
newTokenCount: 100,
};
mockTryCompressChat.mockResolvedValue(compressedResult);
@@ -68,8 +83,9 @@ describe('compressCommand', () => {
compression: {
isPending: true,
compressionStatus: null,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
isManual: true,
},
});
@@ -83,9 +99,11 @@ describe('compressCommand', () => {
type: MessageType.COMPRESSION,
compression: {
isPending: false,
compressionStatus: CompressionStatus.COMPRESSED,
originalTokenCount: 200,
newTokenCount: 100,
compressionStatus: Core.CompressionStatus.COMPRESSED,
beforePercentage: 20,
afterPercentage: 10,
isManual: true,
thresholdPercentage: 20,
},
},
expect.any(Number),
+40 -11
View File
@@ -6,6 +6,7 @@
import { MessageType, type HistoryItemCompression } from '../types.js';
import { CommandKind, type SlashCommand } from './types.js';
import { tokenLimit, type CompressionStatus } from '@google/gemini-cli-core';
export const compressCommand: SlashCommand = {
name: 'compress',
@@ -14,7 +15,21 @@ export const compressCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
autoExecute: true,
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) {
ui.addItem(
{
@@ -30,29 +45,43 @@ export const compressCommand: SlashCommand = {
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: null,
isManual: true,
},
};
try {
ui.setPendingItem(pendingMessage);
const promptId = `compress-${Date.now()}`;
const compressed =
await context.services.agentContext?.geminiClient?.tryCompressChat(
promptId,
true,
);
const compressed = await agentContext.geminiClient.tryCompressChat(
promptId,
true,
);
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(
{
type: MessageType.COMPRESSION,
compression: {
isPending: false,
originalTokenCount: compressed.originalTokenCount,
newTokenCount: compressed.newTokenCount,
compressionStatus: compressed.compressionStatus,
beforePercentage,
afterPercentage,
// 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,
Date.now(),
@@ -17,11 +17,7 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
ApprovalMode,
tokenLimit,
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { ApprovalMode, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
@@ -733,37 +729,6 @@ describe('Composer', () => {
expect(output).toContain('Press Esc again to rewind.');
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', () => {
+2 -22
View File
@@ -20,13 +20,11 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
@@ -257,11 +255,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
const miniMode_ShowContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
// Composite Mini Mode Triggers
const showRow1_MiniMode =
@@ -270,7 +263,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
const showRow2_MiniMode = miniMode_ShowApprovalMode;
// Final Display Rules (Stable Footer Architecture)
const showRow1 = showUiDetails || showRow1_MiniMode;
@@ -488,22 +481,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
{showUiDetails && (
<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>
)}
@@ -72,7 +72,9 @@ const createMockConfig = (overrides = {}) => ({
const renderStatusDisplay = async (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
settings = createMockSettings(),
settings = createMockSettings({
ui: { hideContextSummary: true },
}),
config = createMockConfig(),
) => {
const result = await render(
@@ -97,16 +99,21 @@ describe('StatusDisplay', () => {
vi.restoreAllMocks();
});
it('renders nothing by default if context summary is hidden via props', async () => {
const { lastFrame, unmount } = await renderStatusDisplay({
hideContextSummary: true,
});
it('renders nothing by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders ContextSummaryDisplay by default', async () => {
const { lastFrame, unmount } = await renderStatusDisplay();
it('renders ContextSummaryDisplay when hideContextSummary is false', async () => {
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
undefined,
settings,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -118,34 +125,6 @@ describe('StatusDisplay', () => {
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 () => {
const settings = createMockSettings({
ui: { hideContextSummary: true },
@@ -163,9 +142,13 @@ describe('StatusDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const settings = createMockSettings({
ui: { hideContextSummary: false },
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
settings,
);
expect(lastFrame()).toContain('Shells: 3');
unmount();
@@ -1,11 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `
exports[`StatusDisplay > renders ContextSummaryDisplay when hideContextSummary is false 1`] = `
"Mock Context Summary Display (Skills: 2, Shells: 0)
"
`;
@@ -11,17 +11,22 @@ import {
} from './CompressionMessage.js';
import { CompressionStatus } from '@google/gemini-cli-core';
import { type CompressionProps } from '../../types.js';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
describe('<CompressionMessage />', () => {
afterEach(() => {
vi.restoreAllMocks();
});
const createCompressionProps = (
overrides: Partial<CompressionProps> = {},
): CompressionDisplayProps => ({
compression: {
isPending: false,
originalTokenCount: null,
newTokenCount: null,
beforePercentage: null,
afterPercentage: null,
compressionStatus: CompressionStatus.COMPRESSED,
isManual: true,
...overrides,
},
});
@@ -29,9 +34,10 @@ describe('<CompressionMessage />', () => {
describe('pending state', () => {
it('renders pending message when compression is in progress', async () => {
const props = createCompressionProps({ isPending: true });
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Compressing chat history');
@@ -43,56 +49,28 @@ describe('<CompressionMessage />', () => {
it('renders success message when tokens are reduced', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 100,
newTokenCount: 50,
beforePercentage: 22,
afterPercentage: 6,
compressionStatus: CompressionStatus.COMPRESSED,
thresholdPercentage: 50,
});
const { lastFrame, unmount } = await renderWithProviders(
<CompressionMessage {...props} />,
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compressed from 100 to 50 tokens.',
'Context compressed (22% → 6%). Adjust threshold (50%) in /settings.',
);
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)', () => {
it('renders skip message when compression would increase token count', async () => {
const props = createCompressionProps({
isPending: false,
originalTokenCount: 50,
newTokenCount: 75,
compressionStatus:
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
});
@@ -101,121 +79,12 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Compression was not beneficial for this history size.',
);
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', () => {
@@ -229,9 +98,9 @@ describe('<CompressionMessage />', () => {
);
const output = lastFrame();
expect(output).toContain('✦');
expect(output).not.toContain('✦');
expect(output).toContain(
'Chat history compression failed: the model returned an empty summary.',
'Chat history compression failed: empty summary.',
);
unmount();
});
@@ -22,11 +22,13 @@ export interface CompressionDisplayProps {
export function CompressionMessage({
compression,
}: CompressionDisplayProps): React.JSX.Element {
const { isPending, originalTokenCount, newTokenCount, compressionStatus } =
compression;
const originalTokens = originalTokenCount ?? 0;
const newTokens = newTokenCount ?? 0;
const {
isPending,
beforePercentage,
afterPercentage,
compressionStatus,
thresholdPercentage,
} = compression;
const getCompressionText = () => {
if (isPending) {
@@ -34,20 +36,19 @@ export function CompressionMessage({
}
switch (compressionStatus) {
case CompressionStatus.COMPRESSED:
return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`;
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
// For smaller histories (< 50k tokens), compression overhead likely exceeds benefits
if (originalTokens < 50000) {
return 'Compression was not beneficial for this history size.';
case CompressionStatus.COMPRESSED: {
let text = `Context compressed (${beforePercentage}% → ${afterPercentage}%).`;
if (thresholdPercentage != null) {
text += ` Adjust threshold (${thresholdPercentage}%) in /settings.`;
}
// For larger histories where compression should work but didn't,
// 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.';
return text;
}
case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT:
return 'Compression was not beneficial for this history size.';
case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR:
return 'Could not compress chat history due to a token counting error.';
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:
return 'Nothing to compress.';
default:
@@ -58,20 +59,13 @@ export function CompressionMessage({
const text = getCompressionText();
return (
<Box flexDirection="row">
<Box marginRight={1}>
{isPending ? (
<CliSpinner type="dots" />
) : (
<Text color={theme.text.accent}></Text>
)}
</Box>
<Box flexDirection="row" paddingLeft={1} marginBottom={1}>
<Box marginRight={1}>{isPending && <CliSpinner type="dots" />}</Box>
<Box>
<Text
color={
compression.isPending ? theme.text.accent : theme.status.success
}
color={theme.text.secondary}
aria-label={SCREEN_READER_MODEL_PREFIX}
italic
>
{text}
</Text>
+193 -169
View File
@@ -4,7 +4,7 @@
* 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 {
describe,
it,
@@ -32,10 +32,7 @@ import type {
Config,
EditorType,
AnyToolInvocation,
AnyDeclarativeTool,
SpanMetadata,
CompletedToolCall,
ToolCallRequestInfo,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -52,19 +49,15 @@ import {
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
CompressionStatus,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type {
SlashCommandProcessorResult,
HistoryItemWithoutId,
HistoryItem,
} from '../types.js';
import type { SlashCommandProcessorResult } from '../types.js';
import { MessageType, StreamingState } from '../types.js';
import type { LoadedSettings } from '../../config/settings.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { theme } from '../semantic-colors.js';
// --- MOCKS ---
const mockSendMessageStream = vi
@@ -145,6 +138,7 @@ const mockRunInDevTraceSpan = vi.hoisted(() =>
};
return await fn({
metadata,
endSpan: vi.fn(),
});
}),
);
@@ -249,10 +243,8 @@ describe('useGeminiStream', () => {
let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance;
const emptyHistory: HistoryItem[] = [];
let capturedOnComplete:
| ((tools: CompletedToolCall[]) => Promise<void>)
| null = null;
const emptyHistory: any[] = [];
let capturedOnComplete: any = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
@@ -332,6 +324,9 @@ describe('useGeminiStream', () => {
})),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
getShowContextWindowWarning: vi.fn(() => false),
getShowContextCompression: vi.fn(() => false),
getContextWindowCompressionThreshold: vi.fn(() => 0.2),
} as unknown as Config;
beforeEach(() => {
@@ -411,17 +406,13 @@ describe('useGeminiStream', () => {
lastToolCalls,
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
(
updater:
| TrackedToolCall[]
| ((prev: TrackedToolCall[]) => TrackedToolCall[]),
) => {
(updater: any) => {
lastToolCalls =
typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
},
(signal: AbortSignal) => {
mockCancelAllToolCalls(signal);
(...args: any[]) => {
mockCancelAllToolCalls(...args);
lastToolCalls = lastToolCalls.map((tc) => {
if (
tc.status === CoreToolCallStatus.AwaitingApproval ||
@@ -888,7 +879,7 @@ describe('useGeminiStream', () => {
const fn = spanArgs[1];
const metadata = { attributes: {} };
await act(async () => {
await fn({ metadata });
await fn({ metadata, endSpan: vi.fn() });
});
expect(metadata).toMatchObject({
input: sentParts,
@@ -982,7 +973,7 @@ describe('useGeminiStream', () => {
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
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 () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
const stopExecutionToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'stop-call',
@@ -1081,10 +1072,9 @@ describe('useGeminiStream', () => {
} as unknown as TrackedCompletedToolCall,
];
const lowVerbositySettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { errorVerbosity: 'low' },
},
} as LoadedSettings;
@@ -1935,120 +1925,6 @@ describe('useGeminiStream', () => {
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', () => {
@@ -2076,7 +1952,7 @@ describe('useGeminiStream', () => {
displayName: 'save_memory',
description: 'Saves memory',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
@@ -2150,8 +2026,7 @@ describe('useGeminiStream', () => {
);
const testConfig = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
...(mockConfig as any),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi.fn(() => ({
authType: mockAuthType,
@@ -2316,7 +2191,7 @@ describe('useGeminiStream', () => {
displayName: 'replace',
description: 'Replace text',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2357,7 +2232,7 @@ describe('useGeminiStream', () => {
displayName: 'write_file',
description: 'Write file',
build: vi.fn(),
} as unknown as AnyDeclarativeTool,
} as any,
invocation: {
getDescription: () => 'Mock description',
} as unknown as AnyToolInvocation,
@@ -2474,22 +2349,32 @@ describe('useGeminiStream', () => {
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,
remainingTokens: 80,
shouldShow: false, // The setting itself is false
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,
remainingTokens: 70,
shouldShow: true,
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',
async ({ requestTokens, remainingTokens, expectedMessage }) => {
'should $name',
async ({
requestTokens,
remainingTokens,
shouldShow,
expectedMessage,
}) => {
vi.mocked(mockConfig.getShowContextWindowWarning).mockReturnValue(
shouldShow,
);
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
@@ -2509,10 +2394,12 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith({
type: 'info',
text: expectedMessage,
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
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(
mockConfig.getContextWindowCompressionThreshold,
).mockReturnValue(0.2);
vi.mocked(mockConfig.getShowContextCompression).mockReturnValue(true);
// Setup mock to return a stream with ChatCompressed event
mockSendMessageStream.mockReturnValue(
(async function* () {
@@ -2576,7 +2467,22 @@ describe('useGeminiStream', () => {
value: {
originalTokenCount: 1000,
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(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Context compressed from 10% to 5%.',
secondaryText: 'Change threshold in /settings.',
color: theme.status.warning,
type: 'compression',
compression: {
isPending: false,
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),
);
@@ -2702,14 +2727,14 @@ describe('useGeminiStream', () => {
it('should flush pending text rationale before scheduling tool calls to ensure correct history order', async () => {
const addItemOrder: string[] = [];
let capturedOnComplete: (tools: CompletedToolCall[]) => Promise<void>;
let capturedOnComplete: any;
const mockScheduleToolCalls = vi.fn(async (requests) => {
addItemOrder.push('scheduleToolCalls_START');
// Simulate tools completing and triggering onComplete immediately.
// This mimics the behavior that caused the regression where tool results
// were added to history during the await scheduleToolCalls(...) block.
const tools = requests.map((r: ToolCallRequestInfo) => ({
const tools = requests.map((r: any) => ({
request: r,
status: CoreToolCallStatus.Success,
tool: { displayName: r.name, name: r.name },
@@ -2724,7 +2749,7 @@ describe('useGeminiStream', () => {
addItemOrder.push('scheduleToolCalls_END');
});
mockAddItem.mockImplementation((item: HistoryItemWithoutId) => {
mockAddItem.mockImplementation((item: any) => {
addItemOrder.push(`addItem:${item.type}`);
});
@@ -2954,10 +2979,9 @@ describe('useGeminiStream', () => {
describe('Thought Reset', () => {
it('should keep full thinking entries in history when mode is full', async () => {
const fullThinkingSettings: LoadedSettings = {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockLoadedSettings,
...(mockLoadedSettings as any),
merged: {
...mockLoadedSettings.merged,
...(mockLoadedSettings.merged as any),
ui: { inlineThinkingMode: 'full' },
},
} as unknown as LoadedSettings;
@@ -4036,7 +4060,7 @@ describe('useGeminiStream', () => {
const spanMetadata = {} as SpanMetadata;
await act(async () => {
await userPromptCall![1]({ metadata: spanMetadata });
await userPromptCall![1]({ metadata: spanMetadata, endSpan: vi.fn() });
});
expect(spanMetadata.input).toBe('telemetry test query');
});
+85 -84
View File
@@ -38,22 +38,20 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
isBackgroundExecutionData,
type CompressionStatus,
Kind,
ACTIVATE_SKILL_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
EditorType,
GeminiClient,
ServerGeminiChatCompressedEvent,
ServerGeminiContentEvent as ContentEvent,
ServerGeminiFinishedEvent,
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
type Config,
type EditorType,
type GeminiClient,
type ServerGeminiChatCompressedEvent,
type ServerGeminiContentEvent as ContentEvent,
type ServerGeminiFinishedEvent,
type ServerGeminiStreamEvent as GeminiEvent,
type ThoughtSummary,
type ToolCallRequestInfo,
type ToolCallResponseInfo,
type GeminiErrorEventValue,
type RetryAttemptPayload,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
@@ -61,7 +59,6 @@ import type {
HistoryItemThinking,
HistoryItemWithoutId,
HistoryItemToolGroup,
HistoryItemInfo,
IndividualToolCallDisplay,
SlashCommandProcessorResult,
HistoryItemModel,
@@ -257,6 +254,8 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const handleCompletedToolsRef =
useRef<(completedTools: TrackedToolCall[]) => Promise<void>>(undefined);
const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.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.
setToolCallsForDisplay([]);
// Record tool calls with full metadata before sending responses.
try {
const currentModel =
config.getGeminiClient().getCurrentSequenceModel() ??
config.getModel();
config
.getGeminiClient()
.getChat()
.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
(typeof geminiClient.getCurrentSequenceModel === 'function'
? geminiClient.getCurrentSequenceModel()
: undefined) ?? config.getModel();
const chat =
typeof geminiClient.getChat === 'function'
? geminiClient.getChat()
: undefined;
chat?.recordCompletedToolCalls(
currentModel,
completedToolCallsFromScheduler,
);
await recordToolCallInteractions(
config,
@@ -330,7 +330,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
await handleCompletedToolsRef.current?.(
completedToolCallsFromScheduler as TrackedToolCall[],
);
}
@@ -549,9 +549,11 @@ export const useGeminiStream = (
if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) {
return false;
}
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
if (
@@ -1145,21 +1147,43 @@ export const useGeminiStream = (
}
const limit = tokenLimit(config.getModel());
const originalPercentage = Math.round(
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
);
const newPercentage = Math.round(
((eventValue?.newTokenCount ?? 0) / limit) * 100,
);
const beforePercentage =
eventValue?.originalTokenCount != null
? Math.round((eventValue.originalTokenCount / limit) * 100)
: null;
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(
{
type: MessageType.INFO,
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
secondaryText: `Change threshold in /settings.`,
color: theme.status.warning,
marginBottom: 1,
} as HistoryItemInfo,
type: 'compression',
compression: {
isPending: false,
beforePercentage,
afterPercentage,
/* 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,
);
},
@@ -1182,16 +1206,11 @@ export const useGeminiStream = (
onCancelSubmit(true);
const limit = tokenLimit(config.getModel());
const usedPercentage = Math.round(
((limit - remainingTokenCount) / limit) * 100,
);
const isMoreThan25PercentUsed =
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.';
}
const text = `Context ${usedPercentage}% full. Message may exceed window. Reduce size or /compress.`;
addItem({
type: 'info',
@@ -1538,8 +1557,7 @@ export const useGeminiStream = (
setLoopDetectionConfirmationRequest(null);
if (result.userSelection === 'disable') {
config
.getGeminiClient()
geminiClient
.getLoopDetectionService()
.disableForSession();
addItem({
@@ -1657,7 +1675,7 @@ export const useGeminiStream = (
) {
let awaitingApprovalCalls = toolCalls.filter(
(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)
@@ -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.
const clientTools = completedAndReadyToSubmitTools.filter(
(t) => t.request.isClientInitiated,
);
if (clientTools.length > 0) {
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.
@@ -1906,6 +1906,7 @@ export const useGeminiStream = (
setIsResponding,
],
);
handleCompletedToolsRef.current = handleCompletedTools;
const pendingHistoryItems = useMemo(
() =>
+4 -2
View File
@@ -138,9 +138,11 @@ export interface IndividualToolCallDisplay {
export interface CompressionProps {
isPending: boolean;
originalTokenCount: number | null;
newTokenCount: number | null;
beforePercentage: number | null;
afterPercentage: number | null;
compressionStatus: CompressionStatus | null;
isManual: boolean;
thresholdPercentage?: number | null;
}
/**
+18
View File
@@ -673,6 +673,8 @@ export interface ConfigParameters {
agents?: AgentSettings;
}>;
enableConseca?: boolean;
showContextWindowWarning?: boolean;
showContextCompression?: boolean;
billing?: {
overageStrategy?: OverageStrategy;
};
@@ -708,6 +710,8 @@ export class Config implements McpContext, AgentLoopContext {
private readonly question: string | undefined;
private readonly worktreeSettings: WorktreeSettings | undefined;
readonly enableConseca: boolean;
private readonly showContextWindowWarning: boolean;
private readonly showContextCompression: boolean;
private readonly coreTools: string[] | undefined;
private readonly mainAgentTools: string[] | undefined;
@@ -1146,6 +1150,8 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.showContextWindowWarning = params.showContextWindowWarning ?? false;
this.showContextCompression = params.showContextCompression ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -2053,6 +2059,18 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpEnabled;
}
getShowContextWindowWarning(): boolean {
return this.showContextWindowWarning;
}
getShowContextCompression(): boolean {
return this.showContextCompression;
}
getContextWindowCompressionThreshold(): number {
return this.compressionThreshold ?? 0.5;
}
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
return this.mcpEnablementCallbacks;
}
+8 -1
View File
@@ -248,6 +248,9 @@ describe('Gemini Client (client.ts)', () => {
getEnableHooks: vi.fn().mockReturnValue(false),
getChatCompression: 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),
getShowModelInfoInChat: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn(),
@@ -1617,6 +1620,7 @@ ${JSON.stringify(
originalTokenCount: initialTokenCount,
newTokenCount: 400,
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.objectContaining({
type: GeminiEventType.ChatCompressed,
value: expect.objectContaining({
requestTokenCount: expect.any(Number),
}),
}),
);
+48 -17
View File
@@ -608,17 +608,6 @@ export class GeminiClient {
// Check for context window overflow
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.
// For requests with non-text parts (like images, tools), we use the countTokens API.
const estimatedRequestTokenCount = await calculateRequestTokenCount(
@@ -627,12 +616,48 @@ export class GeminiClient {
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) {
yield {
type: GeminiEventType.ContextWindowWillOverflow,
value: { estimatedRequestTokenCount, remainingTokenCount },
};
return turn;
if (!this.config.getShowContextWindowWarning()) {
const forcedCompressed = await this.tryCompressChat(
prompt_id,
true,
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
@@ -1158,6 +1183,7 @@ export class GeminiClient {
async tryCompressChat(
prompt_id: string,
force: boolean = false,
requestTokenCount?: number,
): Promise<ChatCompressionInfo> {
// If the model is 'auto', we will use a placeholder model to check.
// Compression occurs before we choose a model, so calling `count_tokens`
@@ -1173,6 +1199,11 @@ export class GeminiClient {
this.hasFailedCompressionAttempt,
);
const resultInfo = {
...info,
requestTokenCount,
};
if (
info.compressionStatus ===
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
@@ -1208,7 +1239,7 @@ export class GeminiClient {
}
}
return info;
return resultInfo;
}
/**
+1
View File
@@ -188,6 +188,7 @@ export interface ChatCompressionInfo {
originalTokenCount: number;
newTokenCount: number;
compressionStatus: CompressionStatus;
requestTokenCount?: number;
}
export type ServerGeminiChatCompressedEvent = {
@@ -4,15 +4,42 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LinuxSandboxManager } from './LinuxSandboxManager.js';
import type { SandboxRequest } from '../../services/sandboxManager.js';
import fs from 'node:fs';
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
default: {
// @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")'
...actual.default,
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
writeFileSync: vi.fn(),
},
existsSync: vi.fn(() => true),
realpathSync: vi.fn((p: string | Buffer) => p.toString()),
mkdirSync: vi.fn(),
openSync: vi.fn(),
closeSync: vi.fn(),
writeFileSync: vi.fn(),
};
});
describe('LinuxSandboxManager', () => {
const workspace = '/home/user/workspace';
let manager: LinuxSandboxManager;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString());
manager = new LinuxSandboxManager({ workspace });
});
@@ -52,6 +79,15 @@ describe('LinuxSandboxManager', () => {
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--seccomp',
'9',
'--',
@@ -79,6 +115,15 @@ describe('LinuxSandboxManager', () => {
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
'--bind-try',
'/tmp/cache',
'/tmp/cache',
@@ -88,6 +133,48 @@ describe('LinuxSandboxManager', () => {
]);
});
it('protects real paths of governance files if they are symlinks', async () => {
vi.mocked(fs.realpathSync).mockImplementation((p) => {
if (p.toString() === `${workspace}/.gitignore`)
return '/shared/global.gitignore';
return p.toString();
});
const bwrapArgs = await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(bwrapArgs).toContain('--ro-bind');
expect(bwrapArgs).toContain(`${workspace}/.gitignore`);
expect(bwrapArgs).toContain('/shared/global.gitignore');
// Check that both are bound
const gitignoreIndex = bwrapArgs.indexOf(`${workspace}/.gitignore`);
expect(bwrapArgs[gitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[gitignoreIndex + 1]).toBe(`${workspace}/.gitignore`);
const realGitignoreIndex = bwrapArgs.indexOf('/shared/global.gitignore');
expect(bwrapArgs[realGitignoreIndex - 1]).toBe('--ro-bind');
expect(bwrapArgs[realGitignoreIndex + 1]).toBe('/shared/global.gitignore');
});
it('touches governance files if they do not exist', async () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
await getBwrapArgs({
command: 'ls',
args: [],
cwd: workspace,
env: {},
});
expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.openSync).toHaveBeenCalled();
});
it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => {
const bwrapArgs = await getBwrapArgs({
command: 'ls',
@@ -102,7 +189,20 @@ describe('LinuxSandboxManager', () => {
const bindsIndex = bwrapArgs.indexOf('--seccomp');
const binds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex);
// Should only contain the primary workspace bind, not the second one with a trailing slash
expect(binds).toEqual(['--bind', workspace, workspace]);
// Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash
expect(binds).toEqual([
'--bind',
workspace,
workspace,
'--ro-bind',
`${workspace}/.gitignore`,
`${workspace}/.gitignore`,
'--ro-bind',
`${workspace}/.geminiignore`,
`${workspace}/.geminiignore`,
'--ro-bind',
`${workspace}/.git`,
`${workspace}/.git`,
]);
});
});
@@ -4,14 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { join, normalize } from 'node:path';
import { writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { join, dirname, normalize } from 'node:path';
import os from 'node:os';
import {
type SandboxManager,
type GlobalSandboxOptions,
type SandboxRequest,
type SandboxedCommand,
GOVERNANCE_FILES,
sanitizePaths,
} from '../../services/sandboxManager.js';
import {
@@ -72,11 +73,30 @@ function getSeccompBpfPath(): string {
}
const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`);
writeFileSync(bpfPath, buf);
fs.writeFileSync(bpfPath, buf);
cachedBpfPath = bpfPath;
return bpfPath;
}
/**
* Ensures a file or directory exists.
*/
function touch(filePath: string, isDirectory: boolean) {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
fs.mkdirSync(dirname(filePath), { recursive: true });
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* A SandboxManager implementation for Linux that uses Bubblewrap (bwrap).
*/
@@ -109,6 +129,21 @@ export class LinuxSandboxManager implements SandboxManager {
this.options.workspace,
];
// Protected governance files are bind-mounted as read-only, even if the workspace is RW.
// We ensure they exist on the host and resolve real paths to prevent symlink bypasses.
// In bwrap, later binds override earlier ones for the same path.
for (const file of GOVERNANCE_FILES) {
const filePath = join(this.options.workspace, file.path);
touch(filePath, file.isDirectory);
const realPath = fs.realpathSync(filePath);
bwrapArgs.push('--ro-bind', filePath, filePath);
if (realPath !== filePath) {
bwrapArgs.push('--ro-bind', realPath, realPath);
}
}
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
const normalizedWorkspace = normalize(this.options.workspace).replace(
/\/$/,
@@ -8,20 +8,32 @@ import { MacOsSandboxManager } from './MacOsSandboxManager.js';
import type { ExecutionPolicy } from '../../services/sandboxManager.js';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
describe('MacOsSandboxManager', () => {
const mockWorkspace = '/test/workspace';
const mockAllowedPaths = ['/test/allowed'];
let mockWorkspace: string;
let mockAllowedPaths: string[];
const mockNetworkAccess = true;
const mockPolicy: ExecutionPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
let mockPolicy: ExecutionPolicy;
let manager: MacOsSandboxManager;
beforeEach(() => {
mockWorkspace = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-macos-test-'),
);
mockAllowedPaths = [
path.join(os.tmpdir(), 'gemini-cli-macos-test-allowed'),
];
if (!fs.existsSync(mockAllowedPaths[0])) {
fs.mkdirSync(mockAllowedPaths[0]);
}
mockPolicy = {
allowedPaths: mockAllowedPaths,
networkAccess: mockNetworkAccess,
};
manager = new MacOsSandboxManager({ workspace: mockWorkspace });
// Mock realpathSync to just return the path for testing
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => p as string);
@@ -29,6 +41,10 @@ describe('MacOsSandboxManager', () => {
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(mockWorkspace, { recursive: true, force: true });
if (mockAllowedPaths && mockAllowedPaths[0]) {
fs.rmSync(mockAllowedPaths[0], { recursive: true, force: true });
}
});
describe('prepareCommand', () => {
@@ -50,8 +66,19 @@ describe('MacOsSandboxManager', () => {
expect(profile).not.toContain('(allow network*)');
expect(result.args).toContain('-D');
expect(result.args).toContain('WORKSPACE=/test/workspace');
expect(result.args).toContain(`WORKSPACE=${mockWorkspace}`);
expect(result.args).toContain(`TMPDIR=${os.tmpdir()}`);
// Governance files should be protected
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_0")))',
); // .gitignore
expect(profile).toContain(
'(deny file-write* (literal (param "GOVERNANCE_FILE_1")))',
); // .geminiignore
expect(profile).toContain(
'(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))',
); // .git
});
it('should allow network when networkAccess is true in policy', async () => {
@@ -134,31 +161,41 @@ describe('MacOsSandboxManager', () => {
});
it('should resolve parent directories if a file does not exist', async () => {
const baseTmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-macos-realpath-test-'),
);
const realPath = path.join(baseTmpDir, 'real_path');
const nonexistentFile = path.join(realPath, 'nonexistent.txt');
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => {
if (p === '/test/symlink/nonexistent.txt') {
if (p === nonexistentFile) {
const error = new Error('ENOENT');
Object.assign(error, { code: 'ENOENT' });
throw error;
}
if (p === '/test/symlink') {
return '/test/real_path';
if (p === realPath) {
return path.join(baseTmpDir, 'resolved_path');
}
return p as string;
});
const dynamicManager = new MacOsSandboxManager({
workspace: '/test/symlink/nonexistent.txt',
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: '/test/symlink/nonexistent.txt',
env: {},
});
try {
const dynamicManager = new MacOsSandboxManager({
workspace: nonexistentFile,
});
const dynamicResult = await dynamicManager.prepareCommand({
command: 'echo',
args: ['hello'],
cwd: nonexistentFile,
env: {},
});
expect(dynamicResult.args).toContain(
'WORKSPACE=/test/real_path/nonexistent.txt',
);
expect(dynamicResult.args).toContain(
`WORKSPACE=${path.join(baseTmpDir, 'resolved_path', 'nonexistent.txt')}`,
);
} finally {
fs.rmSync(baseTmpDir, { recursive: true, force: true });
}
});
it('should throw if realpathSync throws a non-ENOENT error', async () => {
@@ -169,7 +206,7 @@ describe('MacOsSandboxManager', () => {
});
const errorManager = new MacOsSandboxManager({
workspace: '/test/workspace',
workspace: mockWorkspace,
});
await expect(
errorManager.prepareCommand({
@@ -14,6 +14,7 @@ import {
type SandboxedCommand,
type ExecutionPolicy,
sanitizePaths,
GOVERNANCE_FILES,
} from '../../services/sandboxManager.js';
import {
sanitizeEnvironment,
@@ -65,6 +66,43 @@ export class MacOsSandboxManager implements SandboxManager {
const workspacePath = this.tryRealpath(options.workspace);
args.push('-D', `WORKSPACE=${workspacePath}`);
// Add explicit deny rules for governance files in the workspace.
// These are added after the workspace allow rule (which is in BASE_SEATBELT_PROFILE)
// to ensure they take precedence (Seatbelt evaluates rules in order, later rules win for same path).
for (let i = 0; i < GOVERNANCE_FILES.length; i++) {
const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path);
// Ensure the file/directory exists so Seatbelt rules are reliably applied.
this.touch(governanceFile, GOVERNANCE_FILES[i].isDirectory);
const realGovernanceFile = this.tryRealpath(governanceFile);
// Determine if it should be treated as a directory (subpath) or a file (literal).
// .git is generally a directory, while ignore files are literals.
let isActuallyDirectory = GOVERNANCE_FILES[i].isDirectory;
try {
if (fs.existsSync(realGovernanceFile)) {
isActuallyDirectory = fs.lstatSync(realGovernanceFile).isDirectory();
}
} catch {
// Ignore errors, use default guess
}
const ruleType = isActuallyDirectory ? 'subpath' : 'literal';
args.push('-D', `GOVERNANCE_FILE_${i}=${governanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "GOVERNANCE_FILE_${i}")))`,
);
if (realGovernanceFile !== governanceFile) {
args.push('-D', `REAL_GOVERNANCE_FILE_${i}=${realGovernanceFile}`);
profileLines.push(
`(deny file-write* (${ruleType} (param "REAL_GOVERNANCE_FILE_${i}")))`,
);
}
}
const tmpPath = this.tryRealpath(os.tmpdir());
args.push('-D', `TMPDIR=${tmpPath}`);
@@ -88,6 +126,28 @@ export class MacOsSandboxManager implements SandboxManager {
return args;
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean) {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
/**
* Resolves symlinks for a given path to prevent sandbox escapes.
* If a file does not exist (ENOENT), it recursively resolves the parent directory.
@@ -76,6 +76,16 @@ export interface SandboxManager {
prepareCommand(req: SandboxRequest): Promise<SandboxedCommand>;
}
/**
* Files that represent the governance or "constitution" of the repository
* and should be write-protected in any sandbox.
*/
export const GOVERNANCE_FILES = [
{ path: '.gitignore', isDirectory: false },
{ path: '.geminiignore', isDirectory: false },
{ path: '.git', isDirectory: true },
] as const;
/**
* A no-op implementation of SandboxManager that silently passes commands
* through while applying environment sanitization.
@@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { WindowsSandboxManager } from './windowsSandboxManager.js';
@@ -17,21 +18,24 @@ vi.mock('../utils/shell-utils.js', () => ({
describe('WindowsSandboxManager', () => {
let manager: WindowsSandboxManager;
let testCwd: string;
beforeEach(() => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
manager = new WindowsSandboxManager({ workspace: '/test/workspace' });
testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
manager = new WindowsSandboxManager({ workspace: testCwd });
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(testCwd, { recursive: true, force: true });
});
it('should prepare a GeminiSandbox.exe command', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: ['/groups'],
cwd: '/test/cwd',
cwd: testCwd,
env: { TEST_VAR: 'test_value' },
policy: {
networkAccess: false,
@@ -41,14 +45,14 @@ describe('WindowsSandboxManager', () => {
const result = await manager.prepareCommand(req);
expect(result.program).toContain('GeminiSandbox.exe');
expect(result.args).toEqual(['0', '/test/cwd', 'whoami', '/groups']);
expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']);
});
it('should handle networkAccess from config', async () => {
const req: SandboxRequest = {
command: 'whoami',
args: [],
cwd: '/test/cwd',
cwd: testCwd,
env: {},
policy: {
networkAccess: true,
@@ -63,7 +67,7 @@ describe('WindowsSandboxManager', () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: '/test/cwd',
cwd: testCwd,
env: {
API_KEY: 'secret',
PATH: '/usr/bin',
@@ -82,29 +86,53 @@ describe('WindowsSandboxManager', () => {
expect(result.env['API_KEY']).toBeUndefined();
});
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
it('should ensure governance files exist', async () => {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: '/test/cwd',
cwd: testCwd,
env: {},
policy: {
allowedPaths: ['/test/allowed1'],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/workspace'),
'/setintegritylevel',
'Low',
]);
expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true);
expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true);
expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true);
});
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve('/test/allowed1'),
'/setintegritylevel',
'Low',
]);
it('should grant Low Integrity access to the workspace and allowed paths', async () => {
const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed');
if (!fs.existsSync(allowedPath)) {
fs.mkdirSync(allowedPath);
}
try {
const req: SandboxRequest = {
command: 'test',
args: [],
cwd: testCwd,
env: {},
policy: {
allowedPaths: [allowedPath],
},
};
await manager.prepareCommand(req);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(testCwd),
'/setintegritylevel',
'Low',
]);
expect(spawnAsync).toHaveBeenCalledWith('icacls', [
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
}
});
});
@@ -12,6 +12,7 @@ import {
type SandboxManager,
type SandboxRequest,
type SandboxedCommand,
GOVERNANCE_FILES,
type GlobalSandboxOptions,
sanitizePaths,
} from './sandboxManager.js';
@@ -39,6 +40,28 @@ export class WindowsSandboxManager implements SandboxManager {
this.helperPath = path.resolve(__dirname, 'scripts', 'GeminiSandbox.exe');
}
/**
* Ensures a file or directory exists.
*/
private touch(filePath: string, isDirectory: boolean): void {
try {
// If it exists (even as a broken symlink), do nothing
if (fs.lstatSync(filePath)) return;
} catch {
// Ignore ENOENT
}
if (isDirectory) {
fs.mkdirSync(filePath, { recursive: true });
} else {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.closeSync(fs.openSync(filePath, 'a'));
}
}
private async ensureInitialized(): Promise<void> {
if (this.initialized) return;
if (os.platform() !== 'win32') {
@@ -164,7 +187,28 @@ export class WindowsSandboxManager implements SandboxManager {
// TODO: handle forbidden paths
// 2. Construct the helper command
// 2. Protected governance files
// These must exist on the host before running the sandbox to prevent
// the sandboxed process from creating them with Low integrity.
// By being created as Medium integrity, they are write-protected from Low processes.
for (const file of GOVERNANCE_FILES) {
const filePath = path.join(this.options.workspace, file.path);
this.touch(filePath, file.isDirectory);
// We resolve real paths to ensure protection for both the symlink and its target.
try {
const realPath = fs.realpathSync(filePath);
if (realPath !== filePath) {
// If it's a symlink, the target is already implicitly protected
// if it's outside the Low integrity workspace (likely Medium).
// If it's inside, we ensure it's not accidentally Low.
}
} catch {
// Ignore realpath errors
}
}
// 3. Construct the helper command
// GeminiSandbox.exe <network:0|1> <cwd> <command> [args...]
const program = this.helperPath;
+15 -1
View File
@@ -327,7 +327,21 @@
"hideContextSummary": {
"title": "Hide Context Summary",
"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,
"type": "boolean"
},
+89 -239
View File
@@ -11,32 +11,8 @@ import path from 'node:path';
import { execSync } from 'node:child_process';
import os from 'node:os';
const args = process.argv.slice(2);
const artifactsDir = args.find((arg) => !arg.startsWith('--')) || '.';
const isPrComment = args.includes('--pr-comment');
const MAX_HISTORY = 7;
// Extract policies from the source code
function getTestPolicies() {
const policies = {};
try {
const evalFiles = fs
.readdirSync('evals')
.filter((f) => f.endsWith('.eval.ts'));
for (const file of evalFiles) {
const content = fs.readFileSync(path.join('evals', file), 'utf-8');
const matches = content.matchAll(
/evalTest\s*\(\s*['"](ALWAYS_PASSES|USUALLY_PASSES)['"]\s*,\s*\{\s*name:\s*['"](.+?)['"]/g,
);
for (const match of matches) {
policies[match[2]] = match[1];
}
}
} catch {
// Ignore errors in policy extraction
}
return policies;
}
const artifactsDir = process.argv[2] || '.';
const MAX_HISTORY = 10;
// Find all report.json files recursively
function findReports(dir) {
@@ -58,6 +34,7 @@ function findReports(dir) {
function getModelFromPath(reportPath) {
const parts = reportPath.split(path.sep);
// Find the part that starts with 'eval-logs-'
const artifactDir = parts.find((p) => p.startsWith('eval-logs-'));
if (!artifactDir) return 'unknown';
@@ -65,12 +42,13 @@ function getModelFromPath(reportPath) {
if (matchNew) return matchNew[1];
const matchOld = artifactDir.match(/^eval-logs-(\d+)$/);
if (matchOld) return 'gemini-2.5-pro';
if (matchOld) return 'gemini-2.5-pro'; // Legacy default
return 'unknown';
}
function getStats(reports) {
// Structure: { [model]: { [testName]: { passed, failed, total } } }
const statsByModel = {};
for (const reportPath of reports) {
@@ -109,41 +87,35 @@ function fetchHistoricalData() {
const history = [];
try {
try {
execSync('gh --version', { stdio: 'ignore' });
} catch {
if (!isPrComment) {
console.warn(
'Warning: GitHub CLI (gh) not found. Historical data will be unavailable.',
);
}
return history;
}
// Determine branch
const branch = 'main';
// Get recent runs
const cmd = `gh run list --workflow evals-nightly.yml --branch "${branch}" --limit ${
MAX_HISTORY + 10
MAX_HISTORY + 5
} --json databaseId,createdAt,url,displayTitle,status,conclusion`;
const runsJson = execSync(cmd, { encoding: 'utf-8' });
let runs = JSON.parse(runsJson);
// Filter out current run
const currentRunId = process.env.GITHUB_RUN_ID;
if (currentRunId) {
runs = runs.filter((r) => r.databaseId.toString() !== currentRunId);
}
runs = runs
.filter((r) => r.status === 'completed')
.slice(0, MAX_HISTORY + 5);
// Filter for runs that likely have artifacts (completed) and take top N
// We accept 'failure' too because we want to see stats.
runs = runs.filter((r) => r.status === 'completed').slice(0, MAX_HISTORY);
// Fetch artifacts for each run
for (const run of runs) {
if (history.length >= MAX_HISTORY) break;
const tmpDir = fs.mkdtempSync(
path.join(os.tmpdir(), `gemini-evals-${run.databaseId}-`),
);
try {
// Download report.json files.
// The artifacts are named 'eval-logs-X' or 'eval-logs-MODEL-X'.
// We use -p to match pattern.
execSync(
`gh run download ${run.databaseId} -p "eval-logs-*" -D "${tmpDir}"`,
{ stdio: 'ignore' },
@@ -151,25 +123,16 @@ function fetchHistoricalData() {
const runReports = findReports(tmpDir);
if (runReports.length > 0) {
const stats = getStats(runReports);
let totalPassed = 0;
let totalTests = 0;
Object.values(stats).forEach((modelStats) => {
Object.values(modelStats).forEach((s) => {
totalPassed += s.passed;
totalTests += s.total;
});
history.push({
run,
stats: getStats(runReports), // Now returns stats grouped by model
});
if (totalTests > 0 && totalPassed === 0) {
continue;
}
history.push({ run, stats });
}
} catch {
// Ignore download errors
} catch (error) {
console.error(
`Failed to download or process artifacts for run ${run.databaseId}:`,
error,
);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
@@ -182,28 +145,18 @@ function fetchHistoricalData() {
}
function generateMarkdown(currentStatsByModel, history) {
const reversedHistory = [...history].reverse();
const models = Object.keys(currentStatsByModel).sort();
const policies = getTestPolicies();
console.log('### Evals Nightly Summary\n');
console.log(
'See [evals/README.md](https://github.com/google-gemini/gemini-cli/tree/main/evals) for more details.\n',
);
const getConsolidatedBaseline = (model) => {
const consolidated = {};
for (const item of history) {
const stats = item.stats[model];
if (!stats) continue;
for (const [name, stat] of Object.entries(stats)) {
if (!consolidated[name]) {
consolidated[name] = { passed: 0, total: 0 };
}
consolidated[name].passed += stat.passed;
consolidated[name].total += stat.total;
}
}
return Object.keys(consolidated).length > 0 ? consolidated : null;
};
// Reverse history to show oldest first
const reversedHistory = [...history].reverse();
const models = Object.keys(currentStatsByModel).sort();
const getPassRate = (statsForModel) => {
if (!statsForModel) return null;
if (!statsForModel) return '-';
const totalStats = Object.values(statsForModel).reduce(
(acc, stats) => {
acc.passed += stats.passed;
@@ -213,188 +166,85 @@ function generateMarkdown(currentStatsByModel, history) {
{ passed: 0, total: 0 },
);
return totalStats.total > 0
? (totalStats.passed / totalStats.total) * 100
: null;
? ((totalStats.passed / totalStats.total) * 100).toFixed(1) + '%'
: '-';
};
const formatPassRate = (rate) =>
rate === null ? '-' : rate.toFixed(1) + '%';
if (isPrComment) {
console.log('### 🤖 Model Steering Impact Report\n');
let blockerRegression = false;
for (const model of models) {
const currentStats = currentStatsByModel[model];
const baselineStats = getConsolidatedBaseline(model);
for (const [name, curr] of Object.entries(currentStats)) {
const policy = policies[name] || 'USUALLY_PASSES';
const currRate = (curr.passed / curr.total) * 100;
const base = baselineStats ? baselineStats[name] : null;
const baseRate = base ? (base.passed / base.total) * 100 : null;
if (policy === 'ALWAYS_PASSES' && currRate < 100) {
blockerRegression = true;
} else if (
policy === 'USUALLY_PASSES' &&
baseRate !== null &&
baseRate > 90 &&
currRate < 60
) {
blockerRegression = true; // Significant drop in a highly stable test
}
}
}
if (blockerRegression) {
console.log('**Status: 🔴 Regression Detected (Blocking)**\n');
console.log(
'This PR has introduced regressions in stable behavioral evaluations. These must be resolved before merging.\n',
);
} else {
console.log('**Status: ✅ Stable**\n');
console.log(
'Behavioral evaluations remain stable compared to the `main` baseline.\n',
);
}
console.log(
`> **Note:** Baseline is averaged from the last ${history.length} healthy nightly runs on \`main\`.\n`,
);
} else {
console.log('### Evals Nightly Summary\n');
}
for (const model of models) {
const currentStats = currentStatsByModel[model];
const currentPassRate = getPassRate(currentStats);
const baselineStats = getConsolidatedBaseline(model);
const baselinePassRate = getPassRate(baselineStats);
const totalPassRate = getPassRate(currentStats);
console.log(`#### Model: ${model}`);
console.log(`**Total Pass Rate: ${totalPassRate}**\n`);
const allTestNames = new Set(Object.keys(currentStats));
if (baselineStats) {
Object.keys(baselineStats).forEach((name) => allTestNames.add(name));
}
// Header
let header = '| Test Name |';
let separator = '| :--- |';
let passRateRow = '| **Overall Pass Rate** |';
const rows = [];
let stableCount = 0;
for (const name of Array.from(allTestNames).sort()) {
const policy = policies[name] || 'USUALLY_PASSES';
const searchUrl = `https://github.com/search?q=repo%3Agoogle-gemini%2Fgemini-cli%20%22${encodeURIComponent(name)}%22&type=code`;
const curr = currentStats[name];
const base = baselineStats ? baselineStats[name] : null;
const currRate = curr ? (curr.passed / curr.total) * 100 : null;
const baseRate = base ? (base.passed / base.total) * 100 : null;
const delta =
currRate !== null && baseRate !== null ? currRate - baseRate : null;
// Smart Noise Filtering
let status = '⚪ Stable';
let isInteresting = false;
if (policy === 'ALWAYS_PASSES') {
if (currRate !== null && currRate < 100) {
status = '🔴 Regression';
isInteresting = true;
}
} else {
// USUALLY_PASSES: Only interesting if drop is > 30% OR it's a new failure
if (delta !== null && delta < -30) {
status = '🔴 Regression';
isInteresting = true;
} else if (delta !== null && delta > 30) {
status = '🟢 Improved';
isInteresting = true;
} else if (baseRate !== null && baseRate > 80 && currRate === 0) {
status = '🔴 Regression';
isInteresting = true;
}
}
// Always show new or missing tests
if (currRate === null || baseRate === null) isInteresting = true;
if (isPrComment && !isInteresting) {
stableCount++;
continue;
}
let row = `| [${name}](${searchUrl}) | ${policy === 'ALWAYS_PASSES' ? '🔒' : '🎲'} |`;
if (!isPrComment) {
for (const item of reversedHistory) {
const stat = item.stats[model] ? item.stats[model][name] : null;
row += ` ${stat ? ((stat.passed / stat.total) * 100).toFixed(0) + '%' : '-'} |`;
}
} else if (baselinePassRate !== null) {
row += ` ${formatPassRate(baseRate)} |`;
}
row += ` ${formatPassRate(currRate)} | ${status} |`;
rows.push(row);
}
if (isPrComment && baselinePassRate !== null) {
const delta = currentPassRate - baselinePassRate;
const deltaStr =
Math.abs(delta) < 5
? ' (Stable)'
: ` (${delta > 0 ? '↑' : '↓'} ${Math.abs(delta).toFixed(1)}%)`;
console.log(
`**Pass Rate: ${formatPassRate(currentPassRate)}** vs. ${formatPassRate(baselinePassRate)} Baseline${deltaStr}\n`,
);
}
if (isPrComment && rows.length === 0) {
console.log('✅ All behavioral evaluations are stable.\n');
continue;
}
let header = `| Test Name | Policy |`;
let separator = `| :--- | :---: |`;
if (!isPrComment) {
for (const item of reversedHistory) {
header += ` [${item.run.databaseId}](${item.run.url}) |`;
separator += ' :---: |';
}
} else if (baselinePassRate !== null) {
header += ' Baseline |';
for (const item of reversedHistory) {
header += ` [${item.run.databaseId}](${item.run.url}) |`;
separator += ' :---: |';
passRateRow += ` **${getPassRate(item.stats[model])}** |`;
}
header += ' Current | Impact |';
separator += ' :---: | :---: |';
// Add Current column last
header += ' Current |';
separator += ' :---: |';
passRateRow += ` **${totalPassRate}** |`;
console.log(header);
console.log(separator);
rows.forEach((row) => console.log(row));
console.log(passRateRow);
if (isPrComment && stableCount > 0) {
console.log(
`\n> **Note:** ${stableCount} stable tests were hidden from this report.\n`,
);
// Collect all test names for this model
const allTestNames = new Set(Object.keys(currentStats));
for (const item of reversedHistory) {
if (item.stats[model]) {
Object.keys(item.stats[model]).forEach((name) =>
allTestNames.add(name),
);
}
}
for (const name of Array.from(allTestNames).sort()) {
const searchUrl = `https://github.com/search?q=repo%3Agoogle-gemini%2Fgemini-cli%20%22${encodeURIComponent(name)}%22&type=code`;
let row = `| [${name}](${searchUrl}) |`;
// History
for (const item of reversedHistory) {
const stat = item.stats[model] ? item.stats[model][name] : null;
if (stat) {
const passRate = ((stat.passed / stat.total) * 100).toFixed(0) + '%';
row += ` ${passRate} |`;
} else {
row += ' - |';
}
}
// Current
const curr = currentStats[name];
if (curr) {
const passRate = ((curr.passed / curr.total) * 100).toFixed(0) + '%';
row += ` ${passRate} |`;
} else {
row += ' - |';
}
console.log(row);
}
console.log('\n');
}
if (isPrComment) {
console.log(
'---\n💡 **Policy Key:** 🔒 `ALWAYS_PASSES` (PR Blocker) | 🎲 `USUALLY_PASSES` (Informational)\n',
);
console.log(
'💡 To investigate regressions locally, run: `gemini /fix-behavioral-eval`',
);
}
}
// --- Main ---
const currentReports = findReports(artifactsDir);
if (currentReports.length === 0) {
console.log('No reports found.');
// We don't exit here because we might still want to see history if available,
// but practically if current has no reports, something is wrong.
// Sticking to original behavior roughly, but maybe we can continue.
process.exit(0);
}