Files
hermes-web-ui/tests/server/group-chat-member-sync.test.ts
2026-05-24 10:11:03 +08:00

306 lines
11 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from 'vitest'
const { socketHandlers, mockSocket, mockIo } = vi.hoisted(() => {
const socketHandlers = new Map<string, (...args: any[]) => void>()
const mockSocket: any = {
id: 'socket-1',
connected: true,
io: { on: vi.fn() },
on: vi.fn((event: string, handler: (...args: any[]) => void) => {
socketHandlers.set(event, handler)
if (event === 'connect') queueMicrotask(() => handler())
return mockSocket
}),
emit: vi.fn(),
disconnect: vi.fn(),
}
const mockIo = vi.fn(() => mockSocket)
return { socketHandlers, mockSocket, mockIo }
})
vi.mock('socket.io-client', () => ({
io: mockIo,
}))
vi.mock('../../packages/server/src/services/auth', () => ({
getToken: vi.fn(async () => 'test-token'),
}))
import { AgentClients } from '../../packages/server/src/services/hermes/group-chat/agent-clients'
import { GroupChatServer } from '../../packages/server/src/services/hermes/group-chat'
import { groupChatRoutes, setGroupChatServer } from '../../packages/server/src/routes/hermes/group-chat'
function routeHandler(path: string, method: string) {
const layer = (groupChatRoutes as any).stack.find((item: any) => item.path === path && item.methods.includes(method))
if (!layer) throw new Error(`Route not found: ${method} ${path}`)
return layer.stack[0]
}
describe('Group Chat member/agent identity sync', () => {
beforeEach(() => {
vi.clearAllMocks()
socketHandlers.clear()
})
it('uses the persisted group-chat agent id as the runtime agent id and socket user id', async () => {
const clients = new AgentClients()
const client = await clients.createAgent({
agentId: 'agent-stable-1',
profile: 'default',
name: 'Worker',
description: '',
invited: 0,
} as any)
expect(client.agentId).toBe('agent-stable-1')
expect(mockIo).toHaveBeenCalledWith(
'http://127.0.0.1:8648/group-chat',
expect.objectContaining({
auth: expect.objectContaining({
token: 'test-token',
userId: 'agent-stable-1',
name: 'Worker',
source: 'agent',
agentSocketSecret: expect.any(String),
}),
}),
)
})
it('passes the same persisted agent id into the runtime client when adding an agent', async () => {
const addRoomAgent = vi.fn((roomId: string, agentId: string, profile: string, name: string, description: string, invited: number) => ({
id: 'row-1', roomId, agentId, profile, name, description, invited,
}))
const chatServer = {
getStorage: () => ({
getRoomAgents: vi.fn(() => []),
addRoomAgent,
}),
agentClients: {
createAgent: vi.fn(async () => ({ agentId: 'runtime-agent' })),
addAgentToRoom: vi.fn(async () => undefined),
},
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx: any = {
params: { roomId: 'room-1' },
request: { body: { profile: 'default', name: 'Worker' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
const persisted = ctx.body.agent
expect(persisted.agentId).toBeTruthy()
expect(chatServer.agentClients.createAgent).toHaveBeenCalledWith(expect.objectContaining({
agentId: persisted.agentId,
profile: 'default',
name: 'Worker',
}))
})
it('does not persist an agent when the runtime client cannot connect', async () => {
const addRoomAgent = vi.fn()
const chatServer = {
getStorage: () => ({
getRoomAgents: vi.fn(() => []),
addRoomAgent,
}),
agentClients: {
createAgent: vi.fn(async () => {
throw new Error('Connection timeout')
}),
addAgentToRoom: vi.fn(),
removeAgentFromRoom: vi.fn(),
},
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx: any = {
params: { roomId: 'room-1' },
request: { body: { profile: 'default', name: 'Worker' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(ctx.status).toBe(502)
expect(ctx.body).toMatchObject({
code: 'PROFILE_AGENT_CONNECT_FAILED',
profile: 'default',
reason: 'Connection timeout',
})
expect(addRoomAgent).not.toHaveBeenCalled()
})
it('does not persist an agent and disconnects runtime state when room join fails', async () => {
const addRoomAgent = vi.fn()
const runtimeClient = { agentId: 'agent-stable-1' }
const chatServer = {
getStorage: () => ({
getRoomAgents: vi.fn(() => []),
addRoomAgent,
}),
agentClients: {
createAgent: vi.fn(async () => runtimeClient),
addAgentToRoom: vi.fn(async () => {
throw new Error('join failed')
}),
removeAgentFromRoom: vi.fn(),
},
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx: any = {
params: { roomId: 'room-1' },
request: { body: { profile: 'default', name: 'Worker' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(ctx.status).toBe(502)
expect(ctx.body).toMatchObject({
code: 'PROFILE_AGENT_CONNECT_FAILED',
profile: 'default',
reason: 'join failed',
})
expect(addRoomAgent).not.toHaveBeenCalled()
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
})
it('rolls back AgentClients room state when joining a room fails', async () => {
const clients = new AgentClients()
const runtimeClient = {
agentId: 'agent-stable-1',
name: 'Worker',
joinRoom: vi.fn(async () => {
throw new Error('join failed')
}),
disconnect: vi.fn(),
}
await expect(clients.addAgentToRoom('room-1', runtimeClient as any)).rejects.toThrow('join failed')
expect(runtimeClient.disconnect).toHaveBeenCalled()
expect(clients.getAgents('room-1')).toEqual([])
})
it('removes the runtime agent by persisted agentId and returns synchronized room state', async () => {
const agentsBefore = [{ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 }]
const storage = {
getRoomAgent: vi.fn(() => agentsBefore[0]),
getRoomAgents: vi.fn(() => []),
removeRoomMembersForAgent: vi.fn(),
removeRoomAgent: vi.fn(),
getRoomMembers: vi.fn(() => [{ id: 'member-1', userId: 'human-1', name: 'Han', description: '', joinedAt: 1 }]),
}
const chatServer = {
getStorage: () => storage,
agentClients: { removeAgentFromRoom: vi.fn() },
}
setGroupChatServer(chatServer as any)
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', 'DELETE')
const ctx: any = {
params: { roomId: 'room-1', agentId: 'row-1' },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
expect(storage.removeRoomMembersForAgent).toHaveBeenCalledWith('room-1', agentsBefore[0])
expect(storage.removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-1')
expect(ctx.body).toEqual({
success: true,
agents: [],
members: [{ id: 'member-1', userId: 'human-1', name: 'Han', description: '', joinedAt: 1 }],
})
})
it('filters room list to rooms containing one of the regular admin profiles', async () => {
const allRooms = [
{ id: 'room-default', name: 'Default', inviteCode: null },
{ id: 'room-private', name: 'Private', inviteCode: null },
]
const visibleRooms = [allRooms[0]]
const storage = {
getAllRooms: vi.fn(() => allRooms),
getRoomsForProfiles: vi.fn(() => visibleRooms),
}
setGroupChatServer({ getStorage: () => storage } as any)
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx: any = {
state: { user: { id: 2, username: 'ops', role: 'admin', profiles: ['default', 'research'] } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(storage.getRoomsForProfiles).toHaveBeenCalledWith(['default', 'research'])
expect(storage.getAllRooms).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ rooms: visibleRooms })
})
it('keeps room list unrestricted for super admins', async () => {
const rooms = [{ id: 'room-1', name: 'All', inviteCode: null }]
const storage = {
getAllRooms: vi.fn(() => rooms),
getRoomsForProfiles: vi.fn(() => []),
}
setGroupChatServer({ getStorage: () => storage } as any)
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx: any = {
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(storage.getAllRooms).toHaveBeenCalledOnce()
expect(storage.getRoomsForProfiles).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ rooms })
})
it('routes @mentions only from user messages, not agent replies', () => {
const server = Object.create(GroupChatServer.prototype) as any
const emit = vi.fn()
server.rooms = new Map([
['room-1', {
hasOnlineMember: vi.fn(() => true),
getOnlineMemberBySocketId: vi.fn((socketId: string) => socketId === 'agent-socket'
? { userId: 'agent-1', name: '丫鬟' }
: { userId: 'human-1', name: 'Human' }),
}],
])
server.socketUserMap = new Map([
['human-socket', 'human-1'],
['agent-socket', 'agent-1'],
])
server.userInfoMap = new Map([
['human-1', { name: 'Human', description: '' }],
['agent-1', { name: '丫鬟', description: '' }],
])
server.agentClients = { processMentions: vi.fn(async () => undefined) }
server.storage = {
saveMessageAndRefreshRoom: vi.fn((msg: any) => ({ message: msg, totalTokens: 123 })),
}
server.nsp = { to: vi.fn(() => ({ emit })) }
server.handleMessage({ id: 'human-socket' }, { roomId: 'room-1', content: '@all hi', role: 'user' }, vi.fn())
expect(server.agentClients.processMentions).toHaveBeenCalledTimes(1)
server.agentClients.processMentions.mockClear()
server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all agent says hi', role: 'assistant', mentionDepth: 1 }, vi.fn())
expect(server.agentClients.processMentions).not.toHaveBeenCalled()
})
})