Files
hermes-ui/tests/server/group-chat-context-projection.test.ts
T
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

216 lines
7.6 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
const { mockIo, mockSocket } = vi.hoisted(() => {
const mockSocket: any = {
id: 'agent-socket-1',
connected: true,
io: { on: vi.fn() },
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
if (event === 'connect') queueMicrotask(() => handler())
return mockSocket
}),
emit: vi.fn(),
disconnect: vi.fn(),
}
return {
mockSocket,
mockIo: vi.fn(() => mockSocket),
}
})
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
vi.mock('../../packages/server/src/services/auth', () => ({
getToken: vi.fn(async () => 'test-token'),
}))
import { ContextEngine } from '../../packages/server/src/services/hermes/context-engine/compressor'
import type {
GatewayCaller,
MessageFetcher,
StoredMessage,
} from '../../packages/server/src/services/hermes/context-engine/types'
import { AgentClients } from '../../packages/server/src/services/hermes/group-chat/agent-clients'
import {
buildProjectedGroupChatHistory,
projectGroupChatMessage,
} from '../../packages/server/src/services/hermes/group-chat/context-projection'
function makeMessage(overrides: Partial<StoredMessage>): StoredMessage {
return {
id: 'm1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'Alice',
content: 'hello',
timestamp: 1,
role: 'user',
...overrides,
}
}
describe('group chat context projection', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('projects own agent messages as assistant and other participants with attribution as user', () => {
expect(projectGroupChatMessage(makeMessage({ senderId: 'agent-1', senderName: 'Worker', role: 'assistant', content: '@Bob I handled it' }), {
agentId: 'agent-1',
socketId: 'agent-socket-1',
name: 'Worker',
})).toEqual({ role: 'assistant', content: '[Worker]: I handled it' })
expect(projectGroupChatMessage(makeMessage({ senderId: 'user-2', senderName: 'Alice', role: 'user', content: '@Worker please help' }), {
socketId: 'agent-socket-1',
name: 'Worker',
})).toEqual({ role: 'user', content: '[Alice]: please help' })
})
it('does not project same-name humans as the own agent when sender ids differ', () => {
expect(projectGroupChatMessage(makeMessage({
senderId: 'user-2',
senderName: 'Worker',
role: 'user',
content: '@Worker I am a different participant',
}), {
socketId: 'agent-socket-1',
name: 'Worker',
})).toEqual({ role: 'user', content: '[Worker]: I am a different participant' })
})
it('formats tool results and assistant tool calls consistently', () => {
expect(projectGroupChatMessage(makeMessage({
senderName: 'Worker',
senderId: 'agent-socket-1',
role: 'tool',
tool_name: 'search',
content: 'found docs',
}), {
socketId: 'agent-socket-1',
name: 'Worker',
})).toEqual({ role: 'user', content: '[Worker] [Tool result: search]\nfound docs' })
expect(projectGroupChatMessage(makeMessage({
senderName: 'Reviewer',
senderId: 'agent-reviewer',
role: 'assistant',
content: '@Worker let me check',
tool_calls: [{ function: { name: 'search', arguments: '{"q":"docs"}' } }],
}), {
socketId: 'agent-socket-1',
name: 'Worker',
})).toEqual({
role: 'user',
content: '[Reviewer]: let me check\n[Reviewer]: [Calling tool: search with arguments: {"q":"docs"}]',
})
})
it('preserves summary prefix while stripping mentions from projected content', () => {
const history = buildProjectedGroupChatHistory('Earlier summary', [
makeMessage({ senderName: 'Alice', content: '@Worker compare this with @Bob' }),
], {
socketId: 'agent-socket-1',
name: 'Worker',
})
expect(history).toEqual([
{ role: 'user', content: '[Previous conversation summary]\nEarlier summary' },
{ role: 'assistant', content: 'I have reviewed the conversation history and understand the context.' },
{ role: 'user', content: '[Alice]: compare this with ' },
])
})
it('uses the same projection semantics for actual model context and final room token estimates', async () => {
const messages = [
makeMessage({ id: 'm1', senderName: 'Alice', senderId: 'user-1', role: 'user', content: '@Worker please compare options' }),
makeMessage({ id: 'm2', senderName: 'Worker', senderId: 'agent-1', role: 'assistant', content: '@Bob I will take this' }),
makeMessage({ id: 'm3', senderName: 'Reviewer', senderId: 'agent-reviewer', role: 'assistant', content: '@Worker checking', tool_calls: [{ function: { name: 'search', arguments: '{"q":"options"}' } }], timestamp: 3 }),
makeMessage({ id: 'm4', senderName: 'Worker', senderId: 'agent-1', role: 'tool', tool_name: 'search', content: 'docs found', timestamp: 4 }),
]
const fetcher: MessageFetcher = {
getMessagesForContext: vi.fn(() => messages),
getContextSnapshot: vi.fn(() => null),
saveContextSnapshot: vi.fn(),
deleteContextSnapshot: vi.fn(),
}
const gatewayCaller: GatewayCaller = {
summarize: vi.fn(),
}
const engine = new ContextEngine({
config: { triggerTokens: 100_000, maxHistoryTokens: 32_000, tailMessageCount: 10, charsPerToken: 4, summarizationTimeoutMs: 30_000 },
messageFetcher: fetcher,
gatewayCaller,
})
const result = await engine.buildContext({
roomId: 'room-1',
agentId: 'agent-1',
agentName: 'Worker',
agentDescription: '',
agentSocketId: 'agent-socket-1',
roomName: 'general',
memberNames: ['Alice', 'Worker', 'Reviewer'],
members: [],
upstream: '',
apiKey: null,
currentMessage: messages[messages.length - 1],
})
const clients = new AgentClients()
const client = await clients.createAgent({
agentId: 'agent-1',
profile: 'default',
name: 'Worker',
description: '',
invited: 0,
} as any)
client.setStorage({
getMessagesForContext: vi.fn(() => messages),
} as any)
expect((client as any).buildRoomEstimateHistory('room-1')).toEqual(result.conversationHistory)
client.disconnect()
})
it('includes persisted summary snapshots in final room token estimate history', async () => {
const messages = [
makeMessage({ id: 'm1', senderName: 'Alice', senderId: 'user-1', role: 'user', content: 'older request', timestamp: 1 }),
makeMessage({ id: 'm2', senderName: 'Worker', senderId: 'agent-1', role: 'assistant', content: 'old answer', timestamp: 2 }),
makeMessage({ id: 'm3', senderName: 'Alice', senderId: 'user-1', role: 'user', content: '@Worker latest request', timestamp: 3 }),
]
const clients = new AgentClients()
const client = await clients.createAgent({
agentId: 'agent-1',
profile: 'default',
name: 'Worker',
description: '',
invited: 0,
} as any)
client.setStorage({
getMessagesForContext: vi.fn(() => messages),
getContextSnapshot: vi.fn(() => ({
roomId: 'room-1',
summary: 'Earlier room summary',
lastMessageId: 'm1',
lastMessageTimestamp: 1,
updatedAt: Date.now(),
})),
} as any)
expect((client as any).buildRoomEstimateHistory('room-1')).toEqual([
{ role: 'user', content: '[Previous conversation summary]\nEarlier room summary' },
{ role: 'assistant', content: 'I have reviewed the conversation history and understand the context.' },
{ role: 'assistant', content: '[Worker]: old answer' },
{ role: 'user', content: '[Alice]: latest request' },
])
client.disconnect()
})
})