Files
hermes-ui/tests/server/group-chat-history-window.test.ts
T
toor a1fff482bf make the test suite and openapi generator platform-independent
Windows checkouts turned LF into CRLF (core.autocrlf=true), and several
tests plus the openapi generator hardcoded LF or POSIX path separators.
The result was a suite that was green on Linux CI but red on Windows, and
an openapi.json whose /api/hermes/* routes silently disappeared when
generated on Windows.

Pin the repository to LF via .gitattributes, strip shebangs before
Vite's SSR transform, and key route files by their posix path. Add a
manually triggered macOS verification workflow so the same checks run
on a self-hosted macOS runner.
2026-10-20 11:17:34 +08:00

249 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { createServer, type Server as HttpServer } from 'http'
const dbMock = vi.hoisted(() => ({
current: null as DatabaseSync | null,
}))
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('../../packages/server/src/db/index', () => ({
getDb: () => dbMock.current,
}))
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
vi.mock('../../packages/server/src/services/auth', () => ({
getToken: vi.fn(async () => 'test-token'),
}))
import { countTokens, SUMMARY_PREFIX } from '../../packages/server/src/lib/context-compressor'
import { initAllHermesTables } from '../../packages/server/src/db/hermes/schemas'
import { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat'
import { AgentClients, groupBridgeSessionId, mentionMessageToStoredContextMessage } from '../../packages/server/src/services/hermes/group-chat/agent-clients'
import { sortGroupMessagesCanonical } from '../../packages/server/src/services/hermes/group-chat/group-message-ordering'
function makeDb(): DatabaseSync {
return new DatabaseSync(':memory:')
}
function makeMessage(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'msg-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'Alice',
content: 'hello',
timestamp: 1,
role: 'user',
...overrides,
}
}
describe('group chat history windows', () => {
it('maps routed mention ids into context-engine current message cursors', () => {
const current = mentionMessageToStoredContextMessage('room-1', {
messageId: 'trigger-msg',
content: '@Worker use the context through this message only',
senderName: 'Alice',
senderId: 'user-1',
timestamp: 123,
senderKind: 'user',
})
expect(current.id).toBe('trigger-msg')
expect(current.roomId).toBe('room-1')
expect(current.role).toBe('user')
})
let httpServer: HttpServer
let groupServer: GroupChatServer
beforeEach(() => {
vi.clearAllMocks()
dbMock.current = makeDb()
initAllHermesTables()
httpServer = createServer()
groupServer = new GroupChatServer(httpServer)
})
afterEach(() => {
groupServer?.getIO().close()
httpServer?.close()
dbMock.current?.close()
dbMock.current = null
})
it('returns a bounded recent UI page while context reads the full retained transcript in canonical order', () => {
const storage = groupServer.getStorage()
storage.saveRoom('room-1', 'Room 1')
const seeded = Array.from({ length: 160 }, (_value, index) => makeMessage({
id: `msg-${index + 1}`,
content: `message ${index + 1}`,
timestamp: index + 1,
}))
for (const message of seeded) storage.saveMessageAndRefreshRoom(message as any)
const recentMessages = storage.getRecentMessagesForUI('room-1')
const contextMessages = storage.getMessagesForContext('room-1')
expect(recentMessages).toHaveLength(150)
expect(recentMessages[0]?.id).toBe('msg-11')
expect(recentMessages.at(-1)?.id).toBe('msg-160')
expect(contextMessages).toHaveLength(160)
expect(contextMessages.map(message => message.id)).toEqual(
sortGroupMessagesCanonical(seeded as Array<{ id: string; timestamp: number }>).map(message => message.id),
)
})
it('does not split same-timestamp multipart assistant/tool runs across UI page boundaries', () => {
const storage = groupServer.getStorage()
storage.saveRoom('room-1', 'Room 1')
const seeded = [
makeMessage({ id: 'run-1_part_0', role: 'assistant', senderId: 'agent-1', senderName: 'Agent', content: 'assistant', timestamp: 100 }),
makeMessage({ id: 'run-1_part_0_toolcall_t', role: 'assistant', senderId: 'agent-1', senderName: 'Agent', content: '', timestamp: 100 }),
makeMessage({ id: 'run-1_part_0_toolresult_t', role: 'tool', senderId: 'agent-1', senderName: 'Agent', content: 'tool result', timestamp: 100 }),
makeMessage({ id: 'run-2', role: 'user', senderId: 'user-1', senderName: 'Human', content: 'next', timestamp: 100 }),
]
for (const message of seeded) storage.saveMessageAndRefreshRoom(message as any)
expect(storage.getRecentMessagesForUI('room-1', 2, 0).map(message => message.id)).toEqual([
'run-1_part_0',
'run-1_part_0_toolcall_t',
'run-1_part_0_toolresult_t',
'run-2',
])
expect(storage.getRecentMessagesForUI('room-1', 2, 2).map(message => message.id)).toEqual([
'run-1_part_0',
'run-1_part_0_toolcall_t',
'run-1_part_0_toolresult_t',
])
})
it('computes room total tokens from the full retained context transcript, not the UI page window', () => {
const storage = groupServer.getStorage()
storage.saveRoom('room-1', 'Room 1')
const seeded = Array.from({ length: 160 }, (_value, index) => makeMessage({
id: `msg-${index + 1}`,
content: `message-${index + 1}`,
timestamp: index + 1,
}))
let latest: { totalTokens: number } | null = null
for (const message of seeded) latest = storage.saveMessageAndRefreshRoom(message as any)
const expectedTotalTokens = seeded.reduce((sum, message) => sum + countTokens(String(message.content)), 0)
expect(storage.getRecentMessagesForUI('room-1')).toHaveLength(150)
expect(storage.getMessagesForContext('room-1')).toHaveLength(160)
expect(latest?.totalTokens).toBe(expectedTotalTokens)
expect(storage.getRoom('room-1')?.totalTokens).toBe(expectedTotalTokens)
}, 60_000)
// 这个用例故意写 501 条消息来触发 prune(保留 500 条)。而
// saveMessageAndRefreshRoom 每写一条都要重读整份转录并重新计一遍 token,
// 单测本身就是 O(n²):单独跑约 1.3s,全量并行时 CPU 被抢,很容易撞上
// Vitest 默认的 5s 上限。这是重测试,不是卡死 —— 显式放宽超时。
it('preserves snapshot summary tokens when the snapshot anchor was pruned from retained history', () => {
const storage = groupServer.getStorage()
storage.saveRoom('room-1', 'Room 1')
const seeded = Array.from({ length: 501 }, (_value, index) => makeMessage({
id: `msg-${index + 1}`,
content: `message-${index + 1}`,
timestamp: index + 1,
}))
storage.saveMessageAndRefreshRoom(seeded[0] as any)
storage.saveContextSnapshot('room-1', 'Earlier summary', 'msg-1', 1)
let latest: { totalTokens: number } | null = null
for (const message of seeded.slice(1)) latest = storage.saveMessageAndRefreshRoom(message as any)
const retained = storage.getMessagesForContext('room-1')
const expectedTotalTokens = countTokens(SUMMARY_PREFIX + 'Earlier summary')
+ retained.reduce((sum, message) => sum + countTokens(String(message.content)), 0)
expect(retained).toHaveLength(500)
expect(retained.some(message => message.id === 'msg-1')).toBe(false)
expect(storage.getContextSnapshot('room-1')?.lastMessageId).toBe('msg-1')
expect(latest?.totalTokens).toBe(expectedTotalTokens)
expect(storage.getRoom('room-1')?.totalTokens).toBe(expectedTotalTokens)
}, 60_000)
it('uses the full context transcript for the final AgentClient room estimate', async () => {
const messages = Array.from({ length: 160 }, (_value, index) => ({
senderId: 'user-1',
senderName: 'Alice',
content: `message ${index + 1}`,
role: 'user',
timestamp: index + 1,
}))
const sessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1')
const storage = {
getMessagesForContext: vi.fn(() => messages),
getRecentMessagesForUI: vi.fn(() => messages.slice(-150)),
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-1' })),
getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Worker' })),
updateRoomTotalTokens: vi.fn(),
}
const bridge = {
contextEstimate: vi.fn(async (_sessionId: string, history: Array<{ role: 'user' | 'assistant'; content: string }>) => ({
token_count: 4321,
fixed_context_tokens: 0,
message_count: history.length,
})),
}
const clients = new AgentClients()
const client = await clients.createAgent({
agentId: 'agent-1',
profile: 'default',
name: 'Worker',
description: '',
invited: 0,
} as any)
client.setStorage(storage as any)
await (client as any).refreshRoomFullContextEstimate('room-1', sessionId, bridge, undefined, { model: '', provider: '' })
expect(storage.getMessagesForContext).toHaveBeenCalledWith('room-1')
expect(storage.getRecentMessagesForUI).not.toHaveBeenCalled()
expect(bridge.contextEstimate).toHaveBeenCalledTimes(1)
expect(bridge.contextEstimate.mock.calls[0][1]).toHaveLength(160)
expect(storage.updateRoomTotalTokens).toHaveBeenCalledWith('room-1', 4321)
expect(mockSocket.emit).toHaveBeenCalledWith('context_status', expect.objectContaining({
roomId: 'room-1',
agentName: 'Worker',
status: 'replying',
totalTokens: 4321,
}))
client.disconnect()
})
})