fix group chat agent and member identity (#1491)

This commit is contained in:
ekko
2026-06-11 10:42:12 +08:00
committed by GitHub
parent f122fa107f
commit 464ce15eeb
11 changed files with 278 additions and 29 deletions
@@ -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.
@@ -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.
+1 -1
View File
@@ -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: {
@@ -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<InputLikeInstance | null>(null)
@@ -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<void>((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 })
@@ -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<GroupModelContext> {
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, unknown>): 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<string, unknown> | AgentBridgeContextEstimate, instructions?: string): void {
private cacheBridgeContext(
sessionId: string,
data: Record<string, unknown> | 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<number | undefined> {
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<void> {
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<string>,
@@ -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<string, unknown>, instructions)
this.cacheBridgeContext(sessionId, ev as Record<string, unknown>, instructions, modelContext)
} else if (eventType === 'tool.started') {
const toolBaseId = await beforeToolStarted()
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
@@ -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 })
@@ -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({
@@ -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)
})
})
+16
View File
@@ -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',
},
])
})
})
@@ -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 },