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
This commit is contained in:
Zhicheng Han
2026-04-23 04:49:00 +02:00
committed by GitHub
parent 32dc084b66
commit 5f40ae6258
12 changed files with 1435 additions and 24 deletions
+4
View File
@@ -149,6 +149,10 @@ describe('ChatPanel session list', () => {
const liveRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Active'))
expect(liveRow?.find('.session-item-active-indicator').exists()).toBe(true)
expect(liveRow?.text()).toContain('chat.liveMode')
const idleRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Older'))
expect(idleRow?.text()).not.toContain('chat.liveMode')
await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
+24
View File
@@ -169,6 +169,30 @@ describe('Chat Store', () => {
expect(window.localStorage.getItem(legacySessionMessagesKey('legacy-1'))).toBeNull()
})
it('marks recently active server sessions as live even when this tab did not start the run', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-22T19:00:00.000Z'))
mockSessionsApi.fetchSessions.mockResolvedValue([
{
...makeSummary('remote-live', 'Remote Live'),
ended_at: null,
last_active: Math.floor(Date.now() / 1000) - 60,
},
{
...makeSummary('remote-idle', 'Remote Idle'),
ended_at: Math.floor(Date.now() / 1000) - 600,
last_active: Math.floor(Date.now() / 1000) - 600,
},
])
const store = useChatStore()
await store.loadSessions()
expect(store.isSessionLive('remote-live')).toBe(true)
expect(store.isSessionLive('remote-idle')).toBe(false)
})
it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
vi.useFakeTimers()
+360
View File
@@ -0,0 +1,360 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const profileDirState = vi.hoisted(() => ({ value: '' }))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: () => profileDirState.value,
}))
function ensureSqliteAvailable() {
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 5)) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
}
}
function createSchema(db: any) {
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
reasoning_content TEXT
);
`)
}
function insertSession(db: any, session: Record<string, unknown>) {
db.prepare(`
INSERT INTO sessions (
id, source, user_id, model, model_config, system_prompt, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, billing_provider, billing_base_url, billing_mode,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
pricing_version, title, api_call_count
) VALUES (
@id, @source, @user_id, @model, @model_config, @system_prompt, @parent_session_id,
@started_at, @ended_at, @end_reason, @message_count, @tool_call_count,
@input_tokens, @output_tokens, @cache_read_tokens, @cache_write_tokens,
@reasoning_tokens, @billing_provider, @billing_base_url, @billing_mode,
@estimated_cost_usd, @actual_cost_usd, @cost_status, @cost_source,
@pricing_version, @title, @api_call_count
)
`).run({
user_id: null,
model_config: null,
system_prompt: null,
billing_base_url: null,
billing_mode: null,
cost_source: null,
pricing_version: null,
api_call_count: 0,
...session,
})
}
function insertMessage(db: any, message: Record<string, unknown>) {
db.prepare(`
INSERT INTO messages (
id, session_id, role, content, tool_call_id, tool_calls, tool_name,
timestamp, token_count, finish_reason, reasoning, reasoning_details,
codex_reasoning_items, reasoning_content
) VALUES (
@id, @session_id, @role, @content, @tool_call_id, @tool_calls, @tool_name,
@timestamp, @token_count, @finish_reason, @reasoning, @reasoning_details,
@codex_reasoning_items, @reasoning_content
)
`).run({
tool_call_id: null,
tool_calls: null,
tool_name: null,
token_count: null,
finish_reason: null,
reasoning: null,
reasoning_details: null,
codex_reasoning_items: null,
reasoning_content: null,
...message,
})
}
describe('conversation DB service', () => {
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-conversations-db-'))
})
afterEach(() => {
vi.useRealTimers()
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
})
it('aggregates a compression continuation without using full CLI export', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 110,
end_reason: 'compression',
message_count: 2,
tool_call_count: 0,
input_tokens: 5,
output_tokens: 8,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.1,
actual_cost_usd: 0.1,
cost_status: 'estimated',
})
insertSession(db, {
id: 'root-cont',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Continuation',
started_at: 110,
ended_at: 111,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.2,
actual_cost_usd: 0.2,
cost_status: 'final',
})
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 })
insertMessage(db, { id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 })
insertMessage(db, { id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 })
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
expect(summaries).toHaveLength(1)
expect(summaries[0]).toEqual(expect.objectContaining({
id: 'root',
thread_session_count: 2,
ended_at: 111,
cost_status: 'mixed',
actual_cost_usd: 0.30000000000000004,
}))
const detail = await mod.getConversationDetailFromDb('root', { humanOnly: true })
expect(detail?.thread_session_count).toBe(2)
expect(detail?.messages.map((message: any) => message.content)).toEqual([
'Start here',
'Assistant reply',
'Continue with more detail',
'Continued answer',
])
})
it('treats branched children as their own visible conversations', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Root',
started_at: 100,
ended_at: 200,
end_reason: 'branched',
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertSession(db, {
id: 'branch-child',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Branch child',
started_at: 201,
ended_at: 210,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 })
insertMessage(db, { id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 })
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
const detail = await mod.getConversationDetailFromDb('branch-child', { humanOnly: true })
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
})
it('excludes synthetic-only roots from human-only summaries and details', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'synthetic-root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 101,
end_reason: null,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertMessage(db, {
id: 1,
session_id: 'synthetic-root',
role: 'user',
content: "You've reached the maximum number of tool-calling iterations allowed.",
timestamp: 100,
})
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
const detail = await mod.getConversationDetailFromDb('synthetic-root', { humanOnly: true })
expect(summaries).toEqual([])
expect(detail).toBeNull()
})
it('returns an empty detail payload for non-human-only sessions with no visible messages', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'assistant-empty',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Empty detail',
started_at: 200,
ended_at: null,
end_reason: null,
message_count: 0,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const detail = await mod.getConversationDetailFromDb('assistant-empty', { humanOnly: false })
expect(detail).toEqual({
session_id: 'assistant-empty',
messages: [],
visible_count: 0,
thread_session_count: 1,
})
})
})
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listConversationSummariesFromDbMock = vi.fn()
const getConversationDetailFromDbMock = vi.fn()
const listConversationSummariesMock = vi.fn()
const getConversationDetailMock = vi.fn()
const loggerWarnMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
getConversationDetailFromDb: getConversationDetailFromDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
listConversationSummaries: listConversationSummariesMock,
getConversationDetail: getConversationDetailMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
warn: loggerWarnMock,
error: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listSessions: vi.fn(),
getSession: vi.fn(),
deleteSession: vi.fn(),
renameSession: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
listSessionSummaries: vi.fn(),
searchSessionSummaries: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
deleteUsage: vi.fn(),
getUsage: vi.fn(),
getUsageBatch: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: vi.fn(),
}))
describe('session conversations controller', () => {
beforeEach(() => {
vi.resetModules()
listConversationSummariesFromDbMock.mockReset()
getConversationDetailFromDbMock.mockReset()
listConversationSummariesMock.mockReset()
getConversationDetailMock.mockReset()
loggerWarnMock.mockReset()
})
it('prefers the DB-backed conversations summary path', async () => {
listConversationSummariesFromDbMock.mockResolvedValue([{ id: 'db-conversation' }])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
await mod.listConversations(ctx)
expect(listConversationSummariesFromDbMock).toHaveBeenCalledWith({ source: undefined, humanOnly: true, limit: 5 })
expect(listConversationSummariesMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ sessions: [{ id: 'db-conversation' }] })
})
it('falls back to the CLI-export conversations summary path when the DB query fails', async () => {
listConversationSummariesFromDbMock.mockRejectedValue(new Error('db unavailable'))
listConversationSummariesMock.mockResolvedValue([{ id: 'fallback-conversation' }])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'false' }, body: null }
await mod.listConversations(ctx)
expect(loggerWarnMock).toHaveBeenCalled()
expect(listConversationSummariesMock).toHaveBeenCalledWith({ source: undefined, humanOnly: false, limit: undefined })
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback-conversation' }] })
})
it('prefers the DB-backed conversation detail path', async () => {
getConversationDetailFromDbMock.mockResolvedValue({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null }
await mod.getConversationMessages(ctx)
expect(getConversationDetailFromDbMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: true })
expect(getConversationDetailMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
})
it('falls back to the CLI-export conversation detail path when the DB query throws', async () => {
getConversationDetailFromDbMock.mockRejectedValue(new Error('db unavailable'))
getConversationDetailMock.mockResolvedValue({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null }
await mod.getConversationMessages(ctx)
expect(loggerWarnMock).toHaveBeenCalled()
expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false })
expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
})
})
+185
View File
@@ -231,6 +231,163 @@ describe('session DB summaries', () => {
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(() => {
@@ -273,4 +430,32 @@ describe('session DB summaries', () => {
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()
})
})