Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
170 changes: 170 additions & 0 deletions packages/core/src/core/geminiChat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ import { CompressionStatus, type ChatCompressionInfo } from './turn.js';
import { ChatCompressionService } from '../services/chatCompressionService.js';
import { SessionStartSource } from '../hooks/types.js';

const { mockGetHeapStatistics } = vi.hoisted(() => ({
mockGetHeapStatistics: vi.fn(),
}));

vi.mock('node:v8', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:v8')>();
return {
...actual,
getHeapStatistics: mockGetHeapStatistics,
};
});

// Mock fs module to prevent actual file system operations during tests
const mockFileSystem = new Map<string, string>();

Expand Down Expand Up @@ -103,6 +115,10 @@ describe('GeminiChat', async () => {

// Default mock implementation for tests that don't care about retry logic
mockRetryWithBackoff.mockImplementation(async (apiCall) => apiCall());
mockGetHeapStatistics.mockReturnValue({
used_heap_size: 0,
heap_size_limit: Number.MAX_SAFE_INTEGER,
});
mockConfig = {
getSessionId: () => 'test-session-id',
getTelemetryLogPromptsEnabled: () => true,
Expand Down Expand Up @@ -1912,6 +1928,26 @@ describe('GeminiChat', async () => {
});
});

describe('getLastHistoryEntry', () => {
it('returns undefined for an empty history', () => {
expect(chat.getLastHistoryEntry()).toBeUndefined();
});

it('returns a defensive copy of only the last raw history entry', () => {
chat.addHistory({ role: 'user', parts: [{ text: 'a' }] });
chat.addHistory({ role: 'model', parts: [{ text: 'b' }] });

const last = chat.getLastHistoryEntry();
expect(last).toEqual({ role: 'model', parts: [{ text: 'b' }] });

last!.parts![0] = { text: 'mutated' };
expect(chat.getLastHistoryEntry()).toEqual({
role: 'model',
parts: [{ text: 'b' }],
});
});
});

describe('sendMessageStream with retries', () => {
it('should retry on invalid content, succeed, and report metrics', async () => {
vi.useFakeTimers();
Expand Down Expand Up @@ -3584,6 +3620,13 @@ describe('GeminiChat', async () => {
return compressSpy;
}

function mockHeapPressure(usedHeapSize: number, heapLimit = 1000) {
mockGetHeapStatistics.mockReturnValue({
used_heap_size: usedHeapSize,
heap_size_limit: heapLimit,
});
}

it('replaces history and updates per-chat lastPromptTokenCount on COMPRESSED', async () => {
mockCompressionService('compressed');
chat.setHistory([userMsg('a'), modelMsg('b'), userMsg('c')]);
Expand Down Expand Up @@ -3647,9 +3690,136 @@ describe('GeminiChat', async () => {

it('forwards force=true to the compression service', async () => {
const compressSpy = mockCompressionService('compressed');
mockHeapPressure(900);

await chat.tryCompress('p1', 'm1', true);
expect(compressSpy.mock.calls[0][1].force).toBe(true);
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
expect(mockGetHeapStatistics).not.toHaveBeenCalled();
});
Comment thread
yiliang114 marked this conversation as resolved.

it('uses heap pressure to bypass the token gate without manual force semantics', async () => {
const compressSpy = mockCompressionService('noop');
mockHeapPressure(750);
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
authType: AuthType.USE_GEMINI,
model: 'test-model',
contextWindowSize: 1000,
});

await chat.tryCompress('p1', 'm1');

expect(compressSpy.mock.calls[0][1].force).toBe(false);
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);
expect(compressSpy.mock.calls[0][1].originalTokenCount).toBe(0);
});

it('does not bypass the token gate below the heap-pressure threshold', async () => {
const compressSpy = mockCompressionService('noop');
mockHeapPressure(650);

await chat.tryCompress('p1', 'm1');

expect(compressSpy.mock.calls[0][1].force).toBe(false);
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
});

it('does not let a failed heap-pressure attempt latch off later auto-compaction', async () => {
const compressSpy = mockCompressionService('failed-inflated');
mockHeapPressure(701);

const first = await chat.tryCompress('p1', 'm1');
expect(first.compressionStatus).toBe(
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
);
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);

compressSpy.mockClear();
compressSpy.mockResolvedValue({
newHistory: null,
info: {
originalTokenCount: 0,
newTokenCount: 0,
compressionStatus: CompressionStatus.NOOP,
},
});
mockHeapPressure(0);

await chat.tryCompress('p2', 'm1');

expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
expect(compressSpy.mock.calls[0][1].hasFailedCompressionAttempt).toBe(
false,
);
});

it('backs off repeated heap-pressure bypasses after a heap-triggered failure', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T00:00:00Z'));
try {
const compressSpy = mockCompressionService('failed-inflated');
mockHeapPressure(800);

await chat.tryCompress('p1', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);

compressSpy.mockClear();
compressSpy.mockResolvedValue({
newHistory: null,
info: {
originalTokenCount: 0,
newTokenCount: 0,
compressionStatus: CompressionStatus.NOOP,
},
});

await chat.tryCompress('p2', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);

vi.setSystemTime(new Date('2026-05-16T00:00:31Z'));
compressSpy.mockClear();

await chat.tryCompress('p3', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);
} finally {
vi.useRealTimers();
}
});

it('backs off repeated heap-pressure bypasses after a heap-triggered NOOP', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-16T00:00:00Z'));
try {
const compressSpy = mockCompressionService('noop');
mockHeapPressure(800);

await chat.tryCompress('p1', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);

compressSpy.mockClear();

await chat.tryCompress('p2', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);

vi.setSystemTime(new Date('2026-05-16T00:00:31Z'));
compressSpy.mockClear();

await chat.tryCompress('p3', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(true);
} finally {
vi.useRealTimers();
}
});

it('falls back to token-threshold behavior if heap statistics are unavailable', async () => {
const compressSpy = mockCompressionService('noop');
mockGetHeapStatistics.mockImplementation(() => {
throw new Error('heap stats unavailable');
});

await chat.tryCompress('p1', 'm1');

expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
});
});
});
77 changes: 77 additions & 0 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
GenerateContentResponseUsageMetadata,
} from '@google/genai';
import { createUserContent, FinishReason } from '@google/genai';
import { getHeapStatistics } from 'node:v8';
import { retryWithBackoff, isUnattendedMode } from '../utils/retry.js';
import { getErrorStatus, isAbortError } from '../utils/errors.js';
import { createDebugLogger } from '../utils/debugLogger.js';
Expand Down Expand Up @@ -58,6 +59,10 @@ import { getCustomSystemPrompt } from './prompts.js';

const debugLogger = createDebugLogger('QWEN_CODE_CHAT');

// Leave roughly 30% V8 heap headroom for compression's transient allocations.
const HEAP_PRESSURE_COMPRESSION_RATIO = 0.7;
const HEAP_PRESSURE_COMPRESSION_COOLDOWN_MS = 30_000;

/**
* Replaces the args on a `structured_output` `functionCall` with the
* same `__redacted` placeholder used by `ToolCallEvent` telemetry
Expand Down Expand Up @@ -436,6 +441,14 @@ export class GeminiChat {
*/
private hasFailedCompressionAttempt = false;

/**
* Heap-pressure compaction is process-wide pressure applied per chat. If one
* heap-triggered attempt cannot reduce history, briefly back off this chat
* so every subsequent send does not immediately pay for another compression
* side query while memory is already tight.
*/
private heapPressureCompressionCooldownUntil = 0;

/**
* Creates a new GeminiChat instance.
*
Expand Down Expand Up @@ -496,6 +509,33 @@ export class GeminiChat {
signal?: AbortSignal,
options?: TryCompressOptions,
): Promise<ChatCompressionInfo> {
const heapPressureRatio = force ? null : this.getHeapPressureRatio();
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
const heapPressureCooldownActive =
!force && Date.now() < this.heapPressureCompressionCooldownUntil;
const bypassTokenThreshold =
heapPressureRatio !== null &&
heapPressureRatio >= HEAP_PRESSURE_COMPRESSION_RATIO &&
!heapPressureCooldownActive;
if (bypassTokenThreshold) {
// Temporary safety net: token-based compaction can be too late for
// large-context sessions because JS heap pressure may hit first.
// Do not use force=true here because that carries manual /compress
// semantics in ChatCompressionService.
Comment thread
yiliang114 marked this conversation as resolved.
debugLogger.warn(
`Heap pressure at ${(heapPressureRatio * 100).toFixed(1)}%; ` +
'attempting auto-compaction before token threshold.',
);
} else if (
heapPressureRatio !== null &&
heapPressureRatio >= HEAP_PRESSURE_COMPRESSION_RATIO &&
heapPressureCooldownActive
) {
debugLogger.debug(
`Heap pressure at ${(heapPressureRatio * 100).toFixed(1)}%; ` +
'skipping heap-pressure auto-compaction during cooldown.',
);
}

const service = new ChatCompressionService();
const { newHistory, info } = await service.compress(this, {
promptId,
Expand All @@ -505,6 +545,7 @@ export class GeminiChat {
hasFailedCompressionAttempt: this.hasFailedCompressionAttempt,
originalTokenCount:
options?.originalTokenCountOverride ?? this.lastPromptTokenCount,
bypassTokenThreshold,
Comment thread
yiliang114 marked this conversation as resolved.
Comment thread
yiliang114 marked this conversation as resolved.
trigger: options?.trigger,
signal,
});
Expand Down Expand Up @@ -533,9 +574,18 @@ export class GeminiChat {
// Re-enable auto-compaction so a forced /compress recovers a chat
// that an earlier auto-attempt latched off.
this.hasFailedCompressionAttempt = false;
this.heapPressureCompressionCooldownUntil = 0;
} else if (bypassTokenThreshold) {
// If heap-pressure compaction cannot reduce history (NOOP or failure),
// avoid repeatedly cloning history and/or paying side-query latency while
// the process-wide pressure remains high.
this.heapPressureCompressionCooldownUntil =
Date.now() + HEAP_PRESSURE_COMPRESSION_COOLDOWN_MS;
} else if (isCompressionFailureStatus(info.compressionStatus)) {
// Track failed attempts (only mark as failed if not forced) so we
// stop spending compression-API calls on a chat that can't shrink.
// Heap-pressure attempts are a safety net, not evidence that normal
// token-threshold compaction should be latched off for this chat.
if (!force) {
this.hasFailedCompressionAttempt = true;
}
Expand All @@ -544,6 +594,24 @@ export class GeminiChat {
return info;
Comment thread
yiliang114 marked this conversation as resolved.
}

private getHeapPressureRatio(): number | null {
Comment thread
yiliang114 marked this conversation as resolved.
try {
const { used_heap_size: usedHeapSize, heap_size_limit: heapLimit } =
getHeapStatistics();
if (
!Number.isFinite(usedHeapSize) ||
usedHeapSize < 0 ||
!Number.isFinite(heapLimit) ||
heapLimit <= 0
) {
return null;
}
return usedHeapSize / heapLimit;
} catch {
return null;
}
}

setSystemInstruction(sysInstr: string) {
this.generationConfig.systemInstruction = sysInstr;
}
Expand Down Expand Up @@ -1169,6 +1237,15 @@ export class GeminiChat {
return structuredClone(history.slice(-count));
}

/**
* Returns a defensive copy of the last raw history entry without cloning the
* full conversation. This avoids O(history) cloning, though cloning the last
* entry is still proportional to that entry's own size.
*/
getLastHistoryEntry(): Content | undefined {
return this.getHistoryTail(1)[0];
}

/**
* Returns the number of entries in the raw chat history. O(1) and
* does not clone — use this when you only need the count and would
Expand Down
Loading
Loading