| import { describe, it, expect, vi } from 'vitest'; |
| import { ContextManagerImpl } from '../core/context-manager'; |
| import type { ProviderAdapter, ParsedResponse, Message } from '../core/types'; |
|
|
| function mockProvider(summaryText: string): ProviderAdapter { |
| return { |
| call: vi.fn().mockResolvedValue({ content: summaryText }), |
| getModel: () => 'test-model', |
| getProvider: () => 'test', |
| supportsTools: () => true, |
| }; |
| } |
|
|
| const defaultConfig = { |
| contextLength: 100000, |
| threshold: 60000, |
| recentKeepRatio: 0.2, |
| summaryTokenRatio: 0.1, |
| buildCompactionPrompt: (prev?: string) => prev ? `prev: ${prev}\nsummarize` : 'summarize', |
| }; |
|
|
| |
| const smallConfig = { |
| contextLength: 1000, |
| threshold: 500, |
| recentKeepRatio: 0.2, |
| summaryTokenRatio: 0.1, |
| buildCompactionPrompt: (prev?: string) => prev ? `prev: ${prev}\nsummarize` : 'summarize', |
| }; |
|
|
| describe('ContextManagerImpl', () => { |
| it('adds user message', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addUserMessage('hello'); |
| const msgs = cm.getMessages(); |
| expect(msgs).toHaveLength(1); |
| expect(msgs[0].role).toBe('user'); |
| expect(msgs[0].content).toBe('hello'); |
| }); |
|
|
| it('adds assistant turn with tool calls', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| const response: ParsedResponse = { |
| content: 'I will help', |
| toolCalls: [{ id: 'tc1', type: 'function', function: { name: 'bash', arguments: '{"command":"ls"}' } }], |
| }; |
| cm.addAssistantTurn(response); |
| const msgs = cm.getMessages(); |
| expect(msgs[0].role).toBe('assistant'); |
| expect(msgs[0].tool_calls).toHaveLength(1); |
| }); |
|
|
| it('adds tool results', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addToolResults([{ tool_call_id: 'tc1', content: 'file.txt', success: true }]); |
| const msgs = cm.getMessages(); |
| expect(msgs[0].role).toBe('tool'); |
| expect(msgs[0].tool_call_id).toBe('tc1'); |
| }); |
|
|
| it('imports messages', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addUserMessage('old message'); |
| const imported: Message[] = [ |
| { role: 'system', content: 'sys' }, |
| { role: 'user', content: 'imported' }, |
| ]; |
| cm.importMessages(imported); |
| expect(cm.getMessages()).toHaveLength(2); |
| expect(cm.getMessages()[1].content).toBe('imported'); |
| }); |
|
|
| it('sets system prompt', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.setSystemPrompt('you are helpful'); |
| expect(cm.getMessages()[0]).toEqual({ role: 'system', content: 'you are helpful' }); |
| cm.setSystemPrompt('updated'); |
| expect(cm.getMessages()[0].content).toBe('updated'); |
| expect(cm.getMessages()).toHaveLength(1); |
| }); |
|
|
| it('reports needsCompaction correctly', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| expect(cm.needsCompaction(50000)).toBe(false); |
| expect(cm.needsCompaction(60000)).toBe(true); |
| expect(cm.needsCompaction(70000)).toBe(true); |
| }); |
|
|
| it('calls onMessageAdded hook', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| const hook = vi.fn(); |
| cm.onMessageAdded = hook; |
| cm.addUserMessage('test'); |
| expect(hook).toHaveBeenCalledWith(expect.objectContaining({ role: 'user', content: 'test' })); |
| }); |
|
|
| it('estimates token count', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addUserMessage('hello world'); |
| const estimate = cm.getTokenEstimate(); |
| expect(estimate).toBeGreaterThan(0); |
| expect(estimate).toBe(Math.round(11 / 3.5)); |
| }); |
|
|
| it('estimates tokens for tool calls', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addAssistantTurn({ |
| content: 'ok', |
| toolCalls: [{ id: 'tc1', type: 'function', function: { name: 'bash', arguments: '{"command":"ls -la"}' } }], |
| }); |
| const estimate = cm.getTokenEstimate(); |
| |
| expect(estimate).toBe(Math.round(22 / 3.5)); |
| }); |
|
|
| it('compacts conversation via provider', async () => { |
| const cm = new ContextManagerImpl(smallConfig); |
| cm.setSystemPrompt('system prompt'); |
| |
| for (let i = 0; i < 10; i++) { |
| cm.addUserMessage(`user msg ${i} - ${'x'.repeat(200)}`); |
| cm.addAssistantTurn({ content: `response ${i} - ${'y'.repeat(200)}` }); |
| } |
| const provider = mockProvider('Summary of conversation'); |
| await cm.compact(provider, { freshSystemPrompt: 'fresh system prompt' }); |
| const msgs = cm.getMessages(); |
| |
| expect(msgs[0].role).toBe('system'); |
| expect(msgs[0].content).toBe('fresh system prompt'); |
| |
| expect(msgs.some(m => typeof m.content === 'string' && m.content.includes('Summary of conversation'))).toBe(true); |
| |
| expect(provider.call).toHaveBeenCalled(); |
| }); |
|
|
| it('uses config.getFreshContext when compact() is called without explicit opts', async () => { |
| const cm = new ContextManagerImpl({ |
| ...smallConfig, |
| getFreshContext: async () => ({ systemPrompt: 'FRESH SYSTEM', projectContext: 'FRESH PROJECT CONTEXT' }), |
| }); |
| cm.setSystemPrompt('stale system prompt'); |
| for (let i = 0; i < 10; i++) { |
| cm.addUserMessage(`user msg ${i} - ${'x'.repeat(200)}`); |
| cm.addAssistantTurn({ content: `response ${i} - ${'y'.repeat(200)}` }); |
| } |
| const provider = mockProvider('Summary of conversation'); |
| await cm.compact(provider); |
| const msgs = cm.getMessages(); |
| expect(msgs[0].content).toBe('FRESH SYSTEM'); |
| expect(typeof msgs[1].content === 'string' && msgs[1].content.includes('FRESH PROJECT CONTEXT')).toBe(true); |
| }); |
|
|
| it('compaction skips when too few messages', async () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.setSystemPrompt('system'); |
| cm.addUserMessage('only message'); |
| const provider = mockProvider('should not be called'); |
| await cm.compact(provider, { freshSystemPrompt: 'fresh' }); |
| |
| expect(provider.call).not.toHaveBeenCalled(); |
| |
| expect(cm.getMessages()).toHaveLength(2); |
| }); |
|
|
| it('compaction includes project context when provided', async () => { |
| const cm = new ContextManagerImpl(smallConfig); |
| cm.setSystemPrompt('system'); |
| for (let i = 0; i < 10; i++) { |
| cm.addUserMessage(`user msg ${i} - ${'x'.repeat(200)}`); |
| cm.addAssistantTurn({ content: `response ${i} - ${'y'.repeat(200)}` }); |
| } |
| const provider = mockProvider('Summary'); |
| await cm.compact(provider, { freshSystemPrompt: 'fresh', projectContext: 'file tree here' }); |
| const msgs = cm.getMessages(); |
| |
| const contextMsg = msgs.find(m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('file tree here')); |
| expect(contextMsg).toBeDefined(); |
| }); |
|
|
| it('compaction increments count', async () => { |
| const cm = new ContextManagerImpl(smallConfig); |
| cm.setSystemPrompt('system'); |
| for (let i = 0; i < 10; i++) { |
| cm.addUserMessage(`user msg ${i} - ${'x'.repeat(200)}`); |
| cm.addAssistantTurn({ content: `response ${i} - ${'y'.repeat(200)}` }); |
| } |
| const provider = mockProvider('Summary'); |
| expect(cm.getCompactionCount()).toBe(0); |
| await cm.compact(provider, { freshSystemPrompt: 'fresh' }); |
| expect(cm.getCompactionCount()).toBe(1); |
| }); |
|
|
| it('compaction fails gracefully on empty provider response', async () => { |
| const cm = new ContextManagerImpl(smallConfig); |
| cm.setSystemPrompt('system'); |
| for (let i = 0; i < 10; i++) { |
| cm.addUserMessage(`user msg ${i} - ${'x'.repeat(200)}`); |
| cm.addAssistantTurn({ content: `response ${i} - ${'y'.repeat(200)}` }); |
| } |
| const provider = mockProvider(''); |
| const msgsBefore = cm.getMessages().length; |
| await cm.compact(provider, { freshSystemPrompt: 'fresh' }); |
| |
| expect(cm.getMessages().length).toBe(msgsBefore); |
| }); |
|
|
| it('repairs orphan tool calls', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| |
| cm.addAssistantTurn({ |
| content: 'let me check', |
| toolCalls: [{ id: 'tc1', type: 'function', function: { name: 'bash', arguments: '{"command":"ls"}' } }], |
| }); |
| |
| cm.addUserMessage('next message'); |
|
|
| const sanitized = cm.getSanitizedMessages(); |
| |
| const toolMsg = sanitized.find(m => m.role === 'tool' && m.tool_call_id === 'tc1'); |
| expect(toolMsg).toBeDefined(); |
| expect(toolMsg!.content).toContain('cancelled'); |
| }); |
|
|
| it('repairs tool calls with empty arguments', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addAssistantTurn({ |
| content: 'text content', |
| toolCalls: [ |
| { id: 'tc1', type: 'function', function: { name: 'bash', arguments: '' } }, |
| { id: 'tc2', type: 'function', function: { name: 'bash', arguments: '{"command":"ls"}' } }, |
| ], |
| }); |
| cm.addToolResults([{ tool_call_id: 'tc2', content: 'result', success: true }]); |
|
|
| const sanitized = cm.getSanitizedMessages(); |
| |
| const assistantMsg = sanitized.find(m => m.role === 'assistant'); |
| expect(assistantMsg?.tool_calls).toHaveLength(1); |
| expect(assistantMsg?.tool_calls![0].id).toBe('tc2'); |
| }); |
|
|
| it('drops assistant message entirely when all tool calls have empty args and no content', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addAssistantTurn({ |
| content: '', |
| toolCalls: [{ id: 'tc1', type: 'function', function: { name: 'bash', arguments: '' } }], |
| }); |
| cm.addUserMessage('next'); |
|
|
| const sanitized = cm.getSanitizedMessages(); |
| |
| expect(sanitized.find(m => m.role === 'assistant')).toBeUndefined(); |
| expect(sanitized).toHaveLength(1); |
| expect(sanitized[0].role).toBe('user'); |
| }); |
|
|
| it('handles multimodal content blocks in user message', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| const blocks = [ |
| { type: 'text' as const, text: 'describe this' }, |
| { type: 'image_url' as const, image_url: { url: 'data:image/png;base64,abc' } }, |
| ]; |
| cm.addUserMessage(blocks); |
| const msgs = cm.getMessages(); |
| expect(msgs[0].content).toEqual(blocks); |
| }); |
|
|
| it('preserves reasoning_details on assistant turns', () => { |
| const cm = new ContextManagerImpl(defaultConfig); |
| cm.addAssistantTurn({ |
| content: 'thought about it', |
| reasoningDetails: [{ type: 'thinking', text: 'internal reasoning' }], |
| }); |
| const msgs = cm.getMessages(); |
| expect(msgs[0].reasoning_details).toHaveLength(1); |
| expect(msgs[0].reasoning_details![0].text).toBe('internal reasoning'); |
| }); |
| }); |
|
|