From 0ebc369d2c96daf63ba9303cac8155d6b982a5b7 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 9 Apr 2026 23:29:35 +0000 Subject: [PATCH] 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. --- .../blobDegradationProcessor.test.ts | 101 +++++++++ .../nodeDistillationProcessor.test.ts | 140 ++++++++++++ .../nodeTruncationProcessor.test.ts | 128 +++++++++++ .../rollingSummaryProcessor.test.ts | 86 +++++++ .../processors/stateSnapshotProcessor.test.ts | 115 ++++++++++ .../processors/stateSnapshotWorker.test.ts | 115 ++++++++++ .../processors/toolMaskingProcessor.test.ts | 68 ++++++ .../src/context/sidecar/SidecarLoader.test.ts | 57 +++++ .../src/context/sidecar/orchestrator.test.ts | 210 ++++++++++++++++++ 9 files changed, 1020 insertions(+) create mode 100644 packages/core/src/context/processors/blobDegradationProcessor.test.ts create mode 100644 packages/core/src/context/processors/nodeDistillationProcessor.test.ts create mode 100644 packages/core/src/context/processors/nodeTruncationProcessor.test.ts create mode 100644 packages/core/src/context/processors/rollingSummaryProcessor.test.ts create mode 100644 packages/core/src/context/processors/stateSnapshotProcessor.test.ts create mode 100644 packages/core/src/context/processors/stateSnapshotWorker.test.ts create mode 100644 packages/core/src/context/processors/toolMaskingProcessor.test.ts create mode 100644 packages/core/src/context/sidecar/SidecarLoader.test.ts create mode 100644 packages/core/src/context/sidecar/orchestrator.test.ts diff --git a/packages/core/src/context/processors/blobDegradationProcessor.test.ts b/packages/core/src/context/processors/blobDegradationProcessor.test.ts new file mode 100644 index 0000000000..60c82f45d6 --- /dev/null +++ b/packages/core/src/context/processors/blobDegradationProcessor.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/context/processors/nodeDistillationProcessor.test.ts b/packages/core/src/context/processors/nodeDistillationProcessor.test.ts new file mode 100644 index 0000000000..0ef16fcf88 --- /dev/null +++ b/packages/core/src/context/processors/nodeDistillationProcessor.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/context/processors/nodeTruncationProcessor.test.ts b/packages/core/src/context/processors/nodeTruncationProcessor.test.ts new file mode 100644 index 0000000000..1d88ba4e48 --- /dev/null +++ b/packages/core/src/context/processors/nodeTruncationProcessor.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/context/processors/rollingSummaryProcessor.test.ts b/packages/core/src/context/processors/rollingSummaryProcessor.test.ts new file mode 100644 index 0000000000..4567d73d18 --- /dev/null +++ b/packages/core/src/context/processors/rollingSummaryProcessor.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.test.ts b/packages/core/src/context/processors/stateSnapshotProcessor.test.ts new file mode 100644 index 0000000000..63aab68ff7 --- /dev/null +++ b/packages/core/src/context/processors/stateSnapshotProcessor.test.ts @@ -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: '', + 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: '', + }, + }, + ]; + + 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'); + }); +}); diff --git a/packages/core/src/context/processors/stateSnapshotWorker.test.ts b/packages/core/src/context/processors/stateSnapshotWorker.test.ts new file mode 100644 index 0000000000..cd262df2f1 --- /dev/null +++ b/packages/core/src/context/processors/stateSnapshotWorker.test.ts @@ -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: '', + 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(''), + }), + ]), + }), + ]), + }), + ); + }); + + 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(); + }); +}); diff --git a/packages/core/src/context/processors/toolMaskingProcessor.test.ts b/packages/core/src/context/processors/toolMaskingProcessor.test.ts new file mode 100644 index 0000000000..0356dabc90 --- /dev/null +++ b/packages/core/src/context/processors/toolMaskingProcessor.test.ts @@ -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(''); + 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); + }); +}); diff --git a/packages/core/src/context/sidecar/SidecarLoader.test.ts b/packages/core/src/context/sidecar/SidecarLoader.test.ts new file mode 100644 index 0000000000..9a3a06ee1f --- /dev/null +++ b/packages/core/src/context/sidecar/SidecarLoader.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/context/sidecar/orchestrator.test.ts b/packages/core/src/context/sidecar/orchestrator.test.ts new file mode 100644 index 0000000000..3e10c0b98d --- /dev/null +++ b/packages/core/src/context/sidecar/orchestrator.test.ts @@ -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 => { + 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): 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 + }); + }); +});