mirror of
https://github.com/EKKOLearnAI/hermes-web-ui.git
synced 2026-05-26 22:10:15 +00:00
ba72264542
* 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>
1328 lines
50 KiB
TypeScript
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)
|
|
})
|
|
})
|