mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-19 00:02:51 -07:00
use fakes in the tracer
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user