[verified] Fix group chat agent member sync (#877)

This commit is contained in:
Zhicheng Han
2026-05-20 11:13:15 +02:00
committed by GitHub
parent 51c3f0c62a
commit 6578873d9e
6 changed files with 246 additions and 32 deletions
+1 -1
View File
@@ -186,7 +186,7 @@ export async function listAgents(roomId: string): Promise<{ agents: RoomAgent[]
return request(`/api/hermes/group-chat/rooms/${roomId}/agents`) return request(`/api/hermes/group-chat/rooms/${roomId}/agents`)
} }
export async function removeAgent(roomId: string, agentId: string): Promise<void> { export async function removeAgent(roomId: string, agentId: string): Promise<{ success: boolean; agents: RoomAgent[]; members: MemberInfo[] }> {
return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, { return request(`/api/hermes/group-chat/rooms/${roomId}/agents/${agentId}`, {
method: 'DELETE', method: 'DELETE',
}) })
@@ -618,8 +618,9 @@ export const useGroupChatStore = defineStore('groupChat', () => {
async function removeAgentFromRoom(roomId: string, agentId: string) { async function removeAgentFromRoom(roomId: string, agentId: string) {
try { try {
await removeAgent(roomId, agentId) const res = await removeAgent(roomId, agentId)
agents.value = agents.value.filter(a => a.id !== agentId) agents.value = res.agents ?? agents.value.filter(a => a.id !== agentId && a.agentId !== agentId)
if (res.members) members.value = res.members
} catch (err: any) { } catch (err: any) {
error.value = err.message error.value = err.message
throw err throw err
@@ -66,6 +66,7 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => {
try { try {
const client = await chatServer.agentClients.createAgent({ const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile, profile: agent.profile,
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
@@ -121,6 +122,7 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) =
try { try {
const client = await chatServer.agentClients.createAgent({ const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile, profile: agent.profile,
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
@@ -240,6 +242,7 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx)
// Auto-connect agent via Socket.IO // Auto-connect agent via Socket.IO
try { try {
const client = await chatServer.agentClients.createAgent({ const client = await chatServer.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile, profile: agent.profile,
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
@@ -273,9 +276,24 @@ groupChatRoutes.delete('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', a
return return
} }
chatServer.getStorage().removeRoomAgent(ctx.params.agentId) const roomId = ctx.params.roomId
chatServer.agentClients.removeAgentFromRoom(ctx.params.roomId, ctx.params.agentId) const requestedAgentId = ctx.params.agentId
ctx.body = { success: true } const storage = chatServer.getStorage()
const agent = storage.getRoomAgent(roomId, requestedAgentId)
if (!agent) {
ctx.status = 404
ctx.body = { error: 'Agent not found' }
return
}
storage.removeRoomMembersForAgent(roomId, agent)
storage.removeRoomAgent(roomId, requestedAgentId)
chatServer.agentClients.removeAgentFromRoom(roomId, agent.agentId)
ctx.body = {
success: true,
agents: storage.getRoomAgents(roomId),
members: storage.getRoomMembers(roomId),
}
}) })
// Delete room // Delete room
@@ -1,4 +1,5 @@
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { randomBytes } from 'crypto'
import { getToken } from '../../../services/auth' import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger' import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store' import { updateUsage } from '../../../db/hermes/usage-store'
@@ -11,9 +12,12 @@ import {
stripMentionRoutingTokens, stripMentionRoutingTokens,
} from './mention-routing' } from './mention-routing'
export const GROUP_CHAT_AGENT_SOCKET_SECRET = randomBytes(32).toString('hex')
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
interface AgentConfig { interface AgentConfig {
agentId?: string
profile: string profile: string
name: string name: string
description: string description: string
@@ -77,7 +81,7 @@ class AgentClient {
private pendingToolBaseIds = new Map<string, string>() private pendingToolBaseIds = new Map<string, string>()
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) { constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) this.agentId = config.agentId || Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
this.profile = config.profile this.profile = config.profile
this.name = config.name this.name = config.name
this.description = config.description this.description = config.description
@@ -107,7 +111,11 @@ class AgentClient {
this.socket = io(`http://127.0.0.1:${actualPort}/group-chat`, { this.socket = io(`http://127.0.0.1:${actualPort}/group-chat`, {
auth: { auth: {
token: token || undefined, token: token || undefined,
userId: this.agentId,
name: this.name, name: this.name,
description: this.description,
source: 'agent',
agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET,
}, },
transports: ['websocket'], transports: ['websocket'],
reconnection: true, reconnection: true,
@@ -243,7 +251,7 @@ class AgentClient {
} }
} }
// ─── Hermes Gateway Integration ──────────────────────────── // ─── Hermes Agent Bridge Integration ───────────────────────
/** /**
* Handle an @mention from the server side. * Handle an @mention from the server side.
@@ -3,7 +3,7 @@ import type { Server as HttpServer } from 'http'
import { getToken } from '../../../services/auth' import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger' import { logger } from '../../../services/logger'
import { getDb } from '../../../db' import { getDb } from '../../../db'
import { AgentClients } from './agent-clients' import { AgentClients, GROUP_CHAT_AGENT_SOCKET_SECRET } from './agent-clients'
import { ContextEngine } from '../context-engine/compressor' import { ContextEngine } from '../context-engine/compressor'
import { SessionDeleter } from '../session-deleter' import { SessionDeleter } from '../session-deleter'
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
@@ -75,6 +75,7 @@ interface Member {
joinedAt: number joinedAt: number
online: boolean online: boolean
socketId: string socketId: string
source?: 'human' | 'agent'
} }
let _tablesEnsured = false let _tablesEnsured = false
@@ -476,8 +477,20 @@ class ChatStorage {
return { id, roomId, agentId, profile, name, description, invited } return { id, roomId, agentId, profile, name, description, invited }
} }
removeRoomAgent(agentId: string): void { getRoomAgent(roomId: string, agentRef: string): RoomAgent | null {
this.db()?.prepare('DELETE FROM gc_room_agents WHERE id = ?').run(agentId) return (this.db()?.prepare(
'SELECT id, roomId, agentId, profile, name, description, invited FROM gc_room_agents WHERE roomId = ? AND (id = ? OR agentId = ?)'
).get(roomId, agentRef, agentRef) as any) ?? null
}
getRoomAgentByAgentId(roomId: string, agentId: string): RoomAgent | null {
return (this.db()?.prepare(
'SELECT id, roomId, agentId, profile, name, description, invited FROM gc_room_agents WHERE roomId = ? AND agentId = ?'
).get(roomId, agentId) as any) ?? null
}
removeRoomAgent(roomId: string, agentRef: string): void {
this.db()?.prepare('DELETE FROM gc_room_agents WHERE roomId = ? AND (id = ? OR agentId = ?)').run(roomId, agentRef, agentRef)
} }
// ─── Context Snapshots ────────────────────────────────── // ─── Context Snapshots ──────────────────────────────────
@@ -512,10 +525,26 @@ class ChatStorage {
getRoomMembers(roomId: string): { id: string; userId: string; name: string; description: string; joinedAt: number }[] { getRoomMembers(roomId: string): { id: string; userId: string; name: string; description: string; joinedAt: number }[] {
return (this.db()?.prepare( return (this.db()?.prepare(
'SELECT id, userId, userName as name, description, joinedAt FROM gc_room_members WHERE roomId = ? ORDER BY joinedAt' `SELECT m.id, m.userId, m.userName as name, m.description, m.joinedAt
FROM gc_room_members m
WHERE m.roomId = ?
AND NOT EXISTS (
SELECT 1 FROM gc_room_agents a
WHERE a.roomId = m.roomId
AND (a.agentId = m.userId OR (m.userId NOT GLOB '????????-????-????-????-????????????' AND COALESCE(m.description, '') = '' AND a.name = m.userName))
)
ORDER BY m.joinedAt`
).all(roomId) || []) as unknown as { id: string; userId: string; name: string; description: string; joinedAt: number }[] ).all(roomId) || []) as unknown as { id: string; userId: string; name: string; description: string; joinedAt: number }[]
} }
removeRoomMembersForAgent(roomId: string, agent: Pick<RoomAgent, 'agentId' | 'name'>): void {
this.db()?.prepare(
`DELETE FROM gc_room_members
WHERE roomId = ?
AND (userId = ? OR (userId NOT GLOB '????????-????-????-????-????????????' AND COALESCE(description, '') = '' AND userName = ?))`
).run(roomId, agent.agentId, agent.name)
}
addRoomMember(roomId: string, userId: string, userName: string, description: string): void { addRoomMember(roomId: string, userId: string, userName: string, description: string): void {
const existing = this.getMemberByUserId(roomId, userId) const existing = this.getMemberByUserId(roomId, userId)
if (existing) { if (existing) {
@@ -565,16 +594,17 @@ class ChatRoom {
this.name = name || id this.name = name || id
} }
addOrUpdateMember(socketId: string, userId: string, name: string, description: string): Member { addOrUpdateMember(socketId: string, userId: string, name: string, description: string, source: 'human' | 'agent' = 'human'): Member {
const existing = this.members.get(userId) const existing = this.members.get(userId)
if (existing) { if (existing) {
existing.name = name existing.name = name
existing.description = description existing.description = description
existing.online = true existing.online = true
existing.socketId = socketId existing.socketId = socketId
existing.source = source
return existing return existing
} }
const member: Member = { id: socketId, userId, name, description, joinedAt: Date.now(), online: true, socketId } const member: Member = { id: socketId, userId, name, description, joinedAt: Date.now(), online: true, socketId, source }
this.members.set(userId, member) this.members.set(userId, member)
return member return member
} }
@@ -589,7 +619,7 @@ class ChatRoom {
} }
getMembersList(): Member[] { getMembersList(): Member[] {
return Array.from(this.members.values()) return Array.from(this.members.values()).filter(member => member.source !== 'agent')
} }
getOnlineMemberBySocketId(socketId: string): Member | undefined { getOnlineMemberBySocketId(socketId: string): Member | undefined {
@@ -615,6 +645,8 @@ export class GroupChatServer {
private socketUserMap = new Map<string, string>() private socketUserMap = new Map<string, string>()
/** Map: userId → { name, description } (from auth) */ /** Map: userId → { name, description } (from auth) */
private userInfoMap = new Map<string, { name: string; description: string }>() private userInfoMap = new Map<string, { name: string; description: string }>()
/** Map: socket.id → requested participant source from handshake */
private socketRequestedSourceMap = new Map<string, 'human' | 'agent'>()
readonly agentClients = new AgentClients() readonly agentClients = new AgentClients()
private _contextEngine: ContextEngine | null = null private _contextEngine: ContextEngine | null = null
private _restoreScheduled = false private _restoreScheduled = false
@@ -721,6 +753,7 @@ export class GroupChatServer {
for (const agent of agents) { for (const agent of agents) {
try { try {
const client = await this.agentClients.createAgent({ const client = await this.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile, profile: agent.profile,
name: agent.name, name: agent.name,
description: agent.description, description: agent.description,
@@ -755,12 +788,14 @@ export class GroupChatServer {
// ─── Connection ───────────────────────────────────────────── // ─── Connection ─────────────────────────────────────────────
private onConnection(socket: Socket): void { private onConnection(socket: Socket): void {
const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string } const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string; source?: string; agentSocketSecret?: string }
const userId = auth.userId || socket.id const userId = auth.userId || socket.id
const userName = auth.name || `User-${userId.slice(0, 6)}` const userName = auth.name || `User-${userId.slice(0, 6)}`
const description = auth.description || '' const description = auth.description || ''
const requestedSource = auth.source === 'agent' && auth.agentSocketSecret === GROUP_CHAT_AGENT_SOCKET_SECRET ? 'agent' : 'human'
this.socketUserMap.set(socket.id, userId) this.socketUserMap.set(socket.id, userId)
this.socketRequestedSourceMap.set(socket.id, requestedSource)
this.userInfoMap.set(userId, { name: userName, description }) this.userInfoMap.set(userId, { name: userName, description })
logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`) logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`)
@@ -786,7 +821,14 @@ export class GroupChatServer {
private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void { private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void {
const socketId = socket.id const socketId = socket.id
const userId = this.socketUserMap.get(socketId) || socketId const userId = this.socketUserMap.get(socketId) || socketId
const requestedSource = this.socketRequestedSourceMap.get(socketId) || 'human'
const roomId = data.roomId || 'general' const roomId = data.roomId || 'general'
const roomAgent = this.storage.getRoomAgentByAgentId(roomId, userId)
const source = requestedSource === 'agent' && roomAgent ? 'agent' : 'human'
if (source === 'human' && roomAgent) {
ack?.({ error: 'Reserved member identity' })
return
}
const existingMember = this.storage.getMemberByUserId(roomId, userId) const existingMember = this.storage.getMemberByUserId(roomId, userId)
const userInfo = this.userInfoMap.get(userId) || { const userInfo = this.userInfoMap.get(userId) || {
name: existingMember?.name || `User-${userId.slice(0, 6)}`, name: existingMember?.name || `User-${userId.slice(0, 6)}`,
@@ -805,19 +847,25 @@ export class GroupChatServer {
this.storage.saveRoom(roomId, roomId) this.storage.saveRoom(roomId, roomId)
} }
// Persist member to SQLite // Persist only human members. Agent sockets are runtime participants
this.storage.addRoomMember(roomId, userId, userName, description) // tracked through gc_room_agents and AgentClients; storing them in
// gc_room_members makes member counts grow on reconnect/restore.
if (source !== 'agent') {
this.storage.addRoomMember(roomId, userId, userName, description)
}
// Add to in-memory online members (keyed by userId) // Add to in-memory online participants (keyed by userId)
room.addOrUpdateMember(socketId, userId, userName, description) room.addOrUpdateMember(socketId, userId, userName, description, source)
socket.join(roomId) socket.join(roomId)
socket.to(roomId).emit('member_joined', { if (source !== 'agent') {
roomId, socket.to(roomId).emit('member_joined', {
memberId: userId, roomId,
memberName: userName, memberId: userId,
members: room.getMembersList(), memberName: userName,
}) members: room.getMembersList(),
})
}
// Load history from SQLite // Load history from SQLite
const messages = this.storage.getMessages(roomId) const messages = this.storage.getMessages(roomId)
@@ -1114,6 +1162,7 @@ export class GroupChatServer {
this.leaveAllRooms(socket, socketId) this.leaveAllRooms(socket, socketId)
this.socketUserMap.delete(socketId) this.socketUserMap.delete(socketId)
this.socketRequestedSourceMap.delete(socketId)
// Don't delete userInfoMap — it persists across reconnects // Don't delete userInfoMap — it persists across reconnects
} }
@@ -1137,12 +1186,14 @@ export class GroupChatServer {
const member = room.getOnlineMemberBySocketId(socketId) const member = room.getOnlineMemberBySocketId(socketId)
room.removeMember(socketId) room.removeMember(socketId)
socket.leave(rid) socket.leave(rid)
this.nsp.to(rid).emit('member_left', { if (member?.source !== 'agent') {
roomId: rid, this.nsp.to(rid).emit('member_left', {
memberId: member?.userId || socketId, roomId: rid,
memberName: member?.name || `User-${socketId.slice(0, 6)}`, memberId: member?.userId || socketId,
members: room.getMembersList(), memberName: member?.name || `User-${socketId.slice(0, 6)}`,
}) members: room.getMembersList(),
})
}
} }
}) })
} }
+136
View File
@@ -0,0 +1,136 @@
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 { 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('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 }],
})
})
})