This commit is contained in:
Your Name
2026-04-07 04:03:54 +00:00
parent 64b8a6f4a8
commit 1754797929
9 changed files with 58 additions and 108 deletions
@@ -1,5 +1,3 @@
import { ProcessorRegistry } from "./sidecar/registry.js";
import { registerBuiltInProcessors } from "./sidecar/builtins.js";
/**
* @license
* Copyright 2026 Google LLC
@@ -21,11 +19,13 @@ import { SidecarLoader } from './sidecar/SidecarLoader.js';
import { ContextTracer } from './tracer.js';
import { ContextEventBus } from './eventBus.js';
import { ContextTokenCalculator } from './utils/contextTokenCalculator.js';
import type { Content } from '@google/genai';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import type { Episode } from './ir/types.js';
import type { SidecarConfig } from './sidecar/types.js';
import { ProcessorRegistry } from "./sidecar/registry.js";
import { registerBuiltInProcessors } from "./sidecar/builtins.js";
expect.addSnapshotSerializer({
test: (val) =>
+1 -22
View File
@@ -4,40 +4,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Content } from '@google/genai';
import type { AgentChatHistory } from '../core/agentChatHistory.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { Episode } from './ir/types.js';
import type { ContextEventBus } from './eventBus.js';
import type { ContextTracer } from './tracer.js';
import type { ContextEnvironment } from './sidecar/environment.js';
import type { SidecarConfig } from './sidecar/types.js';
import { PipelineOrchestrator } from './sidecar/orchestrator.js';
import { HistoryObserver } from './historyObserver.js';
import { generateWorkingBufferView } from './ir/graphUtils.js';
import { IrProjector } from './ir/projector.js';
import './sidecar/builtins.js';
import { ProcessorRegistry } from './sidecar/registry.js';
import { registerBuiltInProcessors } from './sidecar/builtins.js';
import { ProcessorRegistry } from './sidecar/registry.js';
export class ContextManager {
+24 -13
View File
@@ -41,19 +41,30 @@ export function generateWorkingBufferView(
continue;
}
let projectedEp = {
...ep,
trigger: {
let projectedTrigger: typeof ep.trigger;
if (ep.trigger.type === 'USER_PROMPT') {
projectedTrigger = {
...ep.trigger,
metadata: {
...ep.trigger?.metadata,
transformations: [...(ep.trigger?.metadata?.transformations || [])],
...ep.trigger.metadata,
transformations: [...(ep.trigger.metadata?.transformations || [])],
},
semanticParts:
ep.trigger?.type === 'USER_PROMPT'
? [...(ep.trigger.semanticParts || []).map((sp) => ({ ...sp }))]
: undefined,
} as unknown as typeof ep.trigger,
semanticParts: ep.trigger.semanticParts.map(sp => ({...sp}))
};
} else {
projectedTrigger = {
...ep.trigger,
metadata: {
...ep.trigger.metadata,
transformations: [...(ep.trigger.metadata?.transformations || [])],
}
};
}
let projectedEp: Episode = {
...ep,
trigger: projectedTrigger,
steps: ep.steps.map(
(step) =>
({
@@ -62,7 +73,7 @@ export function generateWorkingBufferView(
...step.metadata,
transformations: [...(step.metadata?.transformations || [])],
},
}) as unknown as typeof step,
})
),
yield: ep.yield
? {
@@ -87,7 +98,7 @@ export function generateWorkingBufferView(
snapshot.status === 'ready' &&
snapshot.type === 'snapshot'
) {
projectedEp = snapshot.episode as any;
projectedEp = snapshot.episode;
// Mark all the episodes this snapshot covers to be skipped by the backwards sweep.
for (const id of snapshot.replacedEpisodeIds) {
skippedIds.add(id);
@@ -121,7 +132,7 @@ export function generateWorkingBufferView(
],
},
},
] as any;
] as typeof projectedEp.steps;
projectedEp.yield = undefined;
tracer.logEvent(
'ViewGenerator',
@@ -1,10 +1,10 @@
import { ProcessorRegistry } from "./registry.js";
import { registerBuiltInProcessors } from "./builtins.js";
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ProcessorRegistry } from "./registry.js";
import { registerBuiltInProcessors } from "./builtins.js";
import { describe, it, expect, beforeEach } from 'vitest';
import { SidecarLoader } from './SidecarLoader.js';
import { defaultSidecarProfile } from './profiles.js';
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ProcessorRegistry } from './registry.js';
import type { ProcessorRegistry } from './registry.js';
import { ToolMaskingProcessor, type ToolMaskingProcessorOptions } from '../processors/toolMaskingProcessor.js';
import { BlobDegradationProcessor } from '../processors/blobDegradationProcessor.js';
import { SemanticCompressionProcessor, type SemanticCompressionProcessorOptions } from '../processors/semanticCompressionProcessor.js';
@@ -10,7 +10,7 @@ import { ProcessorRegistry } from './registry.js';
import { createMockEnvironment, createDummyState, createDummyEpisode } from '../testing/contextTestUtils.js';
import type { ContextEnvironment } from './environment.js';
import type { ContextAccountingState, ContextProcessor } from '../pipeline.js';
import type { SidecarConfig } from './types.js';
import type { PipelineDef, ProcessorConfig, SidecarConfig } from './types.js';
import type { ContextEventBus } from '../eventBus.js';
import type { EpisodeEditor } from '../ir/episodeEditor.js';
@@ -56,26 +56,26 @@ class ThrowingProcessor implements ContextProcessor {
describe('PipelineOrchestrator (Component)', () => {
let env: ContextEnvironment;
let eventBus: ContextEventBus;
let registry: ProcessorRegistry;
beforeEach(() => {
vi.resetAllMocks();
env = createMockEnvironment();
eventBus = env.eventBus;
registry = new ProcessorRegistry();
// Register our test processors
ProcessorRegistry.register({ id: 'DummySyncProcessor', create: () => new DummySyncProcessor() });
ProcessorRegistry.register({ id: 'DummyAsyncProcessor', create: () => new DummyAsyncProcessor() });
ProcessorRegistry.register({ id: 'ThrowingProcessor', create: () => new ThrowingProcessor() });
registry.register({ id: 'DummySyncProcessor', create: () => new DummySyncProcessor() });
registry.register({ id: 'DummyAsyncProcessor', create: () => new DummyAsyncProcessor() });
registry.register({ id: 'ThrowingProcessor', create: () => new ThrowingProcessor() });
});
afterEach(() => {
// Cleanup registry to not pollute other tests
(ProcessorRegistry as any).processors.delete('DummySyncProcessor');
(ProcessorRegistry as any).processors.delete('DummyAsyncProcessor');
(ProcessorRegistry as any).processors.delete('ThrowingProcessor');
registry.clear();
});
const createConfig = (pipelines: any[]): SidecarConfig => ({
const createConfig = (pipelines: PipelineDef[]): SidecarConfig => ({
budget: { maxTokens: 100, retainedTokens: 50 },
gcBackstop: { strategy: 'truncate', target: 'max' },
pipelines
@@ -87,11 +87,12 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'Sync',
execution: 'blocking',
triggers: [],
processors: [{ processorId: 'DummySyncProcessor' }]
processors: [{ processorId: 'DummySyncProcessor' } as unknown as ProcessorConfig]
}
]);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer, registry);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((orchestrator as any).instantiatedProcessors.has('DummySyncProcessor')).toBe(true);
});
@@ -101,11 +102,11 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'Bad',
execution: 'blocking',
triggers: [],
processors: [{ processorId: 'DoesNotExist' }]
processors: [{ processorId: 'DoesNotExist' } as unknown as ProcessorConfig]
}
]);
expect(() => new PipelineOrchestrator(config, env, eventBus, env.tracer))
expect(() => new PipelineOrchestrator(config, env, eventBus, env.tracer, registry))
.toThrow('Context Processor [DoesNotExist] is not registered.');
});
@@ -115,10 +116,10 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'SyncPipe',
execution: 'blocking',
triggers: [],
processors: [{ processorId: 'DummySyncProcessor' }]
processors: [{ processorId: 'DummySyncProcessor' } as unknown as ProcessorConfig]
}
]);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer, registry);
const episodes = [createDummyEpisode('1', 'USER_PROMPT', [])];
const state = createDummyState(false);
@@ -126,7 +127,7 @@ describe('PipelineOrchestrator (Component)', () => {
const result = await orchestrator.executePipeline('SyncPipe', episodes, state);
expect(result).toHaveLength(1);
expect((result[0] as any).dummyModified).toBe(true);
expect((result[0] as unknown as {dummyModified: boolean}).dummyModified).toBe(true);
});
it('executes background pipelines asynchronously without blocking the return', async () => {
@@ -135,10 +136,10 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'AsyncPipe',
execution: 'background',
triggers: [],
processors: [{ processorId: 'DummyAsyncProcessor' }]
processors: [{ processorId: 'DummyAsyncProcessor' } as unknown as ProcessorConfig]
}
]);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer, registry);
const episodes = [createDummyEpisode('1', 'USER_PROMPT', [])];
const state = createDummyState(false);
@@ -147,7 +148,7 @@ describe('PipelineOrchestrator (Component)', () => {
const result = await orchestrator.executePipeline('AsyncPipe', episodes, state);
expect(result).toHaveLength(1);
expect((result[0] as any).asyncModified).toBeUndefined(); // Not modified yet!
expect((result[0] as unknown as {asyncModified: unknown}).asyncModified).toBeUndefined(); // Not modified yet!
// Wait for the background task to complete (50ms delay in DummyAsyncProcessor)
await new Promise(resolve => setTimeout(resolve, 60));
@@ -159,10 +160,10 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'ThrowingPipe',
execution: 'blocking',
triggers: [],
processors: [{ processorId: 'ThrowingProcessor' }]
processors: [{ processorId: 'ThrowingProcessor' } as unknown as ProcessorConfig]
}
]);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer);
const orchestrator = new PipelineOrchestrator(config, env, eventBus, env.tracer, registry);
const episodes = [createDummyEpisode('1', 'USER_PROMPT', [])];
const state = createDummyState(false);
@@ -180,14 +181,15 @@ describe('PipelineOrchestrator (Component)', () => {
name: 'PressureRelief',
execution: 'background',
triggers: ['budget_exceeded'],
processors: [{ processorId: 'DummyAsyncProcessor' }]
processors: [{ processorId: 'DummyAsyncProcessor' } as unknown as ProcessorConfig]
}
]);
// Spy on the private method to see if the trigger fires it
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const executeSpy = vi.spyOn(PipelineOrchestrator.prototype as any, 'executePipelineAsync');
new PipelineOrchestrator(config, env, eventBus, env.tracer);
new PipelineOrchestrator(config, env, eventBus, env.tracer, registry);
const episodes = [createDummyEpisode('1', 'USER_PROMPT', [])];
@@ -23,10 +23,10 @@ export class ProcessorRegistry {
private processors = new Map<string, ContextProcessorDef<unknown>>();
register<TOptions>(def: ContextProcessorDef<TOptions>) {
this.processors.set(def.id, def as unknown as ContextProcessorDef<unknown>);
this.processors.set(def.id, def);
}
get(id: string): ContextProcessorDef<unknown> {
get(id: string): ContextProcessorDef {
const def = this.processors.get(id);
if (!def) {
throw new Error(`Context Processor [${id}] is not registered.`);
+1 -1
View File
@@ -3,7 +3,7 @@
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ProcessorRegistry } from './registry.js';
import type { ProcessorRegistry } from './registry.js';
import './builtins.js';
export function getSidecarConfigSchema(registry: ProcessorRegistry) {
-42
View File
@@ -1,42 +0,0 @@
# Context Pipeline Testing Strategy & Audit
## Philosophy: Defense in Depth
Our testing strategy avoids the "endless tax" of brittle tests by strictly separating concerns:
1. **Unit Tests (Processors, System Fakes, Mappers):** Exhaustively test logical boundaries, token math, and state transformations. Driven by shared, DRY test factories (no repetitive boilerplate).
2. **Component Tests (ContextManager, Orchestrator):** Test the *wiring* and *triggers*. Verify that barriers block, background pipelines execute, and events fire correctly.
3. **Golden / E2E Tests:** Test emergent behavior. Pass in complex, raw chat histories and assert the exact final projected `Content[]` output against committed JSON snapshots.
---
## Audit Checklist & Coverage Tracker
### 1. The Tooling Library (`contextTestUtils.ts`)
- [x] Implement `ContextTestBuilder` or shared factory functions (`createDummyEpisode`, `createDummyState`).
- [x] Ensure all existing tests are migrated to use these helpers to establish the pattern.
### 2. Unit Tests (The Processors & Map/Reduce)
Goal: Ensure every component gracefully handles boundary conditions (budget satisfied vs. deficit), skips protected IDs, and correctly transforms IR.
- [x] `BlobDegradationProcessor` (Completed)
- [x] `ToolMaskingProcessor` (Completed)
- [x] `HistorySquashingProcessor` (Completed)
- [x] `SemanticCompressionProcessor` (Completed)
- [x] `StateSnapshotProcessor` (Completed)
- [x] `EmergencyTruncationProcessor` (Completed)
- [x] `ContextTracer` (Completed)
- [x] `SidecarLoader` (Completed)
- [x] `IrMapper` / `graphUtils` (Completed - Handles Multi-Tool Concurrency and Backwards Graph Traversal)
### 3. Component Tests (The Orchestration)
Goal: Prove the sidecar configuration accurately drives runtime behavior without testing the processor logic itself.
- [x] `PipelineOrchestrator`: Test sync vs. async routing, error swallowing, and trigger setup.
- [ ] `ContextManager`: Test `subscribeToHistory` (Opportunistic triggers).
- [ ] `ContextManager`: Test `project()` (Synchronous barrier triggers).
### 4. Golden / E2E Tests
- [ ] `contextManager.golden.test.ts`: Ensure we have a scenario representing a "Day in the Life" of the CLI (some images, some huge tool outputs, deep history) mapping to a snapshot.
---
## Next Actions
1. Audit the ContextManager component tests (opportunistic updates & sync barrier).
2. Finalize the End-to-End "Day in the Life" Golden Snapshot test.