Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f931574
docs: add deliberate context compaction design documents
kimjune01 Nov 24, 2025
e318287
feat: implement since-last-prompt split strategy
kimjune01 Nov 24, 2025
d5b4e6a
feat: enhance compression prompt with user goal support
kimjune01 Nov 24, 2025
de9fb9b
feat: integrate compression service with new options
kimjune01 Nov 24, 2025
9e4b427
feat: implement hybrid compression trigger system
kimjune01 Nov 24, 2025
5c57a1d
feat: add critical concurrency guards for compression
kimjune01 Nov 24, 2025
e6aa821
feat: implement goal extraction service
kimjune01 Nov 24, 2025
8f49d72
feat: add goal selection prompt component
kimjune01 Nov 24, 2025
fbbc374
feat: implement opt-out handling for deliberate compression
kimjune01 Nov 24, 2025
a6d01ef
feat: complete main integration flow for deliberate compression
kimjune01 Nov 24, 2025
36875c4
feat: add configuration settings for deliberate compression
kimjune01 Nov 24, 2025
b9dd41a
feat: enhance telemetry for deliberate compression
kimjune01 Nov 24, 2025
493a42d
fix: resolve TypeScript compilation errors for deliberate compression
kimjune01 Nov 24, 2025
ac4b6cb
refactor: remove unnecessary optional chaining in config method calls
kimjune01 Nov 24, 2025
16e7f62
fix: remove all remaining optional chaining on config methods
kimjune01 Nov 24, 2025
d818ea1
Merge branch 'main' into feat/deliberate-compaction
kimjune01 Nov 24, 2025
b30ec18
feat(cli): add CustomGoalInput component and complete deliberate comp…
kimjune01 Nov 25, 2025
48779e3
refactor(core): move compression trigger from pre-response to post-re…
kimjune01 Nov 25, 2025
b70445a
fix(core): resolve build errors and test failures in deliberate compr…
kimjune01 Nov 25, 2025
f4af7ec
Merge remote-tracking branch 'upstream/main' into feat/deliberate-com…
kimjune01 Nov 25, 2025
d3ab79d
refactor: archive verbose docs and clean up compression implementation
kimjune01 Nov 25, 2025
aae4cf5
fix(core): increment messagesSinceLastCompress counter
kimjune01 Nov 25, 2025
97c64ea
fix(core): add feedback messages when compression prompt is skipped
kimjune01 Nov 25, 2025
6abdfee
refactor(core): improve compression prompt for better context retention
kimjune01 Nov 25, 2025
85f0923
fix(core): refactor compression flow and fix bugs
kimjune01 Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
427 changes: 427 additions & 0 deletions doc/archive/COMPACTION_ANALYSIS.md

Large diffs are not rendered by default.

916 changes: 916 additions & 0 deletions doc/archive/CONCURRENCY_SAFETY.md

Large diffs are not rendered by default.

2,057 changes: 2,057 additions & 0 deletions doc/archive/DESIGN_INTERFACES.md

Large diffs are not rendered by default.

2,000 changes: 2,000 additions & 0 deletions doc/archive/TDD_WORK_PLAN.md

Large diffs are not rendered by default.

597 changes: 597 additions & 0 deletions doc/compaction/ARCHITECTURE.md

Large diffs are not rendered by default.

2,307 changes: 2,307 additions & 0 deletions doc/compaction/DELIBERATE_COMPACTION_DESIGN.md

Large diffs are not rendered by default.

160 changes: 160 additions & 0 deletions integration-tests/context-compress-deliberate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js';
import { join } from 'node:path';

/**
* Integration tests for deliberate compression.
*
* These tests verify the full CLI flow for deliberate context compression:
* - Goal extraction from conversation history
* - Goal selection prompt UI
* - Compression with selected goal (since-last-prompt strategy)
* - Safety valve behavior at high utilization
*
* Note: These tests require the deliberate compression feature to be enabled
* and use fake responses to simulate the model's behavior.
*/
describe('Deliberate Compression Integration', () => {
let rig: TestRig;

beforeEach(() => {
rig = new TestRig();
});

afterEach(async () => {
await rig.cleanup();
});

it('should trigger compression with /compress command and log telemetry', async () => {
await rig.setup('deliberate-compress-basic', {
fakeResponsesPath: join(
import.meta.dirname,
'context-compress-interactive.compress.responses',
),
settings: {
// Enable deliberate compression feature
compression: {
deliberateEnabled: true,
interactive: true,
},
},
});

const run = await rig.runInteractive();

// Build up enough conversation history with a longer response
await run.sendKeys(
'Write a 200 word story about a robot. The story MUST end with the text THE_END followed by a period.',
);
await run.sendKeys('\r');
await run.expectText('THE_END.', 30000);

// Trigger manual compression with /compress command
await run.type('/compress');
await run.type('\r');

// Wait for compression telemetry event
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
25000,
);
expect(foundEvent, 'chat_compression telemetry event was not found').toBe(
true,
);

// Verify compression completed - should show "Chat history compressed"
await run.expectText('Chat history compressed', 10000);
});

// Skip this test until the goal selection UI is fully integrated into the CLI flow
it.skip('should show goal selection prompt when deliberate compression is enabled', async () => {
await rig.setup('deliberate-compress-goal-selection', {
fakeResponsesPath: join(
import.meta.dirname,
'context-compress-deliberate.responses',
),
settings: {
compression: {
deliberateEnabled: true,
interactive: true,
// Lower thresholds for testing
triggerTokens: 1000,
minMessagesSinceLastCompress: 2,
},
},
});

const run = await rig.runInteractive();

// Build up conversation history
await run.sendKeys('Help me implement OAuth authentication. Say OK.');
await run.sendKeys('\r');
await run.expectText('OK', 20000);

await run.sendKeys('Now add JWT token validation. Say DONE.');
await run.sendKeys('\r');
await run.expectText('DONE', 20000);

// Trigger compression - should show goal selection
await run.type('/compress');
await run.type('\r');

// Expect to see the goal selection prompt
// Note: This depends on the UI being fully integrated
await run.expectText('What are you currently working on?', 15000);

// Select first goal option
await run.sendKeys('1');
await run.sendKeys('\r');

// Wait for compression to complete
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
25000,
);
expect(foundEvent).toBe(true);
});

// Skip this test until the auto-trigger mechanism is fully implemented
it.skip('should auto-compress without opt-out options when safety valve triggers', async () => {
await rig.setup('deliberate-compress-safety-valve', {
fakeResponsesPath: join(
import.meta.dirname,
'context-compress-deliberate.responses',
),
settings: {
compression: {
deliberateEnabled: true,
interactive: true,
// Very low threshold to trigger safety valve easily
triggerUtilization: 0.01,
},
},
});

const run = await rig.runInteractive();

// Send a message that should trigger safety valve
await run.sendKeys('This is a test message to trigger compression.');
await run.sendKeys('\r');

// At safety valve, opt-out options should not be shown
// Wait for compression to happen automatically
const foundEvent = await rig.waitForTelemetryEvent(
'chat_compression',
25000,
);

// If the safety valve triggered, compression should have happened
// Note: This test may need adjustment based on actual implementation
if (foundEvent) {
expect(foundEvent).toBe(true);
}
});
});
31 changes: 31 additions & 0 deletions integration-tests/test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,37 @@ export function createToolCallErrorMessage(
);
}

/**
* Simulates building up a conversation history until a certain token utilization is reached.
* @param chat The chat object from makeTestClientWithResponses.
* @param client The client object from makeTestClientWithResponses.
* @param utilizationTarget The target utilization (e.g., 0.5 for 50%).
* @param messageCountIncrement How many messages to send in each batch.
*/
export async function buildUpTo50PercentUtilization(
chat: Record<string, unknown>, // Assuming chat has sendMessage method
client: Record<string, unknown>, // Assuming client has getCurrentTokenCount and getModelMaxTokens
utilizationTarget: number,
messageCountIncrement = 5,
) {
const modelMaxTokens = client.getModelMaxTokens();
const targetTokens = modelMaxTokens * utilizationTarget;
let currentTokens = await client.getCurrentTokenCount();
let messageCounter = 0;

// Loop until target utilization is reached or exceeded
while (currentTokens < targetTokens) {
for (let i = 0; i < messageCountIncrement; i++) {
await chat.sendMessage(
`Test message to build history: ${messageCounter++}`,
);
}
currentTokens = await client.getCurrentTokenCount();
// Add a small delay to avoid overwhelming the mock server in rapid succession, if any
await new Promise((resolve) => setTimeout(resolve, 50));
}
}

// Helper to print debug information when tests fail
export function printDebugInfo(
rig: TestRig,
Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,14 @@ export interface SettingDefinition {
* Optional reference identifier for generators that emit a `$ref`.
*/
ref?: string;
/**
* Minimum value for number settings.
*/
min?: number;
/**
* Maximum value for number settings.
*/
max?: number;
}

export interface SettingsSchema {
Expand Down Expand Up @@ -698,6 +706,84 @@ const SETTINGS_SCHEMA = {
'The fraction of context usage at which to trigger context compression (e.g. 0.2, 0.3).',
showInDialog: true,
},
compressionInteractive: {
type: 'boolean',
label: 'Interactive Compression',
category: 'Model',
requiresRestart: false,
default: true,
description: "Ask what you're working on before compressing.",
showInDialog: true,
},
compressionPromptTimeout: {
type: 'number',
label: 'Compression Prompt Timeout',
category: 'Model',
requiresRestart: false,
default: 30,
description:
'Seconds to wait for goal selection before auto-compressing.',
showInDialog: true,
min: 10,
max: 300,
},
compressionTriggerTokens: {
type: 'number',
label: 'Compression Trigger Tokens',
category: 'Model',
requiresRestart: false,
default: 40000,
description:
'Trigger compression at this token count (cost optimization).',
showInDialog: true,
min: 10000,
max: 200000,
},
compressionTriggerUtilization: {
type: 'number',
label: 'Compression Trigger Utilization',
category: 'Model',
requiresRestart: false,
default: 0.5,
description:
'Maximum utilization before forcing compression (safety valve).',
showInDialog: true,
min: 0.3,
max: 0.95,
},
compressionMinMessages: {
type: 'number',
label: 'Compression Minimum Messages',
category: 'Model',
requiresRestart: false,
default: 25,
description: 'Minimum messages required between compressions.',
showInDialog: true,
min: 5,
max: 100,
},
compressionMinTimeBetweenPrompts: {
type: 'number',
label: 'Compression Minimum Time Between Prompts',
category: 'Model',
requiresRestart: false,
default: 300,
description: 'Minimum time in seconds between compression prompts.',
showInDialog: false,
min: 60,
max: 1800,
},
compressionFrequencyMultiplier: {
type: 'number',
label: 'Compression Frequency Multiplier',
category: 'Model',
requiresRestart: false,
default: 1.5,
description: 'Multiplier when user requests less frequent check-ins.',
showInDialog: false,
min: 1.2,
max: 3.0,
},
skipNextSpeakerCheck: {
type: 'boolean',
label: 'Skip Next Speaker Check',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/test-utils/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
setCustomDialog: vi.fn(),
};

export const renderWithProviders = (
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
import { enableBracketedPaste } from './utils/bracketedPaste.js';
import { GoalSelectionPrompt } from './components/GoalSelectionPrompt.js';
import { CustomGoalInput } from './components/CustomGoalInput.js';

const WARNING_PROMPT_DURATION_MS = 1000;
const QUEUE_ERROR_DISPLAY_DURATION_MS = 3000;
Expand Down Expand Up @@ -705,6 +707,47 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [pendingRestorePrompt, userMessages, historyManager.history]);

const handleCompressionPrompt = useCallback(
async (goals: string[], isSafetyValve: boolean = false) =>
new Promise<string>((resolve) => {
const showCustomGoalInput = () => {
setCustomDialog(
<CustomGoalInput
terminalWidth={terminalWidth}
onSubmit={(customGoal) => {
setCustomDialog(null);
// If user submits empty string, fall back to auto
resolve(customGoal.trim() || 'auto');
}}
onCancel={() => {
setCustomDialog(null);
resolve('auto');
}}
/>,
);
};

setCustomDialog(
<GoalSelectionPrompt
goals={goals}
terminalWidth={terminalWidth}
isSafetyValve={isSafetyValve}
timeoutSeconds={config.getCompressionPromptTimeout()}
onSelect={(goal) => {
if (goal === 'other') {
// User wants to enter a custom goal
showCustomGoalInput();
} else {
setCustomDialog(null);
resolve(goal === null ? 'auto' : goal);
}
}}
/>,
);
}),
[setCustomDialog, terminalWidth, config],
);

const {
streamingState,
submitQuery,
Expand Down Expand Up @@ -735,6 +778,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalWidth,
terminalHeight,
embeddedShellFocused,
handleCompressionPrompt,
);

// Auto-accept indicator
Expand Down Expand Up @@ -1607,6 +1651,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setEmbeddedShellFocused,
setCustomDialog,
}),
[
handleThemeSelect,
Expand Down Expand Up @@ -1638,6 +1683,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setEmbeddedShellFocused,
setCustomDialog,
],
);

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/commands/compressCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ describe('compressCommand', () => {
({
tryCompressChat: mockTryCompressChat,
}) as unknown as GeminiClient,
// Mock interactive compression as disabled for basic compression tests
isCompressionInteractive: () => false,
isDeliberateCompressionEnabled: async () => false,
},
},
});
Expand Down
Loading