Files
hermes-ui/tests/server/context-engine.test.ts
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

588 lines
24 KiB
TypeScript

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> = {}): 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 = {
getMessagesForContext: 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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.getMessagesForContext = 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')
})
})