From 464ce15eeb4a025ef148eeedfb8a7aef2a120d83 Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:42:12 +0800 Subject: [PATCH] fix group chat agent and member identity (#1491) --- ...1-pending-group-chat-agent-model-config.md | 8 +++ ...pending-group-chat-auth-member-identity.md | 8 +++ packages/client/src/api/hermes/group-chat.ts | 2 +- .../hermes/group-chat/CreateRoomForm.vue | 3 +- .../client/src/stores/hermes/group-chat.ts | 23 ++++++-- .../hermes/group-chat/agent-clients.ts | 52 +++++++++++++++--- .../src/services/hermes/group-chat/index.ts | 49 ++++++++++++----- .../client/group-chat-store-streaming.test.ts | 55 +++++++++++++++++++ .../group-chat-agent-model-config.test.ts | 42 ++++++++++++++ tests/server/group-chat-avatar.test.ts | 16 ++++++ tests/server/group-chat-member-sync.test.ts | 49 +++++++++++++++++ 11 files changed, 278 insertions(+), 29 deletions(-) create mode 100644 docs/chat-chain-changes/2026-06-11-pending-group-chat-agent-model-config.md create mode 100644 docs/chat-chain-changes/2026-06-11-pending-group-chat-auth-member-identity.md create mode 100644 tests/server/group-chat-agent-model-config.test.ts diff --git a/docs/chat-chain-changes/2026-06-11-pending-group-chat-agent-model-config.md b/docs/chat-chain-changes/2026-06-11-pending-group-chat-agent-model-config.md new file mode 100644 index 00000000..b0414462 --- /dev/null +++ b/docs/chat-chain-changes/2026-06-11-pending-group-chat-agent-model-config.md @@ -0,0 +1,8 @@ +--- +date: 2026-06-11 +pr: 1491 +feature: Group chat agent model config +impact: Group chat agents now resolve and pass their profile default model/provider into Agent Bridge runs and context estimates. +--- + +This keeps group-chat agent execution aligned with ordinary Hermes bridge chat model resolution. diff --git a/docs/chat-chain-changes/2026-06-11-pending-group-chat-auth-member-identity.md b/docs/chat-chain-changes/2026-06-11-pending-group-chat-auth-member-identity.md new file mode 100644 index 00000000..55c23b7b --- /dev/null +++ b/docs/chat-chain-changes/2026-06-11-pending-group-chat-auth-member-identity.md @@ -0,0 +1,8 @@ +--- +date: 2026-06-11 +pr: 1491 +feature: Group chat authenticated member identity +impact: Group chat human members now use a stable account-scoped identity across browsers and merge older browser-local member rows by auth user id. +--- + +This keeps group chat display names, avatars, and member rows stable when a signed-in user changes browser or clears local storage. diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index 35979d78..39d3eea7 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -83,7 +83,7 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de const token = getApiKey() const userId = opts?.userId || localStorage.getItem('gc_user_id') || generateUUID() - localStorage.setItem('gc_user_id', userId) + if (!opts?.userId) localStorage.setItem('gc_user_id', userId) socket = io('/group-chat', { auth: { diff --git a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue index 81625f6d..833bede3 100644 --- a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue +++ b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue @@ -2,6 +2,7 @@ import { ref, nextTick } from 'vue' import { useI18n } from 'vue-i18n' import { NInput, NButton, NSpace, NInputNumber, NCollapse, NCollapseItem } from 'naive-ui' +import { getStoredUsername } from '@/api/client' type InputLikeInstance = { focus: () => void @@ -15,7 +16,7 @@ const emit = defineEmits<{ const roomName = ref('') const inviteCode = ref('') -const userName = ref(localStorage.getItem('gc_user_name') || '') +const userName = ref(localStorage.getItem('gc_user_name') || getStoredUsername() || '') const description = ref(localStorage.getItem('gc_user_description') || '') const roomInput = ref(null) diff --git a/packages/client/src/stores/hermes/group-chat.ts b/packages/client/src/stores/hermes/group-chat.ts index ea7a98ca..752b81c9 100644 --- a/packages/client/src/stores/hermes/group-chat.ts +++ b/packages/client/src/stores/hermes/group-chat.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' -import { getActiveProfileName, getApiKey } from '@/api/client' +import { getActiveProfileName, getApiKey, getStoredUsername } from '@/api/client' import { fetchCurrentUser } from '@/api/auth' import { getDownloadUrl } from '@/api/hermes/download' import type { Attachment, ContentBlock } from './chat' @@ -85,6 +85,14 @@ function hasText(value?: string | null): boolean { return !!value?.trim() } +function authenticatedGroupUserId(authUserId: number): string { + return `auth:${authUserId}` +} + +function getStoredGroupUserName(): string { + return getStoredUserName()?.trim() || '' +} + function hasToolCalls(message: ChatMessage): boolean { return !!message.tool_calls?.length } @@ -199,12 +207,15 @@ const currentUserAvatar = ref('') return null }) const userId = ref(getStoredUserId()) - const userName = ref(getStoredUserName() || '') + const userName = ref(getStoredGroupUserName() || getStoredUsername() || '') function applyRealtimeJoinState(res: any, options: { syncMessages?: boolean } = {}) { members.value = res.members || [] if (res.agents) agents.value = res.agents if (res.roomName) roomName.value = res.roomName + const currentMember = members.value.find(member => member.userId === userId.value) + if (currentMember?.name) userName.value = currentMember.name + if (currentMember?.avatar) currentUserAvatar.value = currentMember.avatar if (options.syncMessages && Array.isArray(res.messages)) { const byId = new Map(messages.value.map(message => [message.id, message])) for (const message of res.messages) { @@ -244,11 +255,12 @@ const currentUserAvatar = ref('') async function joinRealtimeRoom(roomId: string, options: { syncMessages?: boolean } = {}) { const socket = getSocket() if (!socket) return + const storedName = getStoredGroupUserName() await new Promise((resolve) => { socket.emit('join', { roomId, - name: userName.value || undefined, + name: storedName || undefined, description: localStorage.getItem('gc_user_description') || undefined, }, (res: any) => { if (currentRoomId.value !== roomId) { @@ -287,14 +299,17 @@ const currentUserAvatar = ref('') // ─── Connection ──────────────────────────────────────── async function connect() { let authUserId: number | undefined + const connectionName = getStoredGroupUserName() try { const user = await fetchCurrentUser() authUserId = user.id + userId.value = authenticatedGroupUserId(user.id) + if (!connectionName) userName.value = user.username currentUserAvatar.value = user.avatar || '' } catch { /* non-critical: avatar fallback handles missing id */ } const socket = connectGroupChat({ userId: userId.value, - userName: userName.value || undefined, + userName: connectionName || undefined, authUserId, }) console.log('[GroupChat] connecting...', { userId: userId.value, userName: userName.value }) 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 33c985c9..061bde79 100644 --- a/packages/server/src/services/hermes/group-chat/agent-clients.ts +++ b/packages/server/src/services/hermes/group-chat/agent-clients.ts @@ -6,6 +6,7 @@ import { updateUsage } from '../../../db/hermes/usage-store' import { countTokens } from '../../../lib/context-compressor' import { AgentBridgeClient, type AgentBridgeContextEstimate, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge' import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks' +import { resolveBridgeRunModelConfig } from '../run-chat/model-config' import type { ContentBlock } from '../run-chat/types' import { isAllAgentsMentioned, @@ -44,6 +45,7 @@ type MentionMessage = { } type GroupEstimateMessage = { role: 'user' | 'assistant'; content: string } +export type GroupModelContext = { model: string; provider: string } interface BridgeContextCache { fixedContextTokens: number @@ -58,6 +60,10 @@ interface BridgeContextCache { provider?: string } +export async function resolveGroupAgentModelContext(profile: string): Promise { + return resolveBridgeRunModelConfig({ profile }) +} + export function estimateGroupHistoryMessageTokens(history: Array<{ content?: unknown }>): number { return history.reduce((sum, message) => sum + countTokens(String(message.content || '')), 0) } @@ -72,6 +78,16 @@ export function groupContextTokensWithFixedOverhead( return Math.floor(fixedContextTokens) + estimateGroupHistoryMessageTokens(history) } +export function isGroupBridgeContextCacheCompatible( + cache: { model?: string; provider?: string } | null | undefined, + modelContext: GroupModelContext, +): boolean { + if (!cache) return false + if (modelContext.model && cache.model !== modelContext.model) return false + if (modelContext.provider && cache.provider !== modelContext.provider) return false + return true +} + export function groupBridgeReasoningDeltaFromEvent(event: Record): string | null { if (String(event.event || '') !== 'reasoning.delta') return null const text = String(event.text || '') @@ -289,7 +305,12 @@ class AgentClient { : undefined } - private cacheBridgeContext(sessionId: string, data: Record | AgentBridgeContextEstimate, instructions?: string): void { + private cacheBridgeContext( + sessionId: string, + data: Record | AgentBridgeContextEstimate, + instructions?: string, + modelContext: GroupModelContext = { model: '', provider: '' }, + ): void { const fixedContextTokens = this.finiteToken(data.fixed_context_tokens) if (fixedContextTokens == null) return this.bridgeContextCache.set(sessionId, { @@ -301,8 +322,8 @@ class AgentClient { toolCount: this.finiteToken(data.tool_count), toolNames: Array.isArray(data.tool_names) ? data.tool_names.map(String) : undefined, profile: typeof data.profile === 'string' ? data.profile : undefined, - model: typeof data.model === 'string' ? data.model : undefined, - provider: typeof data.provider === 'string' ? data.provider : undefined, + model: typeof data.model === 'string' ? data.model : modelContext.model || undefined, + provider: typeof data.provider === 'string' ? data.provider : modelContext.provider || undefined, }) } @@ -310,10 +331,11 @@ class AgentClient { return estimateGroupHistoryMessageTokens(history) } - private estimateWithCachedBridgeContext(sessionId: string, history: GroupEstimateMessage[], instructions?: string): number | undefined { + private estimateWithCachedBridgeContext(sessionId: string, history: GroupEstimateMessage[], instructions: string | undefined, modelContext: GroupModelContext): number | undefined { const cache = this.bridgeContextCache.get(sessionId) if (!cache) return undefined if (cache.instructions !== instructions) return undefined + if (!isGroupBridgeContextCacheCompatible(cache, modelContext)) return undefined return groupContextTokensWithFixedOverhead(cache.fixedContextTokens, history) } @@ -323,9 +345,10 @@ class AgentClient { bridge: AgentBridgeClient, history: GroupEstimateMessage[], instructions: string | undefined, + modelContext: GroupModelContext, phase: string, ): Promise { - const cachedTokens = this.estimateWithCachedBridgeContext(sessionId, history, instructions) + const cachedTokens = this.estimateWithCachedBridgeContext(sessionId, history, instructions, modelContext) if (cachedTokens != null) { logger.info({ roomId, @@ -347,8 +370,12 @@ class AgentClient { history, instructions, this.profile, + { + ...(modelContext.model ? { model: modelContext.model } : {}), + ...(modelContext.provider ? { provider: modelContext.provider } : {}), + }, ) - this.cacheBridgeContext(sessionId, estimate, instructions) + this.cacheBridgeContext(sessionId, estimate, instructions, modelContext) const totalTokens = Number(estimate.token_count || 0) logger.info({ roomId, @@ -402,6 +429,7 @@ class AgentClient { const bridge = new AgentBridgeClient() const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0') const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed) + const modelContext = await resolveGroupAgentModelContext(this.profile) if (this.contextEngine && this.storage) { try { @@ -446,6 +474,7 @@ class AgentClient { bridge, history, estimateInstructions, + modelContext, 'build', ) }, @@ -496,6 +525,8 @@ class AgentClient { instructions, this.profile, { + ...(modelContext.model ? { model: modelContext.model } : {}), + ...(modelContext.provider ? { provider: modelContext.provider } : {}), source: 'api_server', }, ) @@ -504,7 +535,7 @@ class AgentClient { streamStarted = true for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) { lastChunk = chunk - reasoningContent += await this.recordBridgeEvents(roomId, sessionId, instructions, chunk, () => streamMessageId, async () => { + reasoningContent += await this.recordBridgeEvents(roomId, sessionId, instructions, modelContext, chunk, () => streamMessageId, async () => { const toolBaseId = streamMessageId if (currentContent.trim()) { await this.sendMessage(roomId, currentContent, streamMessageId, { @@ -554,7 +585,7 @@ class AgentClient { reasoning_content: reasoningContent || null, }) this.emitMessageStreamEnd(roomId, streamMessageId) - await this.refreshRoomFullContextEstimate(roomId, sessionId, bridge, instructions) + await this.refreshRoomFullContextEstimate(roomId, sessionId, bridge, instructions, modelContext) onStatus?.('ready') return } @@ -580,6 +611,7 @@ class AgentClient { sessionId: string, bridge: AgentBridgeClient, instructions?: string, + modelContext: GroupModelContext = { model: '', provider: '' }, ): Promise { if (!this.storage?.getMessages) return try { @@ -590,6 +622,7 @@ class AgentClient { bridge, history, instructions, + modelContext, 'final', ) if (cachedTokens == null || cachedTokens <= 0) return @@ -675,6 +708,7 @@ class AgentClient { roomId: string, sessionId: string, instructions: string | undefined, + modelContext: GroupModelContext, chunk: AgentBridgeOutput, getCurrentMessageId: () => string, beforeToolStarted: () => Promise, @@ -683,7 +717,7 @@ class AgentClient { for (const ev of chunk.events || []) { const eventType = String((ev as any)?.event || '') if (eventType === 'bridge.context.ready') { - this.cacheBridgeContext(sessionId, ev as Record, instructions) + this.cacheBridgeContext(sessionId, ev as Record, instructions, modelContext) } else if (eventType === 'tool.started') { const toolBaseId = await beforeToolStarted() this.recordToolStarted(roomId, ev as Record, toolBaseId) diff --git a/packages/server/src/services/hermes/group-chat/index.ts b/packages/server/src/services/hermes/group-chat/index.ts index eb38dde9..8bd5c20c 100644 --- a/packages/server/src/services/hermes/group-chat/index.ts +++ b/packages/server/src/services/hermes/group-chat/index.ts @@ -8,7 +8,7 @@ import { ContextEngine } from '../context-engine/compressor' import { SessionDeleter } from '../session-deleter' import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor' import { AgentBridgeClient } from '../agent-bridge' -import { authenticateUserToken, isAuthEnabled } from '../../../middleware/user-auth' +import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth' import { findUserByUsername, getUserAvatar } from '../../../db/hermes/users-store' import { config } from '../../../config' import { createSocketIoCorsOrigin, shouldRejectUpgradeOrigin } from '../../../security' @@ -99,6 +99,10 @@ interface Member { authUserId?: number | null } +function authenticatedGroupUserId(authUserId: number): string { + return `auth:${authUserId}` +} + let _tablesEnsured = false interface PendingSessionDelete { @@ -632,7 +636,8 @@ class ChatStorage { } } - const existing = this.getMemberByUserId(roomId, userId) + const existing = this.getMemberByUserId(roomId, userId) || + (typeof authUserId === 'number' && authUserId > 0 ? this.getMemberByAuthUserId(roomId, authUserId) : null) if (existing) { const nextAvatar = resolvedAvatar || existing.avatar || '' const nextAuthUserId = typeof authUserId === 'number' && authUserId > 0 @@ -640,8 +645,8 @@ class ChatStorage { : existing.authUserId ?? null // Update name/description/avatar on rejoin, refresh updatedAt this.db()?.prepare( - 'UPDATE gc_room_members SET userName = ?, description = ?, avatar = ?, authUserId = ?, updatedAt = ? WHERE roomId = ? AND userId = ?' - ).run(userName, description, nextAvatar, nextAuthUserId, Date.now(), roomId, userId) + 'UPDATE gc_room_members SET userId = ?, userName = ?, description = ?, avatar = ?, authUserId = ?, updatedAt = ? WHERE id = ?' + ).run(userId, userName, description, nextAvatar, nextAuthUserId, Date.now(), existing.id) return } const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8) @@ -657,6 +662,12 @@ class ChatStorage { ).get(roomId, userId) as any) ?? null } + getMemberByAuthUserId(roomId: string, authUserId: number): Member | null { + return (this.db()?.prepare( + 'SELECT id, userId, userName as name, description, joinedAt, avatar, authUserId FROM gc_room_members WHERE roomId = ? AND authUserId = ? ORDER BY updatedAt DESC LIMIT 1' + ).get(roomId, authUserId) as any) ?? null + } + updateMemberActivity(roomId: string, userId: string): void { this.db()?.prepare( 'UPDATE gc_room_members SET updatedAt = ? WHERE roomId = ? AND userId = ?' @@ -883,8 +894,10 @@ export class GroupChatServer { } const token = auth.token || socket.handshake.query.token || '' - if (await isAuthEnabled() && !await authenticateUserToken(String(token))) { - return next(new Error('Unauthorized')) + if (await isAuthEnabled()) { + const user = await authenticateUserToken(String(token)) + if (!user) return next(new Error('Unauthorized')) + socket.data.authUser = user } next() } @@ -893,16 +906,20 @@ export class GroupChatServer { private onConnection(socket: Socket): void { const auth = socket.handshake.auth as { userId?: string; name?: string; description?: string; source?: string; agentSocketSecret?: string; authUserId?: number } - 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' + const authenticatedUser = socket.data.authUser as AuthenticatedUser | undefined + const authUserId = requestedSource === 'human' + ? authenticatedUser?.id ?? (typeof auth.authUserId === 'number' && auth.authUserId > 0 ? auth.authUserId : undefined) + : undefined + const userId = authUserId ? authenticatedGroupUserId(authUserId) : auth.userId || socket.id + const userName = auth.name || authenticatedUser?.username || `User-${userId.slice(0, 6)}` + const description = auth.description || '' this.socketUserMap.set(socket.id, userId) this.socketRequestedSourceMap.set(socket.id, requestedSource) this.userInfoMap.set(userId, { name: userName, description }) - if (typeof auth.authUserId === 'number') { - this.socketAuthUserIdMap.set(socket.id, auth.authUserId) + if (typeof authUserId === 'number') { + this.socketAuthUserIdMap.set(socket.id, authUserId) } logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`) @@ -936,13 +953,17 @@ export class GroupChatServer { ack?.({ error: 'Reserved member identity' }) return } - const existingMember = this.storage.getMemberByUserId(roomId, userId) + const socketAuthUserId = this.socketAuthUserIdMap.get(socket.id) + const existingMember = this.storage.getMemberByUserId(roomId, userId) || + (typeof socketAuthUserId === 'number' ? this.storage.getMemberByAuthUserId(roomId, socketAuthUserId) : null) const userInfo = this.userInfoMap.get(userId) || { name: existingMember?.name || `User-${userId.slice(0, 6)}`, description: existingMember?.description || '', } - const userName = data.name || existingMember?.name || userInfo.name - const description = data.description || existingMember?.description || userInfo.description + const requestedName = typeof data.name === 'string' ? data.name.trim() : '' + const requestedDescription = typeof data.description === 'string' ? data.description.trim() : '' + const userName = requestedName || existingMember?.name || userInfo.name + const description = requestedDescription || existingMember?.description || userInfo.description // Update stored user info this.userInfoMap.set(userId, { name: userName, description }) diff --git a/tests/client/group-chat-store-streaming.test.ts b/tests/client/group-chat-store-streaming.test.ts index 6447957e..d77e5c3f 100644 --- a/tests/client/group-chat-store-streaming.test.ts +++ b/tests/client/group-chat-store-streaming.test.ts @@ -43,11 +43,16 @@ const groupChatApiMock = vi.hoisted(() => { const clientApiMock = vi.hoisted(() => ({ getApiKey: vi.fn(() => 'test-token'), getActiveProfileName: vi.fn(() => 'research'), + getStoredUsername: vi.fn(() => null), +})) +const authApiMock = vi.hoisted(() => ({ + fetchCurrentUser: vi.fn(), })) const fetchMock = vi.hoisted(() => vi.fn()) vi.mock('@/api/hermes/group-chat', () => groupChatApiMock) vi.mock('@/api/client', () => clientApiMock) +vi.mock('@/api/auth', () => authApiMock) vi.mock('@/api/hermes/download', () => ({ getDownloadUrl: vi.fn((path: string) => `/download?path=${path}`) })) vi.stubGlobal('fetch', fetchMock) @@ -104,6 +109,8 @@ describe('group chat store streaming merge', () => { groupChatApiMock.getStoredUserName.mockReturnValue('tester') clientApiMock.getApiKey.mockReturnValue('test-token') clientApiMock.getActiveProfileName.mockReturnValue('research') + clientApiMock.getStoredUsername.mockReturnValue(null) + authApiMock.fetchCurrentUser.mockRejectedValue(new Error('not signed in')) fetchMock.mockReset() groupChatApiMock.socket.on.mockClear() groupChatApiMock.socket.emit.mockReset() @@ -348,6 +355,54 @@ describe('group chat store streaming merge', () => { ) }) + it('uses authenticated account identity and restores the persisted room member name', async () => { + groupChatApiMock.getStoredUserId.mockReturnValue('browser-local-id') + groupChatApiMock.getStoredUserName.mockReturnValue(null) + clientApiMock.getStoredUsername.mockReturnValue('alice-login') + authApiMock.fetchCurrentUser.mockResolvedValue({ + id: 42, + username: 'alice-login', + role: 'admin', + status: 'active', + created_at: 1, + updated_at: 1, + last_login_at: null, + avatar: '', + }) + groupChatApiMock.getRoomDetail.mockResolvedValue({ + room, + messages: [], + agents: [], + members: [], + }) + groupChatApiMock.socket.emit.mockImplementation((event: string, data?: any, ack?: Function) => { + if (event === 'join' && ack) { + expect(data).toMatchObject({ roomId: 'room-1' }) + expect(data.name).toBeUndefined() + ack({ + members: [{ id: 'member-1', userId: 'auth:42', name: 'Alice Display', description: '', joinedAt: 1 }], + agents: [], + typingUsers: [], + contextStatuses: [], + }) + } + return groupChatApiMock.socket + }) + const { useGroupChatStore } = await import('@/stores/hermes/group-chat') + const store = useGroupChatStore() + + await store.connect() + await store.joinRoom('room-1') + + expect(groupChatApiMock.connectGroupChat).toHaveBeenCalledWith({ + userId: 'auth:42', + userName: undefined, + authUserId: 42, + }) + expect(store.userId).toBe('auth:42') + expect(store.userName).toBe('Alice Display') + }) + it('adds auth and active profile headers to group chat uploads', async () => { const store = await createJoinedStore() fetchMock.mockResolvedValue({ diff --git a/tests/server/group-chat-agent-model-config.test.ts b/tests/server/group-chat-agent-model-config.test.ts new file mode 100644 index 00000000..6e6028af --- /dev/null +++ b/tests/server/group-chat-agent-model-config.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const readConfigYamlForProfileMock = vi.fn() + +vi.mock('../../packages/server/src/services/config-helpers', () => ({ + readConfigYamlForProfile: readConfigYamlForProfileMock, +})) + +describe('group chat agent model config', () => { + beforeEach(() => { + readConfigYamlForProfileMock.mockReset() + }) + + it('resolves the mentioned agent profile default model and provider', async () => { + readConfigYamlForProfileMock.mockResolvedValueOnce({ + model: { default: 'research-model', provider: 'research-provider' }, + }) + const { resolveGroupAgentModelContext } = await import('../../packages/server/src/services/hermes/group-chat/agent-clients') + + const result = await resolveGroupAgentModelContext('research') + + expect(readConfigYamlForProfileMock).toHaveBeenCalledWith('research') + expect(result).toEqual({ model: 'research-model', provider: 'research-provider' }) + }) + + it('requires cached context metadata to match the active model and provider', async () => { + const { isGroupBridgeContextCacheCompatible } = await import('../../packages/server/src/services/hermes/group-chat/agent-clients') + + expect(isGroupBridgeContextCacheCompatible( + { model: 'research-model', provider: 'research-provider' }, + { model: 'research-model', provider: 'research-provider' }, + )).toBe(true) + expect(isGroupBridgeContextCacheCompatible( + { model: 'other-model', provider: 'research-provider' }, + { model: 'research-model', provider: 'research-provider' }, + )).toBe(false) + expect(isGroupBridgeContextCacheCompatible( + { fixedContextTokens: 120 } as { model?: string; provider?: string }, + { model: 'research-model', provider: 'research-provider' }, + )).toBe(false) + }) +}) diff --git a/tests/server/group-chat-avatar.test.ts b/tests/server/group-chat-avatar.test.ts index 9bc2424b..4bcf6a6a 100644 --- a/tests/server/group-chat-avatar.test.ts +++ b/tests/server/group-chat-avatar.test.ts @@ -82,4 +82,20 @@ describe('group chat member avatars', () => { { userId: 'anonymous-participant', name: 'Unmatched Display', avatar: '' }, ]) }) + + it('merges a browser-local member row into the authenticated account identity', async () => { + const { users, storage } = await initStorage() + const alice = users.createUser({ username: 'alice-login', password: 'pw' })! + + storage.addRoomMember('room-1', 'browser-local-id', 'Alice Display', 'saved description', '', alice.id) + storage.addRoomMember('room-1', `auth:${alice.id}`, 'Alice Display', 'saved description', '', alice.id) + + expect(storage.getRoomMembers('room-1')).toMatchObject([ + { + userId: `auth:${alice.id}`, + name: 'Alice Display', + description: 'saved description', + }, + ]) + }) }) diff --git a/tests/server/group-chat-member-sync.test.ts b/tests/server/group-chat-member-sync.test.ts index 72035d23..371f09e1 100644 --- a/tests/server/group-chat-member-sync.test.ts +++ b/tests/server/group-chat-member-sync.test.ts @@ -224,6 +224,55 @@ describe('Group Chat member/agent identity sync', () => { }) }) + 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(), + getMessages: 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 },