mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 22:02:59 -07:00
chore(context): remove dead initialization trigger and unawaited promise
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('Context Management Resume E2E', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should preserve and utilize GC snapshot boundaries when resuming a session', async () => {
|
||||
const snapshotResponse = {
|
||||
method: 'generateContent',
|
||||
response: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
text: JSON.stringify({
|
||||
new_facts: ['GC Triggered.'],
|
||||
new_constraints: [],
|
||||
new_tasks: [],
|
||||
resolved_task_ids: [],
|
||||
obsolete_fact_indices: [],
|
||||
obsolete_constraint_indices: [],
|
||||
chronological_summary: 'Snapshot created.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
role: 'model',
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const countTokensResponse = {
|
||||
method: 'countTokens',
|
||||
response: { totalTokens: 50000 },
|
||||
};
|
||||
|
||||
const streamResponse = (text: string) => ({
|
||||
method: 'generateContentStream',
|
||||
response: [
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text }], role: 'model' },
|
||||
finishReason: 'STOP',
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const setupResponses = (fileName: string, mocks: any[]) => {
|
||||
const filePath = path.join(rig.testDir!, fileName);
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
mocks.map((m) => JSON.stringify(m)).join('\n'),
|
||||
);
|
||||
return filePath;
|
||||
};
|
||||
|
||||
await rig.setup('resume-gc-snapshot', {
|
||||
settings: {
|
||||
experimental: {
|
||||
stressTestProfile: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const massivePayload = 'X'.repeat(40000);
|
||||
const logFile = path.join(rig.testDir!, 'debug.log');
|
||||
const traceDir = path.join(rig.testDir!, 'traces');
|
||||
fs.mkdirSync(traceDir, { recursive: true });
|
||||
const traceLog = path.join(traceDir, 'trace.log');
|
||||
|
||||
const commonEnv = {
|
||||
GEMINI_API_KEY: 'mock-key',
|
||||
GEMINI_DEBUG_LOG_FILE: logFile,
|
||||
GEMINI_CONTEXT_TRACE_DIR: traceDir,
|
||||
};
|
||||
|
||||
// Provide a massive pool of responses to prevent exhaustion
|
||||
const runMocks = [streamResponse('Acknowledged block.')];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
runMocks.push(snapshotResponse);
|
||||
runMocks.push(countTokensResponse);
|
||||
}
|
||||
|
||||
console.log('=== STARTING RUN 1 ===');
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp1.json', runMocks),
|
||||
'Turn 1: ' + massivePayload,
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
console.log('=== STARTING RUN 2 ===');
|
||||
await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp2.json', runMocks),
|
||||
'Turn 2: ' + massivePayload,
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
console.log('=== STARTING RUN 3 ===');
|
||||
const result3 = await rig.run({
|
||||
args: [
|
||||
'--debug',
|
||||
'--resume',
|
||||
'latest',
|
||||
'--fake-responses-non-strict',
|
||||
setupResponses('resp3.json', runMocks),
|
||||
'continue',
|
||||
],
|
||||
env: commonEnv,
|
||||
});
|
||||
|
||||
expect(result3).toContain('Acknowledged block');
|
||||
|
||||
const traces = fs.readFileSync(traceLog, 'utf-8');
|
||||
expect(traces).toContain('Hitting Synchronous Pressure Barrier');
|
||||
console.log('GC Trigger Verification: SUCCESS');
|
||||
|
||||
expect(traces).toContain('GC Triggered.');
|
||||
console.log('Snapshot Utilization Verification: SUCCESS');
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,7 @@ export interface CliArgs {
|
||||
useWriteTodos: boolean | undefined;
|
||||
outputFormat: string | undefined;
|
||||
fakeResponses: string | undefined;
|
||||
fakeResponsesNonStrict?: string | undefined;
|
||||
recordResponses: string | undefined;
|
||||
startupMessages?: string[];
|
||||
rawOutput: boolean | undefined;
|
||||
@@ -474,6 +475,12 @@ export async function parseArguments(
|
||||
description: 'Path to a file with fake model responses for testing.',
|
||||
hidden: true,
|
||||
})
|
||||
.option('fake-responses-non-strict', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Path to a file with fake model responses for testing (non-strict mode).',
|
||||
hidden: true,
|
||||
})
|
||||
.option('record-responses', {
|
||||
type: 'string',
|
||||
description: 'Path to a file to record model responses for testing.',
|
||||
@@ -1074,6 +1081,7 @@ export async function loadCliConfig(
|
||||
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
|
||||
adk: settings.experimental?.adk,
|
||||
fakeResponses: argv.fakeResponses,
|
||||
fakeResponsesNonStrict: argv.fakeResponsesNonStrict,
|
||||
recordResponses: argv.recordResponses,
|
||||
retryFetchErrors: settings.general?.retryFetchErrors,
|
||||
billing: settings.billing,
|
||||
|
||||
@@ -688,6 +688,7 @@ export interface ConfigParameters {
|
||||
enableShellOutputEfficiency?: boolean;
|
||||
shellToolInactivityTimeout?: number;
|
||||
fakeResponses?: string;
|
||||
fakeResponsesNonStrict?: string;
|
||||
recordResponses?: string;
|
||||
ptyInfo?: string;
|
||||
disableYoloMode?: boolean;
|
||||
@@ -919,6 +920,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
private readonly enableShellOutputEfficiency: boolean;
|
||||
private readonly shellToolInactivityTimeout: number;
|
||||
readonly fakeResponses?: string;
|
||||
readonly fakeResponsesNonStrict?: string;
|
||||
readonly recordResponses?: string;
|
||||
private readonly disableYoloMode: boolean;
|
||||
private readonly disableAlwaysAllow: boolean;
|
||||
@@ -1299,6 +1301,7 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
this.storage.setCustomPlansDir(params.planSettings?.directory);
|
||||
|
||||
this.fakeResponses = params.fakeResponses;
|
||||
this.fakeResponsesNonStrict = params.fakeResponsesNonStrict;
|
||||
this.recordResponses = params.recordResponses;
|
||||
this.fileExclusions = new FileExclusions(this);
|
||||
this.eventEmitter = params.eventEmitter;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js';
|
||||
|
||||
export type PipelineTrigger =
|
||||
| 'initialization'
|
||||
| 'new_message'
|
||||
| 'retained_exceeded'
|
||||
| 'gc_backstop'
|
||||
|
||||
@@ -437,7 +437,7 @@ export class ContextManager {
|
||||
return SnapshotStateHelper.exportState(this.buffer.nodes);
|
||||
}
|
||||
|
||||
async restoreState(state: ContextEngineState): Promise<void> {
|
||||
restoreState(state: ContextEngineState): void {
|
||||
if (!state) return;
|
||||
SnapshotStateHelper.restoreState(state, this.env.inbox);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ export function createStateSnapshotProcessor(
|
||||
const isValid = consumedIds.every((id) => targetIds.has(id));
|
||||
|
||||
if (isValid) {
|
||||
debugLogger.log(
|
||||
`[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`,
|
||||
);
|
||||
// If valid, apply it!
|
||||
const newId = randomUUID();
|
||||
|
||||
@@ -120,6 +123,10 @@ export function createStateSnapshotProcessor(
|
||||
|
||||
inbox.consume(proposed.id);
|
||||
return returnedNodes;
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[StateSnapshotProcessor] Rejected PROPOSED_SNAPSHOT from Inbox because one or more target IDs were missing from the current graph window.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,12 +82,21 @@ export function findLatestSnapshotBaseline(
|
||||
|
||||
import type { LiveInbox } from '../pipeline/inbox.js';
|
||||
import type { ContextEngineState } from '../../services/chatRecordingTypes.js';
|
||||
import { debugLogger } from '../../utils/debugLogger.js';
|
||||
|
||||
export const SnapshotStateHelper = {
|
||||
exportState(nodes: readonly ConcreteNode[]): ContextEngineState {
|
||||
const baseline = findLatestSnapshotBaseline(nodes);
|
||||
if (!baseline) return {};
|
||||
if (!baseline) {
|
||||
debugLogger.log(
|
||||
'[SnapshotStateHelper] exportState: No snapshot baseline found in current nodes.',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
|
||||
debugLogger.log(
|
||||
`[SnapshotStateHelper] exportState: Exporting snapshot ID ${baseline.id} representing ${baseline.abstractsIds.length} consumed nodes.`,
|
||||
);
|
||||
return {
|
||||
snapshot: {
|
||||
text: baseline.text,
|
||||
@@ -98,18 +107,30 @@ export const SnapshotStateHelper = {
|
||||
},
|
||||
|
||||
restoreState(state: ContextEngineState, inbox: LiveInbox): void {
|
||||
if (!state.snapshot) return;
|
||||
if (!state.snapshot) {
|
||||
debugLogger.log(
|
||||
'[SnapshotStateHelper] restoreState: No snapshot found in provided ContextEngineState.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof state.snapshot.text === 'string' &&
|
||||
Array.isArray(state.snapshot.consumedIds)
|
||||
) {
|
||||
debugLogger.log(
|
||||
`[SnapshotStateHelper] restoreState: Publishing hydrated snapshot to LiveInbox with ${state.snapshot.consumedIds.length} consumed IDs.`,
|
||||
);
|
||||
inbox.publish('PROPOSED_SNAPSHOT', {
|
||||
newText: state.snapshot.text,
|
||||
consumedIds: state.snapshot.consumedIds,
|
||||
type: 'accumulate',
|
||||
timestamp: state.snapshot.timestamp ?? Date.now(),
|
||||
});
|
||||
} else {
|
||||
debugLogger.log(
|
||||
'[SnapshotStateHelper] restoreState: Invalid snapshot structural format.',
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import { LoggingContentGenerator } from './loggingContentGenerator.js';
|
||||
import { InstallationManager } from '../utils/installationManager.js';
|
||||
import { FakeContentGenerator } from './fakeContentGenerator.js';
|
||||
import { NonStrictFakeContentGenerator } from './nonStrictFakeContentGenerator.js';
|
||||
import { parseCustomHeaders } from '../utils/customHeaderUtils.js';
|
||||
import { determineSurface } from '../utils/surface.js';
|
||||
import { RecordingContentGenerator } from './recordingContentGenerator.js';
|
||||
@@ -193,6 +194,12 @@ export async function createContentGenerator(
|
||||
sessionId?: string,
|
||||
): Promise<ContentGenerator> {
|
||||
const generator = await (async () => {
|
||||
if (gcConfig.fakeResponsesNonStrict) {
|
||||
const fakeGenerator = await NonStrictFakeContentGenerator.fromFile(
|
||||
gcConfig.fakeResponsesNonStrict,
|
||||
);
|
||||
return new LoggingContentGenerator(fakeGenerator, gcConfig);
|
||||
}
|
||||
if (gcConfig.fakeResponses) {
|
||||
const fakeGenerator = await FakeContentGenerator.fromFile(
|
||||
gcConfig.fakeResponses,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
GenerateContentResponse,
|
||||
type CountTokensResponse,
|
||||
type GenerateContentParameters,
|
||||
type CountTokensParameters,
|
||||
EmbedContentResponse,
|
||||
type EmbedContentParameters,
|
||||
} from '@google/genai';
|
||||
import { promises } from 'node:fs';
|
||||
import type { ContentGenerator } from './contentGenerator.js';
|
||||
import type { UserTierId, GeminiUserTier } from '../code_assist/types.js';
|
||||
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
|
||||
import type { LlmRole } from '../telemetry/types.js';
|
||||
import type { FakeResponse } from './fakeContentGenerator.js';
|
||||
|
||||
/**
|
||||
* A ContentGenerator that responds with canned responses, but unlike FakeContentGenerator,
|
||||
* it is "non-strict": it will find and use the first available response that matches
|
||||
* the requested method, rather than strictly following the input order.
|
||||
*
|
||||
* This is useful for testing asynchronous or non-deterministic background tasks
|
||||
* (like token calibration or background snapshots) that might fire out-of-order.
|
||||
*/
|
||||
export class NonStrictFakeContentGenerator implements ContentGenerator {
|
||||
userTier?: UserTierId;
|
||||
userTierName?: string;
|
||||
paidTier?: GeminiUserTier;
|
||||
|
||||
constructor(private readonly responses: FakeResponse[]) {}
|
||||
|
||||
static async fromFile(
|
||||
filePath: string,
|
||||
): Promise<NonStrictFakeContentGenerator> {
|
||||
const fileContent = await promises.readFile(filePath, 'utf-8');
|
||||
const responses = fileContent
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
.map((line) => JSON.parse(line) as FakeResponse);
|
||||
return new NonStrictFakeContentGenerator(responses);
|
||||
}
|
||||
|
||||
private getNextResponse<
|
||||
M extends FakeResponse['method'],
|
||||
R = Extract<FakeResponse, { method: M }>['response'],
|
||||
>(method: M, request: unknown): R {
|
||||
const index = this.responses.findIndex((r) => r.method === method);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
`No more mock responses for ${method}, got request:\n` +
|
||||
safeJsonStringify(request),
|
||||
);
|
||||
}
|
||||
const response = this.responses.splice(index, 1)[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return response.response as R;
|
||||
}
|
||||
|
||||
async generateContent(
|
||||
request: GenerateContentParameters,
|
||||
_userPromptId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
role: LlmRole,
|
||||
): Promise<GenerateContentResponse> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Object.setPrototypeOf(
|
||||
this.getNextResponse('generateContent', request),
|
||||
GenerateContentResponse.prototype,
|
||||
);
|
||||
}
|
||||
|
||||
async generateContentStream(
|
||||
request: GenerateContentParameters,
|
||||
_userPromptId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
role: LlmRole,
|
||||
): Promise<AsyncGenerator<GenerateContentResponse>> {
|
||||
const responses = this.getNextResponse('generateContentStream', request);
|
||||
async function* stream() {
|
||||
for (const response of responses) {
|
||||
yield Object.setPrototypeOf(
|
||||
response,
|
||||
GenerateContentResponse.prototype,
|
||||
);
|
||||
}
|
||||
}
|
||||
return stream();
|
||||
}
|
||||
|
||||
async countTokens(
|
||||
request: CountTokensParameters,
|
||||
): Promise<CountTokensResponse> {
|
||||
return this.getNextResponse('countTokens', request);
|
||||
}
|
||||
|
||||
async embedContent(
|
||||
request: EmbedContentParameters,
|
||||
): Promise<EmbedContentResponse> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return Object.setPrototypeOf(
|
||||
this.getNextResponse('embedContent', request),
|
||||
EmbedContentResponse.prototype,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user