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.
834 lines
33 KiB
TypeScript
834 lines
33 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, groupBridgeSessionId } 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 calls: string[] = []
|
|
const addRoomAgent = vi.fn((roomId: string, agentId: string, profile: string, name: string, description: string, invited: number) => {
|
|
calls.push('persist-agent')
|
|
return { id: 'row-1', roomId, agentId, profile, name, description, invited }
|
|
})
|
|
const chatServer = {
|
|
getStorage: () => ({
|
|
getRoomAgents: vi.fn(() => []),
|
|
addRoomAgent,
|
|
removeRoomAgent: vi.fn(),
|
|
}),
|
|
agentClients: {
|
|
createAgent: vi.fn(async () => ({ agentId: 'runtime-agent' })),
|
|
addAgentToRoom: vi.fn(async () => { calls.push('join-room') }),
|
|
},
|
|
}
|
|
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(calls).toEqual(['persist-agent', 'join-room'])
|
|
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('disconnects a newly created runtime agent when persistence fails before room join', async () => {
|
|
const runtimeClient = { agentId: 'agent-stable-1', disconnect: vi.fn() }
|
|
const addRoomAgent = vi.fn(() => {
|
|
throw new Error('database locked')
|
|
})
|
|
const chatServer = {
|
|
getStorage: () => ({
|
|
getRoomAgents: vi.fn(() => []),
|
|
addRoomAgent,
|
|
removeRoomAgent: vi.fn(),
|
|
}),
|
|
agentClients: {
|
|
createAgent: vi.fn(async () => runtimeClient),
|
|
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: 'database locked',
|
|
})
|
|
expect(chatServer.agentClients.addAgentToRoom).not.toHaveBeenCalled()
|
|
expect(runtimeClient.disconnect).toHaveBeenCalled()
|
|
expect(chatServer.agentClients.removeAgentFromRoom).toHaveBeenCalledWith('room-1', 'agent-stable-1')
|
|
})
|
|
|
|
it('does not leave a persisted agent row and disconnects runtime state when room join fails', 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 removeRoomAgent = vi.fn()
|
|
const runtimeClient = { agentId: 'agent-stable-1' }
|
|
const chatServer = {
|
|
getStorage: () => ({
|
|
getRoomAgents: vi.fn(() => []),
|
|
addRoomAgent,
|
|
removeRoomAgent,
|
|
}),
|
|
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).toHaveBeenCalledWith('room-1', expect.any(String), 'default', 'Worker', '', 0)
|
|
expect(removeRoomAgent).toHaveBeenCalledWith('room-1', 'row-1')
|
|
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('interrupts runtime room state before deleting persisted room data', async () => {
|
|
const calls: string[] = []
|
|
const storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1', ownerAuthUserId: 7 })),
|
|
deleteRoom: vi.fn(() => { calls.push('storage-delete') }),
|
|
}
|
|
const chatServer = {
|
|
getStorage: () => storage,
|
|
deleteRoomRuntimeState: vi.fn(async () => { calls.push('runtime-delete') }),
|
|
}
|
|
setGroupChatServer(chatServer as any)
|
|
|
|
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId', 'DELETE')
|
|
const ctx: any = {
|
|
params: { roomId: 'room-1' },
|
|
state: { user: { id: 1, username: 'root', role: 'super_admin' } },
|
|
status: 200,
|
|
body: undefined,
|
|
}
|
|
await handler(ctx, async () => {})
|
|
|
|
expect(calls).toEqual(['runtime-delete', 'storage-delete'])
|
|
expect(chatServer.deleteRoomRuntimeState).toHaveBeenCalledWith('room-1')
|
|
expect(ctx.body).toEqual({ success: true })
|
|
})
|
|
|
|
it('does not delete persisted room data when runtime interrupt does not complete', async () => {
|
|
const storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room 1', ownerAuthUserId: 7 })),
|
|
deleteRoom: vi.fn(),
|
|
}
|
|
const chatServer = {
|
|
getStorage: () => storage,
|
|
deleteRoomRuntimeState: vi.fn(async () => { throw Object.assign(new Error('still running'), { status: 409 }) }),
|
|
}
|
|
setGroupChatServer(chatServer as any)
|
|
|
|
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId', 'DELETE')
|
|
const ctx: any = {
|
|
params: { roomId: 'room-1' },
|
|
state: { user: { id: 1, username: 'root', role: 'super_admin' } },
|
|
status: 200,
|
|
body: undefined,
|
|
}
|
|
await handler(ctx, async () => {})
|
|
|
|
expect(ctx.status).toBe(409)
|
|
expect(ctx.body).toEqual({ error: 'still running' })
|
|
expect(storage.deleteRoom).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('interrupts agents before evicting in-memory room sockets so deleted rooms reject late realtime messages', async () => {
|
|
const calls: string[] = []
|
|
const socketsLeave = vi.fn(() => { calls.push('sockets-leave') })
|
|
const saveMessageAndRefreshRoom = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map([['room-1', { hasOnlineMember: vi.fn(() => true) }]])
|
|
server.typingState = new Map([['room-1', new Map([['human-1', { userName: 'Human', timer: setTimeout(() => {}, 1000) }]])]])
|
|
server.contextStatusState = new Map([['room-1', new Map([['Worker', { agentName: 'Worker', status: 'replying' }]])]])
|
|
server.agentClients = {
|
|
interruptRoom: vi.fn(async () => { calls.push('interrupt') }),
|
|
disconnectRoom: vi.fn(() => { calls.push('disconnect') }),
|
|
}
|
|
server.nsp = {
|
|
in: vi.fn(() => ({ socketsLeave })),
|
|
to: vi.fn(() => ({ emit: vi.fn() })),
|
|
}
|
|
server.storage = { saveMessageAndRefreshRoom }
|
|
|
|
await server.deleteRoomRuntimeState('room-1')
|
|
const ack = vi.fn()
|
|
server.handleMessage({ id: 'socket-1' }, { roomId: 'room-1', content: 'late', role: 'user' }, ack)
|
|
|
|
expect(calls).toEqual(['interrupt', 'disconnect', 'sockets-leave'])
|
|
expect(server.rooms.has('room-1')).toBe(false)
|
|
expect(server.agentClients.disconnectRoom).toHaveBeenCalledWith('room-1')
|
|
expect(server.nsp.in).toHaveBeenCalledWith('room-1')
|
|
expect(socketsLeave).toHaveBeenCalledWith('room-1')
|
|
expect(saveMessageAndRefreshRoom).not.toHaveBeenCalled()
|
|
expect(ack).toHaveBeenCalledWith({ error: 'Not in room' })
|
|
})
|
|
|
|
it('rejects stale agent context and stream side-channel events after session rotation', () => {
|
|
const broadcastEmit = vi.fn()
|
|
const roomEmit = vi.fn()
|
|
const updateRoomTotalTokens = vi.fn()
|
|
const agentMember = {
|
|
id: 'agent-socket-1',
|
|
userId: 'agent-stable-1',
|
|
name: 'Worker',
|
|
description: '',
|
|
joinedAt: Date.now(),
|
|
online: true,
|
|
socketId: 'agent-socket-1',
|
|
source: 'agent',
|
|
avatar: '',
|
|
}
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map([['room-1', {
|
|
getOnlineMemberBySocketId: vi.fn(() => agentMember),
|
|
}]])
|
|
server.contextStatusState = new Map()
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-2' })),
|
|
getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker' })),
|
|
updateRoomTotalTokens,
|
|
}
|
|
server.nsp = { to: vi.fn(() => ({ emit: broadcastEmit })) }
|
|
const socket = { id: 'agent-socket-1', to: vi.fn(() => ({ emit: roomEmit })) }
|
|
const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1')
|
|
const currentSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-2')
|
|
|
|
server.handleContextStatus(socket, {
|
|
roomId: 'room-1',
|
|
agentName: 'Worker',
|
|
status: 'replying',
|
|
totalTokens: 123,
|
|
agentSessionId: staleSessionId,
|
|
})
|
|
server.handleMessageStreamStart(socket, {
|
|
roomId: 'room-1',
|
|
id: 'late-stream',
|
|
agentSessionId: staleSessionId,
|
|
})
|
|
|
|
expect(updateRoomTotalTokens).not.toHaveBeenCalled()
|
|
expect(roomEmit).not.toHaveBeenCalled()
|
|
expect(broadcastEmit).not.toHaveBeenCalled()
|
|
expect(server.contextStatusState.size).toBe(0)
|
|
|
|
server.handleContextStatus(socket, {
|
|
roomId: 'room-1',
|
|
agentName: 'Worker',
|
|
status: 'replying',
|
|
totalTokens: 456,
|
|
agentSessionId: currentSessionId,
|
|
})
|
|
server.handleMessageStreamStart(socket, {
|
|
roomId: 'room-1',
|
|
id: 'current-stream',
|
|
agentSessionId: currentSessionId,
|
|
})
|
|
|
|
expect(updateRoomTotalTokens).toHaveBeenCalledWith('room-1', 456)
|
|
expect(roomEmit).toHaveBeenCalledWith('context_status', expect.objectContaining({ roomId: 'room-1', agentName: 'Worker', status: 'replying' }))
|
|
expect(broadcastEmit).toHaveBeenCalledWith('room_updated', { roomId: 'room-1', totalTokens: 456 })
|
|
expect(broadcastEmit).toHaveBeenCalledWith('message_stream_start', expect.objectContaining({ id: 'current-stream', senderName: 'Worker' }))
|
|
})
|
|
|
|
it('does not drop queued mentions when room interrupt is not synchronized', async () => {
|
|
const clients = new AgentClients() as any
|
|
const agent = { name: 'Worker', interrupt: vi.fn(async () => false) }
|
|
clients.rooms = new Map([['room-1', new Map([['agent-stable-1', agent]])]])
|
|
clients._mentionQueue = new Map([
|
|
['room-1', [{ agent, msg: { content: '@Worker one', senderName: 'Han', senderId: 'user-1', timestamp: 1 } }]],
|
|
['room-1:Worker', [{ agent, msg: { content: '@Worker two', senderName: 'Han', senderId: 'user-1', timestamp: 2 } }]],
|
|
])
|
|
|
|
await expect(clients.interruptRoom('room-1')).rejects.toMatchObject({ status: 409 })
|
|
|
|
expect(clients._mentionQueue.has('room-1')).toBe(true)
|
|
expect(clients._mentionQueue.has('room-1:Worker')).toBe(true)
|
|
})
|
|
|
|
it('rejects stale agent assistant/tool messages at persistence time after session rotation', () => {
|
|
const emit = vi.fn()
|
|
const saveMessageAndRefreshRoom = vi.fn()
|
|
const agentMember = {
|
|
id: 'agent-socket-1',
|
|
userId: 'agent-stable-1',
|
|
name: 'Worker',
|
|
description: '',
|
|
joinedAt: Date.now(),
|
|
online: true,
|
|
socketId: 'agent-socket-1',
|
|
source: 'agent',
|
|
avatar: '',
|
|
}
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map([['room-1', {
|
|
hasOnlineMember: vi.fn(() => true),
|
|
getOnlineMemberBySocketId: vi.fn(() => agentMember),
|
|
}]])
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', sessionSeed: 'seed-2' })),
|
|
getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker' })),
|
|
saveMessageAndRefreshRoom,
|
|
}
|
|
server.nsp = { to: vi.fn(() => ({ emit })) }
|
|
const ack = vi.fn()
|
|
const staleSessionId = groupBridgeSessionId('room-1', 'default', 'Worker', 'seed-1')
|
|
|
|
server.handleMessage({ id: 'agent-socket-1' }, {
|
|
roomId: 'room-1',
|
|
content: 'late',
|
|
role: 'assistant',
|
|
agentSessionId: staleSessionId,
|
|
}, ack)
|
|
|
|
expect(ack).toHaveBeenCalledWith({ error: 'Stale room session' })
|
|
expect(saveMessageAndRefreshRoom).not.toHaveBeenCalled()
|
|
expect(emit).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('clears runtime state before rotating persisted room context', async () => {
|
|
const calls: string[] = []
|
|
const room = { id: 'room-1', name: 'Room 1', inviteCode: 'invite', ownerAuthUserId: 7, workspace: '/tmp/workspace' }
|
|
const storage = {
|
|
getRoom: vi.fn(() => room),
|
|
clearRoomContext: vi.fn(() => { calls.push('storage-clear') }),
|
|
}
|
|
const chatServer = {
|
|
getStorage: () => storage,
|
|
clearRoomRuntimeState: vi.fn(async () => { calls.push('runtime-clear') }),
|
|
}
|
|
setGroupChatServer(chatServer as any)
|
|
|
|
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/clear-context', 'POST')
|
|
const ctx: any = {
|
|
params: { roomId: 'room-1' },
|
|
state: { user: { id: 1, username: 'root', role: 'super_admin' } },
|
|
status: 200,
|
|
body: undefined,
|
|
}
|
|
await handler(ctx, async () => {})
|
|
|
|
expect(calls).toEqual(['runtime-clear', 'storage-clear'])
|
|
expect(chatServer.clearRoomRuntimeState).toHaveBeenCalledWith('room-1')
|
|
expect(ctx.body).toEqual({ success: true, room: expect.objectContaining({ id: 'room-1', workspace: '/tmp/workspace' }) })
|
|
})
|
|
|
|
it('does not clear persisted context when runtime interrupt does not complete', async () => {
|
|
const room = { id: 'room-1', name: 'Room 1', inviteCode: 'invite', ownerAuthUserId: 7, workspace: '/tmp/workspace' }
|
|
const storage = {
|
|
getRoom: vi.fn(() => room),
|
|
clearRoomContext: vi.fn(),
|
|
}
|
|
const chatServer = {
|
|
getStorage: () => storage,
|
|
clearRoomRuntimeState: vi.fn(async () => { throw Object.assign(new Error('still running'), { status: 409 }) }),
|
|
}
|
|
setGroupChatServer(chatServer as any)
|
|
|
|
const handler = routeHandler('/api/hermes/group-chat/rooms/:roomId/clear-context', 'POST')
|
|
const ctx: any = {
|
|
params: { roomId: 'room-1' },
|
|
state: { user: { id: 1, username: 'root', role: 'super_admin' } },
|
|
status: 200,
|
|
body: undefined,
|
|
}
|
|
await handler(ctx, async () => {})
|
|
|
|
expect(ctx.status).toBe(409)
|
|
expect(ctx.body).toEqual({ error: 'still running' })
|
|
expect(storage.clearRoomContext).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('rejects authenticated Socket.IO room joins without invite, membership, owner, or profile scope', () => {
|
|
const emit = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map()
|
|
server.socketUserMap = new Map([['socket-1', 'auth:42']])
|
|
server.socketRequestedSourceMap = new Map([['socket-1', 'human']])
|
|
server.socketAuthUserIdMap = new Map([['socket-1', 42]])
|
|
server.userInfoMap = new Map([['auth:42', { name: 'alice', description: '' }]])
|
|
server.typingState = new Map()
|
|
server.contextStatusState = new Map()
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })),
|
|
getRoomAgentByAgentId: vi.fn(() => null),
|
|
getMemberByUserId: vi.fn(() => null),
|
|
getMemberByAuthUserId: vi.fn(() => null),
|
|
getRoomsForProfiles: vi.fn(() => []),
|
|
saveRoom: vi.fn(),
|
|
addRoomMember: vi.fn(),
|
|
getRecentMessagesForUI: vi.fn(() => []),
|
|
getRoomAgents: vi.fn(() => []),
|
|
}
|
|
const socket = {
|
|
id: 'socket-1',
|
|
data: { authUser: { id: 42, username: 'alice', role: 'admin', profiles: ['other'] } },
|
|
join: vi.fn(),
|
|
to: vi.fn(() => ({ emit })),
|
|
}
|
|
const ack = vi.fn()
|
|
|
|
server.handleJoin(socket, { roomId: 'room-1' }, ack)
|
|
|
|
expect(ack).toHaveBeenCalledWith({ error: 'Access denied' })
|
|
expect(server.storage.addRoomMember).not.toHaveBeenCalled()
|
|
expect(socket.join).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('denies read-only room members realtime management actions', async () => {
|
|
const emit = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map([['room-1', { hasOnlineMember: vi.fn(() => true) }]])
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', ownerAuthUserId: 7, inviteCode: 'secret' })),
|
|
getRoomsForProfiles: vi.fn(() => []),
|
|
}
|
|
server.agentClients = { interruptAgent: vi.fn() }
|
|
server.nsp = { to: vi.fn(() => ({ emit })) }
|
|
const socket = {
|
|
id: 'socket-1',
|
|
data: { authUser: { id: 42, username: 'member', role: 'admin', profiles: ['other'] } },
|
|
}
|
|
const interruptAck = vi.fn()
|
|
const approvalAck = vi.fn()
|
|
|
|
await server.handleInterruptAgent(socket, { roomId: 'room-1', agentName: 'Worker' }, interruptAck)
|
|
await server.handleApprovalRespond(socket, { roomId: 'room-1', approval_id: 'approval-1', choice: 'once' }, approvalAck)
|
|
|
|
expect(interruptAck).toHaveBeenCalledWith({ error: 'Access denied' })
|
|
expect(approvalAck).toHaveBeenCalledWith({ error: 'Access denied' })
|
|
expect(server.agentClients.interruptAgent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('denies runtime agent sockets realtime management actions even after they join the room', async () => {
|
|
const emit = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map([['room-1', { hasOnlineMember: vi.fn(() => true) }]])
|
|
server.socketRequestedSourceMap = new Map([['agent-socket', 'agent']])
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', ownerAuthUserId: 7, inviteCode: 'secret' })),
|
|
getRoomsForProfiles: vi.fn(() => []),
|
|
}
|
|
server.agentClients = { interruptAgent: vi.fn() }
|
|
server.nsp = { to: vi.fn(() => ({ emit })) }
|
|
const socket = { id: 'agent-socket', data: {} }
|
|
const interruptAck = vi.fn()
|
|
const approvalAck = vi.fn()
|
|
|
|
await server.handleInterruptAgent(socket, { roomId: 'room-1', agentName: 'Worker' }, interruptAck)
|
|
await server.handleApprovalRespond(socket, { roomId: 'room-1', approval_id: 'approval-1', choice: 'once' }, approvalAck)
|
|
|
|
expect(interruptAck).toHaveBeenCalledWith({ error: 'Access denied' })
|
|
expect(approvalAck).toHaveBeenCalledWith({ error: 'Access denied' })
|
|
expect(server.agentClients.interruptAgent).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('allows pre-persisted agent sockets to join without creating human membership', () => {
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map()
|
|
server.socketUserMap = new Map([['socket-agent', 'agent-stable-1']])
|
|
server.socketRequestedSourceMap = new Map([['socket-agent', 'agent']])
|
|
server.socketAuthUserIdMap = new Map()
|
|
server.userInfoMap = new Map([['agent-stable-1', { name: 'Worker', description: 'runtime agent' }]])
|
|
server.typingState = new Map()
|
|
server.contextStatusState = new Map()
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })),
|
|
getRoomAgentByAgentId: vi.fn(() => ({ id: 'row-1', roomId: 'room-1', agentId: 'agent-stable-1', profile: 'default', name: 'Worker', description: '', invited: 0 })),
|
|
getMemberByUserId: vi.fn(() => null),
|
|
getMemberByAuthUserId: vi.fn(() => null),
|
|
saveRoom: vi.fn(),
|
|
addRoomMember: vi.fn(),
|
|
getRecentMessagesForUI: vi.fn(() => []),
|
|
getRoomAgents: vi.fn(() => []),
|
|
}
|
|
const socket = {
|
|
id: 'socket-agent',
|
|
data: {},
|
|
join: vi.fn(),
|
|
to: vi.fn(() => ({ emit: vi.fn() })),
|
|
}
|
|
const ack = vi.fn()
|
|
|
|
server.handleJoin(socket, { roomId: 'room-1' }, ack)
|
|
|
|
expect(server.storage.getRoomAgentByAgentId).toHaveBeenCalledWith('room-1', 'agent-stable-1')
|
|
expect(server.storage.addRoomMember).not.toHaveBeenCalled()
|
|
expect(socket.join).toHaveBeenCalledWith('room-1')
|
|
expect(ack.mock.calls[0][0]).toEqual(expect.objectContaining({ roomId: 'room-1', messages: [], agents: [] }))
|
|
})
|
|
|
|
it('allows Socket.IO room joins with the matching invite code and then persists membership', () => {
|
|
const emit = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map()
|
|
server.socketUserMap = new Map([['socket-1', 'auth:42']])
|
|
server.socketRequestedSourceMap = new Map([['socket-1', 'human']])
|
|
server.socketAuthUserIdMap = new Map([['socket-1', 42]])
|
|
server.userInfoMap = new Map([['auth:42', { name: 'alice', description: '' }]])
|
|
server.typingState = new Map()
|
|
server.contextStatusState = new Map()
|
|
server.storage = {
|
|
getRoom: vi.fn(() => ({ id: 'room-1', name: 'Room', inviteCode: 'secret', ownerAuthUserId: 7 })),
|
|
getRoomAgentByAgentId: vi.fn(() => null),
|
|
getMemberByUserId: vi.fn(() => null),
|
|
getMemberByAuthUserId: vi.fn(() => null),
|
|
getRoomsForProfiles: vi.fn(() => []),
|
|
saveRoom: vi.fn(),
|
|
addRoomMember: vi.fn(),
|
|
getRecentMessagesForUI: vi.fn(() => []),
|
|
getRoomAgents: vi.fn(() => []),
|
|
}
|
|
const socket = {
|
|
id: 'socket-1',
|
|
data: { authUser: { id: 42, username: 'alice', role: 'admin', profiles: ['other'] } },
|
|
join: vi.fn(),
|
|
to: vi.fn(() => ({ emit })),
|
|
}
|
|
const ack = vi.fn()
|
|
|
|
server.handleJoin(socket, { roomId: 'room-1', inviteCode: 'secret' }, ack)
|
|
|
|
expect(server.storage.addRoomMember).toHaveBeenCalledWith('room-1', 'auth:42', 'alice', '', expect.any(String), 42)
|
|
expect(socket.join).toHaveBeenCalledWith('room-1')
|
|
expect(ack.mock.calls[0][0]).toEqual(expect.objectContaining({ roomId: 'room-1', messages: [], agents: [] }))
|
|
})
|
|
|
|
it('reuses an authenticated member name when the browser has no local group-chat name', () => {
|
|
const emit = vi.fn()
|
|
const server = Object.create(GroupChatServer.prototype) as any
|
|
server.rooms = new Map()
|
|
server.socketUserMap = new Map([['socket-1', 'auth:42']])
|
|
server.socketRequestedSourceMap = new Map([['socket-1', 'human']])
|
|
server.socketAuthUserIdMap = new Map([['socket-1', 42]])
|
|
server.userInfoMap = new Map([['auth:42', { name: 'alice-login', description: '' }]])
|
|
server.typingState = new Map()
|
|
server.contextStatusState = new Map()
|
|
server.storage = {
|
|
getRoomAgentByAgentId: vi.fn(() => null),
|
|
getMemberByUserId: vi.fn(() => null),
|
|
getMemberByAuthUserId: vi.fn(() => ({
|
|
id: 'member-old',
|
|
userId: 'browser-local-id',
|
|
name: 'Alice Display',
|
|
description: 'saved description',
|
|
joinedAt: 1,
|
|
avatar: '',
|
|
authUserId: 42,
|
|
})),
|
|
saveRoom: vi.fn(),
|
|
addRoomMember: vi.fn(),
|
|
getRecentMessagesForUI: vi.fn(() => []),
|
|
getRoomAgents: vi.fn(() => []),
|
|
}
|
|
const socket = {
|
|
id: 'socket-1',
|
|
join: vi.fn(),
|
|
to: vi.fn(() => ({ emit })),
|
|
}
|
|
const ack = vi.fn()
|
|
|
|
server.handleJoin(socket, { roomId: 'room-1' }, ack)
|
|
|
|
expect(server.storage.addRoomMember).toHaveBeenCalledWith(
|
|
'room-1',
|
|
'auth:42',
|
|
'Alice Display',
|
|
'saved description',
|
|
'',
|
|
42,
|
|
)
|
|
expect(ack.mock.calls[0][0].members).toEqual([
|
|
expect.objectContaining({ userId: 'auth:42', name: 'Alice Display' }),
|
|
])
|
|
})
|
|
|
|
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: [expect.objectContaining({ id: 'room-default', inviteCode: null, canManage: true })] })
|
|
})
|
|
|
|
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: [expect.objectContaining({ id: 'room-1', inviteCode: null, canManage: true })] })
|
|
})
|
|
|
|
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.socketRequestedSourceMap = new Map([
|
|
['human-socket', 'human'],
|
|
['agent-socket', 'agent'],
|
|
])
|
|
server.userInfoMap = new Map([
|
|
['human-1', { name: 'Human', description: '' }],
|
|
['agent-1', { name: '丫鬟', description: '' }],
|
|
])
|
|
server.agentClients = { processMentions: vi.fn(async () => undefined) }
|
|
const agentSessionId = groupBridgeSessionId('room-1', 'default', '丫鬟', 'seed-1')
|
|
server.storage = {
|
|
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: '丫鬟' })),
|
|
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, agentSessionId }, 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, agentSessionId }, vi.fn())
|
|
expect(server.agentClients.processMentions).not.toHaveBeenCalled()
|
|
})
|
|
})
|