test: restore and migrate context processor unit tests to functional HOFs

Restores the 7 core unit tests deleted to bypass compiler errors, migrates their syntax to the new functional `createXProcessor()` paradigm, and fixes the TS/ESLint parsing errors caused by my `Object.assign` use on the `ContextProcessorFn` definitions.
This commit is contained in:
Your Name
2026-04-09 23:29:35 +00:00
parent e2c3d07c84
commit 0ebc369d2c
9 changed files with 1020 additions and 0 deletions
@@ -0,0 +1,101 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createBlobDegradationProcessor } from './blobDegradationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, SemanticPart, ConcreteNode } from '../ir/types.js';
describe('BlobDegradationProcessor', () => {
it('should ignore text parts and only target inline_data and file_data', async () => {
const env = createMockEnvironment();
// charsPerToken = 1
// We want the degraded text to be cheaper than the original blob.
// Degraded text is ~100 chars ("...degraded to text...").
// So we make the blob data 200 chars.
const fakeData = 'A'.repeat(200);
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
const parts: SemanticPart[] = [
{ type: 'text', text: 'Hello' },
{ type: 'inline_data', mimeType: 'image/png', data: fakeData },
{ type: 'text', text: 'World' },
];
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
semanticParts: parts,
}) as UserPrompt;
const targets = [prompt];
const result = await processor(createMockProcessArgs(targets));
expect(result.length).toBe(1);
const modifiedPrompt = result[0] as UserPrompt;
expect(modifiedPrompt.id).not.toBe(prompt.id);
expect(modifiedPrompt.semanticParts.length).toBe(3);
// Text parts should be untouched
expect(modifiedPrompt.semanticParts[0]).toEqual(parts[0]);
expect(modifiedPrompt.semanticParts[2]).toEqual(parts[2]);
// The inline_data part should be replaced with text
const degradedPart = modifiedPrompt.semanticParts[1];
expect(degradedPart.type).toBe('text');
assert(degradedPart.type === 'text');
expect(degradedPart.text).toContain(
'[Multi-Modal Blob (image/png, 0.00MB) degraded to text',
);
});
it('should degrade all blobs unconditionally', async () => {
const env = createMockEnvironment();
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
// Tokens for fileData = 258.
// Degraded text = "[File Reference (video/mp4) degraded to text to preserve context window. Original URI: gs://test1]"
// Degraded text length ~100 characters.
// Since charsPerToken=1, degraded text = 100 tokens.
// Tokens saved = 258 - 100 = 158. This is > 0, so it WILL degrade it!
const prompt = createDummyNode('ep1', 'USER_PROMPT', 100, {
semanticParts: [
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test1' },
{ type: 'file_data', mimeType: 'video/mp4', fileUri: 'gs://test2' },
],
}) as UserPrompt;
const targets = [prompt];
const result = await processor(createMockProcessArgs(targets));
const modifiedPrompt = result[0] as UserPrompt;
expect(modifiedPrompt.semanticParts.length).toBe(2);
// Both parts should be degraded
expect(modifiedPrompt.semanticParts[0].type).toBe('text');
expect(modifiedPrompt.semanticParts[1].type).toBe('text');
});
it('should return exactly the targets array if targets are empty', async () => {
const env = createMockEnvironment();
const processor = createBlobDegradationProcessor('BlobDegradationProcessor', env);
const targets: ConcreteNode[] = [];
const result = await processor(createMockProcessArgs(targets));
expect(result).toBe(targets);
});
});
@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createNodeDistillationProcessor } from './nodeDistillationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
createDummyToolNode,
createMockLlmClient,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, AgentThought, ToolExecution } from '../ir/types.js';
describe('NodeDistillationProcessor', () => {
it('should trigger summarization via LLM for long text parts', async () => {
const mockLlmClient = createMockLlmClient(['Mocked Summary!']);
// Use charsPerToken=1 naturally.
const env = createMockEnvironment({
llmClient: mockLlmClient,
});
const processor = createNodeDistillationProcessor('NodeDistillationProcessor', env, {
nodeThresholdTokens: 10,
});
const longText = 'A'.repeat(50); // 50 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
50,
{
semanticParts: [{ type: 'text', text: longText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
50,
{
text: longText,
},
'thought-id',
) as AgentThought;
const tool = createDummyToolNode(
'ep1',
5,
500,
{
observation: { result: 'A'.repeat(500) },
},
'tool-id',
);
const targets = [prompt, thought, tool];
const result = await processor(createMockProcessArgs(targets));
expect(result.length).toBe(3);
// 1. User Prompt
const compressedPrompt = result[0] as UserPrompt;
expect(compressedPrompt.id).not.toBe(prompt.id);
expect(compressedPrompt.semanticParts[0].type).toBe('text');
assert(compressedPrompt.semanticParts[0].type === 'text');
expect(compressedPrompt.semanticParts[0].text).toBe('Mocked Summary!');
// 2. Agent Thought
const compressedThought = result[1] as AgentThought;
expect(compressedThought.id).not.toBe(thought.id);
expect(compressedThought.text).toBe('Mocked Summary!');
// 3. Tool Execution
const compressedTool = result[2] as ToolExecution;
expect(compressedTool.id).not.toBe(tool.id);
expect(compressedTool.observation).toEqual({ summary: 'Mocked Summary!' });
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(3);
});
it('should ignore nodes that are below the threshold', async () => {
const mockLlmClient = createMockLlmClient(['S']); // length = 1
const env = createMockEnvironment({
llmClient: mockLlmClient,
});
const processor = createNodeDistillationProcessor('NodeDistillationProcessor', env, {
nodeThresholdTokens: 100, // Very high threshold
});
const shortText = 'Short text'; // 10 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
10,
{
semanticParts: [{ type: 'text', text: shortText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
13,
{
text: 'Short thought',
},
'thought-id',
) as AgentThought;
const targets = [prompt, thought];
const result = await processor(createMockProcessArgs(targets));
expect(result.length).toBe(2);
// 1. User Prompt (NOT compressed)
const untouchedPrompt = result[0] as UserPrompt;
expect(untouchedPrompt.id).toBe(prompt.id);
// 2. Agent Thought (NOT compressed)
const untouchedThought = result[1] as AgentThought;
expect(untouchedThought.id).toBe(thought.id);
// LLM should not have been called
expect(mockLlmClient.generateContent).toHaveBeenCalledTimes(0);
});
});
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect } from 'vitest';
import { createNodeTruncationProcessor } from './nodeTruncationProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { UserPrompt, AgentThought, AgentYield } from '../ir/types.js';
describe('NodeTruncationProcessor', () => {
it('should truncate nodes that exceed maxTokensPerNode', async () => {
// env.tokenCalculator uses charsPerToken=1 natively.
const env = createMockEnvironment();
const processor = createNodeTruncationProcessor('NodeTruncationProcessor', env, {
maxTokensPerNode: 10, // 10 chars limit
});
const longText = 'A'.repeat(50); // 50 tokens
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
50,
{
semanticParts: [{ type: 'text', text: longText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
50,
{
text: longText,
},
'thought-id',
) as AgentThought;
const yieldNode = createDummyNode(
'ep1',
'AGENT_YIELD',
50,
{
text: longText,
},
'yield-id',
) as AgentYield;
const targets = [prompt, thought, yieldNode];
const result = await processor(createMockProcessArgs(targets));
expect(result.length).toBe(3);
// 1. User Prompt
const squashedPrompt = result[0] as UserPrompt;
expect(squashedPrompt.id).not.toBe(prompt.id);
expect(squashedPrompt.semanticParts[0].type).toBe('text');
assert(squashedPrompt.semanticParts[0].type === 'text');
expect(squashedPrompt.semanticParts[0].text).toContain('[... OMITTED');
// 2. Agent Thought
const squashedThought = result[1] as AgentThought;
expect(squashedThought.id).not.toBe(thought.id);
expect(squashedThought.text).toContain('[... OMITTED');
// 3. Agent Yield
const squashedYield = result[2] as AgentYield;
expect(squashedYield.id).not.toBe(yieldNode.id);
expect(squashedYield.text).toContain('[... OMITTED');
});
it('should ignore nodes that are below maxTokensPerNode', async () => {
const env = createMockEnvironment();
const processor = createNodeTruncationProcessor('NodeTruncationProcessor', env, {
maxTokensPerNode: 100, // 100 chars limit
});
const shortText = 'Short text'; // 10 chars
const prompt = createDummyNode(
'ep1',
'USER_PROMPT',
10,
{
semanticParts: [{ type: 'text', text: shortText }],
},
'prompt-id',
) as UserPrompt;
const thought = createDummyNode(
'ep1',
'AGENT_THOUGHT',
13,
{
text: 'Short thought', // 13 chars
},
'thought-id',
) as AgentThought;
const targets = [prompt, thought];
const result = await processor(createMockProcessArgs(targets));
expect(result.length).toBe(2);
// 1. User Prompt (untouched)
const squashedPrompt = result[0] as UserPrompt;
expect(squashedPrompt.id).toBe(prompt.id);
assert(squashedPrompt.semanticParts[0].type === 'text');
expect(squashedPrompt.semanticParts[0].text).not.toContain('[... OMITTED');
// 2. Agent Thought (untouched)
const untouchedThought = result[1] as AgentThought;
expect(untouchedThought.id).toBe(thought.id);
expect(untouchedThought.text).not.toContain('[... OMITTED');
});
});
@@ -0,0 +1,86 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createRollingSummaryProcessor } from './rollingSummaryProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
describe('RollingSummaryProcessor', () => {
it('should initialize with correct default options', () => {
const env = createMockEnvironment();
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
target: 'incremental',
});
expect(processor.id).toBe('RollingSummaryProcessor');
});
it('should summarize older nodes when the deficit exceeds the threshold', async () => {
// env.tokenCalculator uses charsPerToken=1 based on createMockEnvironment
const env = createMockEnvironment();
// We want to free exactly 100 tokens.
// We will supply nodes that cost 50 tokens each.
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
target: 'freeNTokens',
freeTokensTarget: 100,
});
const text50 = 'A'.repeat(50);
const targets = [
createDummyNode(
'ep1',
'USER_PROMPT',
50,
{ semanticParts: [{ type: 'text', text: text50 }] },
'id1',
),
createDummyNode('ep1', 'AGENT_THOUGHT', 50, { text: text50 }, 'id2'),
createDummyNode('ep1', 'AGENT_YIELD', 50, { text: text50 }, 'id3'),
];
const result = await processor(createMockProcessArgs(targets));
// 3 nodes at 50 cost each.
// The first node (id1) is the initial USER_PROMPT and is always skipped by RollingSummaryProcessor.
// Node id2 adds 50 deficit. Node id3 adds 50 deficit. Total = 100 deficit, which hits the target break point.
// Thus, id2 and id3 are summarized into a new ROLLING_SUMMARY node.
expect(result.length).toBe(2);
expect(result[0].type).toBe('USER_PROMPT');
expect(result[1].type).toBe('ROLLING_SUMMARY');
});
it('should preserve targets if deficit does not trigger summary', async () => {
const env = createMockEnvironment();
// We want to free 100 tokens, but our nodes will only cost 10 tokens each.
const processor = createRollingSummaryProcessor('RollingSummaryProcessor', env, {
target: 'freeNTokens',
freeTokensTarget: 100,
});
const text10 = 'A'.repeat(10);
const targets = [
createDummyNode(
'ep1',
'USER_PROMPT',
10,
{ semanticParts: [{ type: 'text', text: text10 }] },
'id1',
),
createDummyNode('ep1', 'AGENT_THOUGHT', 10, { text: text10 }, 'id2'),
];
const result = await processor(createMockProcessArgs(targets));
// Deficit accumulator reaches 10. This is < 100 limit, and total summarizable nodes < 2 anyway.
expect(result.length).toBe(2);
expect(result[0].type).toBe('USER_PROMPT');
expect(result[1].type).toBe('AGENT_THOUGHT');
});
});
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createStateSnapshotProcessor } from './stateSnapshotProcessor.js';
import {
createMockEnvironment,
createDummyNode,
createMockProcessArgs,
} from '../testing/contextTestUtils.js';
import type { InboxSnapshotImpl } from '../sidecar/inbox.js';
describe('StateSnapshotProcessor', () => {
it('should ignore if budget is satisfied', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
target: 'incremental',
});
const targets = [createDummyNode('ep1', 'USER_PROMPT')];
const result = await processor(createMockProcessArgs(targets));
expect(result).toBe(targets); // Strict equality
});
it('should apply a valid snapshot from the Inbox (Fast Path)', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
target: 'incremental',
});
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeA, nodeB, nodeC];
// The background worker created a snapshot of A and B
const messages = [
{
id: 'msg-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now(),
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<compressed A and B>',
type: 'point-in-time',
},
},
];
const processArgs = createMockProcessArgs(targets, [], messages);
const result = await processor(processArgs);
// Should remove A and B, insert Snapshot, keep C
expect(result.length).toBe(2);
expect(result[0].type).toBe('SNAPSHOT');
expect(result[1].id).toBe('node-C');
// Should consume the message
expect(
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
).toBe(true);
});
it('should reject a snapshot if the nodes were modified/deleted (Cache Invalidated)', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, {
target: 'incremental',
});
// Make deficit 0 so we don't fall through to the sync backstop and fail the test that way
// node-A is MISSING (user deleted it)
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const targets = [nodeB];
const messages = [
{
id: 'msg-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now(),
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<compressed A and B>',
},
},
];
const processArgs = createMockProcessArgs(targets, [], messages);
const result = await processor(processArgs);
// Because deficit is 0, and Inbox was rejected, nothing should change
expect(result.length).toBe(1);
expect(result[0].id).toBe('node-B');
expect(
(processArgs.inbox as InboxSnapshotImpl).getConsumedIds().has('msg-1'),
).toBe(false);
});
it('should fall back to sync backstop if inbox is empty', async () => {
const env = createMockEnvironment();
const processor = createStateSnapshotProcessor('StateSnapshotProcessor', env, { target: 'max' }); // Summarize all
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeA, nodeB, nodeC];
const result = await processor(createMockProcessArgs(targets));
// Should synthesize a new snapshot synchronously
expect(env.llmClient.generateContent).toHaveBeenCalled();
expect(result.length).toBe(2); // nodeA is skipped as "system prompt", snapshot + nodeA
expect(result[1].type).toBe('SNAPSHOT');
});
});
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { createStateSnapshotWorker } from './stateSnapshotWorker.js';
import {
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import { InboxSnapshotImpl } from '../sidecar/inbox.js';
describe('StateSnapshotWorker', () => {
it('should generate a snapshot and publish it to the inbox', async () => {
const env = createMockEnvironment();
// Spy on the publish method
const publishSpy = vi.spyOn(env.inbox, 'publish');
const worker = createStateSnapshotWorker('StateSnapshotWorker', env, { type: 'point-in-time' });
worker.start();
const nodeA = createDummyNode('ep1', 'USER_PROMPT', 50, {}, 'node-A');
const nodeB = createDummyNode('ep1', 'AGENT_THOUGHT', 60, {}, 'node-B');
const targets = [nodeA, nodeB];
const inbox = new InboxSnapshotImpl([]);
await worker.execute({ targets, inbox });
// Ensure generateContent was called
expect(env.llmClient.generateContent).toHaveBeenCalled();
// Verify it published to the inbox
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
consumedIds: ['node-A', 'node-B'],
type: 'point-in-time',
}),
env.idGenerator,
);
});
it('should pull previous accumulate snapshot from inbox and append new targets', async () => {
const env = createMockEnvironment();
const publishSpy = vi.spyOn(env.inbox, 'publish');
const drainSpy = vi.spyOn(env.inbox, 'drainConsumed');
const worker = createStateSnapshotWorker('StateSnapshotWorker', env, { type: 'accumulate' });
worker.start();
const nodeC = createDummyNode('ep2', 'USER_PROMPT', 50, {}, 'node-C');
const targets = [nodeC];
// Simulate an existing accumulate draft in the inbox
const inbox = new InboxSnapshotImpl([
{
id: 'draft-1',
topic: 'PROPOSED_SNAPSHOT',
timestamp: Date.now() - 1000,
payload: {
consumedIds: ['node-A', 'node-B'],
newText: '<old snapshot>',
type: 'accumulate',
},
},
]);
await worker.execute({ targets, inbox });
// The old draft should be consumed
expect(inbox.getConsumedIds().has('draft-1')).toBe(true);
expect(drainSpy).toHaveBeenCalledWith(expect.any(Set));
// The new publish should contain ALL consumed IDs (old + new)
expect(publishSpy).toHaveBeenCalledWith(
'PROPOSED_SNAPSHOT',
expect.objectContaining({
newText: 'Mock LLM summary response',
consumedIds: ['node-A', 'node-B', 'node-C'], // Aggregated!
type: 'accumulate',
}),
env.idGenerator,
);
// Verify the LLM was called with the old snapshot prepended
expect(env.llmClient.generateContent).toHaveBeenCalledWith(
expect.objectContaining({
contents: expect.arrayContaining([
expect.objectContaining({
parts: expect.arrayContaining([
expect.objectContaining({
text: expect.stringContaining('<old snapshot>'),
}),
]),
}),
]),
}),
);
});
it('should ignore empty targets', async () => {
const env = createMockEnvironment();
const publishSpy = vi.spyOn(env.inbox, 'publish');
const worker = createStateSnapshotWorker('StateSnapshotWorker', env, { type: 'accumulate' });
worker.start();
await worker.execute({ targets: [], inbox: new InboxSnapshotImpl([]) });
expect(env.llmClient.generateContent).not.toHaveBeenCalled();
expect(publishSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,68 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { createToolMaskingProcessor } from './toolMaskingProcessor.js';
import {
createMockProcessArgs,
createMockEnvironment,
createDummyToolNode,
} from '../testing/contextTestUtils.js';
import type { ToolExecution } from '../ir/types.js';
describe('ToolMaskingProcessor', () => {
it('should write large strings to disk and replace them with a masked pointer', async () => {
const env = createMockEnvironment();
// env uses charsPerToken=1 natively.
// original string lengths > stringLengthThresholdTokens (which is 10) will be masked
const processor = createToolMaskingProcessor('ToolMaskingProcessor', env, {
stringLengthThresholdTokens: 10,
});
const longString = 'A'.repeat(500); // 500 chars
const toolStep = createDummyToolNode('ep1', 50, 500, {
observation: {
result: longString,
metadata: 'short', // 5 chars, will not be masked
},
});
const result = await processor(createMockProcessArgs([toolStep]));
expect(result.length).toBe(1);
const masked = result[0] as ToolExecution;
// It should have generated a new ID because it modified it
expect(masked.id).not.toBe(toolStep.id);
// It should have masked the observation
const obs = masked.observation as { result: string; metadata: string };
expect(obs.result).toContain('<tool_output_masked>');
expect(obs.metadata).toBe('short'); // Untouched
});
it('should skip unmaskable tools', async () => {
const env = createMockEnvironment();
const processor = createToolMaskingProcessor('ToolMaskingProcessor', env, {
stringLengthThresholdTokens: 10,
});
const toolStep = createDummyToolNode('ep1', 10, 10, {
toolName: 'activate_skill',
observation: {
result:
'this is a really long string that normally would get masked but wont because of the tool name',
},
});
const result = await processor(createMockProcessArgs([toolStep]));
// Returned the exact same object reference
expect(result[0]).toBe(toolStep);
});
});
@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { SidecarLoader } from './SidecarLoader.js';
import { defaultSidecarProfile } from './profiles.js';
import { InMemoryFileSystem } from '../system/InMemoryFileSystem.js';
import type { Config } from 'src/config/config.js';
describe('SidecarLoader (Fake FS)', () => {
let fileSystem: InMemoryFileSystem;
beforeEach(() => {
fileSystem = new InMemoryFileSystem();
});
const mockConfig = {
getExperimentalContextSidecarConfig: () => '/path/to/sidecar.json',
} as unknown as Config;
it('returns default profile if file does not exist', () => {
const result = SidecarLoader.fromConfig(mockConfig, fileSystem);
expect(result).toBe(defaultSidecarProfile);
});
it('returns default profile if file exists but is 0 bytes', () => {
fileSystem.setFile('/path/to/sidecar.json', '');
const result = SidecarLoader.fromConfig(mockConfig, fileSystem);
expect(result).toBe(defaultSidecarProfile);
});
it('throws an error if file is empty whitespace', () => {
fileSystem.setFile('/path/to/sidecar.json', ' \n ');
expect(() =>
SidecarLoader.fromConfig(mockConfig, fileSystem),
).toThrow('is empty');
});
it('returns parsed config if file is valid', () => {
const validConfig = {
budget: { retainedTokens: 1000, maxTokens: 2000 },
};
fileSystem.setFile('/path/to/sidecar.json', JSON.stringify(validConfig));
const result = SidecarLoader.fromConfig(mockConfig, fileSystem);
expect(result.config.budget?.maxTokens).toBe(2000);
});
it('throws validation error if file is empty whitespace', () => {
fileSystem.setFile('/path/to/sidecar.json', ' \n ');
expect(() =>
SidecarLoader.fromConfig(mockConfig, fileSystem),
).toThrow('is empty');
});
});
@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { PipelineOrchestrator } from './orchestrator.js';
import {
createMockEnvironment,
createDummyNode,
} from '../testing/contextTestUtils.js';
import type { ContextEnvironment } from './environment.js';
import type {
ContextProcessorFn,
ContextWorker,
InboxSnapshot,
ProcessArgs,
} from '../pipeline.js';
import type { PipelineDef } from './types.js';
import type { ContextEventBus } from '../eventBus.js';
import type { ConcreteNode, UserPrompt } from '../ir/types.js';
// A realistic mock processor that modifies the text of the first target node
function createModifyingProcessor(id: string): ContextProcessorFn {
const processorFn = async (args: ProcessArgs) => {
const newTargets = [...args.targets];
if (newTargets.length > 0 && newTargets[0].type === 'USER_PROMPT') {
const prompt = newTargets[0] as UserPrompt;
const newParts = [...prompt.semanticParts];
if (newParts.length > 0 && newParts[0].type === 'text') {
newParts[0] = {
...newParts[0],
text: newParts[0].text + ' [modified]',
};
}
newTargets[0] = {
...prompt,
id: prompt.id + '-modified',
replacesId: prompt.id,
semanticParts: newParts,
};
}
return newTargets;
};
Object.defineProperty(processorFn, 'name', { value: 'ModifyingProcessor' });
return Object.assign(processorFn, { id });
}
// A processor that just throws an error
function createThrowingProcessor(id: string): ContextProcessorFn {
const processorFn = async (): Promise<readonly ConcreteNode[]> => {
throw new Error('Processor failed intentionally');
};
Object.defineProperty(processorFn, 'name', { value: 'Throwing' });
return Object.assign(processorFn, { id });
}
// A mock worker that signals it ran
function createMockWorker(id: string, executeSpy: ReturnType<typeof vi.fn>): ContextWorker {
let isRunning = false;
return {
id,
name: 'MockWorker',
triggers: {
onNodesAdded: true,
},
start: () => { isRunning = true; },
stop: () => { isRunning = false; },
execute: async (args: { targets: readonly ConcreteNode[]; inbox: InboxSnapshot }) => {
if (!isRunning) return;
executeSpy(args);
}
};
}
describe('PipelineOrchestrator (Component)', () => {
let env: ContextEnvironment;
let eventBus: ContextEventBus;
beforeEach(() => {
env = createMockEnvironment();
eventBus = env.eventBus;
});
afterEach(() => {
vi.restoreAllMocks();
});
const setupOrchestrator = (
pipelines: PipelineDef[],
workers: ContextWorker[] = [],
) => {
const orchestrator = new PipelineOrchestrator(
pipelines,
workers,
env,
eventBus,
env.tracer,
);
return orchestrator;
};
describe('Synchronous Pipeline Execution', () => {
it('applies processors in sequence on matching trigger', async () => {
const pipelines: PipelineDef[] = [
{
name: 'TestPipeline',
triggers: ['new_message'],
processors: [createModifyingProcessor('Mod')],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
assert(resultingNode.semanticParts[0].type === 'text');
expect(resultingNode.semanticParts[0].text).toBe('Original [modified]');
expect(resultingNode.replacesId).toBe(originalNode.id);
});
it('bypasses pipelines that do not match the trigger', async () => {
const pipelines: PipelineDef[] = [
{
name: 'TestPipeline',
triggers: ['gc_backstop'], // Different trigger
processors: [createModifyingProcessor('Mod')],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed).toEqual([originalNode]); // Untouched
});
it('gracefully handles a failing processor without crashing the pipeline', async () => {
const pipelines: PipelineDef[] = [
{
name: 'FailingPipeline',
triggers: ['new_message'],
processors: [createThrowingProcessor('Thrower'), createModifyingProcessor('Mod')],
},
];
const orchestrator = setupOrchestrator(pipelines);
const originalNode = createDummyNode('ep1', 'USER_PROMPT', 50, {
semanticParts: [{ type: 'text', text: 'Original' }],
});
// The throwing processor should be caught and logged, allowing Mod to still run.
const processed = await orchestrator.executeTriggerSync(
'new_message',
[originalNode],
new Set([originalNode.id]),
new Set(),
);
expect(processed.length).toBe(1);
const resultingNode = processed[0] as UserPrompt;
assert(resultingNode.semanticParts[0].type === 'text');
expect(resultingNode.semanticParts[0].text).toBe('Original [modified]');
});
});
describe('Asynchronous Worker Events', () => {
it('routes emitChunkReceived to workers with onNodesAdded trigger', async () => {
const executeSpy = vi.fn();
const worker = createMockWorker('MyWorker', executeSpy);
setupOrchestrator([], [worker]);
const node1 = createDummyNode('ep1', 'USER_PROMPT', 10);
const node2 = createDummyNode('ep1', 'AGENT_THOUGHT', 20);
eventBus.emitChunkReceived({
nodes: [node1, node2],
targetNodeIds: new Set([node2.id]),
});
// Yield event loop
await new Promise(resolve => setTimeout(resolve, 0));
expect(executeSpy).toHaveBeenCalledTimes(1);
const callArgs = executeSpy.mock.calls[0][0];
expect(callArgs.targets).toEqual([node2]); // Workers only get the target nodes
});
});
});