mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 12:57:12 -07:00
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:
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user