Files
hermes-ui/tests/server/group-chat-routes-baseline.test.ts
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

179 lines
8.0 KiB
TypeScript

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<string> {
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<typeof vi.fn>
beforeEach(async () => {
storage = {
rooms: new Map<string, any>(),
agents: new Map<string, any[]>(),
messages: new Map<string, any[]>(),
members: new Map<string, any[]>(),
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' } })
})
})