feat: add session search modal (#128)
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const allMock = vi.fn()
|
||||
const prepareMock = vi.fn(() => ({ all: allMock }))
|
||||
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')
|
||||
@@ -18,6 +26,9 @@ describe('session DB summaries', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
allMock.mockReset()
|
||||
titleAllMock.mockReset()
|
||||
contentAllMock.mockReset()
|
||||
likeAllMock.mockReset()
|
||||
prepareMock.mockClear()
|
||||
closeMock.mockClear()
|
||||
databaseSyncMock.mockClear()
|
||||
@@ -122,4 +133,144 @@ describe('session DB summaries', () => {
|
||||
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/services/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 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/services/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('记忆断裂')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,111 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const listSessionSummariesMock = vi.fn()
|
||||
const listSessionsMock = vi.fn()
|
||||
const listConversationSummariesMock = vi.fn()
|
||||
const getConversationDetailMock = vi.fn()
|
||||
const listConversationsMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 'conversation-1' }] } })
|
||||
const getConversationMessagesMock = vi.fn(async (ctx: any) => { ctx.body = { session_id: ctx.params.id, messages: [] } })
|
||||
const listMock = vi.fn(async (ctx: any) => { ctx.body = { sessions: [{ id: 's1' }] } })
|
||||
const searchMock = vi.fn(async (ctx: any) => { ctx.body = { results: [{ id: 'search-1' }] } })
|
||||
const getMock = vi.fn(async (ctx: any) => { ctx.body = { session: { id: ctx.params.id } } })
|
||||
const removeMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
|
||||
const renameMock = vi.fn(async (ctx: any) => { ctx.body = { ok: true } })
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: listSessionSummariesMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
|
||||
listConversationSummaries: listConversationSummariesMock,
|
||||
getConversationDetail: getConversationDetailMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
|
||||
listSessions: listSessionsMock,
|
||||
getSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
renameSession: vi.fn(),
|
||||
vi.mock('../../packages/server/src/controllers/hermes/sessions', () => ({
|
||||
listConversations: listConversationsMock,
|
||||
getConversationMessages: getConversationMessagesMock,
|
||||
list: listMock,
|
||||
search: searchMock,
|
||||
get: getMock,
|
||||
remove: removeMock,
|
||||
rename: renameMock,
|
||||
}))
|
||||
|
||||
describe('session routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
listSessionSummariesMock.mockReset()
|
||||
listSessionsMock.mockReset()
|
||||
listConversationSummariesMock.mockReset()
|
||||
getConversationDetailMock.mockReset()
|
||||
listConversationsMock.mockClear()
|
||||
getConversationMessagesMock.mockClear()
|
||||
listMock.mockClear()
|
||||
searchMock.mockClear()
|
||||
getMock.mockClear()
|
||||
removeMock.mockClear()
|
||||
renameMock.mockClear()
|
||||
})
|
||||
|
||||
it('serves summaries from sqlite-backed helper when available', async () => {
|
||||
listSessionSummariesMock.mockResolvedValue([{ id: 's1' }])
|
||||
it('registers conversations, session list, and search routes', async () => {
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions')
|
||||
const paths = sessionRoutes.stack.map((entry: any) => entry.path)
|
||||
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'/api/hermes/sessions/conversations',
|
||||
'/api/hermes/sessions/conversations/:id/messages',
|
||||
'/api/hermes/sessions',
|
||||
'/api/hermes/search/sessions',
|
||||
'/api/hermes/sessions/search',
|
||||
'/api/hermes/sessions/:id',
|
||||
'/api/hermes/sessions/:id/rename',
|
||||
]))
|
||||
})
|
||||
|
||||
it('delegates session search to the controller', async () => {
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/search/sessions')
|
||||
const handler = layer.stack[0]
|
||||
const ctx: any = { query: { source: 'cli', limit: '5' }, body: null }
|
||||
const ctx: any = { query: { q: 'docker', limit: '8' }, body: null, params: {} }
|
||||
|
||||
await handler(ctx)
|
||||
|
||||
expect(listSessionSummariesMock).toHaveBeenCalledWith('cli', 5)
|
||||
expect(listSessionsMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 's1' }] })
|
||||
expect(searchMock).toHaveBeenCalledWith(ctx)
|
||||
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
|
||||
})
|
||||
|
||||
it('falls back to CLI wrapper when sqlite summary query fails', async () => {
|
||||
listSessionSummariesMock.mockRejectedValue(new Error('sqlite unavailable'))
|
||||
listSessionsMock.mockResolvedValue([{ id: 'fallback' }])
|
||||
it('keeps the legacy search path wired to the same controller', async () => {
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/search')
|
||||
const handler = layer.stack[0]
|
||||
const ctx: any = { query: { limit: '7' }, body: null }
|
||||
const ctx: any = { query: { q: 'docker' }, body: null, params: {} }
|
||||
|
||||
await handler(ctx)
|
||||
|
||||
expect(listSessionSummariesMock).toHaveBeenCalledWith(undefined, 7)
|
||||
expect(listSessionsMock).toHaveBeenCalledWith(undefined, 7)
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback' }] })
|
||||
expect(searchMock).toHaveBeenCalledWith(ctx)
|
||||
expect(ctx.body).toEqual({ results: [{ id: 'search-1' }] })
|
||||
})
|
||||
|
||||
it('serves live conversations with humanOnly defaulting to true', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'conversation-1' }])
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const layer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
const handler = layer.stack[0]
|
||||
const ctx: any = { query: {}, body: null }
|
||||
|
||||
await handler(ctx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: true, source: undefined, limit: undefined })
|
||||
expect(ctx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
|
||||
})
|
||||
|
||||
it('supports disabling humanOnly and forwarding limit/source for live conversations', async () => {
|
||||
listConversationSummariesMock.mockResolvedValue([{ id: 'child-session' }])
|
||||
it('delegates conversations list and detail routes', async () => {
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const listLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations')
|
||||
|
||||
const listCtx: any = { query: { humanOnly: 'false', source: 'cli', limit: '25' }, body: null }
|
||||
await listLayer.stack[0](listCtx)
|
||||
|
||||
expect(listConversationSummariesMock).toHaveBeenCalledWith({ humanOnly: false, source: 'cli', limit: 25 })
|
||||
expect(listCtx.body).toEqual({ sessions: [{ id: 'child-session' }] })
|
||||
})
|
||||
|
||||
it('returns conversation detail and forwards humanOnly/source', async () => {
|
||||
getConversationDetailMock.mockResolvedValue({ session_id: 'child-session', messages: [] })
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'child-session' }, query: { humanOnly: 'false', source: 'discord' }, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
const listCtx: any = { query: {}, body: null, params: {} }
|
||||
await listLayer.stack[0](listCtx)
|
||||
expect(listConversationsMock).toHaveBeenCalledWith(listCtx)
|
||||
expect(listCtx.body).toEqual({ sessions: [{ id: 'conversation-1' }] })
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('child-session', { humanOnly: false, source: 'discord' })
|
||||
const detailCtx: any = { params: { id: 'child-session' }, query: {}, body: null }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
expect(getConversationMessagesMock).toHaveBeenCalledWith(detailCtx)
|
||||
expect(detailCtx.body).toEqual({ session_id: 'child-session', messages: [] })
|
||||
})
|
||||
|
||||
it('returns 404 when a conversation detail is not found', async () => {
|
||||
getConversationDetailMock.mockResolvedValue(null)
|
||||
const { sessionRoutes } = await import('../../packages/server/src/routes/hermes/sessions')
|
||||
const detailLayer = sessionRoutes.stack.find((entry: any) => entry.path === '/api/hermes/sessions/conversations/:id/messages')
|
||||
|
||||
const detailCtx: any = { params: { id: 'missing' }, query: {}, body: null, status: 200 }
|
||||
await detailLayer.stack[0](detailCtx)
|
||||
|
||||
expect(getConversationDetailMock).toHaveBeenCalledWith('missing', { humanOnly: true, source: undefined })
|
||||
expect(detailCtx.status).toBe(404)
|
||||
expect(detailCtx.body).toEqual({ error: 'Conversation not found' })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user