[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,4 +1,5 @@
import { io, Socket } from 'socket.io-client'
import { randomBytes } from 'crypto'
import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store'
@@ -11,9 +12,12 @@ import {
stripMentionRoutingTokens,
} from './mention-routing'
export const GROUP_CHAT_AGENT_SOCKET_SECRET = randomBytes(32).toString('hex')
// ─── Types ────────────────────────────────────────────────────
interface AgentConfig {
agentId?: string
profile: string
name: string
description: string
@@ -77,7 +81,7 @@ class AgentClient {
private pendingToolBaseIds = new Map<string, string>()
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.name = config.name
this.description = config.description
@@ -107,7 +111,11 @@ class AgentClient {
this.socket = io(`http://127.0.0.1:${actualPort}/group-chat`, {
auth: {
token: token || undefined,
userId: this.agentId,
name: this.name,
description: this.description,
source: 'agent',
agentSocketSecret: GROUP_CHAT_AGENT_SOCKET_SECRET,
},
transports: ['websocket'],
reconnection: true,
@@ -243,7 +251,7 @@ class AgentClient {
}
}
// ─── Hermes Gateway Integration ────────────────────────────
// ─── Hermes Agent Bridge Integration ───────────────────────
/**
* Handle an @mention from the server side.
@@ -3,7 +3,7 @@ import type { Server as HttpServer } from 'http'
import { getToken } from '../../../services/auth'
import { logger } from '../../../services/logger'
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 { SessionDeleter } from '../session-deleter'
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
@@ -75,6 +75,7 @@ interface Member {
joinedAt: number
online: boolean
socketId: string
source?: 'human' | 'agent'
}
let _tablesEnsured = false
@@ -476,8 +477,20 @@ class ChatStorage {
return { id, roomId, agentId, profile, name, description, invited }
}
removeRoomAgent(agentId: string): void {
this.db()?.prepare('DELETE FROM gc_room_agents WHERE id = ?').run(agentId)
getRoomAgent(roomId: string, agentRef: string): RoomAgent | null {
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 ──────────────────────────────────
@@ -512,10 +525,26 @@ class ChatStorage {
getRoomMembers(roomId: string): { id: string; userId: string; name: string; description: string; joinedAt: number }[] {
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 }[]
}
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 {
const existing = this.getMemberByUserId(roomId, userId)
if (existing) {
@@ -565,16 +594,17 @@ class ChatRoom {
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)
if (existing) {
existing.name = name
existing.description = description
existing.online = true
existing.socketId = socketId
existing.source = source
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)
return member
}
@@ -589,7 +619,7 @@ class ChatRoom {
}
getMembersList(): Member[] {
return Array.from(this.members.values())
return Array.from(this.members.values()).filter(member => member.source !== 'agent')
}
getOnlineMemberBySocketId(socketId: string): Member | undefined {
@@ -615,6 +645,8 @@ export class GroupChatServer {
private socketUserMap = new Map<string, string>()
/** Map: userId → { name, description } (from auth) */
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()
private _contextEngine: ContextEngine | null = null
private _restoreScheduled = false
@@ -721,6 +753,7 @@ export class GroupChatServer {
for (const agent of agents) {
try {
const client = await this.agentClients.createAgent({
agentId: agent.agentId,
profile: agent.profile,
name: agent.name,
description: agent.description,
@@ -755,12 +788,14 @@ export class GroupChatServer {
// ─── Connection ─────────────────────────────────────────────
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 userName = auth.name || `User-${userId.slice(0, 6)}`
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.socketRequestedSourceMap.set(socket.id, requestedSource)
this.userInfoMap.set(userId, { name: userName, description })
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 {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId) || socketId
const requestedSource = this.socketRequestedSourceMap.get(socketId) || 'human'
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 userInfo = this.userInfoMap.get(userId) || {
name: existingMember?.name || `User-${userId.slice(0, 6)}`,
@@ -805,19 +847,25 @@ export class GroupChatServer {
this.storage.saveRoom(roomId, roomId)
}
// Persist member to SQLite
this.storage.addRoomMember(roomId, userId, userName, description)
// Persist only human members. Agent sockets are runtime participants
// 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)
room.addOrUpdateMember(socketId, userId, userName, description)
// Add to in-memory online participants (keyed by userId)
room.addOrUpdateMember(socketId, userId, userName, description, source)
socket.join(roomId)
socket.to(roomId).emit('member_joined', {
roomId,
memberId: userId,
memberName: userName,
members: room.getMembersList(),
})
if (source !== 'agent') {
socket.to(roomId).emit('member_joined', {
roomId,
memberId: userId,
memberName: userName,
members: room.getMembersList(),
})
}
// Load history from SQLite
const messages = this.storage.getMessages(roomId)
@@ -1114,6 +1162,7 @@ export class GroupChatServer {
this.leaveAllRooms(socket, socketId)
this.socketUserMap.delete(socketId)
this.socketRequestedSourceMap.delete(socketId)
// Don't delete userInfoMap — it persists across reconnects
}
@@ -1137,12 +1186,14 @@ export class GroupChatServer {
const member = room.getOnlineMemberBySocketId(socketId)
room.removeMember(socketId)
socket.leave(rid)
this.nsp.to(rid).emit('member_left', {
roomId: rid,
memberId: member?.userId || socketId,
memberName: member?.name || `User-${socketId.slice(0, 6)}`,
members: room.getMembersList(),
})
if (member?.source !== 'agent') {
this.nsp.to(rid).emit('member_left', {
roomId: rid,
memberId: member?.userId || socketId,
memberName: member?.name || `User-${socketId.slice(0, 6)}`,
members: room.getMembersList(),
})
}
}
})
}