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> = {}) { 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) }) 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) }) 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() }) })