use fakes in the tracer

This commit is contained in:
Your Name
2026-04-06 22:17:32 +00:00
parent a4b6372d31
commit cf6866c38d
10 changed files with 250 additions and 63 deletions
@@ -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,
@@ -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}`;
}
}
@@ -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;
}
@@ -0,0 +1,9 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export interface IIdGenerator {
generateId(): string;
}
@@ -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<string, string>();
// Helper for tests
getFiles(): ReadonlyMap<string, string> {
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('/') || '/';
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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(),
+45 -45
View File
@@ -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);
});
});
+30 -14
View File
@@ -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) {