import { describe, expect, it, vi, beforeEach } from 'vitest' const { socketHandlers, mockSocket, mockIo } = vi.hoisted(() => { const socketHandlers = new Map 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 from users and bounded 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: '丫鬟', source: 'agent' } : { userId: 'human-1', name: 'Human', source: '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) expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({ content: '@all hi', senderId: 'human-1', mentionDepth: 0, })) 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).toHaveBeenCalledTimes(1) expect(server.agentClients.processMentions).toHaveBeenLastCalledWith('room-1', expect.objectContaining({ content: '@all agent says hi', senderId: 'agent-1', mentionDepth: 1, })) server.agentClients.processMentions.mockClear() server.handleMessage({ id: 'agent-socket' }, { roomId: 'room-1', content: '@all too deep', role: 'assistant', mentionDepth: 4 }, vi.fn()) expect(server.agentClients.processMentions).not.toHaveBeenCalled() }) })