Refine user profile access and chat sync

This commit is contained in:
Codex
2026-05-23 19:41:51 +08:00
committed by ekko
parent 3f6a25d8f1
commit 7b05731d44
17 changed files with 223 additions and 57 deletions
+33
View File
@@ -51,6 +51,13 @@ export interface RunEvent {
session_id?: string
/** Queue length from run.queued event */
queue_length?: number
/** User message broadcast to other windows already watching the same session. */
message?: {
id?: string | number
role?: string
content?: string
timestamp?: number
}
}
// ============================
@@ -83,8 +90,11 @@ const sessionEventHandlers = new Map<string, {
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
/**
* Global message.delta event handler
* Distributes events to appropriate session based on session_id
@@ -324,6 +334,20 @@ function globalApprovalResolvedHandler(event: RunEvent): void {
}
}
function globalPeerUserMessageHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onPeerUserMessage) {
handlers.onPeerUserMessage(event)
}
for (const handler of peerUserMessageHandlers) {
handler(event)
}
}
/**
* Register event handlers for a session
* @param sessionId - Session ID
@@ -351,6 +375,7 @@ export function registerSessionHandlers(
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
@@ -369,6 +394,13 @@ export function unregisterSessionHandlers(sessionId: string): void {
sessionEventHandlers.delete(sessionId)
}
export function onPeerUserMessage(handler: (event: RunEvent) => void): () => void {
peerUserMessageHandlers.add(handler)
return () => {
peerUserMessageHandlers.delete(handler)
}
}
export function respondToolApproval(
sessionId: string,
approvalId: string,
@@ -441,6 +473,7 @@ export function connectChatRun(): Socket {
chatRunSocket.on('run.queued', globalRunQueuedHandler)
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
+2 -2
View File
@@ -47,7 +47,7 @@ export default {
username: 'Benutzername',
role: 'Rolle',
statusLabel: 'Status',
profiles: 'Profiles',
profiles: 'Zugreifbare Profile',
profilesPlaceholder: 'Zugreifbare Profile auswaehlen',
allProfiles: 'Alle Profile',
noProfiles: 'Keine Profile zugewiesen',
@@ -679,7 +679,7 @@ jobTriggered: 'Job ausgelost',
stopped: 'Gestoppt',
restartGateway: 'Gateway neu starten',
restartProfile: 'Profil neu starten',
switchProfile: 'Profil wechseln',
switchProfile: 'Frontend-Profil wechseln',
gatewayRestarted: 'Gateway neu gestartet: {name}',
gatewayRestartFailed: 'Gateway-Neustart fehlgeschlagen',
profileRestarted: 'Profil neu gestartet: {name}',
+2 -2
View File
@@ -47,7 +47,7 @@ export default {
username: 'Username',
role: 'Role',
statusLabel: 'Status',
profiles: 'Profiles',
profiles: 'Accessible Profiles',
profilesPlaceholder: 'Select accessible profiles',
allProfiles: 'All profiles',
noProfiles: 'No profiles assigned',
@@ -782,7 +782,7 @@ export default {
stopped: 'Stopped',
restartGateway: 'Restart Gateway',
restartProfile: 'Restart Profile',
switchProfile: 'Switch Profile',
switchProfile: 'Switch Frontend Profile',
gatewayRestarted: 'Gateway restarted: {name}',
gatewayRestartFailed: 'Failed to restart gateway',
profileRestarted: 'Profile restarted: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: 'Gestion de cuentas',
description: 'Crea usuarios, asigna roles y controla que Profile pueden usar los administradores normales.',
description: 'Crea usuarios, asigna roles y controla que perfiles pueden usar los administradores normales.',
create: 'Crear usuario',
edit: 'Editar usuario',
username: 'Nombre de usuario',
role: 'Rol',
statusLabel: 'Estado',
profiles: 'Profiles',
profilesPlaceholder: 'Selecciona Profile accesibles',
allProfiles: 'Todos los Profile',
noProfiles: 'Sin Profile asignados',
profiles: 'Perfiles accesibles',
profilesPlaceholder: 'Selecciona perfiles accesibles',
allProfiles: 'Todos los perfiles',
noProfiles: 'Sin perfiles asignados',
lastLogin: 'Ultimo inicio',
newPasswordOptional: 'Nueva contrasena (dejar vacio para conservar)',
loadFailed: 'No se pudieron cargar los usuarios',
@@ -679,7 +679,7 @@ jobTriggered: 'Job ejecutado',
stopped: 'Detenido',
restartGateway: 'Reiniciar gateway',
restartProfile: 'Reiniciar perfil',
switchProfile: 'Cambiar perfil',
switchProfile: 'Cambiar perfil frontend',
gatewayRestarted: 'Gateway reiniciado: {name}',
gatewayRestartFailed: 'No se pudo reiniciar el gateway',
profileRestarted: 'Perfil reiniciado: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: 'Gestion des comptes',
description: 'Creez des utilisateurs, attribuez des roles et controlez les Profile accessibles aux administrateurs standard.',
description: 'Creez des utilisateurs, attribuez des roles et controlez les profils accessibles aux administrateurs standard.',
create: 'Creer un utilisateur',
edit: 'Modifier l utilisateur',
username: 'Nom d utilisateur',
role: 'Role',
statusLabel: 'Statut',
profiles: 'Profiles',
profilesPlaceholder: 'Selectionner les Profile accessibles',
allProfiles: 'Tous les Profile',
noProfiles: 'Aucun Profile assigne',
profiles: 'Profils accessibles',
profilesPlaceholder: 'Selectionner les profils accessibles',
allProfiles: 'Tous les profils',
noProfiles: 'Aucun profil assigne',
lastLogin: 'Derniere connexion',
newPasswordOptional: 'Nouveau mot de passe (laisser vide pour conserver)',
loadFailed: 'Echec du chargement des utilisateurs',
@@ -679,7 +679,7 @@ jobTriggered: 'Job declenche',
stopped: 'Arrêté',
restartGateway: 'Redémarrer le gateway',
restartProfile: 'Redémarrer le profil',
switchProfile: 'Changer de profil',
switchProfile: 'Changer le profil frontend',
gatewayRestarted: 'Gateway redémarré : {name}',
gatewayRestartFailed: 'Échec du redémarrage du gateway',
profileRestarted: 'Profil redémarré : {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: 'アカウント管理',
description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできる Profile を制御します。',
description: 'ユーザーを作成し、ロールを割り当て、通常管理者がアクセスできるプロファイルを制御します。',
create: 'ユーザー作成',
edit: 'ユーザー編集',
username: 'ユーザー名',
role: 'ロール',
statusLabel: 'ステータス',
profiles: 'Profiles',
profilesPlaceholder: 'アクセス可能な Profile を選択',
allProfiles: 'すべての Profile',
noProfiles: 'Profile 未割り当て',
profiles: 'アクセス可能なプロファイル',
profilesPlaceholder: 'アクセス可能なプロファイルを選択',
allProfiles: 'すべてのプロファイル',
noProfiles: 'プロファイル未割り当て',
lastLogin: '最終ログイン',
newPasswordOptional: '新しいパスワード(空欄なら変更なし)',
loadFailed: 'ユーザー一覧の読み込みに失敗しました',
@@ -679,7 +679,7 @@ export default {
stopped: '停止中',
restartGateway: 'Gateway を再起動',
restartProfile: 'プロファイルを再起動',
switchProfile: 'プロファイルを切り替え',
switchProfile: 'フロントエンドプロファイルを切り替え',
gatewayRestarted: 'Gateway を再起動しました: {name}',
gatewayRestartFailed: 'Gateway の再起動に失敗しました',
profileRestarted: 'プロファイルを再起動しました: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: '계정 관리',
description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 Profile 을 제어합니다.',
description: '사용자를 만들고 역할을 할당하며 일반 관리자가 접근할 수 있는 프로필을 제어합니다.',
create: '사용자 만들기',
edit: '사용자 편집',
username: '사용자 이름',
role: '역할',
statusLabel: '상태',
profiles: 'Profiles',
profilesPlaceholder: '접근 가능한 Profile 선택',
allProfiles: '모든 Profile',
noProfiles: '할당된 Profile 없음',
profiles: '접근 가능한 프로필',
profilesPlaceholder: '접근 가능한 프로필 선택',
allProfiles: '모든 프로필',
noProfiles: '할당된 프로필 없음',
lastLogin: '마지막 로그인',
newPasswordOptional: '새 비밀번호 (비워두면 유지)',
loadFailed: '사용자 목록을 불러오지 못했습니다',
@@ -679,7 +679,7 @@ export default {
stopped: '중지됨',
restartGateway: 'Gateway 재시작',
restartProfile: '프로필 재시작',
switchProfile: '프로필 전환',
switchProfile: '프론트엔드 프로필 전환',
gatewayRestarted: 'Gateway가 재시작되었습니다: {name}',
gatewayRestartFailed: 'Gateway 재시작 실패',
profileRestarted: '프로필이 재시작되었습니다: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: 'Gerenciamento de contas',
description: 'Crie usuarios, atribua funcoes e controle quais Profile administradores comuns podem acessar.',
description: 'Crie usuarios, atribua funcoes e controle quais perfis administradores comuns podem acessar.',
create: 'Criar usuario',
edit: 'Editar usuario',
username: 'Nome de usuario',
role: 'Funcao',
statusLabel: 'Status',
profiles: 'Profiles',
profilesPlaceholder: 'Selecione Profile acessiveis',
allProfiles: 'Todos os Profile',
noProfiles: 'Nenhum Profile atribuido',
profiles: 'Perfis acessiveis',
profilesPlaceholder: 'Selecione perfis acessiveis',
allProfiles: 'Todos os perfis',
noProfiles: 'Nenhum perfil atribuido',
lastLogin: 'Ultimo login',
newPasswordOptional: 'Nova senha (deixe em branco para manter)',
loadFailed: 'Falha ao carregar usuarios',
@@ -679,7 +679,7 @@ jobTriggered: 'Job acionado',
stopped: 'Parado',
restartGateway: 'Reiniciar gateway',
restartProfile: 'Reiniciar perfil',
switchProfile: 'Trocar perfil',
switchProfile: 'Trocar perfil frontend',
gatewayRestarted: 'Gateway reiniciado: {name}',
gatewayRestartFailed: 'Falha ao reiniciar gateway',
profileRestarted: 'Perfil reiniciado: {name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: '帳號管理',
description: '建立使用者、分配角色,並控制一般管理員可存取的 Profile。',
description: '建立使用者、分配角色,並控制一般管理員可存取的設定檔。',
create: '建立使用者',
edit: '編輯使用者',
username: '使用者名稱',
role: '角色',
statusLabel: '狀態',
profiles: 'Profiles',
profilesPlaceholder: '選擇可存取的 Profile',
allProfiles: '全部 Profile',
noProfiles: '未關聯 Profile',
profiles: '可存取設定檔',
profilesPlaceholder: '選擇可存取的設定檔',
allProfiles: '全部設定檔',
noProfiles: '未關聯設定檔',
lastLogin: '最後登入',
newPasswordOptional: '新密碼(留空不修改)',
loadFailed: '使用者列表載入失敗',
@@ -774,7 +774,7 @@ export default {
stopped: '已停止',
restartGateway: '重啟閘道',
restartProfile: '重啟設定檔',
switchProfile: '切換前端 Profile',
switchProfile: '切換前端設定檔',
gatewayRestarted: '閘道已重啟:{name}',
gatewayRestartFailed: '重啟閘道失敗',
profileRestarted: '設定檔已重啟:{name}',
+6 -6
View File
@@ -41,16 +41,16 @@ export default {
users: {
title: '账户管理',
description: '创建用户、分配角色,并控制普通管理员可访问的 Profile。',
description: '创建用户、分配角色,并控制普通管理员可访问的配置。',
create: '创建用户',
edit: '编辑用户',
username: '用户名',
role: '角色',
statusLabel: '状态',
profiles: 'Profiles',
profilesPlaceholder: '选择可访问的 Profile',
allProfiles: '全部 Profile',
noProfiles: '未关联 Profile',
profiles: '可访问配置',
profilesPlaceholder: '选择可访问的配置',
allProfiles: '全部配置',
noProfiles: '未关联配置',
lastLogin: '最后登录',
newPasswordOptional: '新密码(留空不修改)',
loadFailed: '用户列表加载失败',
@@ -774,7 +774,7 @@ export default {
stopped: '已停止',
restartGateway: '重启网关',
restartProfile: '重启配置',
switchProfile: '切换配置',
switchProfile: '切换前端配置',
gatewayRestarted: '网关已重启:{name}',
gatewayRestartFailed: '重启网关失败',
profileRestarted: '配置已重启:{name}',
+36 -3
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getApiKey } from '@/api/client'
import { defineStore } from 'pinia'
@@ -1451,11 +1451,11 @@ export const useChatStore = defineStore('chat', () => {
* Emits 'resume' to join the session room on the server,
* then sets up event listeners to receive ongoing events.
*/
function resumeServerWorkingRun(sid: string) {
function resumeServerWorkingRun(sid: string, force = false) {
// Don't register duplicate listeners if already streaming
if (streamStates.value.has(sid)) return
// Only set up listeners if the server reported an active run during resume.
if (!serverWorking.value.has(sid)) return
if (!force && !serverWorking.value.has(sid)) return
let closed = false
let runProducedAssistantText = false
@@ -1872,6 +1872,39 @@ export const useChatStore = defineStore('chat', () => {
})
}
function handlePeerUserMessage(evt: RunEvent) {
const sid = evt.session_id
if (!sid || activeSessionId.value !== sid || !activeSession.value) return
const peer = evt.message
const content = typeof peer?.content === 'string' ? peer.content : ''
if (!content.trim()) return
const messageId = peer?.id != null ? String(peer.id) : ''
const msgs = getSessionMsgs(sid)
if (messageId && msgs.some(msg => msg.id === messageId)) {
serverWorking.value.add(sid)
resumeServerWorkingRun(sid, true)
return
}
const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp)
? Math.round(peer.timestamp * 1000)
: Date.now()
addMessage(sid, {
id: messageId || uid(),
role: 'user',
content,
timestamp,
})
updateSessionTitle(sid)
serverWorking.value.add(sid)
resumeServerWorkingRun(sid, true)
}
onPeerUserMessage(handlePeerUserMessage)
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return
+7 -1
View File
@@ -3,6 +3,7 @@ import { createHmac, timingSafeEqual } from 'crypto'
import { getToken } from '../services/auth'
import {
findUserById,
listUserProfiles,
touchUserLogin,
userCanAccessProfile,
type UserRecord,
@@ -13,6 +14,7 @@ export interface AuthenticatedUser {
id: number
username: string
role: UserRole
profiles?: string[]
}
export interface RequestProfile {
@@ -110,11 +112,15 @@ export async function issueUserJwt(user: Pick<UserRecord, 'id' | 'username' | 'r
}
export function toAuthenticatedUser(user: Pick<UserRecord, 'id' | 'username' | 'role'>): AuthenticatedUser {
return {
const authenticated: AuthenticatedUser = {
id: user.id,
username: user.username,
role: user.role,
}
if (user.role !== 'super_admin') {
authenticated.profiles = listUserProfiles(user.id).map(profile => profile.profile_name)
}
return authenticated
}
export async function authenticateUserToken(token: string): Promise<AuthenticatedUser | null> {
@@ -196,7 +196,11 @@ groupChatRoutes.get('/api/hermes/group-chat/rooms', async (ctx) => {
return
}
const rooms = chatServer.getStorage().getAllRooms()
const user = ctx.state.user
const storage = chatServer.getStorage()
const rooms = !user || user.role === 'super_admin'
? storage.getAllRooms()
: storage.getRoomsForProfiles(user.profiles || [])
ctx.body = { rooms }
})
@@ -72,6 +72,17 @@ interface RoomAgent {
invited: number
}
interface RoomInfo {
id: string
name: string
inviteCode: string | null
triggerTokens: number
maxHistoryTokens: number
tailMessageCount: number
totalTokens: number
sessionSeed: string
}
interface Member {
id: string
userId: string
@@ -278,18 +289,31 @@ class ChatStorage {
// ─── Rooms ────────────────────────────────────────────────
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
getRoom(roomId: string): RoomInfo | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any
}
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
getRoomByInviteCode(code: string): RoomInfo | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any
}
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] {
getAllRooms(): RoomInfo[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[]
}
getRoomsForProfiles(profiles: string[]): RoomInfo[] {
const uniqueProfiles = [...new Set(profiles.map(profile => profile.trim()).filter(Boolean))]
if (!uniqueProfiles.length) return []
const placeholders = uniqueProfiles.map(() => '?').join(', ')
return (this.db()?.prepare(
`SELECT DISTINCT r.id, r.name, r.inviteCode, r.triggerTokens, r.maxHistoryTokens, r.tailMessageCount, r.totalTokens, r.sessionSeed
FROM gc_rooms r
INNER JOIN gc_room_agents a ON a.roomId = r.id
WHERE a.profile IN (${placeholders})
ORDER BY r.id`
).all(...uniqueProfiles) || []) as any[]
}
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
this.db()?.prepare(
'INSERT OR IGNORE INTO gc_rooms (id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount) VALUES (?, ?, ?, ?, ?, ?)'
@@ -109,6 +109,7 @@ export async function handleApiRun(
state.source = 'api_server'
state.activeRunMarker = runMarker
let peerUserMessage: { id?: number; role: 'user'; content: string; timestamp: number } | null = null
if (!skipUserMessage) {
const inputStr = contentBlocksToString(input)
state.messages.push({
@@ -126,12 +127,13 @@ export async function handleApiRun(
createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview })
}
addMessage({
const messageId = addMessage({
session_id,
role: 'user',
content: inputStr,
timestamp: now,
})
peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now }
} else {
const inputStr = contentBlocksToString(input)
state.messages.push({
@@ -147,15 +149,23 @@ export async function handleApiRun(
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: 'api_server', model, provider, title: preview })
}
addMessage({
const messageId = addMessage({
session_id,
role: 'user',
content: inputStr,
timestamp: now,
})
peerUserMessage = { id: messageId, role: 'user', content: inputStr, timestamp: now }
}
socket.join(`session:${session_id}`)
if (peerUserMessage) {
socket.to(`session:${session_id}`).emit('run.peer_user_message', {
event: 'run.peer_user_message',
session_id,
message: peerUserMessage,
})
}
}
const emit = (event: string, payload: any) => {
@@ -173,7 +173,7 @@ export async function handleBridgeRun(
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview })
}
addMessage({
const messageId = addMessage({
session_id,
role: 'user',
content: inputStr,
@@ -181,6 +181,16 @@ export async function handleBridgeRun(
})
socket.join(`session:${session_id}`)
socket.to(`session:${session_id}`).emit('run.peer_user_message', {
event: 'run.peer_user_message',
session_id,
message: {
id: messageId,
role: 'user',
content: inputStr,
timestamp: now,
},
})
const emit = (event: string, payload: any) => {
const tagged = { ...payload, session_id }
nsp.to(`session:${session_id}`).emit(event, tagged)
@@ -224,6 +224,52 @@ describe('Group Chat member/agent identity sync', () => {
})
})
it('filters room list to rooms containing one of the regular admin profiles', async () => {
const allRooms = [
{ id: 'room-default', name: 'Default', inviteCode: null },
{ id: 'room-private', name: 'Private', inviteCode: null },
]
const visibleRooms = [allRooms[0]]
const storage = {
getAllRooms: vi.fn(() => allRooms),
getRoomsForProfiles: vi.fn(() => visibleRooms),
}
setGroupChatServer({ getStorage: () => storage } as any)
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx: any = {
state: { user: { id: 2, username: 'ops', role: 'admin', profiles: ['default', 'research'] } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(storage.getRoomsForProfiles).toHaveBeenCalledWith(['default', 'research'])
expect(storage.getAllRooms).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ rooms: visibleRooms })
})
it('keeps room list unrestricted for super admins', async () => {
const rooms = [{ id: 'room-1', name: 'All', inviteCode: null }]
const storage = {
getAllRooms: vi.fn(() => rooms),
getRoomsForProfiles: vi.fn(() => []),
}
setGroupChatServer({ getStorage: () => storage } as any)
const handler = routeHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx: any = {
state: { user: { id: 1, username: 'admin', role: 'super_admin' } },
status: 200,
body: undefined,
}
await handler(ctx, async () => {})
expect(storage.getAllRooms).toHaveBeenCalledOnce()
expect(storage.getRoomsForProfiles).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ rooms })
})
it('routes @mentions only from user messages, not agent replies', () => {
const server = Object.create(GroupChatServer.prototype) as any
const emit = vi.fn()