mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-07-16 21:50:15 +00:00
fix group chat agent and member identity (#1491)
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user