diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index 75fa15a4..7b427c3a 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -23,6 +23,15 @@ export interface RoomAgent { invited: number } +export interface AgentAddResult { + profile: string + ok: boolean + agent?: RoomAgent + code?: string + error?: string + reason?: string +} + export interface ChatMessage { id: string roomId: string @@ -133,7 +142,7 @@ export async function createRoom(data: { inviteCode: string agents?: { profile: string; name?: string; description?: string; invited?: boolean }[] compression?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number } -}): Promise<{ room: RoomInfo; agents: RoomAgent[] }> { +}): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { return request('/api/hermes/group-chat/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -141,7 +150,7 @@ export async function createRoom(data: { }) } -export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[] }> { +export async function cloneRoom(roomId: string, data?: { name?: string; inviteCode?: string }): Promise<{ room: RoomInfo; agents: RoomAgent[]; agentResults?: AgentAddResult[] }> { return request(`/api/hermes/group-chat/rooms/${roomId}/clone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue index 106b7537..902a0ee6 100644 --- a/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue +++ b/packages/client/src/components/hermes/group-chat/GroupChatPanel.vue @@ -64,12 +64,36 @@ function generateCode(): string { return code } +function formatAgentFailures(results?: Array<{ ok: boolean; profile: string; error?: string; reason?: string }>): string | null { + const failed = results?.filter(result => !result.ok) || [] + if (failed.length === 0) return null + const details = failed.map(result => result.reason || result.error || result.profile).join('; ') + return t('groupChat.agentAddFailedCount', { count: failed.length, details }) +} + +function extractApiErrorMessage(err: any): string { + const raw = err?.message || '' + const jsonStart = raw.indexOf('{') + if (jsonStart >= 0) { + try { + const parsed = JSON.parse(raw.slice(jsonStart)) + if (parsed?.code === 'PROFILE_AGENT_CONNECT_FAILED' && parsed?.error) { + return parsed.reason ? `${parsed.error}: ${parsed.reason}` : parsed.error + } + if (parsed?.error) return parsed.error + } catch { /* ignore */ } + } + return raw || t('common.saveFailed') +} + async function handleCreateRoom(name: string, inviteCode: string, userName: string, description: string, compression: { triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number }) { try { store.setUserInfo(userName, description) const res = await store.createNewRoom(name, inviteCode, undefined, compression) showCreateModal.value = false - message.success(t('groupChat.roomCreated')) + const failureMessage = formatAgentFailures(res.agentResults) + if (failureMessage) message.warning(failureMessage) + else message.success(t('groupChat.roomCreated')) await store.joinRoom(res.room.id) } catch { message.error(t('common.saveFailed')) @@ -105,7 +129,9 @@ async function confirmCloneRoom() { cloneRoomName.value = '' cloneInviteCode.value = '' await store.joinRoom(res.room.id) - message.success(t('groupChat.roomCloned')) + const failureMessage = formatAgentFailures(res.agentResults) + if (failureMessage) message.warning(failureMessage) + else message.success(t('groupChat.roomCloned')) } catch { message.error(t('common.saveFailed')) } @@ -170,7 +196,7 @@ async function confirmAddAgent() { if (err.message?.includes('already')) { message.warning(t('groupChat.agentAlreadyInRoom')) } else { - message.error(t('common.saveFailed')) + message.error(extractApiErrorMessage(err)) } } } diff --git a/packages/client/src/i18n/locales/de.ts b/packages/client/src/i18n/locales/de.ts index 816a7228..e8713941 100644 --- a/packages/client/src/i18n/locales/de.ts +++ b/packages/client/src/i18n/locales/de.ts @@ -1104,6 +1104,7 @@ jobTriggered: 'Job ausgelost', selectProfile: 'Wahlen Sie ein Profil', agentAdded: 'Agent hinzugefugt', agentAlreadyInRoom: 'Agent ist bereits in diesem Raum', + agentAddFailedCount: '{count} Agent(en) wurden nicht hinzugefugt: {details}', noAgents: 'Keine Agenten in diesem Raum', members: 'Mitglieder', roomCreated: 'Raum erstellt', diff --git a/packages/client/src/i18n/locales/en.ts b/packages/client/src/i18n/locales/en.ts index bbab7e68..770c0760 100644 --- a/packages/client/src/i18n/locales/en.ts +++ b/packages/client/src/i18n/locales/en.ts @@ -1053,6 +1053,7 @@ export default { selectProfile: 'Select a profile', agentAdded: 'Agent added', agentAlreadyInRoom: 'Agent already in this room', + agentAddFailedCount: '{count} agent(s) were not added: {details}', noAgents: 'No agents in this room', members: 'members', roomCreated: 'Room created', diff --git a/packages/client/src/i18n/locales/es.ts b/packages/client/src/i18n/locales/es.ts index 866363bf..2c7092ec 100644 --- a/packages/client/src/i18n/locales/es.ts +++ b/packages/client/src/i18n/locales/es.ts @@ -1104,6 +1104,7 @@ jobTriggered: 'Job ejecutado', selectProfile: 'Seleccione un perfil', agentAdded: 'Agente agregado', agentAlreadyInRoom: 'El agente ya esta en esta sala', + agentAddFailedCount: 'No se agregaron {count} agente(s): {details}', noAgents: 'No hay agentes en esta sala', members: 'Miembros', roomCreated: 'Sala creada', diff --git a/packages/client/src/i18n/locales/fr.ts b/packages/client/src/i18n/locales/fr.ts index c70aa448..efe4afb4 100644 --- a/packages/client/src/i18n/locales/fr.ts +++ b/packages/client/src/i18n/locales/fr.ts @@ -1104,6 +1104,7 @@ jobTriggered: 'Job declenche', selectProfile: 'Selectionnez un profil', agentAdded: 'Agent ajoute', agentAlreadyInRoom: "L'agent est deja dans ce salon", + agentAddFailedCount: "{count} agent(s) n'ont pas ete ajoutes : {details}", noAgents: 'Aucun agent dans ce salon', members: 'Membres', roomCreated: 'Salon cree', diff --git a/packages/client/src/i18n/locales/ja.ts b/packages/client/src/i18n/locales/ja.ts index c653f192..12caceb3 100644 --- a/packages/client/src/i18n/locales/ja.ts +++ b/packages/client/src/i18n/locales/ja.ts @@ -1104,6 +1104,7 @@ export default { selectProfile: 'プロファイルを選択', agentAdded: 'エージェントが追加されました', agentAlreadyInRoom: 'このエージェントは既にルームにいます', + agentAddFailedCount: '{count} 件のエージェントを追加できませんでした: {details}', noAgents: 'このルームにエージェントはいません', members: 'メンバー', roomCreated: 'ルームが作成されました', diff --git a/packages/client/src/i18n/locales/ko.ts b/packages/client/src/i18n/locales/ko.ts index e1355110..88f949f3 100644 --- a/packages/client/src/i18n/locales/ko.ts +++ b/packages/client/src/i18n/locales/ko.ts @@ -1104,6 +1104,7 @@ export default { selectProfile: '프로필 선택', agentAdded: '에이전트가 추가되었습니다', agentAlreadyInRoom: '해당 에이전트가 이미 방에 있습니다', + agentAddFailedCount: '{count}개의 에이전트를 추가하지 못했습니다: {details}', noAgents: '이 방에 에이전트가 없습니다', members: '멤버', roomCreated: '방이 생성되었습니다', diff --git a/packages/client/src/i18n/locales/pt.ts b/packages/client/src/i18n/locales/pt.ts index baed158a..a7d390d4 100644 --- a/packages/client/src/i18n/locales/pt.ts +++ b/packages/client/src/i18n/locales/pt.ts @@ -1104,6 +1104,7 @@ jobTriggered: 'Job acionado', selectProfile: 'Selecione um perfil', agentAdded: 'Agente adicionado', agentAlreadyInRoom: 'O agente ja esta nesta sala', + agentAddFailedCount: '{count} agente(s) nao foram adicionados: {details}', noAgents: 'Nenhum agente nesta sala', members: 'Membros', roomCreated: 'Sala criada', diff --git a/packages/client/src/i18n/locales/zh-TW.ts b/packages/client/src/i18n/locales/zh-TW.ts index 3effe88a..945de8d6 100644 --- a/packages/client/src/i18n/locales/zh-TW.ts +++ b/packages/client/src/i18n/locales/zh-TW.ts @@ -1056,6 +1056,7 @@ export default { selectProfile: '選擇一個設定檔', agentAdded: '智慧代理已新增', agentAlreadyInRoom: '該智慧代理已在房間中', + agentAddFailedCount: '{count} 個智慧代理未新增:{details}', noAgents: '目前房間無智慧代理', members: '成員', roomCreated: '房間已建立', diff --git a/packages/client/src/i18n/locales/zh.ts b/packages/client/src/i18n/locales/zh.ts index 4d4d545a..0ace8fca 100644 --- a/packages/client/src/i18n/locales/zh.ts +++ b/packages/client/src/i18n/locales/zh.ts @@ -1055,6 +1055,7 @@ export default { selectProfile: '选择一个配置', agentAdded: '智能体已添加', agentAlreadyInRoom: '该智能体已在房间中', + agentAddFailedCount: '{count} 个智能体未添加:{details}', noAgents: '当前房间暂无智能体', members: '成员', roomCreated: '房间已创建', diff --git a/packages/server/src/routes/hermes/group-chat.ts b/packages/server/src/routes/hermes/group-chat.ts index 5d07768d..5159c674 100644 --- a/packages/server/src/routes/hermes/group-chat.ts +++ b/packages/server/src/routes/hermes/group-chat.ts @@ -27,6 +27,47 @@ function generateInviteCode(): string { return code } +type AgentInput = { profile: string; name?: string; description?: string; invited?: boolean | number } + +function sanitizeAgentConnectReason(reason?: string): string { + return (reason || 'agent runtime connection failed') + .replace(/Bearer\s+[A-Za-z0-9._~+\/-]+/gi, 'Bearer [REDACTED]') + .replace(/(api[_-]?key|token|secret|password)=([^\s]+)/gi, '$1=[REDACTED]') + .split('\n')[0] + .slice(0, 240) +} + +function agentConnectFailureBody(profile: string, err: any) { + return { + code: 'PROFILE_AGENT_CONNECT_FAILED', + error: `Failed to connect agent "${profile}" to room`, + profile, + reason: sanitizeAgentConnectReason(err?.message), + } +} + +async function connectAndPersistRoomAgent(server: GroupChatServer, roomId: string, input: AgentInput, agentId = generateId()) { + const profile = input.profile + const name = input.name || profile + const description = input.description || '' + const invited = input.invited ? 1 : 0 + const client = await server.agentClients.createAgent({ + agentId, + profile, + name, + description, + invited, + }) + + try { + await server.agentClients.addAgentToRoom(roomId, client) + return server.getStorage().addRoomAgent(roomId, agentId, profile, name, description, invited) + } catch (err) { + server.agentClients.removeAgentFromRoom(roomId, client.agentId) + throw err + } +} + // Create room groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { if (!chatServer) { @@ -57,29 +98,26 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms', async (ctx) => { const storage = chatServer.getStorage() storage.saveRoom(roomId, name, inviteCode, compression) - // Save agents to DB and auto-connect via Socket.IO const addedAgents = [] + const agentResults = [] for (const a of agents || []) { - const agentId = generateId() - const agent = storage.addRoomAgent(roomId, agentId, a.profile, a.name || a.profile, a.description || '', a.invited ? 1 : 0) - addedAgents.push(agent) - try { - const client = await chatServer.agentClients.createAgent({ - agentId: agent.agentId, - profile: agent.profile, - name: agent.name, - description: agent.description, - invited: agent.invited, + const agent = await connectAndPersistRoomAgent(chatServer, roomId, { + profile: a.profile, + name: a.name || a.profile, + description: a.description || '', + invited: a.invited, }) - await chatServer.agentClients.addAgentToRoom(roomId, client) + addedAgents.push(agent) + agentResults.push({ profile: a.profile, ok: true, agent }) } catch (err: any) { - console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${err.message}`) + console.error(`[GroupChat] Failed to connect agent ${a.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) + agentResults.push({ ok: false, ...agentConnectFailureBody(a.profile, err) }) } } const room = storage.getRoom(roomId) - ctx.body = { room, agents: addedAgents } + ctx.body = { room, agents: addedAgents, agentResults } }) // Clone room roles/config without copying the conversation context. @@ -108,34 +146,25 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/clone', async (ctx) = }) const addedAgents = [] + const agentResults = [] for (const sourceAgent of storage.getRoomAgents(sourceRoom.id)) { - const agentId = generateId() - const agent = storage.addRoomAgent( - roomId, - agentId, - sourceAgent.profile, - sourceAgent.name, - sourceAgent.description, - sourceAgent.invited, - ) - addedAgents.push(agent) - try { - const client = await chatServer.agentClients.createAgent({ - agentId: agent.agentId, - profile: agent.profile, - name: agent.name, - description: agent.description, - invited: agent.invited, + const agent = await connectAndPersistRoomAgent(chatServer, roomId, { + profile: sourceAgent.profile, + name: sourceAgent.name, + description: sourceAgent.description, + invited: sourceAgent.invited, }) - await chatServer.agentClients.addAgentToRoom(roomId, client) + addedAgents.push(agent) + agentResults.push({ profile: sourceAgent.profile, ok: true, agent }) } catch (err: any) { - console.error(`[GroupChat] Failed to connect cloned agent ${agent.profile} to room ${roomId}: ${err.message}`) + console.error(`[GroupChat] Failed to connect cloned agent ${sourceAgent.profile} to room ${roomId}: ${sanitizeAgentConnectReason(err.message)}`) + agentResults.push({ ok: false, ...agentConnectFailureBody(sourceAgent.profile, err) }) } } const room = storage.getRoom(roomId) - ctx.body = { room, agents: addedAgents } + ctx.body = { room, agents: addedAgents, agentResults } }) // Get room detail and messages @@ -236,24 +265,19 @@ groupChatRoutes.post('/api/hermes/group-chat/rooms/:roomId/agents', async (ctx) return } - const agentId = generateId() - const agent = chatServer.getStorage().addRoomAgent(ctx.params.roomId, agentId, profile, name || profile, description || '', invited ? 1 : 0) - - // Auto-connect agent via Socket.IO try { - const client = await chatServer.agentClients.createAgent({ - agentId: agent.agentId, - profile: agent.profile, - name: agent.name, - description: agent.description, - invited: agent.invited, + const agent = await connectAndPersistRoomAgent(chatServer, ctx.params.roomId, { + profile, + name: name || profile, + description: description || '', + invited, }) - await chatServer.agentClients.addAgentToRoom(ctx.params.roomId, client) + ctx.body = { agent } } catch (err: any) { - console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${err.message}`) + console.error(`[GroupChat] Failed to connect agent ${profile} to room ${ctx.params.roomId}: ${sanitizeAgentConnectReason(err.message)}`) + ctx.status = 502 + ctx.body = agentConnectFailureBody(profile, err) } - - ctx.body = { agent } }) // List agents in room diff --git a/packages/server/src/services/hermes/group-chat/agent-clients.ts b/packages/server/src/services/hermes/group-chat/agent-clients.ts index d1e1e676..26d9aed2 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -711,9 +711,16 @@ export class AgentClients { } room.set(client.agentId, client) - const result = await client.joinRoom(roomId) - logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`) - return result + try { + const result = await client.joinRoom(roomId) + logger.info(`[AgentClients] ${client.name} joined room: ${roomId}`) + return result + } catch (err) { + room.delete(client.agentId) + if (room.size === 0) this.rooms.delete(roomId) + client.disconnect() + throw err + } } /** diff --git a/tests/server/group-chat-member-sync.test.ts b/tests/server/group-chat-member-sync.test.ts index ea754924..5be27312 100644 --- a/tests/server/group-chat-member-sync.test.ts +++ b/tests/server/group-chat-member-sync.test.ts @@ -101,6 +101,95 @@ describe('Group Chat member/agent identity sync', () => { })) }) + 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 = {