import Koa from 'koa' import bodyParser from '@koa/bodyparser' import { createServer, type Server as HttpServer } from 'http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat' function listen(server: HttpServer): Promise { return new Promise(resolve => server.listen(0, '127.0.0.1', () => { const addr = server.address() if (!addr || typeof addr === 'string') throw new Error('missing address') resolve(`http://127.0.0.1:${addr.port}`) })) } describe('group chat REST route baseline', () => { let httpServer: HttpServer let baseUrl: string let storage: any let agentClients: any let clearRoomRuntimeState: ReturnType beforeEach(async () => { storage = { rooms: new Map(), agents: new Map(), messages: new Map(), members: new Map(), saveRoom: vi.fn((id, name, inviteCode, config) => storage.rooms.set(id, { id, name, inviteCode, totalTokens: 0, sessionSeed: '0', ...config })), getRoom: vi.fn((id) => storage.rooms.get(id)), getAllRooms: vi.fn(() => [...storage.rooms.values()]), getRoomsForProfiles: vi.fn(() => [...storage.rooms.values()]), getRecentMessagesForUI: vi.fn((roomId, limit = 150, offset = 0) => (storage.messages.get(roomId) || []).slice(offset, offset + limit)), getMessageCount: vi.fn((roomId) => (storage.messages.get(roomId) || []).length), getRoomAgents: vi.fn((roomId) => storage.agents.get(roomId) || []), getRoomMembers: vi.fn((roomId) => storage.members.get(roomId) || []), getRoomByInviteCode: vi.fn((code) => [...storage.rooms.values()].find((r: any) => r.inviteCode === code)), addRoomAgent: vi.fn((roomId, agentId, profile, name, description, invited) => { const row = { id: `row-${agentId}`, roomId, agentId, profile, name, description, invited } storage.agents.set(roomId, [...(storage.agents.get(roomId) || []), row]) return row }), getRoomAgent: vi.fn((roomId, ref) => (storage.agents.get(roomId) || []).find((a: any) => a.id === ref || a.agentId === ref) || null), removeRoomMembersForAgent: vi.fn(), removeRoomAgent: vi.fn((roomId, ref) => storage.agents.set(roomId, (storage.agents.get(roomId) || []).filter((a: any) => a.id !== ref && a.agentId !== ref))), clearRoomContext: vi.fn((roomId) => { const room = storage.rooms.get(roomId); if (room) Object.assign(room, { totalTokens: 0, sessionSeed: 'rotated' }) }), deleteRoom: vi.fn((roomId) => storage.rooms.delete(roomId)), } agentClients = { createAgent: vi.fn(async (cfg: any) => { if (cfg.profile === 'bad-profile') throw new Error('agent runtime unavailable') return { ...cfg, joinRoom: vi.fn(async () => ({})), disconnect: vi.fn() } }), addAgentToRoom: vi.fn(async () => ({})), removeAgentFromRoom: vi.fn(), disconnectRoom: vi.fn(), } clearRoomRuntimeState = vi.fn() setGroupChatServer({ getStorage: () => storage, agentClients, clearRoomRuntimeState } as any) const app = new Koa() app.use(bodyParser()) app.use(groupChatRoutes.routes()) httpServer = createServer(app.callback()) baseUrl = await listen(httpServer) }) afterEach(() => { httpServer.close() setGroupChatServer(null as any) }) it('requires name and inviteCode when creating a room', async () => { const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Room' }), }) expect(res.status).toBe(400) await expect(res.json()).resolves.toEqual({ error: 'name and inviteCode are required' }) }) it('rejects reserved @all agent names when creating a room', async () => { const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Room', inviteCode: 'ROOM1', agents: [{ profile: 'default', name: 'all' }] }), }) expect(res.status).toBe(400) await expect(res.json()).resolves.toMatchObject({ error: '`all` is reserved for @all mentions' }) }) it('creates a room, persists successful agents, and reports agent connection failures', async () => { const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Room', inviteCode: 'ROOM1', agents: [ { profile: 'default', name: 'Worker' }, { profile: 'bad-profile', name: 'Broken' }, ], }), }) const body = await res.json() expect(res.status).toBe(200) expect(body.room).toMatchObject({ name: 'Room', inviteCode: 'ROOM1' }) expect(body.agents).toHaveLength(1) expect(body.agentResults).toEqual([ expect.objectContaining({ profile: 'default', ok: true }), expect.objectContaining({ profile: 'bad-profile', ok: false, code: 'PROFILE_AGENT_CONNECT_FAILED' }), ]) expect(storage.saveRoom).toHaveBeenCalled() }) it('returns room detail with paging metadata, agents, and members', async () => { storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1' }) storage.messages.set('room-1', [{ id: 'msg-1' }, { id: 'msg-2' }]) storage.agents.set('room-1', [{ id: 'row-agent', agentId: 'agent-1', profile: 'default', name: 'Agent' }]) storage.members.set('room-1', [{ userId: 'user-1', name: 'Alice' }]) const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1?limit=1&offset=1`) const body = await res.json() expect(res.status).toBe(200) expect(body).toMatchObject({ room: { id: 'room-1', name: 'Room' }, messages: [{ id: 'msg-2' }], agents: [{ agentId: 'agent-1' }], members: [{ userId: 'user-1' }], total: 2, offset: 1, limit: 1, hasMore: false, }) }) it('rejects duplicate room agent profiles', async () => { storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1' }) storage.agents.set('room-1', [{ id: 'row-agent', agentId: 'agent-1', profile: 'default', name: 'Agent' }]) const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile: 'default', name: 'Agent' }), }) expect(res.status).toBe(409) await expect(res.json()).resolves.toEqual({ error: 'Agent already in room' }) }) it('removes an agent by row id and disconnects runtime by persisted agent id', async () => { const agent = { id: 'row-agent', roomId: 'room-1', agentId: 'agent-1', profile: 'default', name: 'Agent' } storage.agents.set('room-1', [agent]) const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/agents/row-agent`, { method: 'DELETE' }) const body = await res.json() expect(res.status).toBe(200) expect(storage.removeRoomMembersForAgent).toHaveBeenCalledWith('room-1', agent) expect(storage.removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-agent') expect(agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-1') expect(body).toMatchObject({ success: true, agents: [], members: [] }) }) it('clears room context and runtime state while returning the updated room', async () => { storage.rooms.set('room-1', { id: 'room-1', name: 'Room', inviteCode: 'ROOM1', totalTokens: 99, sessionSeed: 'old' }) const res = await fetch(`${baseUrl}/api/hermes/group-chat/rooms/room-1/clear-context`, { method: 'POST' }) const body = await res.json() expect(res.status).toBe(200) expect(storage.clearRoomContext).toHaveBeenCalledWith('room-1') expect(clearRoomRuntimeState).toHaveBeenCalledWith('room-1') expect(body).toMatchObject({ success: true, room: { id: 'room-1', totalTokens: 0, sessionSeed: 'rotated' } }) }) })