From 78a0e5d457432f6c7fb01fb05c7486341cabba62 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 13 May 2026 00:54:24 +0000 Subject: [PATCH] chore(context): remove dead initialization trigger and unawaited promise --- integration-tests/resume-gc.test.ts | 149 ++++++++++++++++++ packages/cli/src/config/config.ts | 8 + packages/core/src/config/config.ts | 3 + packages/core/src/context/config/types.ts | 1 - packages/core/src/context/contextManager.ts | 2 +- .../processors/stateSnapshotProcessor.ts | 7 + .../src/context/utils/snapshotGenerator.ts | 25 ++- packages/core/src/core/contentGenerator.ts | 7 + .../src/core/nonStrictFakeContentGenerator.ts | 111 +++++++++++++ 9 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 integration-tests/resume-gc.test.ts create mode 100644 packages/core/src/core/nonStrictFakeContentGenerator.ts diff --git a/integration-tests/resume-gc.test.ts b/integration-tests/resume-gc.test.ts new file mode 100644 index 0000000000..670517d308 --- /dev/null +++ b/integration-tests/resume-gc.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6444ac4f83..278fd0695a 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 21a6c7a402..bb9e8d09e6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; diff --git a/packages/core/src/context/config/types.ts b/packages/core/src/context/config/types.ts index 425e07f39b..caa3aecfec 100644 --- a/packages/core/src/context/config/types.ts +++ b/packages/core/src/context/config/types.ts @@ -7,7 +7,6 @@ import type { ContextProcessor, AsyncContextProcessor } from '../pipeline.js'; export type PipelineTrigger = - | 'initialization' | 'new_message' | 'retained_exceeded' | 'gc_backstop' diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index 338cad63eb..7fe9e46a76 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -437,7 +437,7 @@ export class ContextManager { return SnapshotStateHelper.exportState(this.buffer.nodes); } - async restoreState(state: ContextEngineState): Promise { + restoreState(state: ContextEngineState): void { if (!state) return; SnapshotStateHelper.restoreState(state, this.env.inbox); } diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.ts b/packages/core/src/context/processors/stateSnapshotProcessor.ts index 3ad3001a16..7473e0431e 100644 --- a/packages/core/src/context/processors/stateSnapshotProcessor.ts +++ b/packages/core/src/context/processors/stateSnapshotProcessor.ts @@ -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.`, + ); } } } diff --git a/packages/core/src/context/utils/snapshotGenerator.ts b/packages/core/src/context/utils/snapshotGenerator.ts index b0c6c6f8a6..9cd5a1d27d 100644 --- a/packages/core/src/context/utils/snapshotGenerator.ts +++ b/packages/core/src/context/utils/snapshotGenerator.ts @@ -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.', + ); } }, }; diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index bcee8cfef4..a2ac1024e9 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -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 { 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, diff --git a/packages/core/src/core/nonStrictFakeContentGenerator.ts b/packages/core/src/core/nonStrictFakeContentGenerator.ts new file mode 100644 index 0000000000..febdbfe39b --- /dev/null +++ b/packages/core/src/core/nonStrictFakeContentGenerator.ts @@ -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 { + 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['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 { + // 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> { + 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 { + return this.getNextResponse('countTokens', request); + } + + async embedContent( + request: EmbedContentParameters, + ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return Object.setPrototypeOf( + this.getNextResponse('embedContent', request), + EmbedContentResponse.prototype, + ); + } +}