import { HumanMessage, AIMessage, SystemMessage, ToolMessage, } from '@langchain/core/messages'; import { buildContextAnalytics } from './contextAnalytics'; describe('buildContextAnalytics', () => { describe('with proper BaseMessage instances', () => { it('should count messages and compute breakdown by type', () => { const messages = [ new SystemMessage('You are helpful'), new HumanMessage('Hello'), new AIMessage('Hi there'), ]; const result = buildContextAnalytics(messages, {}); expect(result.messageCount).toBe(3); expect(result.breakdown).toBeDefined(); expect(result.breakdown!['system']).toBeDefined(); expect(result.breakdown!['human']).toBeDefined(); expect(result.breakdown!['ai']).toBeDefined(); }); it('should use tokenCounter when provided', () => { const messages = [ new HumanMessage('Hello world'), new AIMessage('Response'), ]; const tokenCounter = jest.fn().mockReturnValue(10); const result = buildContextAnalytics(messages, { tokenCounter }); expect(tokenCounter).toHaveBeenCalledTimes(2); expect(result.totalTokens).toBe(20); }); it('should prefer indexTokenCountMap over tokenCounter', () => { const messages = [new HumanMessage('Hello')]; const tokenCounter = jest.fn().mockReturnValue(999); const result = buildContextAnalytics(messages, { tokenCounter, indexTokenCountMap: { '0': 42 }, }); expect(tokenCounter).not.toHaveBeenCalled(); expect(result.totalTokens).toBe(42); }); it('should calculate utilization percentage', () => { const messages = [new HumanMessage('Hello')]; const result = buildContextAnalytics(messages, { tokenCounter: () => 500, maxContextTokens: 1000, }); expect(result.utilizationPercent).toBe(50); }); it('should handle ToolMessage instances', () => { const messages = [ new HumanMessage('Do something'), new AIMessage({ content: '', tool_calls: [{ id: 'tc1', name: 'test', args: {} }], }), new ToolMessage({ content: 'result', tool_call_id: 'tc1' }), ]; const result = buildContextAnalytics(messages, {}); expect(result.messageCount).toBe(3); expect(result.breakdown!['tool']).toBeDefined(); }); it('should calculate correct percentage breakdown', () => { const messages = [new HumanMessage('Hello'), new AIMessage('World')]; const result = buildContextAnalytics(messages, { tokenCounter: () => 50, maxContextTokens: 1000, }); expect(result.breakdown!['human'].percent).toBe(50); expect(result.breakdown!['ai'].percent).toBe(50); expect(result.utilizationPercent).toBe(10); }); it('should pass through maxContextTokens and instructionTokens', () => { const result = buildContextAnalytics([], { maxContextTokens: 4096, instructionTokens: 200, }); expect(result.maxContextTokens).toBe(4096); expect(result.instructionTokens).toBe(200); expect(result.messageCount).toBe(0); expect(result.totalTokens).toBe(0); }); }); describe('with cross-package or plain objects (defensive handling)', () => { it('should resolve type via _getType when getType is missing', () => { // Simulates a message from a different @langchain/core installation // where instanceof fails but _getType still works const fakeMsg = { content: 'hello', _getType: (): string => 'human', // Deliberately no getType }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = buildContextAnalytics([fakeMsg as any], { tokenCounter: () => 5, }); expect(result.messageCount).toBe(1); expect(result.breakdown!['human']).toBeDefined(); expect(result.breakdown!['human'].tokens).toBe(5); }); it('should fall back to type field when both getType and _getType are missing', () => { // Simulates a fully serialized message (e.g., from JSON deserialization) const plainObj = { content: 'test message', type: 'ai', }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = buildContextAnalytics([plainObj as any], { tokenCounter: () => 10, }); expect(result.breakdown!['ai']).toBeDefined(); }); it('should label as unknown when no type information is available', () => { const bareObj = { content: 'orphan' }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = buildContextAnalytics([bareObj as any], { tokenCounter: () => 3, }); expect(result.breakdown!['unknown']).toBeDefined(); expect(result.totalTokens).toBe(3); }); it('should estimate tokens from content when tokenCounter throws', () => { const messages = [new HumanMessage('twelve chars')]; // 12 chars -> ceil(12/4) = 3 const tokenCounter = jest.fn(() => { throw new Error('counter failed'); }); const result = buildContextAnalytics(messages, { tokenCounter }); expect(result.totalTokens).toBe(Math.ceil('twelve chars'.length / 4)); }); it('should estimate tokens from JSON-stringified content for structured content', () => { const msg = new HumanMessage({ content: [{ type: 'text', text: 'hi' }], }); const tokenCounter = jest.fn(() => { throw new Error('counter failed'); }); const result = buildContextAnalytics([msg], { tokenCounter }); const expectedContent = JSON.stringify([{ type: 'text', text: 'hi' }]); expect(result.totalTokens).toBe(Math.ceil(expectedContent.length / 4)); }); }); describe('edge cases', () => { it('should handle empty messages array', () => { const result = buildContextAnalytics([], {}); expect(result.messageCount).toBe(0); expect(result.totalTokens).toBe(0); expect(result.breakdown).toEqual({}); }); it('should handle zero maxContextTokens without dividing by zero', () => { const result = buildContextAnalytics([new HumanMessage('x')], { tokenCounter: () => 100, maxContextTokens: 0, }); expect(result.utilizationPercent).toBeUndefined(); }); it('should handle undefined maxContextTokens', () => { const result = buildContextAnalytics([new HumanMessage('x')], { tokenCounter: () => 100, }); expect(result.utilizationPercent).toBeUndefined(); }); it('should handle large message arrays efficiently', () => { const messages = Array.from({ length: 1000 }, (_, i) => i % 2 === 0 ? new HumanMessage(`msg ${i}`) : new AIMessage(`reply ${i}`) ); const start = Date.now(); const result = buildContextAnalytics(messages, { tokenCounter: () => 10, }); const elapsed = Date.now() - start; expect(result.messageCount).toBe(1000); expect(result.totalTokens).toBe(10000); // Should complete in well under 1 second expect(elapsed).toBeLessThan(1000); }); }); });