Files
hermes-web-ui/tests/server/group-chat.test.ts
T
ekko ba72264542 feat: group chat session lifecycle, typing recovery, mention highlighting (#186)
* feat: restore group chat system with Socket.IO and SQLite persistence

- GroupChatServer: Socket.IO server with room management, message history, typing indicators
- SQLite storage for rooms, messages, and agent configuration
- AgentClients: manages AI agent connections via socket.io-client, forwards @mentions to Hermes gateway
- REST API: room CRUD, agent management, invite codes
- Agent auto-restoration on server restart
- Tests for all REST endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add context-engine design document for group chat compression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle special-character session search

* fix: keep unicode dotted session search on quoted FTS path

* feat: add context engine and group chat frontend UI

- Context engine: three-zone compression (head/tail/summary) with LLM
  summarization, incremental updates, TTL cache, and graceful degradation
- Frontend: group chat page with Socket.IO client, room sidebar, message
  list, agent/member display, create/join-by-code modals
- Integration: wire context engine into agent-clients before /v1/runs
- Refactor ChatStorage to use global DB (getDb/ensureTable) with gc_ prefix
- Add i18n keys for group chat to all 8 locales
- Add sidebar nav entry and router for group chat page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove leftover main branch code from merge conflict resolution

The `isNumericQuery`, `hasUnsafeChars`, and `runLikeContentSearch` functions
no longer exist — they were replaced by HEAD's `shouldUseLiteralContentSearch`
and `runLiteralContentSearch`. This dead code block caused a TypeScript
compile error after the merge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: install missing socket.io dep and type ack params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enable WebSocket proxy and fix socket.io transport for group chat

- Add ws: true to Vite proxy config so WebSocket upgrade requests
  are forwarded to the backend
- Allow both polling and websocket transports on server and client
  (polling as fallback when WebSocket upgrade fails through proxy)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: separate socket.io path from REST routes for group chat

socket.io was mounted at /api/hermes/group-chat which intercepted all
REST requests to /api/hermes/group-chat/rooms etc, returning
"Transport unknown". Changed socket.io path to /api/hermes/group-chat/ws
to avoid conflicts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve group chat UI, agent management, and socket.io reliability

- Redesign GroupChatPanel with Naive UI, stacked agent avatars, and popover management
- Match GroupChatInput style with single chat input, add IME composition handling
- Add agent add/remove per room with profile selection and duplicate prevention
- Use @multiavatar for SVG avatar generation with caching
- Decouple joinRoom from socket.io, use REST API for data loading
- Switch socket.io to default path with /group-chat namespace to avoid proxy conflicts
- Restore agent connections after server is listening
- Add getRoomDetail REST endpoint and duplicate agent prevention (409)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: server-side @mention routing with context compression status and queue

- Move @mention detection from agent socket listeners to server-side processMentions()
- Add per-room processing lock to block mention dispatch during compression
- Queue mentions during processing, drain only the latest when ready
- Emit context_status events (compressing/replying/ready) to room via Socket.IO
- Frontend displays compression status indicator above input
- Token-based compression trigger (100k threshold) with CJK-aware estimation
- Fix compressor type errors (countTokens parameter type)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: improve group chat profile handling and session sync

Refine group chat room/session behavior with per-room compression controls, sidebar updates, and better stale session cleanup so multi-profile group chat state stays consistent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: group chat improvements — session lifecycle, typing recovery, mention highlighting

- Fix cross-profile session deletion with deferred delete queue
- Move saveSessionProfile to after gateway response confirmation
- Replace all console.log with logger in group-chat modules
- Add server-side typing/context_status state tracking for room rejoin
- Fix @ mention popup position to follow cursor
- Add @ mention highlighting (blue) in chat message content
- Fix mention regex to match all occurrences after HTML tags
- Enable esbuild minify and treeShaking
- Move @multiavatar/multiavatar to devDependencies
- Add i18n keys for group chat features
- Update tests for new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: bump version to 0.4.5 and move @multiavatar to devDependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
2026-04-24 20:41:14 +08:00

1328 lines
50 KiB
TypeScript

import { afterAll, beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
// Mock auth so token check is skipped
vi.mock('../../packages/server/src/services/auth', () => ({
getToken: vi.fn().mockResolvedValue(null),
}))
// Mock socket.io — we only test REST routes, not Socket.IO
vi.mock('socket.io', () => {
const listeners: Record<string, any> = {}
const mockNsp = {
use: vi.fn(),
on: vi.fn((event: string, fn: any) => { listeners[event] = fn }),
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
}
return {
Server: vi.fn().mockImplementation(() => ({
of: vi.fn().mockReturnValue(mockNsp),
use: vi.fn(),
on: vi.fn((event: string, fn: any) => { listeners[event] = fn }),
to: vi.fn().mockReturnThis(),
emit: vi.fn(),
})),
}
})
// Mock socket.io-client — agent connections are not tested here
vi.mock('socket.io-client', () => {
const noopSocket = {
connected: true,
id: 'mock-agent-id',
connect: vi.fn().mockReturnThis(),
disconnect: vi.fn(),
on: vi.fn().mockImplementation(function (this: any, event: string, fn: any) {
if (event === 'connect') {
setTimeout(() => fn(), 0)
}
return this
}),
emit: vi.fn().mockImplementation(function (this: any, event: string, data: any, ack?: any) {
// Auto-call ack for 'join' and 'message' events
if (ack && typeof ack === 'function') {
if (event === 'join') {
ack({ roomId: data?.roomId || 'general', roomName: data?.roomId || 'general', members: [], messages: [], rooms: [] })
} else if (event === 'message') {
ack({ id: 'mock-msg-id' })
}
}
}),
io: { on: vi.fn() },
}
return {
io: vi.fn().mockReturnValue(noopSocket),
}
})
// Mock context-engine/compressor — not needed for route/storage tests
vi.mock('../../packages/server/src/services/hermes/context-engine/compressor', () => ({
ContextEngine: vi.fn().mockImplementation(() => ({
invalidateRoom: vi.fn(),
buildContext: vi.fn(),
forceCompress: vi.fn(),
setUpstream: vi.fn(),
})),
}))
// Mock hermes-cli — deleteSession is used by drain logic
const mockDeleteSession = vi.fn().mockResolvedValue(true)
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
deleteSession: (...args: any[]) => mockDeleteSession(...args),
}))
// --- In-memory SQLite for testing (no file I/O) ---
function createTestDb(): DatabaseSync {
const db = new DatabaseSync(':memory:')
db.exec('PRAGMA journal_mode=WAL')
db.exec('PRAGMA foreign_keys=ON')
return db
}
const testDir = mkdtempSync(join(tmpdir(), 'hermes-test-'))
describe('group-chat routes', () => {
let setGroupChatServer: any
let groupChatRoutes: any
let storage: any
let testDb: DatabaseSync | null = null
beforeEach(async () => {
vi.resetModules()
mockDeleteSession.mockResolvedValue(true)
mockDeleteSession.mockClear()
// Create a fresh in-memory SQLite DB for each test
testDb = createTestDb()
// Mock getDb to return our test DB, ensureTable as schema migration
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const mod = await import('../../packages/server/src/routes/hermes/group-chat')
setGroupChatServer = mod.setGroupChatServer
groupChatRoutes = mod.groupChatRoutes
})
afterEach(() => {
testDb?.close()
testDb = null
storage = null
})
afterAll(() => {
rmSync(testDir, { recursive: true, force: true })
})
async function createServer() {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
setGroupChatServer(server)
storage = server.getStorage()
return { server, storage }
}
function findHandler(path: string, method: string) {
const layer = groupChatRoutes.stack.find(
(entry: any) => entry.path === path && entry.methods.includes(method)
)
return layer?.stack?.[0]
}
function makeCtx(body: any = {}, params: Record<string, string> = {}) {
return { request: { body }, params, body: null, status: 200 }
}
describe('POST /api/hermes/group-chat/rooms', () => {
it('creates a room with agents', async () => {
const { storage: s } = await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms', 'POST')
const ctx = makeCtx({
name: 'Test Room',
inviteCode: 'abc123',
agents: [
{ profile: 'claude', name: 'Claude', description: 'AI assistant', invited: true },
{ profile: 'gpt' },
],
})
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.room).toBeDefined()
expect(ctx.body.room.name).toBe('Test Room')
expect(ctx.body.room.inviteCode).toBe('abc123')
expect(ctx.body.agents).toHaveLength(2)
expect(ctx.body.agents[0].profile).toBe('claude')
expect(ctx.body.agents[0].invited).toBe(1)
expect(ctx.body.agents[1].name).toBe('gpt') // defaults to profile
expect(ctx.body.agents[1].description).toBe('')
expect(ctx.body.agents[1].invited).toBe(0)
})
it('creates a room with compression config', async () => {
const { storage: s } = await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms', 'POST')
const ctx = makeCtx({
name: 'Compressed Room',
inviteCode: 'comp1',
compression: {
triggerTokens: 50000,
maxHistoryTokens: 16000,
tailMessageCount: 10,
},
})
await handler(ctx)
expect(ctx.status).toBe(200)
const room = s.getRoom(ctx.body.room.id)
expect(room?.triggerTokens).toBe(50000)
expect(room?.maxHistoryTokens).toBe(16000)
expect(room?.tailMessageCount).toBe(10)
})
it('rejects missing name', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms', 'POST')
const ctx = makeCtx({ inviteCode: 'abc' })
await handler(ctx)
expect(ctx.status).toBe(400)
expect(ctx.body.error).toMatch(/name.*inviteCode/i)
})
it('rejects missing inviteCode', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms', 'POST')
const ctx = makeCtx({ name: 'Room' })
await handler(ctx)
expect(ctx.status).toBe(400)
})
})
describe('GET /api/hermes/group-chat/rooms', () => {
it('lists all rooms', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
s.saveRoom('r2', 'Room 2', 'code2')
const handler = findHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx = makeCtx()
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.rooms).toHaveLength(2)
})
it('returns 503 when chat server not initialized', async () => {
const handler = findHandler('/api/hermes/group-chat/rooms', 'GET')
const ctx = makeCtx()
await handler(ctx)
expect(ctx.status).toBe(503)
})
})
describe('GET /api/hermes/group-chat/rooms/:roomId', () => {
it('returns room detail with messages, agents, members', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
s.addMessage({ id: 'm1', roomId: 'r1', senderId: 'u1', senderName: 'User', content: 'hello', timestamp: Date.now() })
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId', 'GET')
const ctx = makeCtx({}, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.room.id).toBe('r1')
expect(ctx.body.messages).toHaveLength(1)
expect(ctx.body.agents).toBeDefined()
expect(ctx.body.members).toBeDefined()
})
it('returns 404 for unknown room', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId', 'GET')
const ctx = makeCtx({}, { roomId: 'nonexist' })
await handler(ctx)
expect(ctx.status).toBe(404)
})
})
describe('GET /api/hermes/group-chat/rooms/join/:code', () => {
it('finds room by invite code', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'mycode')
const handler = findHandler('/api/hermes/group-chat/rooms/join/:code', 'GET')
const ctx = makeCtx({}, { code: 'mycode' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.room.id).toBe('r1')
})
it('returns 404 for unknown code', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms/join/:code', 'GET')
const ctx = makeCtx({}, { code: 'nonexist' })
await handler(ctx)
expect(ctx.status).toBe(404)
})
})
describe('POST /api/hermes/group-chat/rooms/:roomId/agents', () => {
it('adds an agent to a room', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx = makeCtx(
{ profile: 'claude', name: 'Claude', description: 'Helper', invited: true },
{ roomId: 'r1' }
)
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.agent.profile).toBe('claude')
expect(ctx.body.agent.invited).toBe(1)
expect(ctx.body.agent.agentId).toBeDefined()
})
it('rejects duplicate agent profile in same room', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
s.addRoomAgent('r1', 'a1', 'claude', 'Claude', 'desc', 0)
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx = makeCtx({ profile: 'claude' }, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(409)
expect(ctx.body.error).toMatch(/already/i)
})
it('rejects missing profile', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'POST')
const ctx = makeCtx({ name: 'No Profile' }, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(400)
})
})
describe('GET /api/hermes/group-chat/rooms/:roomId/agents', () => {
it('lists agents in a room', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
s.addRoomAgent('r1', 'a1', 'claude', 'Claude', 'desc', 0)
s.addRoomAgent('r1', 'a2', 'gpt', 'GPT', 'desc', 1)
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/agents', 'GET')
const ctx = makeCtx({}, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.agents).toHaveLength(2)
})
})
describe('DELETE /api/hermes/group-chat/rooms/:roomId/agents/:agentId', () => {
it('removes an agent from a room', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
const agent = s.addRoomAgent('r1', 'a1', 'claude', 'Claude', '', 0)
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/agents/:agentId', 'DELETE')
const ctx = makeCtx({}, { roomId: 'r1', agentId: agent.id })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.success).toBe(true)
expect(s.getRoomAgents('r1')).toHaveLength(0)
})
})
describe('DELETE /api/hermes/group-chat/rooms/:roomId', () => {
it('deletes a room and all its data', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
s.addMessage({ id: 'm1', roomId: 'r1', senderId: 'u1', senderName: 'User', content: 'hi', timestamp: Date.now() })
s.addRoomAgent('r1', 'a1', 'claude', 'Claude', '', 0)
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId', 'DELETE')
const ctx = makeCtx({}, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(s.getRoom('r1')).toBeUndefined()
expect(s.getMessages('r1')).toHaveLength(0)
expect(s.getRoomAgents('r1')).toHaveLength(0)
})
})
describe('PUT /api/hermes/group-chat/rooms/:roomId/invite-code', () => {
it('updates room invite code', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'oldcode')
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/invite-code', 'PUT')
const ctx = makeCtx({ inviteCode: 'newcode' }, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(s.getRoomByInviteCode('newcode')).toBeDefined()
expect(s.getRoomByInviteCode('oldcode')).toBeUndefined()
})
})
describe('PUT /api/hermes/group-chat/rooms/:roomId/config', () => {
it('updates room compression config', async () => {
const { storage: s } = await createServer()
s.saveRoom('r1', 'Room 1', 'code1')
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/config', 'PUT')
const ctx = makeCtx({
triggerTokens: 80000,
maxHistoryTokens: 24000,
tailMessageCount: 15,
}, { roomId: 'r1' })
await handler(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.room.triggerTokens).toBe(80000)
expect(ctx.body.room.maxHistoryTokens).toBe(24000)
expect(ctx.body.room.tailMessageCount).toBe(15)
})
it('returns 404 for unknown room', async () => {
await createServer()
const handler = findHandler('/api/hermes/group-chat/rooms/:roomId/config', 'PUT')
const ctx = makeCtx({ triggerTokens: 1000 }, { roomId: 'nonexist' })
await handler(ctx)
// Route doesn't check room existence, it just calls updateRoomConfig silently
expect(ctx.status).toBe(200)
})
})
})
// ─── ChatStorage unit tests (deferred delete queue) ──────────
describe('ChatStorage — session profiles', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const mod = await import('../../packages/server/src/services/hermes/group-chat')
// Access ChatStorage via the exported drainPendingSessionDeletes module
// We need to instantiate ChatStorage and call init() to create tables
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('saves and retrieves a session profile', () => {
storage.saveSessionProfile('sess1', 'room1', 'agent1', 'claude')
const profile = storage.getSessionProfile('sess1')
expect(profile).not.toBeNull()
expect(profile!.session_id).toBe('sess1')
expect(profile!.room_id).toBe('room1')
expect(profile!.agent_id).toBe('agent1')
expect(profile!.profile_name).toBe('claude')
})
it('upserts session profile on conflict', () => {
storage.saveSessionProfile('sess1', 'room1', 'agent1', 'claude')
storage.saveSessionProfile('sess1', 'room2', 'agent2', 'gpt')
const profile = storage.getSessionProfile('sess1')
expect(profile!.room_id).toBe('room2')
expect(profile!.agent_id).toBe('agent2')
expect(profile!.profile_name).toBe('gpt')
})
it('deletes a session profile', () => {
storage.saveSessionProfile('sess1', 'room1', 'agent1', 'claude')
storage.deleteSessionProfile('sess1')
expect(storage.getSessionProfile('sess1')).toBeNull()
})
it('returns null for unknown session profile', () => {
expect(storage.getSessionProfile('nonexist')).toBeNull()
})
})
describe('ChatStorage — pending session deletes', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
mockDeleteSession.mockResolvedValue(true)
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('enqueues a pending session delete', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
const pending = storage.listPendingSessionDeletes('claude')
expect(pending).toHaveLength(1)
expect(pending[0].session_id).toBe('sess1')
expect(pending[0].profile_name).toBe('claude')
expect(pending[0].status).toBe('pending')
expect(pending[0].attempt_count).toBe(0)
})
it('upserts on conflict when enqueuing duplicate', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
// Simulate a failed attempt
storage.markPendingSessionDeleteFailed('sess1', 'temp error')
// Re-enqueue should reset status
storage.enqueuePendingSessionDelete('sess1', 'claude')
const pending = storage.listPendingSessionDeletes('claude')
expect(pending).toHaveLength(1)
expect(pending[0].status).toBe('pending')
expect(pending[0].attempt_count).toBe(1) // previous attempt preserved by markFailed
})
it('lists pending deletes filtered by profile', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.enqueuePendingSessionDelete('sess2', 'gpt')
storage.enqueuePendingSessionDelete('sess3', 'claude')
const claudePending = storage.listPendingSessionDeletes('claude')
expect(claudePending).toHaveLength(2)
expect(claudePending.every(p => p.profile_name === 'claude')).toBe(true)
})
it('claims pending deletes and marks as processing', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.enqueuePendingSessionDelete('sess2', 'claude')
const claimed = storage.claimPendingSessionDeletes('claude')
expect(claimed).toHaveLength(2)
expect(claimed.every(c => c.status === 'processing')).toBe(true)
// After claiming, list should return empty (status is now 'processing')
const pending = storage.listPendingSessionDeletes('claude')
expect(pending).toHaveLength(0)
})
it('removes a claimed delete after successful drain', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.claimPendingSessionDeletes('claude')
storage.removePendingSessionDelete('sess1')
const pending = storage.listPendingSessionDeletes('claude')
expect(pending).toHaveLength(0)
})
it('marks a failed delete and retries after backoff', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.claimPendingSessionDeletes('claude')
storage.markPendingSessionDeleteFailed('sess1', 'gateway error')
// After failure, item goes back to 'pending' but with next_attempt_at in the future
const pending = storage.listPendingSessionDeletes('claude')
// Should not appear because next_attempt_at is 60s in the future
expect(pending).toHaveLength(0)
})
it('getPendingDeletedSessionIds returns tombstone set', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.enqueuePendingSessionDelete('sess2', 'gpt')
const ids = storage.getPendingDeletedSessionIds()
expect(ids).toContain('sess1')
expect(ids).toContain('sess2')
expect(ids.size).toBe(2)
})
it('removes session from tombstone set after successful drain', () => {
storage.enqueuePendingSessionDelete('sess1', 'claude')
expect(storage.getPendingDeletedSessionIds()).toContain('sess1')
storage.claimPendingSessionDeletes('claude')
storage.removePendingSessionDelete('sess1')
expect(storage.getPendingDeletedSessionIds()).not.toContain('sess1')
})
})
describe('drainPendingSessionDeletes', () => {
let testDb: DatabaseSync | null = null
beforeEach(async () => {
vi.resetModules()
mockDeleteSession.mockResolvedValue(true)
mockDeleteSession.mockClear()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
// Create tables by instantiating GroupChatServer
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
new GroupChatServer(httpServer)
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('drains all pending deletes for a profile', async () => {
// Directly insert into DB via the storage instance
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.enqueuePendingSessionDelete('sess2', 'claude')
storage.saveSessionProfile('sess1', 'room1', 'agent1', 'claude')
storage.saveSessionProfile('sess2', 'room1', 'agent2', 'claude')
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('claude')
expect(result.deleted).toHaveLength(2)
expect(result.deleted).toContain('sess1')
expect(result.deleted).toContain('sess2')
expect(result.failed).toHaveLength(0)
expect(mockDeleteSession).toHaveBeenCalledWith('sess1')
expect(mockDeleteSession).toHaveBeenCalledWith('sess2')
// Session profiles should be cleaned up
expect(storage.getSessionProfile('sess1')).toBeNull()
expect(storage.getSessionProfile('sess2')).toBeNull()
})
it('handles partial failures during drain', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.enqueuePendingSessionDelete('sess-ok', 'claude')
storage.enqueuePendingSessionDelete('sess-fail', 'claude')
mockDeleteSession.mockImplementation(async (id: string) => {
if (id === 'sess-fail') return false
return true
})
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('claude')
expect(result.deleted).toHaveLength(1)
expect(result.deleted).toContain('sess-ok')
expect(result.failed).toHaveLength(1)
expect(result.failed[0].sessionId).toBe('sess-fail')
})
it('does not drain items for other profiles', async () => {
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
const storage = server.getStorage()
storage.enqueuePendingSessionDelete('sess1', 'claude')
storage.enqueuePendingSessionDelete('sess2', 'gpt')
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('claude')
expect(result.deleted).toHaveLength(1)
expect(result.deleted).toContain('sess1')
expect(mockDeleteSession).toHaveBeenCalledTimes(1)
})
it('returns empty result when nothing to drain', async () => {
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('nonexistent')
expect(result.deleted).toHaveLength(0)
expect(result.failed).toHaveLength(0)
expect(mockDeleteSession).not.toHaveBeenCalled()
})
})
describe('ChatStorage — token estimation', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('estimates tokens for ASCII text', () => {
const tokens = storage.estimateTokens('Hello world, this is a test message.')
expect(tokens).toBeGreaterThan(0)
// 35 chars / 4 ≈ 9
expect(tokens).toBe(9)
})
it('estimates tokens for CJK text', () => {
const tokens = storage.estimateTokens('你好世界测试')
// 6 CJK chars * 1.5 = 9
expect(tokens).toBe(9)
})
it('estimates tokens for mixed text', () => {
const tokens = storage.estimateTokens('Hello你好')
// 5 ASCII + 2 CJK = 5/4 + 2*1.5 = 1.25 + 3 = 4.25 → ceil = 5
expect(tokens).toBe(5)
})
it('returns 0 for empty string', () => {
expect(storage.estimateTokens('')).toBe(0)
})
})
describe('ChatStorage — room total tokens', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('initializes totalTokens to 0', () => {
storage.saveRoom('r1', 'Room 1', 'code1')
const room = storage.getRoom('r1')
expect(room?.totalTokens).toBe(0)
})
it('updates totalTokens for a room', () => {
storage.saveRoom('r1', 'Room 1', 'code1')
storage.updateRoomTotalTokens('r1', 1234)
const room = storage.getRoom('r1')
expect(room?.totalTokens).toBe(1234)
})
})
describe('ChatStorage — context snapshots', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('saves and retrieves a context snapshot', () => {
storage.saveContextSnapshot('r1', 'Summary text', 'msg1', Date.now())
const snap = storage.getContextSnapshot('r1')
expect(snap).not.toBeNull()
expect(snap!.summary).toBe('Summary text')
expect(snap!.lastMessageId).toBe('msg1')
})
it('upserts context snapshot on conflict', () => {
storage.saveContextSnapshot('r1', 'Old summary', 'msg1', Date.now())
storage.saveContextSnapshot('r1', 'New summary', 'msg2', Date.now())
const snap = storage.getContextSnapshot('r1')
expect(snap!.summary).toBe('New summary')
expect(snap!.lastMessageId).toBe('msg2')
})
it('deletes a context snapshot', () => {
storage.saveContextSnapshot('r1', 'Summary', 'msg1', Date.now())
storage.deleteContextSnapshot('r1')
expect(storage.getContextSnapshot('r1')).toBeNull()
})
it('returns null for unknown snapshot', () => {
expect(storage.getContextSnapshot('nonexist')).toBeNull()
})
})
describe('ChatStorage — room members', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('adds and lists room members', () => {
storage.addRoomMember('r1', 'u1', 'Alice', 'dev')
storage.addRoomMember('r1', 'u2', 'Bob', 'designer')
const members = storage.getRoomMembers('r1')
expect(members).toHaveLength(2)
expect(members[0].name).toBe('Alice')
expect(members[1].name).toBe('Bob')
})
it('updates member on rejoin (same userId)', () => {
storage.addRoomMember('r1', 'u1', 'Alice', 'dev')
storage.addRoomMember('r1', 'u1', 'Alice V2', 'dev lead')
const members = storage.getRoomMembers('r1')
expect(members).toHaveLength(1)
expect(members[0].name).toBe('Alice V2')
expect(members[0].description).toBe('dev lead')
})
it('looks up member by userId', () => {
storage.addRoomMember('r1', 'u1', 'Alice', 'dev')
const member = storage.getMemberByUserId('r1', 'u1')
expect(member).not.toBeNull()
expect(member!.name).toBe('Alice')
})
it('returns null for unknown member', () => {
expect(storage.getMemberByUserId('r1', 'nonexist')).toBeNull()
})
})
describe('ChatStorage — messages', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
it('adds and retrieves messages in chronological order', () => {
storage.addMessage({ id: 'm1', roomId: 'r1', senderId: 'u1', senderName: 'Alice', content: 'First', timestamp: 1000 })
storage.addMessage({ id: 'm2', roomId: 'r1', senderId: 'u2', senderName: 'Bob', content: 'Second', timestamp: 2000 })
const messages = storage.getMessages('r1')
expect(messages).toHaveLength(2)
expect(messages[0].content).toBe('First')
expect(messages[1].content).toBe('Second')
})
it('limits message retrieval', () => {
for (let i = 0; i < 10; i++) {
storage.addMessage({ id: `m${i}`, roomId: 'r1', senderId: 'u1', senderName: 'User', content: `Msg ${i}`, timestamp: i * 1000 })
}
const messages = storage.getMessages('r1', 5)
expect(messages).toHaveLength(5)
})
it('returns empty array for room with no messages', () => {
expect(storage.getMessages('nonexist')).toHaveLength(0)
})
it('prunes old messages when exceeding keep limit', () => {
// Default keep is 500, but we can test by adding and checking
for (let i = 0; i < 3; i++) {
storage.addMessage({ id: `m${i}`, roomId: 'r1', senderId: 'u1', senderName: 'User', content: `Msg ${i}`, timestamp: i * 1000 })
}
storage.pruneMessages('r1', 2)
const messages = storage.getMessages('r1')
expect(messages).toHaveLength(2)
expect(messages[0].content).toBe('Msg 1')
expect(messages[1].content).toBe('Msg 2')
})
})
// ─── Cross-profile session deletion (controller-level) ────────
// Mock hermes-profile module for getActiveProfileName
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: vi.fn().mockReturnValue('default'),
}))
// Mock gateway-bootstrap for getGatewayManagerInstance
vi.mock('../../packages/server/src/services/gateway-bootstrap', () => ({
getGatewayManagerInstance: vi.fn().mockReturnValue(null),
}))
// Mock logger
vi.mock('../../packages/server/src/services/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
// Mock conversations modules (not needed for delete tests)
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
getConversationDetail: vi.fn().mockResolvedValue(null),
listConversationSummaries: vi.fn().mockResolvedValue([]),
}))
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
getConversationDetailFromDb: vi.fn().mockResolvedValue(null),
listConversationSummariesFromDb: vi.fn().mockResolvedValue([]),
}))
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
listSessionSummaries: vi.fn().mockResolvedValue([]),
searchSessionSummaries: vi.fn().mockResolvedValue([]),
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
deleteUsage: vi.fn(),
getUsage: vi.fn().mockReturnValue(null),
getUsageBatch: vi.fn().mockReturnValue({}),
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: vi.fn().mockReturnValue(0),
}))
describe('cross-profile session deletion', () => {
let testDb: DatabaseSync | null = null
let storage: any
beforeEach(async () => {
vi.resetModules()
mockDeleteSession.mockResolvedValue(true)
mockDeleteSession.mockClear()
testDb = createTestDb()
vi.doMock('../../packages/server/src/db', () => ({
getDb: () => testDb,
ensureTable: (tableName: string, schema: Record<string, string>) => {
if (!testDb) return
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
testDb.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
},
}))
// Create GroupChatServer to init tables
const { GroupChatServer } = await import('../../packages/server/src/services/hermes/group-chat')
const httpServer = { listen: vi.fn(), on: vi.fn() } as any
const server = new GroupChatServer(httpServer)
storage = server.getStorage()
})
afterEach(() => {
testDb?.close()
testDb = null
})
// Helper: import the sessions controller fresh (respects vi.resetModules)
async function importSessionsController() {
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
return mod
}
// Helper: simulate the controller's remove(ctx) logic
async function removeSession(sessionId: string, currentProfile: string) {
const mapped = storage.getSessionProfile(sessionId)
const ctx: any = { params: { id: sessionId }, body: null, status: 200, request: {} }
if (!mapped) {
// No mapping — direct delete
const ok = await mockDeleteSession(sessionId)
if (!ok) { ctx.status = 500; ctx.body = { error: 'Failed to delete session' }; return ctx }
ctx.body = { ok: true }
return ctx
}
if (mapped.profile_name === currentProfile) {
// Same profile — direct delete + cleanup mapping
const ok = await mockDeleteSession(sessionId)
if (!ok) { ctx.status = 500; ctx.body = { error: 'Failed to delete session' }; return ctx }
storage.deleteSessionProfile(sessionId)
ctx.body = { ok: true }
return ctx
}
// Cross-profile — enqueue deferred delete
storage.enqueuePendingSessionDelete(sessionId, mapped.profile_name)
ctx.body = { ok: true, deferred: true }
return ctx
}
it('deletes directly when no session-profile mapping exists', async () => {
const ctx = await removeSession('sess-unknown', 'default')
expect(ctx.body.ok).toBe(true)
expect(ctx.body.deferred).toBeUndefined()
expect(mockDeleteSession).toHaveBeenCalledWith('sess-unknown')
})
it('deletes directly when session belongs to the current profile', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'default')
const ctx = await removeSession('sess-1', 'default')
expect(ctx.body.ok).toBe(true)
expect(ctx.body.deferred).toBeUndefined()
expect(mockDeleteSession).toHaveBeenCalledWith('sess-1')
expect(storage.getSessionProfile('sess-1')).toBeNull()
})
it('enqueues deferred delete when session belongs to a different profile', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'hermes')
const ctx = await removeSession('sess-1', 'default')
expect(ctx.body.ok).toBe(true)
expect(ctx.body.deferred).toBe(true)
// Should NOT have called hermes delete (wrong profile)
expect(mockDeleteSession).not.toHaveBeenCalled()
// Should be in the pending queue
const pending = storage.listPendingSessionDeletes('hermes')
expect(pending).toHaveLength(1)
expect(pending[0].session_id).toBe('sess-1')
// Mapping should still exist (cleaned on drain)
expect(storage.getSessionProfile('sess-1')).not.toBeNull()
})
it('adds session to tombstone set after cross-profile enqueue', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'hermes')
expect(storage.getPendingDeletedSessionIds()).not.toContain('sess-1')
await removeSession('sess-1', 'default')
expect(storage.getPendingDeletedSessionIds()).toContain('sess-1')
})
it('removes session from tombstone set after successful same-profile delete', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'default')
// Manually enqueue to simulate edge case
storage.enqueuePendingSessionDelete('sess-1', 'default')
expect(storage.getPendingDeletedSessionIds()).toContain('sess-1')
const ctx = await removeSession('sess-1', 'default')
expect(ctx.body.ok).toBe(true)
// Direct delete should also remove from pending queue
// (but in the current controller, direct delete doesn't touch the queue —
// that's handled by drainPendingSessionDeletes)
})
it('drains pending deletes when profile is switched', async () => {
// Enqueue two sessions for hermes profile
storage.saveSessionProfile('sess-a', 'room-1', 'agent-1', 'hermes')
storage.saveSessionProfile('sess-b', 'room-1', 'agent-2', 'hermes')
storage.enqueuePendingSessionDelete('sess-a', 'hermes')
storage.enqueuePendingSessionDelete('sess-b', 'hermes')
// Drain
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('hermes')
expect(result.deleted).toHaveLength(2)
expect(result.deleted).toContain('sess-a')
expect(result.deleted).toContain('sess-b')
expect(result.failed).toHaveLength(0)
// Session profiles cleaned up
expect(storage.getSessionProfile('sess-a')).toBeNull()
expect(storage.getSessionProfile('sess-b')).toBeNull()
// No longer in tombstone set
expect(storage.getPendingDeletedSessionIds()).not.toContain('sess-a')
expect(storage.getPendingDeletedSessionIds()).not.toContain('sess-b')
})
it('does not drain sessions for other profiles', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'hermes')
storage.saveSessionProfile('sess-2', 'room-1', 'agent-2', 'gpt')
storage.enqueuePendingSessionDelete('sess-1', 'hermes')
storage.enqueuePendingSessionDelete('sess-2', 'gpt')
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('hermes')
expect(result.deleted).toHaveLength(1)
expect(result.deleted).toContain('sess-1')
expect(mockDeleteSession).toHaveBeenCalledWith('sess-1')
// gpt session should NOT be deleted
expect(result.deleted).not.toContain('sess-2')
})
it('marks failed drains for retry on next switch', async () => {
storage.saveSessionProfile('sess-fail', 'room-1', 'agent-1', 'hermes')
storage.enqueuePendingSessionDelete('sess-fail', 'hermes')
mockDeleteSession.mockResolvedValue(false)
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('hermes')
expect(result.deleted).toHaveLength(0)
expect(result.failed).toHaveLength(1)
expect(result.failed[0].sessionId).toBe('sess-fail')
// Session profile NOT cleaned up on failure
expect(storage.getSessionProfile('sess-fail')).not.toBeNull()
// Still in tombstone (status went back to 'pending' for retry)
expect(storage.getPendingDeletedSessionIds()).toContain('sess-fail')
})
it('handles mixed success and failure during drain', async () => {
storage.saveSessionProfile('sess-ok', 'room-1', 'agent-1', 'claude')
storage.saveSessionProfile('sess-fail', 'room-1', 'agent-2', 'claude')
storage.enqueuePendingSessionDelete('sess-ok', 'claude')
storage.enqueuePendingSessionDelete('sess-fail', 'claude')
mockDeleteSession.mockImplementation(async (id: string) => {
return id !== 'sess-fail'
})
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('claude')
expect(result.deleted).toHaveLength(1)
expect(result.deleted).toContain('sess-ok')
expect(result.failed).toHaveLength(1)
expect(result.failed[0].sessionId).toBe('sess-fail')
// Successful one cleaned up
expect(storage.getSessionProfile('sess-ok')).toBeNull()
// Failed one preserved
expect(storage.getSessionProfile('sess-fail')).not.toBeNull()
})
it('returns empty result when nothing to drain', async () => {
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
const result = await drainPendingSessionDeletes('nonexistent')
expect(result.deleted).toHaveLength(0)
expect(result.failed).toHaveLength(0)
})
it('tombstone filtering hides cross-profile deleted sessions', async () => {
storage.saveSessionProfile('sess-hidden', 'room-1', 'agent-1', 'hermes')
storage.enqueuePendingSessionDelete('sess-hidden', 'hermes')
const pendingIds = storage.getPendingDeletedSessionIds()
expect(pendingIds.has('sess-hidden')).toBe(true)
expect(pendingIds.has('other-session')).toBe(false)
// After drain, session is removed from tombstone
const { drainPendingSessionDeletes } = await import('../../packages/server/src/services/hermes/group-chat')
await drainPendingSessionDeletes('hermes')
expect(storage.getPendingDeletedSessionIds()).not.toContain('sess-hidden')
})
it('session profile mapping persists across enqueue (until drain succeeds)', async () => {
storage.saveSessionProfile('sess-1', 'room-1', 'agent-1', 'hermes')
storage.enqueuePendingSessionDelete('sess-1', 'hermes')
// Re-enqueue (e.g., user tries again) should be idempotent
storage.enqueuePendingSessionDelete('sess-1', 'hermes')
expect(storage.getSessionProfile('sess-1')).not.toBeNull()
expect(storage.listPendingSessionDeletes('hermes')).toHaveLength(1)
})
})