| |
| |
| |
| |
| |
|
|
| import { describe, it, expect, vi } from 'vitest'; |
| import { render } from './render.js'; |
| import type { ConcreteNode } from './types.js'; |
| import { NodeType } from './types.js'; |
| import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js'; |
| import type { ContextEnvironment } from '../pipeline/environment.js'; |
| import type { ContextTracer } from '../tracer.js'; |
| import type { ContextProfile } from '../config/profiles.js'; |
| import type { PipelineOrchestrator } from '../pipeline/orchestrator.js'; |
| import type { Part } from '@google/genai'; |
|
|
| import { ContextWorkingBufferImpl } from '../pipeline/contextWorkingBuffer.js'; |
|
|
| describe('render', () => { |
| it('should render all provided nodes', async () => { |
| const mockNodes: ConcreteNode[] = [ |
| { |
| id: '1', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: '2', |
| type: NodeType.AGENT_THOUGHT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'preview-1', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| ]; |
|
|
| const orchestrator = {} as PipelineOrchestrator; |
| const sidecar = { config: {} } as ContextProfile; |
| const mockAdvancedTokenCalculator = { |
| calculateTokensAndBaseUnits: vi.fn().mockReturnValue({ |
| tokens: 100, |
| baseUnits: 100, |
| }), |
| getRawBaseUnits: vi.fn().mockReturnValue(100), |
| calculateConcreteListTokens: vi.fn().mockReturnValue(100), |
| getRawBaseUnitsForContent: vi.fn().mockReturnValue(0), |
| }; |
|
|
| const env = { |
| tokenCalculator: { |
| calculateConcreteListTokens: vi.fn().mockReturnValue(100), |
| calculateTokenBreakdown: vi.fn().mockReturnValue({}), |
| }, |
| graphMapper: { |
| fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => |
| nodes.map((n) => ({ text: n.id })), |
| ), |
| }, |
| } as unknown as ContextEnvironment; |
| const tracer = { |
| logEvent: vi.fn(), |
| } as unknown as ContextTracer; |
|
|
| const result = await render( |
| mockNodes, |
| orchestrator, |
| sidecar, |
| tracer, |
| env, |
| mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, |
| { |
| protectionReasons: new Map(), |
| header: undefined, |
| }, |
| ); |
|
|
| expect(result.history).toEqual([ |
| { text: '1' }, |
| { text: '2' }, |
| { text: 'preview-1' }, |
| ]); |
| expect(result.baseUnits).toBe(100); |
| }); |
|
|
| it('simulates the boundary knapsack problem (loose boundary policy)', async () => { |
| |
| const mockNodes: ConcreteNode[] = [ |
| { |
| id: 'D', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'C', |
| type: NodeType.AGENT_THOUGHT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'B', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'A', |
| type: NodeType.AGENT_THOUGHT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| ]; |
|
|
| const tokenMap: Record<string, number> = { |
| D: 5000, |
| C: 40000, |
| B: 20000, |
| A: 10000, |
| }; |
|
|
| const orchestrator = { |
| executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => { |
| const filteredNodes = buffer.nodes.filter( |
| (n: ConcreteNode) => !agedOutNodes.has(n.id), |
| ); |
| return ContextWorkingBufferImpl.initialize(filteredNodes); |
| }), |
| } as unknown as PipelineOrchestrator; |
|
|
| const sidecar = { |
| config: { |
| budget: { maxTokens: 150000, retainedTokens: 65000 }, |
| }, |
| } as unknown as ContextProfile; |
|
|
| const currentTokens = 160000; |
|
|
| const mockAdvancedTokenCalculator = { |
| calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => { |
| const tokens = |
| nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens; |
| return { tokens, baseUnits: tokens }; |
| }), |
| getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| }; |
|
|
| const env = { |
| llmClient: { |
| countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }), |
| }, |
| tokenCalculator: { |
| calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| calculateTokenBreakdown: vi.fn(() => ({})), |
| }, |
| graphMapper: { |
| fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => |
| nodes.map((n) => ({ text: n.id })), |
| ), |
| }, |
| } as unknown as ContextEnvironment; |
|
|
| const tracer = { |
| logEvent: vi.fn(), |
| } as unknown as ContextTracer; |
|
|
| const result = await render( |
| mockNodes, |
| orchestrator, |
| sidecar, |
| tracer, |
| env, |
| mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, |
| { |
| protectionReasons: new Map(), |
| header: undefined, |
| }, |
| ); |
|
|
| |
| const surviving = result.history.map((c: any) => c.text); |
| |
| |
| |
| expect(surviving).toEqual(['C', 'B', 'A']); |
| expect(result.baseUnits).toBe(160000); |
| }); |
|
|
| it('drops nodes that are STRICTLY older than the boundary node', async () => { |
| const mockNodes: ConcreteNode[] = [ |
| { |
| id: 'A', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'B', |
| type: NodeType.AGENT_THOUGHT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: 'C', |
| type: NodeType.USER_PROMPT, |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| ]; |
|
|
| const tokenMap: Record<string, number> = { |
| C: 40000, |
| B: 40000, |
| A: 10000, |
| }; |
|
|
| const orchestrator = { |
| executeTriggerSync: vi.fn(async (trigger, buffer, agedOutNodes) => { |
| const filteredNodes = buffer.nodes.filter( |
| (n: ConcreteNode) => !agedOutNodes.has(n.id), |
| ); |
| return ContextWorkingBufferImpl.initialize(filteredNodes); |
| }), |
| } as unknown as PipelineOrchestrator; |
|
|
| const sidecar = { |
| config: { |
| budget: { maxTokens: 150000, retainedTokens: 65000 }, |
| }, |
| } as unknown as ContextProfile; |
|
|
| const currentTokens = 160000; |
|
|
| const mockAdvancedTokenCalculator = { |
| calculateTokensAndBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => { |
| const tokens = |
| nodes.length === 1 ? tokenMap[nodes[0].id] : currentTokens; |
| return { tokens, baseUnits: tokens }; |
| }), |
| getRawBaseUnits: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| }; |
|
|
| const env = { |
| llmClient: { |
| countTokens: vi.fn().mockResolvedValue({ totalTokens: 1000 }), |
| }, |
| tokenCalculator: { |
| calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { |
| if (nodes.length === 1) return tokenMap[nodes[0].id]; |
| return currentTokens; |
| }), |
| calculateTokenBreakdown: vi.fn(() => ({})), |
| }, |
| graphMapper: { |
| fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => |
| nodes.map((n) => ({ text: n.id })), |
| ), |
| }, |
| } as unknown as ContextEnvironment; |
|
|
| const tracer = { |
| logEvent: vi.fn(), |
| } as unknown as ContextTracer; |
|
|
| const result = await render( |
| mockNodes, |
| orchestrator, |
| sidecar, |
| tracer, |
| env, |
| mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, |
| { |
| protectionReasons: new Map(), |
| header: undefined, |
| }, |
| ); |
|
|
| |
| const surviving = result.history.map((c: any) => c.text); |
| |
| expect(surviving).toEqual(['B', 'C']); |
| expect(result.baseUnits).toBe(160000); |
| }); |
|
|
| it('should exclude the last turn when lateBindPrompt is true', async () => { |
| const mockNodes: ConcreteNode[] = [ |
| { |
| id: '1', |
| type: NodeType.USER_PROMPT, |
| turnId: 'turn-1', |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| { |
| id: '2', |
| type: NodeType.AGENT_THOUGHT, |
| turnId: 'turn-2', |
| payload: {} as Part, |
| } as unknown as ConcreteNode, |
| ]; |
|
|
| const orchestrator = { |
| executeTriggerSync: vi.fn(async (trigger, buffer) => buffer), |
| } as unknown as PipelineOrchestrator; |
| const sidecar = { config: {} } as ContextProfile; |
| const mockAdvancedTokenCalculator = { |
| calculateTokensAndBaseUnits: vi.fn().mockReturnValue({ |
| tokens: 100, |
| baseUnits: 100, |
| }), |
| getRawBaseUnits: vi.fn().mockReturnValue(50), |
| calculateConcreteListTokens: vi.fn().mockReturnValue(100), |
| getRawBaseUnitsForContent: vi.fn().mockReturnValue(0), |
| }; |
|
|
| const env = { |
| tokenCalculator: { |
| calculateConcreteListTokens: vi.fn().mockReturnValue(100), |
| calculateTokenBreakdown: vi.fn().mockReturnValue({}), |
| }, |
| graphMapper: { |
| fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => |
| nodes.map((n) => ({ text: n.id })), |
| ), |
| }, |
| } as unknown as ContextEnvironment; |
| const tracer = { |
| logEvent: vi.fn(), |
| } as unknown as ContextTracer; |
|
|
| const result = await render( |
| mockNodes, |
| orchestrator, |
| sidecar, |
| tracer, |
| env, |
| mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, |
| { |
| lateBindPrompt: true, |
| }, |
| ); |
|
|
| expect(result.history).toEqual([{ text: '1' }]); |
| expect(result.pendingHistory).toEqual([{ text: '2' }]); |
| expect(result.baseUnits).toBe(50); |
| }); |
| }); |
|
|