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.
249 lines
9.4 KiB
TypeScript
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()
|
|
})
|
|
})
|