mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-15 05:00:19 +00:00
f5be266775
* group-chat: separate UI history from model context * docs: note group-chat context chain changes * fix: pass group-chat trigger message cursor
216 lines
7.6 KiB
TypeScript
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()
|
|
})
|
|
})
|