Files
Hermes-ui/tests/server/sessions-db.test.ts
T
Zhicheng Han 5f40ae6258 feat(chat): add direct Live badge and harden Live monitor backend (#138)
* feat(chat): add direct live badge to session rows

* fix(live): use session DB for conversations monitor

* docs: add chat vs live monitor direction plan

* fix(search): avoid numeric session search 500 without FTS table
2026-04-23 10:49:00 +08:00

462 lines
14 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const allMock = vi.fn()
const titleAllMock = vi.fn()
const contentAllMock = vi.fn()
const likeAllMock = vi.fn()
const prepareMock = vi.fn((sql: string) => {
if (sql.includes('messages_fts MATCH')) return ({ all: contentAllMock })
if (sql.includes('m.content LIKE ?')) return ({ all: likeAllMock })
if (sql.includes("LOWER(COALESCE(base.title, '')) LIKE ?")) return ({ all: titleAllMock })
return ({ all: allMock })
})
const closeMock = vi.fn()
const databaseSyncMock = vi.fn(() => ({ prepare: prepareMock, close: closeMock }))
const getActiveProfileDirMock = vi.fn(() => '/tmp/hermes-profile')
vi.doMock('node:sqlite', () => ({
DatabaseSync: databaseSyncMock,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: getActiveProfileDirMock,
}))
describe('session DB summaries', () => {
beforeEach(() => {
vi.resetModules()
allMock.mockReset()
titleAllMock.mockReset()
contentAllMock.mockReset()
likeAllMock.mockReset()
prepareMock.mockClear()
closeMock.mockClear()
databaseSyncMock.mockClear()
getActiveProfileDirMock.mockReset()
getActiveProfileDirMock.mockReturnValue('/tmp/hermes-profile')
})
it('queries sqlite for lightweight session summaries', async () => {
allMock.mockReturnValue([
{
id: 's1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Named session',
started_at: 1710000000,
ended_at: null,
end_reason: '',
message_count: 3,
tool_call_count: 1,
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0.01,
actual_cost_usd: null,
cost_status: 'estimated',
preview: 'hello world',
last_active: 1710000005,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries(undefined, 50)
expect(databaseSyncMock).toHaveBeenCalledWith('/tmp/hermes-profile/state.db', { open: true, readOnly: true })
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining("AND s.source != 'tool'"))
expect(allMock).toHaveBeenCalledWith(50)
expect(closeMock).toHaveBeenCalled()
expect(rows).toEqual([
{
id: 's1',
source: 'cli',
user_id: null,
model: 'openai/gpt-5.4',
title: 'Named session',
started_at: 1710000000,
ended_at: null,
end_reason: null,
message_count: 3,
tool_call_count: 1,
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openrouter',
estimated_cost_usd: 0.01,
actual_cost_usd: null,
cost_status: 'estimated',
preview: 'hello world',
last_active: 1710000005,
},
])
})
it('adds source filter and falls back last_active to started_at', async () => {
allMock.mockReturnValue([
{
id: 's2',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710000100,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 4,
output_tokens: 5,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'preview text',
last_active: null,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries('telegram', 2)
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('AND s.source = ?'))
expect(allMock).toHaveBeenCalledWith('telegram', 2)
expect(rows[0].last_active).toBe(1710000100)
expect(rows[0].source).toBe('telegram')
expect(rows[0].title).toBe('preview text')
})
it('searches session titles and content with deduped results', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: null,
snippet: 'Docker debugging',
rank: 0,
},
])
contentAllMock.mockReturnValue([
{
id: 'title-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Docker debugging',
started_at: 1710001000,
ended_at: null,
end_reason: '',
message_count: 2,
tool_call_count: 0,
input_tokens: 1,
output_tokens: 2,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title preview',
last_active: 1710001005,
matched_message_id: 42,
snippet: '>>>docker<<< compose up',
rank: 0.25,
},
{
id: 'content-2',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'content preview',
last_active: 1710002001,
matched_message_id: 7,
snippet: '>>>docker<<< swarm',
rank: 0.1,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('docker', undefined, 10)
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('messages_fts MATCH'))
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('title-1')
expect(rows[0].matched_message_id).toBeNull()
expect(rows[0].snippet).toBe('Docker debugging')
expect(rows[1].id).toBe('content-2')
expect(rows[1].matched_message_id).toBe(7)
expect(rows[1].snippet).toContain('docker')
})
it('falls back to LIKE search when messages_fts is missing for numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
likeAllMock.mockReturnValue([
{
id: 'numeric-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002800,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'numeric preview',
last_active: 1710002805,
matched_message_id: 9,
snippet: 'ticket 12345',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('123', '%123%')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('numeric-1')
expect(rows[0].snippet).toContain('123')
})
it('keeps the source filter when messages_fts is missing for numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
likeAllMock.mockReturnValue([
{
id: 'numeric-telegram-1',
source: 'telegram',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002850,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'telegram numeric preview',
last_active: 1710002855,
matched_message_id: 12,
snippet: 'telegram 123 body',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', 'telegram', 10)
expect(likeAllMock).toHaveBeenCalledWith('telegram', '123', '%123%')
expect(rows).toHaveLength(1)
expect(rows[0].source).toBe('telegram')
expect(rows[0].id).toBe('numeric-telegram-1')
})
it('preserves title matches when messages_fts is missing for numeric queries', async () => {
titleAllMock.mockReturnValue([
{
id: 'title-123',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: 'Issue 123',
started_at: 1710002900,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'title numeric preview',
last_active: 1710002910,
matched_message_id: null,
snippet: 'Issue 123',
rank: 0,
},
])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
likeAllMock.mockReturnValue([
{
id: 'content-123',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710002890,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 2,
output_tokens: 3,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: 'content numeric preview',
last_active: 1710002895,
matched_message_id: 10,
snippet: 'content 123 body',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('123', undefined, 10)
expect(rows).toHaveLength(2)
expect(rows[0].id).toBe('title-123')
expect(rows[0].matched_message_id).toBeNull()
expect(rows[1].id).toBe('content-123')
expect(rows[1].matched_message_id).toBe(10)
})
it('falls back to LIKE search for CJK queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('fts5 tokenizer miss')
})
likeAllMock.mockReturnValue([
{
id: 'cjk-1',
source: 'cli',
user_id: '',
model: 'openai/gpt-5.4',
title: '',
started_at: 1710003000,
ended_at: null,
end_reason: '',
message_count: 1,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: '',
estimated_cost_usd: 0,
actual_cost_usd: null,
cost_status: '',
preview: '中文预览',
last_active: 1710003002,
matched_message_id: 11,
snippet: '这是一段记忆断裂的内容',
rank: 0,
},
])
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('记忆断裂', undefined, 10)
expect(likeAllMock).toHaveBeenCalledWith('记忆断裂', '%记忆断裂%')
expect(rows).toHaveLength(1)
expect(rows[0].id).toBe('cjk-1')
expect(rows[0].snippet).toContain('记忆断裂')
})
it('does not fall back to LIKE when messages_fts is missing for non-numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('no such table: messages_fts')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('docker', undefined, 10)).rejects.toThrow(
'Failed to search sessions: no such table: messages_fts',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
it('does not swallow unrelated database failures for numeric queries', async () => {
titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => {
throw new Error('database malformed')
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
await expect(mod.searchSessionSummaries('123', undefined, 10)).rejects.toThrow(
'Failed to search sessions: database malformed',
)
expect(likeAllMock).not.toHaveBeenCalled()
})
})