diff --git a/packages/core/src/context/contextManager.golden.test.ts b/packages/core/src/context/contextManager.golden.test.ts index d5a2f6950c..25529438e5 100644 --- a/packages/core/src/context/contextManager.golden.test.ts +++ b/packages/core/src/context/contextManager.golden.test.ts @@ -71,7 +71,7 @@ describe('ContextManager Golden Tests', () => { }; const sidecar = SidecarLoader.fromConfig(mockConfig as any); - const tracer = new ContextTracer('/tmp', 'test-session'); + const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'test-session' }); const eventBus = new ContextEventBus(); const env = new ContextEnvironmentImpl( {} as any, @@ -132,7 +132,7 @@ describe('ContextManager Golden Tests', () => { ).IrMapper.toIr(history, new ContextTokenCalculator(4)); // In Golden Tests, we just want to ensure the logic doesn't throw or alter unprotected history in weird ways. // Since we're skipping processors due to being under budget, it should equal history. - const tracer2 = new ContextTracer('/tmp', 'test2'); + const tracer2 = new ContextTracer({ targetDir: '/tmp', sessionId: 'test2' }); const eventBus2 = new ContextEventBus(); const env2 = new ContextEnvironmentImpl( {} as any, diff --git a/packages/core/src/context/system/DeterministicIdGenerator.ts b/packages/core/src/context/system/DeterministicIdGenerator.ts new file mode 100644 index 0000000000..ae3dad6d33 --- /dev/null +++ b/packages/core/src/context/system/DeterministicIdGenerator.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IIdGenerator } from './IIdGenerator.js'; + +export class DeterministicIdGenerator implements IIdGenerator { + private counter = 0; + + constructor(private prefix: string = 'id-') {} + + generateId(): string { + this.counter++; + return `${this.prefix}${this.counter}`; + } +} diff --git a/packages/core/src/context/system/IFileSystem.ts b/packages/core/src/context/system/IFileSystem.ts new file mode 100644 index 0000000000..ab53eccc40 --- /dev/null +++ b/packages/core/src/context/system/IFileSystem.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IFileSystem { + existsSync(path: string): boolean; + statSyncSize(path: string): number; + readFileSync(path: string, encoding: 'utf8'): string; + writeFileSync(path: string, data: string, encoding: 'utf-8'): void; + appendFileSync(path: string, data: string, encoding: 'utf-8'): void; + mkdirSync(path: string, options?: { recursive?: boolean }): void; + join(...paths: string[]): string; + dirname(path: string): string; +} diff --git a/packages/core/src/context/system/IIdGenerator.ts b/packages/core/src/context/system/IIdGenerator.ts new file mode 100644 index 0000000000..2f5cb38449 --- /dev/null +++ b/packages/core/src/context/system/IIdGenerator.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface IIdGenerator { + generateId(): string; +} diff --git a/packages/core/src/context/system/InMemoryFileSystem.ts b/packages/core/src/context/system/InMemoryFileSystem.ts new file mode 100644 index 0000000000..5bd713b525 --- /dev/null +++ b/packages/core/src/context/system/InMemoryFileSystem.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IFileSystem } from './IFileSystem.js'; + +export class InMemoryFileSystem implements IFileSystem { + private files = new Map(); + + // Helper for tests + getFiles(): ReadonlyMap { + return this.files; + } + + // Helper for tests + setFile(path: string, content: string) { + this.files.set(this.normalize(path), content); + } + + private normalize(p: string): string { + // A very naive normalization for testing purposes. + // Ensures '/foo/bar' and '/foo//bar' map to the same key. + return p.replace(/\/+/g, '/'); + } + + existsSync(p: string): boolean { + return this.files.has(this.normalize(p)); + } + + statSyncSize(p: string): number { + const content = this.files.get(this.normalize(p)); + if (content === undefined) { + throw new Error(`ENOENT: no such file or directory, stat '${p}'`); + } + return content.length; // Naive char length = byte size for testing + } + + readFileSync(p: string, encoding: 'utf8'): string { + const content = this.files.get(this.normalize(p)); + if (content === undefined) { + throw new Error(`ENOENT: no such file or directory, open '${p}'`); + } + return content; + } + + writeFileSync(p: string, data: string, encoding: 'utf-8'): void { + this.files.set(this.normalize(p), data); + } + + appendFileSync(p: string, data: string, encoding: 'utf-8'): void { + const norm = this.normalize(p); + const existing = this.files.get(norm) || ''; + this.files.set(norm, existing + data); + } + + mkdirSync(p: string, options?: { recursive?: boolean }): void { + // In-memory fake doesn't track directories separately from files for our simple use cases + } + + join(...paths: string[]): string { + return this.normalize(paths.join('/')); + } + + dirname(p: string): string { + const parts = this.normalize(p).split('/'); + parts.pop(); + return parts.length === 0 ? '.' : parts.join('/') || '/'; + } +} diff --git a/packages/core/src/context/system/NodeFileSystem.ts b/packages/core/src/context/system/NodeFileSystem.ts new file mode 100644 index 0000000000..4eae7b1b90 --- /dev/null +++ b/packages/core/src/context/system/NodeFileSystem.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { IFileSystem } from './IFileSystem.js'; + +export class NodeFileSystem implements IFileSystem { + existsSync(p: string): boolean { + return fs.existsSync(p); + } + + statSyncSize(p: string): number { + return fs.statSync(p).size; + } + + readFileSync(p: string, encoding: 'utf8'): string { + return fs.readFileSync(p, encoding); + } + + writeFileSync(p: string, data: string, encoding: 'utf-8'): void { + fs.writeFileSync(p, data, encoding); + } + + appendFileSync(p: string, data: string, encoding: 'utf-8'): void { + fs.appendFileSync(p, data, encoding); + } + + mkdirSync(p: string, options?: { recursive?: boolean }): void { + fs.mkdirSync(p, options); + } + + join(...paths: string[]): string { + return path.join(...paths); + } + + dirname(p: string): string { + return path.dirname(p); + } +} diff --git a/packages/core/src/context/system/NodeIdGenerator.ts b/packages/core/src/context/system/NodeIdGenerator.ts new file mode 100644 index 0000000000..540ec673ba --- /dev/null +++ b/packages/core/src/context/system/NodeIdGenerator.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { IIdGenerator } from './IIdGenerator.js'; + +export class NodeIdGenerator implements IIdGenerator { + generateId(): string { + return randomUUID(); + } +} diff --git a/packages/core/src/context/testing/contextTestUtils.ts b/packages/core/src/context/testing/contextTestUtils.ts index 4d5b4811a4..a55c916a20 100644 --- a/packages/core/src/context/testing/contextTestUtils.ts +++ b/packages/core/src/context/testing/contextTestUtils.ts @@ -24,7 +24,7 @@ export function createMockEnvironment(): ContextEnvironment { traceDir: '/tmp/.gemini/trace', projectTempDir: '/tmp/.gemini/tool-outputs', eventBus: new ContextEventBus(), - tracer: new ContextTracer('/tmp', 'mock-session'), + tracer: new ContextTracer({ targetDir: '/tmp', sessionId: 'mock-session' }), charsPerToken: 1, tokenCalculator: new ContextTokenCalculator(1), }; @@ -97,7 +97,7 @@ import type { BaseLlmClient } from 'src/core/baseLlmClient.js'; export function setupContextComponentTest(config: Config) { const chatHistory = new AgentChatHistory(); const sidecar = SidecarLoader.fromConfig(config); - const tracer = new ContextTracer('/tmp', 'test-session'); + const tracer = new ContextTracer({ targetDir: '/tmp', sessionId: 'test-session' }); const eventBus = new ContextEventBus(); const env = new ContextEnvironmentImpl( config.getBaseLlmClient(), diff --git a/packages/core/src/context/tracer.test.ts b/packages/core/src/context/tracer.test.ts index c6ab819bc5..335b08438f 100644 --- a/packages/core/src/context/tracer.test.ts +++ b/packages/core/src/context/tracer.test.ts @@ -1,67 +1,67 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { ContextTracer } from './tracer.js'; +import { InMemoryFileSystem } from './system/InMemoryFileSystem.js'; +import { DeterministicIdGenerator } from './system/DeterministicIdGenerator.js'; -vi.mock('node:fs'); - -describe('ContextTracer', () => { - const originalEnv = process.env; +describe('ContextTracer (Fake FS & ID Gen)', () => { + let fileSystem: InMemoryFileSystem; + let idGenerator: DeterministicIdGenerator; beforeEach(() => { - vi.resetAllMocks(); - process.env = { ...originalEnv }; - }); - - afterEach(() => { - process.env = originalEnv; - }); - - it('initializes, logs events, and auto-saves large assets when GEMINI_CONTEXT_TRACE is true', () => { - process.env['GEMINI_CONTEXT_TRACE'] = 'true'; - const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); - const appendFileSyncSpy = vi.spyOn(fs, 'appendFileSync'); - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); - - const tracer = new ContextTracer('/fake/target', 'test-session'); - - expect(mkdirSyncSpy).toHaveBeenCalled(); + fileSystem = new InMemoryFileSystem(); + idGenerator = new DeterministicIdGenerator('mock-uuid-'); + // We must mock Date.now() to ensure asset file names are perfectly deterministic + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T12:00:00Z')); + }); + + it('initializes, logs events, and auto-saves large assets deterministically', () => { + const tracer = new ContextTracer( + { enabled: true, targetDir: '/fake/target', sessionId: 'test-session' }, + fileSystem, + idGenerator + ); + + // Verify Initialization + const initTraceLog = fileSystem.readFileSync('/fake/target/.gemini/context_trace/test-session/trace.log', 'utf8'); + expect(initTraceLog).toContain('[SYSTEM] Context Tracer Initialized'); + // Small logging: shouldn't trigger saveAsset tracer.logEvent('TestComponent', 'TestAction', { key: 'value' }); - - expect(appendFileSyncSpy).toHaveBeenCalledTimes(2); // 1 for init, 1 for TestAction - expect(writeFileSyncSpy).not.toHaveBeenCalled(); - const logCall = appendFileSyncSpy.mock.calls[1][1] as string; - expect(logCall).toContain('[TestComponent] TestAction'); - expect(logCall).toContain('{"key":"value"}'); + const smallTraceLog = fileSystem.readFileSync('/fake/target/.gemini/context_trace/test-session/trace.log', 'utf8'); + expect(smallTraceLog).toContain('[TestComponent] TestAction'); + expect(smallTraceLog).toContain('{"key":"value"}'); // Large logging: should trigger auto-asset save const hugeString = 'a'.repeat(2000); tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString }); - expect(writeFileSyncSpy).toHaveBeenCalled(); // asset saved + // 1767268800000 is 2026-01-01T12:00:00Z + const expectedAssetPath = '/fake/target/.gemini/context_trace/test-session/assets/1767268800000-mock-uuid-1-largeKey.json'; - expect(appendFileSyncSpy).toHaveBeenCalledTimes(4); // init + TestAction + the inner saveAsset log + LargeAction log - const largeLogCall = appendFileSyncSpy.mock.calls[3][1] as string; - expect(largeLogCall).toContain('LargeAction'); - expect(largeLogCall).toContain('"$asset":'); // verifies it was extracted + // Assert asset was written to FS + expect(fileSystem.existsSync(expectedAssetPath)).toBe(true); + + const largeTraceLog = fileSystem.readFileSync('/fake/target/.gemini/context_trace/test-session/trace.log', 'utf8'); + expect(largeTraceLog).toContain('[TestComponent] LargeAction'); + expect(largeTraceLog).toContain(`{"largeKey":{"$asset":"1767268800000-mock-uuid-1-largeKey.json"}}`); }); - it('silently ignores logging when GEMINI_CONTEXT_TRACE is false', () => { - process.env['GEMINI_CONTEXT_TRACE'] = 'false'; - const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); - const appendFileSyncSpy = vi.spyOn(fs, 'appendFileSync'); - const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); - - const tracer = new ContextTracer('/fake/target', 'test-session'); - expect(mkdirSyncSpy).not.toHaveBeenCalled(); + it('silently ignores logging when disabled', () => { + const tracer = new ContextTracer( + { enabled: false, targetDir: '/fake/target', sessionId: 'test-session' }, + fileSystem, + idGenerator + ); tracer.logEvent('TestComponent', 'TestAction'); - expect(appendFileSyncSpy).not.toHaveBeenCalled(); - + const hugeString = 'a'.repeat(2000); tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString }); - expect(writeFileSyncSpy).not.toHaveBeenCalled(); + + // FS should be completely empty + expect(fileSystem.getFiles().size).toBe(0); }); }); diff --git a/packages/core/src/context/tracer.ts b/packages/core/src/context/tracer.ts index 7d67075660..ac79d2ef9c 100644 --- a/packages/core/src/context/tracer.ts +++ b/packages/core/src/context/tracer.ts @@ -4,27 +4,43 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { randomUUID } from 'node:crypto'; import { debugLogger } from '../utils/debugLogger.js'; +import type { IFileSystem } from './system/IFileSystem.js'; +import { NodeFileSystem } from './system/NodeFileSystem.js'; +import type { IIdGenerator } from './system/IIdGenerator.js'; +import { NodeIdGenerator } from './system/NodeIdGenerator.js'; + +export interface ContextTracerOptions { + enabled?: boolean; + targetDir: string; + sessionId: string; +} export class ContextTracer { private traceDir: string; private assetsDir: string; private enabled: boolean; + private fileSystem: IFileSystem; + private idGenerator: IIdGenerator; private readonly MAX_INLINE_SIZE = 1000; - constructor(targetDir: string, sessionId: string) { - this.enabled = process.env['GEMINI_CONTEXT_TRACE'] === 'true'; - this.traceDir = path.join(targetDir, '.gemini', 'context_trace', sessionId); - this.assetsDir = path.join(this.traceDir, 'assets'); + constructor( + options: ContextTracerOptions, + fileSystem: IFileSystem = new NodeFileSystem(), + idGenerator: IIdGenerator = new NodeIdGenerator() + ) { + this.enabled = options.enabled ?? false; + this.fileSystem = fileSystem; + this.idGenerator = idGenerator; + + this.traceDir = this.fileSystem.join(options.targetDir, '.gemini', 'context_trace', options.sessionId); + this.assetsDir = this.fileSystem.join(this.traceDir, 'assets'); if (this.enabled) { try { - fs.mkdirSync(this.assetsDir, { recursive: true }); - this.logEvent('SYSTEM', 'Context Tracer Initialized', { sessionId }); + this.fileSystem.mkdirSync(this.assetsDir, { recursive: true }); + this.logEvent('SYSTEM', 'Context Tracer Initialized', { sessionId: options.sessionId }); } catch (e) { debugLogger.error('Failed to initialize ContextTracer', e); this.enabled = false; @@ -59,8 +75,8 @@ export class ContextTracer { ? ` | Details: ${JSON.stringify(processedDetails)}` : ''; const logLine = `[${timestamp}] [${component}] ${action}${detailsStr}\n`; - fs.appendFileSync( - path.join(this.traceDir, 'trace.log'), + this.fileSystem.appendFileSync( + this.fileSystem.join(this.traceDir, 'trace.log'), logLine, 'utf-8', ); @@ -72,10 +88,10 @@ export class ContextTracer { private saveAsset(component: string, assetName: string, data: unknown): string { if (!this.enabled) return 'asset-recording-disabled'; try { - const assetId = `${Date.now()}-${randomUUID().slice(0, 6)}-${assetName}.json`; - const assetPath = path.join(this.assetsDir, assetId); + const assetId = `${Date.now()}-${this.idGenerator.generateId()}-${assetName}.json`; + const assetPath = this.fileSystem.join(this.assetsDir, assetId); - fs.writeFileSync(assetPath, JSON.stringify(data, null, 2), 'utf-8'); + this.fileSystem.writeFileSync(assetPath, JSON.stringify(data, null, 2), 'utf-8'); this.logEvent(component, `Saved asset: ${assetName}`, { assetId }); return assetId; } catch (e) {