import { beforeEach, describe, expect, it, vi } from 'vitest' import { SummaryCache } from '../../packages/server/src/services/hermes/context-engine/summary-cache' import { buildAgentInstructions, buildSummarizationSystemPrompt, buildFullSummaryPrompt, buildIncrementalUpdatePrompt, } from '../../packages/server/src/services/hermes/context-engine/prompt' import { ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor' import type { StoredMessage, MessageFetcher, GatewayCaller } from '../../packages/server/src/services/hermes/context-engine/types' // ─── Helpers ───────────────────────────────────────────────── function makeMessage(overrides: Partial = {}): StoredMessage { return { id: 'msg-1', roomId: 'room-1', senderId: 'user-1', senderName: 'Alice', content: 'Hello world', timestamp: 1000, ...overrides, } } function makeMessages(count: number, roomId = 'room-1', startTimestamp = 1000): StoredMessage[] { return Array.from({ length: count }, (_, i) => makeMessage({ id: `msg-${i}`, roomId, senderId: i % 3 === 0 ? 'agent-socket' : `user-${i}`, senderName: i % 3 === 0 ? 'Claude' : `User${i}`, content: `Message ${i} with some content`, timestamp: startTimestamp + i * 1000, })) } // ─── SummaryCache ───────────────────────────────────────────── describe('SummaryCache', () => { it('stores and retrieves entries', () => { const cache = new SummaryCache(60_000) cache.set('room-1', { summary: 'Summary text', lastMessageId: 'msg-10', lastMessageTimestamp: 5000, createdAt: Date.now(), }) const entry = cache.get('room-1') expect(entry).toBeDefined() expect(entry!.summary).toBe('Summary text') }) it('returns undefined for expired entries', () => { const cache = new SummaryCache(100) // 100ms TTL cache.set('room-1', { summary: 'Old summary', lastMessageId: 'msg-5', lastMessageTimestamp: 5000, createdAt: Date.now() - 200, // created 200ms ago }) expect(cache.get('room-1')).toBeUndefined() }) it('invalidates entries for a room', () => { const cache = new SummaryCache(60_000) cache.set('room-1', { summary: 'A', lastMessageId: 'msg-1', lastMessageTimestamp: 1000, createdAt: Date.now() }) cache.set('room-2', { summary: 'C', lastMessageId: 'msg-3', lastMessageTimestamp: 3000, createdAt: Date.now() }) cache.invalidate('room-1') expect(cache.get('room-1')).toBeUndefined() expect(cache.get('room-2')).toBeDefined() }) it('enforces max entry limit', () => { const cache = new SummaryCache(60_000) // Fill cache beyond limit (internal MAX_ENTRIES = 200) for (let i = 0; i < 210; i++) { cache.set(`room-${i}`, { summary: `Summary ${i}`, lastMessageId: `msg-${i}`, lastMessageTimestamp: i * 1000, createdAt: Date.now() - (210 - i), // earlier entries have older createdAt }) } // Cache should not exceed 200 entries expect(cache.size).toBeLessThanOrEqual(200) }) }) // ─── Prompts ────────────────────────────────────────────────── describe('prompts', () => { it('builds agent instructions with all fields', () => { const result = buildAgentInstructions({ agentName: 'Claude', roomName: 'general', agentDescription: 'AI coding assistant', memberNames: ['Alice', 'Bob', 'Claude'], members: [ { userId: 'u1', name: 'Alice', description: 'dev' }, { userId: 'u2', name: 'Bob', description: 'designer' }, { userId: 'u3', name: 'Claude', description: '' }, ], }) expect(result).toContain('"Claude"') expect(result).toContain('general') expect(result).toContain('AI coding assistant') expect(result).toContain('Alice') expect(result).toContain('Bob') expect(result).toContain('- Claude') expect(result).not.toContain('@Claude') }) it('builds agent instructions with empty member list', () => { const result = buildAgentInstructions({ agentName: 'GPT', roomName: 'dev', agentDescription: 'Helper', memberNames: [], members: [], }) expect(result).toContain('"GPT"') expect(result).toContain('未知') }) it('builds agent instructions using memberNames when members is empty', () => { const result = buildAgentInstructions({ agentName: 'GPT', roomName: 'dev', agentDescription: 'Helper', memberNames: ['Alice', 'Bob'], members: [], }) expect(result).toContain('Alice') expect(result).toContain('Bob') }) it('builds summarization system prompt', () => { const result = buildSummarizationSystemPrompt() expect(result).toContain('摘要') }) it('builds full summary prompt', () => { const result = buildFullSummaryPrompt() expect(result).toContain('摘要') }) it('builds incremental update prompt', () => { const result = buildIncrementalUpdatePrompt() expect(result).toContain('更新') }) }) // ─── ContextEngine.buildContext ──────────────────────────────── describe('ContextEngine.buildContext', () => { let mockSummarize = vi.fn().mockResolvedValue({ summary: 'Summary of conversation.', sessionId: 'comp-1' }) const mockGatewayCaller: GatewayCaller = { summarize: mockSummarize, } let mockFetcher: MessageFetcher let engine: ContextEngine beforeEach(() => { vi.clearAllMocks() mockFetcher = { getMessages: vi.fn().mockReturnValue([]), getContextSnapshot: vi.fn().mockReturnValue(null), saveContextSnapshot: vi.fn(), deleteContextSnapshot: vi.fn(), } engine = new ContextEngine({ config: { maxHistoryTokens: 4000, tailMessageCount: 10, triggerTokens: 100_000, charsPerToken: 4, summarizationTimeoutMs: 30_000 }, messageFetcher: mockFetcher, gatewayCaller: { summarize: mockSummarize }, }) }) it('returns all messages as history when under threshold', async () => { const messages = makeMessages(10) // 10 messages, under trigger threshold mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: ['Alice'], members: [{ userId: 'u1', name: 'Alice', description: '' }], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) expect(result.meta.totalMessages).toBe(10) expect(result.meta.compressed).toBe(false) expect(result.conversationHistory).toHaveLength(10) expect(result.instructions).toContain('Claude') // No LLM call for short conversations expect(mockSummarize).not.toHaveBeenCalled() }) it('records full context token estimates without compressing when under threshold', async () => { const messages = makeMessages(3) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const contextTokenEstimator = vi.fn().mockResolvedValue(19_379) const onProgress = vi.fn() const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: ['Alice'], members: [{ userId: 'u1', name: 'Alice', description: '' }], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], contextTokenEstimator, onProgress, }) expect(result.meta.compressed).toBe(false) expect(result.meta.contextTokenEstimate).toBe(19_379) expect(result.meta.messageTokenEstimate).toBeGreaterThan(0) expect(contextTokenEstimator).toHaveBeenCalledWith( expect.arrayContaining([{ role: 'assistant', content: expect.stringContaining('[Claude]') }]), expect.stringContaining('"Claude"'), ) expect(mockSummarize).not.toHaveBeenCalled() expect(onProgress).not.toHaveBeenCalled() }) it('uses full context token estimates to trigger group compression', async () => { const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const onProgress = vi.fn() const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), onProgress, }) expect(result.meta.compressed).toBe(true) expect(result.meta.contextTokenEstimate).toBe(120_000) expect(mockSummarize).toHaveBeenCalledTimes(1) expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledTimes(1) expect(onProgress).toHaveBeenCalledWith({ status: 'compressing', path: 'full', messageCount: 20, tokenCount: 120_000, }) }) it('throws when group prompt and tools exceed threshold with too little history to compress', async () => { const messages = makeMessages(4) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) await expect(engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), })).rejects.toThrow('Context window is too small') expect(mockSummarize).not.toHaveBeenCalled() expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() }) it('throws on snapshot path when overhead plus new messages exceed threshold without compressible history', async () => { const messages = makeMessages(12) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({ roomId: 'room-1', summary: 'Existing summary', lastMessageId: 'msg-9', lastMessageTimestamp: messages[9].timestamp, updatedAt: Date.now(), }) await expect(engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], contextTokenEstimator: vi.fn().mockResolvedValue(120_000), })).rejects.toThrow('Context window is too small') expect(mockSummarize).not.toHaveBeenCalled() expect(mockFetcher.saveContextSnapshot).not.toHaveBeenCalled() }) it('splits into head/tail and compresses middle when over threshold', async () => { const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Helper', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, // Force compression with tiny threshold }) expect(result.meta.totalMessages).toBe(20) expect(result.meta.compressed).toBe(true) expect(mockSummarize).toHaveBeenCalledTimes(1) }) it('uses cache hit when available and no new messages', async () => { const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) // First call — creates snapshot (with forced compression) await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) // Verify snapshot was saved expect(mockFetcher.saveContextSnapshot).toHaveBeenCalledTimes(1) // Simulate that the snapshot now exists in storage const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0] mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({ roomId: 'room-1', summary: savedSnapshot[1], lastMessageId: savedSnapshot[2], lastMessageTimestamp: savedSnapshot[3], updatedAt: Date.now(), }) // Second call — cache hit (snapshot exists, same messages) const result2 = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) expect(result2.meta.hadSnapshot).toBe(true) // Only one LLM call (from the first buildContext) expect(mockSummarize).toHaveBeenCalledTimes(1) }) it('does incremental update when cache hit with new messages', async () => { const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) // First call — full compression (with forced compression) await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) // Simulate that the snapshot now exists in storage const savedSnapshot = mockFetcher.saveContextSnapshot.mock.calls[0] mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({ roomId: 'room-1', summary: savedSnapshot[1], lastMessageId: savedSnapshot[2], lastMessageTimestamp: savedSnapshot[3], updatedAt: Date.now(), }) expect(mockSummarize).toHaveBeenCalledTimes(1) // First call: no previousSummary // GatewayCaller.summarize signature: upstream, apiKey, systemPrompt, messages, roomId, profile, previousSummary const firstCallArgs = mockSummarize.mock.calls[0] expect(firstCallArgs[4]).toBe('room-1') // roomId expect(firstCallArgs[5]).toBe('default') // profile expect(firstCallArgs[6]).toBeUndefined() // previousSummary not passed // Insert a new message const middleInsert = makeMessage({ id: 'msg-new', roomId: 'room-1', senderId: 'user-99', senderName: 'NewUser', content: 'New middle message', timestamp: 12000, }) const updatedMessages = [...messages.slice(0, 9), middleInsert, ...messages.slice(9)] mockFetcher.getMessages = vi.fn().mockReturnValue(updatedMessages) const onProgress = vi.fn() // Second call — incremental update await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: updatedMessages[updatedMessages.length - 1], compression: { triggerTokens: 10 }, onProgress, }) expect(mockSummarize).toHaveBeenCalledTimes(2) // Second call: has previousSummary const secondCallArgs = mockSummarize.mock.calls[1] expect(secondCallArgs[6]).toBe('Summary of conversation.') expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ status: 'compressing', path: 'snapshot', })) }) it('falls back to no-summary on LLM failure', async () => { mockSummarize.mockRejectedValue(new Error('LLM timeout')) const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], compression: { triggerTokens: 10 }, }) // Should not throw, and should still return history expect(result.conversationHistory.length).toBeGreaterThan(0) // No summary pair in the output expect(result.conversationHistory[0]?.content).not.toContain('Previous conversation summary') }) it('trims tail when over token budget', async () => { const engine = new ContextEngine({ config: { maxHistoryTokens: 200, // small budget tailMessageCount: 10, triggerTokens: 10, // force compression charsPerToken: 4, summarizationTimeoutMs: 30_000, }, messageFetcher: mockFetcher, gatewayCaller: { summarize: mockSummarize }, }) const messages = makeMessages(20) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) // History should be trimmed to fit within 200 tokens // Use same estimation logic as compressor: CJK * 1.5 + other / charsPerToken const totalChars = result.conversationHistory.reduce((sum, m) => sum + m.content.length, 0) const cjk = (result.conversationHistory.map(m => m.content).join('').match(/[⺀-鿿가-힯 -〿＀-￯]/g) || []).length const other = totalChars - cjk const estimatedTokens = Math.ceil(cjk * 1.5 + other / 4) expect(estimatedTokens).toBeLessThanOrEqual(200) }) it('maps agent messages to assistant role', async () => { const messages = [ makeMessage({ senderId: 'user-1', senderName: 'Alice', content: 'Hello', timestamp: 1000 }), makeMessage({ senderId: 'agent-socket', senderName: 'Claude', content: 'Hi there', timestamp: 2000 }), ] mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) // First message from user → 'user' role with name prefix expect(result.conversationHistory[0].role).toBe('user') expect(result.conversationHistory[0].content).toContain('[Alice]') // Second message from agent → 'assistant' role with sender prefix for group-chat context. expect(result.conversationHistory[1].role).toBe('assistant') expect(result.conversationHistory[1].content).toBe('[Claude]: Hi there') }) it('maps other messages to user role with name prefix', async () => { const messages = [ makeMessage({ senderId: 'user-2', senderName: 'Bob', content: 'Hey', timestamp: 1000 }), ] mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) expect(result.conversationHistory[0].role).toBe('user') expect(result.conversationHistory[0].content).toBe('[Bob]: Hey') }) it('generates instructions with agent identity', async () => { const messages = makeMessages(1) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) const result = await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: 'Code helper', agentSocketId: 'agent-socket', roomName: 'dev', memberNames: ['Alice', 'Bob'], members: [ { userId: 'u1', name: 'Alice', description: 'dev' }, { userId: 'u2', name: 'Bob', description: 'designer' }, ], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[0], }) expect(result.instructions).toContain('"Claude"') expect(result.instructions).toContain('Code helper') expect(result.instructions).toContain('dev') expect(result.instructions).toContain('Alice') }) it('invalidates room cache', async () => { // Create a snapshot via the fetcher mock mockFetcher.getContextSnapshot = vi.fn().mockReturnValue({ roomId: 'room-1', summary: 'Test', lastMessageId: 'msg-10', lastMessageTimestamp: 1000, updatedAt: Date.now(), }) const messages = makeMessages(5) mockFetcher.getMessages = vi.fn().mockReturnValue(messages) // Build context to create snapshot await engine.buildContext({ roomId: 'room-1', agentId: 'agent-1', agentName: 'Claude', agentDescription: '', agentSocketId: 'agent-socket', roomName: 'general', memberNames: [], members: [], upstream: 'http://localhost:8642', apiKey: null, currentMessage: messages[messages.length - 1], }) // Invalidate engine.invalidateRoom('room-1') expect(mockFetcher.deleteContextSnapshot).toHaveBeenCalledWith('room-1') }) })