From 5f40ae6258769e8a44dafae79d382c832de10418 Mon Sep 17 00:00:00 2001
From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com>
Date: Thu, 23 Apr 2026 04:49:00 +0200
Subject: [PATCH] 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-22-chat-live-monitor-direction.md | 131 +++++
.../src/components/hermes/chat/ChatPanel.vue | 21 +
.../hermes/chat/SessionListItem.vue | 1 +
packages/client/src/stores/hermes/chat.ts | 11 +-
.../server/src/controllers/hermes/sessions.ts | 27 +
.../server/src/db/hermes/conversations-db.ts | 513 ++++++++++++++++++
packages/server/src/db/hermes/sessions-db.ts | 75 ++-
tests/client/chat-panel.test.ts | 4 +
tests/client/chat-store.test.ts | 24 +
tests/server/conversations-db.test.ts | 360 ++++++++++++
tests/server/sessions-controller.test.ts | 107 ++++
tests/server/sessions-db.test.ts | 185 +++++++
12 files changed, 1435 insertions(+), 24 deletions(-)
create mode 100644 docs/plans/2026-04-22-chat-live-monitor-direction.md
create mode 100644 packages/server/src/db/hermes/conversations-db.ts
create mode 100644 tests/server/conversations-db.test.ts
create mode 100644 tests/server/sessions-controller.test.ts
diff --git a/docs/plans/2026-04-22-chat-live-monitor-direction.md b/docs/plans/2026-04-22-chat-live-monitor-direction.md
new file mode 100644
index 0000000..05391ec
--- /dev/null
+++ b/docs/plans/2026-04-22-chat-live-monitor-direction.md
@@ -0,0 +1,131 @@
+# Hermes Web UI Chat / Live Monitor Direction Plan
+
+> For Hermes: use subagent-driven-development only after Han explicitly approves execution.
+
+Goal: clarify whether Chat and Live should both exist, and record the current product recommendation while shipping the bundled live-badge PR.
+
+Architecture: keep the interactive chat write path and any read-only monitor path conceptually separate. In the current product, the immediate user need is best served by direct Live badges in the Chat session list. A separate Live surface is justified only if it becomes a real monitor with distinct observability and triage value.
+
+Tech stack: Vue 3, Pinia, Naive UI, Koa, Hermes session DB.
+
+---
+
+## Current findings
+
+1. Original reason for Live
+- Live was introduced as a read-only monitoring surface inside the Chat page.
+- The intent was to avoid a separate route/page while still allowing users to inspect conversations without sending messages there.
+
+2. Current product problem
+- In practice, Live is too close to a second session browser.
+- Chat already contains the main session list and now supports direct Live badges on active rows.
+- Without stronger monitor-specific affordances, the Chat/Live toggle weakens the information architecture.
+
+3. External dashboard pattern check
+- Useful live monitors are observability surfaces, not duplicate navigators.
+- Common differentiators:
+ - search
+ - source/status filters
+ - active vs recent grouping
+ - read-only drilldown across many runs
+ - monitoring metadata such as live state, last active, errors, counts, source/model, stuck state
+
+4. Decision
+- Keep direct Live badges in Chat session rows.
+- Do not keep the current Chat/Live toggle long-term unless we rebuild it as a real monitor surface.
+- Preferred direction right now: remove the current Live toggle after the bundled PR lands, unless Han wants an explicit monitor rebuild.
+
+---
+
+## Recommended roadmap
+
+### Phase 0: ship the bundled Live badge PR
+
+Objective: land the immediate UX improvement and backend fix already implemented on `feat/chat-session-live-badge`.
+
+Scope:
+- direct `Live` badge in normal Chat session rows
+- stronger but on-brand badge styling
+- DB-backed fix for the current Live monitor backend so the existing surface stops failing on large histories
+- tests for both client and server changes
+
+Done when:
+- PR is open against `upstream/main`
+- branch includes the implementation commits plus this plan doc
+- targeted tests and build pass
+
+### Phase 1: product simplification decision
+
+Objective: decide whether to keep or remove the current Chat/Live toggle.
+
+Recommended default:
+- remove the current `Chat / Live` toggle
+- keep only Chat + row-level Live badges
+
+Why:
+- this solves the real user need: show active chats directly where users already work
+- it avoids maintaining a half-monitor that duplicates Chat semantics
+
+Done when:
+- product decision is explicit: `remove-live-toggle` or `rebuild-monitor`
+
+### Phase 2A: if simplifying, remove the current Live surface
+
+Objective: cleanly remove the current in-Chat Live mode.
+
+Files likely involved:
+- `packages/client/src/components/hermes/chat/ChatPanel.vue`
+- `packages/client/src/components/hermes/chat/ConversationMonitorPane.vue`
+- `packages/client/src/components/hermes/settings/SessionSettings.vue`
+- `packages/client/src/stores/hermes/session-browser-prefs.ts`
+- related i18n keys and tests
+
+Expected effect:
+- Chat remains the only session interaction surface
+- active work is indicated directly by row-level `Live` badges
+- no duplicate list/detail workflow inside Chat
+
+### Phase 2B: if keeping a monitor, rebuild it as a true monitor
+
+Objective: keep a separate read-only surface only if it becomes clearly distinct from Chat.
+
+Required monitor traits:
+- read-only only
+- search
+- source/type/status filters
+- active vs recent grouping
+- conversation-chain aggregation rather than raw session browsing
+- metadata useful for triage: last active, live/running, visible message count, linked session count, source/model, errors/stuck state
+
+Preferred naming:
+- `Monitor` or `Conversations`, not `Live`
+
+Preferred surface:
+- a dedicated page/route rather than a peer toggle inside Chat
+
+---
+
+## Review inputs
+
+Independent review summary:
+- Branch implementation for the bundled PR is PR-ready; no blocker/major findings.
+- Product review recommendation: remove the current Live toggle now unless we commit to rebuilding it as a distinct monitor surface.
+
+---
+
+## Validation commands
+
+Run from repo root:
+
+`npm test -- tests/server/conversations-db.test.ts tests/server/sessions-controller.test.ts tests/client/chat-store.test.ts tests/client/chat-panel.test.ts`
+
+`npm run build`
+
+---
+
+## Artifact note
+
+Canonical plan path:
+- `docs/plans/2026-04-22-chat-live-monitor-direction.md`
+
+This file is the source of truth for the current Chat-vs-Live recommendation tied to the bundled live-badge PR.
diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue
index 6ba7df6..d7b4bbf 100644
--- a/packages/client/src/components/hermes/chat/ChatPanel.vue
+++ b/packages/client/src/components/hermes/chat/ChatPanel.vue
@@ -605,6 +605,8 @@ async function handleRenameConfirm() {
:deep(.session-item-title) {
display: block;
+ flex: 1 1 auto;
+ min-width: 0;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
@@ -624,6 +626,25 @@ async function handleRenameConfirm() {
filter: drop-shadow(0 0 6px rgba(var(--accent-primary-rgb), 0.35));
}
+:deep(.session-item-live-badge) {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ padding: 0 8px;
+ min-height: 20px;
+ border-radius: 999px;
+ font-size: 11px;
+ line-height: 20px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: $accent-primary;
+ background: rgba(var(--accent-primary-rgb), 0.18);
+ border: 1px solid rgba(var(--accent-primary-rgb), 0.34);
+ box-shadow: 0 0 0 1px rgba(var(--accent-primary-rgb), 0.06), 0 0 10px rgba(var(--accent-primary-rgb), 0.14);
+}
+
:deep(.session-item-pin) {
display: inline-flex;
align-items: center;
diff --git a/packages/client/src/components/hermes/chat/SessionListItem.vue b/packages/client/src/components/hermes/chat/SessionListItem.vue
index dcbf575..4fbf0c1 100644
--- a/packages/client/src/components/hermes/chat/SessionListItem.vue
+++ b/packages/client/src/components/hermes/chat/SessionListItem.vue
@@ -45,6 +45,7 @@ const { t } = useI18n()
{{ session.title }}
+ {{ t('chat.liveMode') }}
{{ session.model }}
diff --git a/packages/client/src/stores/hermes/chat.ts b/packages/client/src/stores/hermes/chat.ts
index 2980941..d0210cd 100644
--- a/packages/client/src/stores/hermes/chat.ts
+++ b/packages/client/src/stores/hermes/chat.ts
@@ -40,6 +40,8 @@ export interface Session {
messageCount?: number
inputTokens?: number
outputTokens?: number
+ endedAt?: number | null
+ lastActiveAt?: number
}
function uid(): string {
@@ -155,6 +157,8 @@ function mapHermesSession(s: SessionSummary): Session {
model: s.model,
provider: (s as any).billing_provider || '',
messageCount: s.message_count,
+ endedAt: s.ended_at != null ? Math.round(s.ended_at * 1000) : null,
+ lastActiveAt: s.last_active != null ? Math.round(s.last_active * 1000) : undefined,
}
}
@@ -169,6 +173,7 @@ const LEGACY_SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1'
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
const POLL_INTERVAL_MS = 2000
const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished
+const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000
// 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取,
@@ -324,7 +329,11 @@ export const useChatStore = defineStore('chat', () => {
const messages = computed(() => activeSession.value?.messages || [])
function isSessionLive(sessionId: string): boolean {
- return streamStates.value.has(sessionId) || resumingRuns.value.has(sessionId)
+ if (streamStates.value.has(sessionId) || resumingRuns.value.has(sessionId)) return true
+
+ const session = sessions.value.find(candidate => candidate.id === sessionId)
+ if (!session?.lastActiveAt || session.endedAt != null) return false
+ return Date.now() - session.lastActiveAt <= LIVE_BADGE_WINDOW_MS
}
function persistSessionsList() {
diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts
index 8b5b843..1fad54a 100644
--- a/packages/server/src/controllers/hermes/sessions.ts
+++ b/packages/server/src/controllers/hermes/sessions.ts
@@ -1,5 +1,9 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { getConversationDetail, listConversationSummaries } from '../../services/hermes/conversations'
+import {
+ getConversationDetailFromDb,
+ listConversationSummariesFromDb,
+} from '../../db/hermes/conversations-db'
import { listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db'
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context'
@@ -20,6 +24,15 @@ export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined
const humanOnly = parseHumanOnly(ctx.query.humanOnly)
const limit = parseLimit(ctx.query.limit)
+
+ try {
+ const sessions = await listConversationSummariesFromDb({ source, humanOnly, limit })
+ ctx.body = { sessions }
+ return
+ } catch (err) {
+ logger.warn(err, 'Hermes Conversation DB: summary query failed, falling back to CLI export')
+ }
+
const sessions = await listConversationSummaries({ source, humanOnly, limit })
ctx.body = { sessions }
}
@@ -27,6 +40,20 @@ export async function listConversations(ctx: any) {
export async function getConversationMessages(ctx: any) {
const source = (ctx.query.source as string) || undefined
const humanOnly = parseHumanOnly(ctx.query.humanOnly)
+
+ try {
+ const detail = await getConversationDetailFromDb(ctx.params.id, { source, humanOnly })
+ if (!detail) {
+ ctx.status = 404
+ ctx.body = { error: 'Conversation not found' }
+ return
+ }
+ ctx.body = detail
+ return
+ } catch (err) {
+ logger.warn(err, 'Hermes Conversation DB: detail query failed, falling back to CLI export')
+ }
+
const detail = await getConversationDetail(ctx.params.id, { source, humanOnly })
if (!detail) {
ctx.status = 404
diff --git a/packages/server/src/db/hermes/conversations-db.ts b/packages/server/src/db/hermes/conversations-db.ts
new file mode 100644
index 0000000..54de656
--- /dev/null
+++ b/packages/server/src/db/hermes/conversations-db.ts
@@ -0,0 +1,513 @@
+import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
+import type {
+ ConversationDetail,
+ ConversationListOptions,
+ ConversationMessage,
+ ConversationSummary,
+} from '../../services/hermes/conversations'
+
+const SQLITE_AVAILABLE = (() => {
+ const [major, minor] = process.versions.node.split('.').map(Number)
+ return major > 22 || (major === 22 && minor >= 5)
+})()
+
+const LINEAGE_TOLERANCE_SECONDS = 3
+const LIVE_WINDOW_SECONDS = 300
+const DEFAULT_CONVERSATION_LIMIT = 200
+const SYNTHETIC_USER_PREFIXES = [
+ '[system:',
+ "you've reached the maximum number of tool-calling iterations allowed.",
+ 'you have reached the maximum number of tool-calling iterations allowed.',
+]
+
+const VISIBLE_HUMAN_MESSAGE_SQL = `
+ m.content IS NOT NULL
+ AND m.content != ''
+ AND (
+ m.role = 'assistant'
+ OR (
+ m.role = 'user'
+ AND LOWER(m.content) NOT LIKE '[system:%'
+ AND LOWER(m.content) NOT LIKE 'you''ve reached the maximum number of tool-calling iterations allowed.%'
+ AND LOWER(m.content) NOT LIKE 'you have reached the maximum number of tool-calling iterations allowed.%'
+ )
+ )
+`
+
+interface ConversationSessionRow {
+ id: string
+ source: string
+ user_id: string | null
+ model: string
+ title: string | null
+ parent_session_id: string | null
+ started_at: number
+ ended_at: number | null
+ end_reason: string | null
+ message_count: number
+ tool_call_count: number
+ input_tokens: number
+ output_tokens: number
+ cache_read_tokens: number
+ cache_write_tokens: number
+ reasoning_tokens: number
+ billing_provider: string | null
+ estimated_cost_usd: number
+ actual_cost_usd: number | null
+ cost_status: string
+ preview: string
+ last_active: number
+ has_visible_messages: boolean
+ is_active: boolean
+}
+
+function conversationDbPath(): string {
+ return `${getActiveProfileDir()}/state.db`
+}
+
+function normalizeNumber(value: unknown, fallback = 0): number {
+ if (value == null || value === '') return fallback
+ const num = Number(value)
+ return Number.isFinite(num) ? num : fallback
+}
+
+function normalizeNullableNumber(value: unknown): number | null {
+ if (value == null || value === '') return null
+ const num = Number(value)
+ return Number.isFinite(num) ? num : null
+}
+
+function normalizeNullableString(value: unknown): string | null {
+ if (value == null || value === '') return null
+ return String(value)
+}
+
+function safeText(value: unknown): string {
+ if (typeof value === 'string') return value
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ return ''
+}
+
+function textFromContent(value: unknown): string {
+ if (typeof value === 'string') {
+ const trimmed = value.trim()
+ if (trimmed && (trimmed.startsWith('{') || trimmed.startsWith('['))) {
+ try {
+ const parsed = JSON.parse(trimmed)
+ const nested = textFromContent(parsed)
+ if (nested) return nested
+ } catch {
+ // Fall back to the original string below.
+ }
+ }
+ return value
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+ if (Array.isArray(value)) {
+ return value
+ .map(item => textFromContent(item).trim())
+ .filter(Boolean)
+ .join('\n')
+ }
+ if (!value || typeof value !== 'object') return ''
+
+ const record = value as Record
+ for (const key of ['text', 'content', 'value'] as const) {
+ const direct = record[key]
+ if (typeof direct === 'string') return direct
+ if (Array.isArray(direct)) {
+ const nested = textFromContent(direct)
+ if (nested) return nested
+ }
+ }
+
+ for (const key of ['parts', 'children', 'items'] as const) {
+ if (Array.isArray(record[key])) {
+ const nested = textFromContent(record[key])
+ if (nested) return nested
+ }
+ }
+
+ const flattened = Object.values(record)
+ .map(entry => textFromContent(entry).trim())
+ .filter(Boolean)
+ .join('\n')
+ if (flattened) return flattened
+
+ try {
+ return JSON.stringify(record)
+ } catch {
+ return ''
+ }
+}
+
+function normalizeText(value: unknown): string {
+ return textFromContent(value).replace(/\s+/g, ' ').trim().toLowerCase()
+}
+
+function excerpt(value: unknown, width = 80): string {
+ const text = textFromContent(value).replace(/\s+/g, ' ').trim()
+ if (!text) return ''
+ return text.length > width ? `${text.slice(0, width)}…` : text
+}
+
+function isSyntheticUserText(content: unknown): boolean {
+ const text = normalizeText(content)
+ return SYNTHETIC_USER_PREFIXES.some(prefix => text.startsWith(prefix))
+}
+
+function mapSessionRow(row: Record, nowSeconds: number): ConversationSessionRow {
+ const startedAt = normalizeNumber(row.started_at)
+ const endedAt = normalizeNullableNumber(row.ended_at)
+ const preview = excerpt(row.preview || '')
+ const rawTitle = normalizeNullableString(row.title)
+ const title = rawTitle || (preview ? (preview.length > 40 ? `${preview.slice(0, 40)}...` : preview) : null)
+ const lastActive = normalizeNumber(row.last_active, startedAt)
+
+ return {
+ id: String(row.id || ''),
+ source: String(row.source || ''),
+ user_id: normalizeNullableString(row.user_id),
+ model: String(row.model || ''),
+ title,
+ parent_session_id: normalizeNullableString(row.parent_session_id),
+ started_at: startedAt,
+ ended_at: endedAt,
+ end_reason: normalizeNullableString(row.end_reason),
+ message_count: normalizeNumber(row.message_count),
+ tool_call_count: normalizeNumber(row.tool_call_count),
+ input_tokens: normalizeNumber(row.input_tokens),
+ output_tokens: normalizeNumber(row.output_tokens),
+ cache_read_tokens: normalizeNumber(row.cache_read_tokens),
+ cache_write_tokens: normalizeNumber(row.cache_write_tokens),
+ reasoning_tokens: normalizeNumber(row.reasoning_tokens),
+ billing_provider: normalizeNullableString(row.billing_provider),
+ estimated_cost_usd: normalizeNumber(row.estimated_cost_usd),
+ actual_cost_usd: normalizeNullableNumber(row.actual_cost_usd),
+ cost_status: String(row.cost_status || ''),
+ preview,
+ last_active: lastActive,
+ has_visible_messages: !!normalizeNumber(row.has_visible_messages),
+ is_active: endedAt == null && nowSeconds - lastActive <= LIVE_WINDOW_SECONDS,
+ }
+}
+
+function sortByRecency(items: T[]): T[] {
+ return [...items].sort((a, b) => {
+ if (b.last_active !== a.last_active) return b.last_active - a.last_active
+ if (b.started_at !== a.started_at) return b.started_at - a.started_at
+ return a.id.localeCompare(b.id)
+ })
+}
+
+function timingMatchesParent(parent: ConversationSessionRow | undefined, child: ConversationSessionRow | undefined): boolean {
+ if (!parent || !child || parent.ended_at == null) return false
+ return Math.abs(Number(child.started_at || 0) - Number(parent.ended_at || 0)) <= LINEAGE_TOLERANCE_SECONDS
+}
+
+function isBranchRoot(session: ConversationSessionRow | undefined, byId: Map): boolean {
+ if (!session?.parent_session_id) return false
+ const parent = byId.get(session.parent_session_id)
+ return !!parent && parent.end_reason === 'branched' && timingMatchesParent(parent, session)
+}
+
+function isVisibleRoot(session: ConversationSessionRow | undefined, byId: Map): boolean {
+ if (!session || session.source === 'tool') return false
+ return session.parent_session_id == null || isBranchRoot(session, byId)
+}
+
+function continuationCandidates(parent: ConversationSessionRow, byId: Map, childrenByParent: Map): ConversationSessionRow[] {
+ const childIds = childrenByParent.get(parent.id) || []
+ return childIds
+ .map(childId => byId.get(childId))
+ .filter((child): child is ConversationSessionRow => !!child)
+ .filter(child => child.source !== 'tool')
+ .filter(child => child.source === parent.source)
+ .filter(child => timingMatchesParent(parent, child))
+ .sort((a, b) => {
+ const aDelta = Math.abs(Number(a.started_at || 0) - Number(parent.ended_at || 0))
+ const bDelta = Math.abs(Number(b.started_at || 0) - Number(parent.ended_at || 0))
+ if (aDelta !== bDelta) return aDelta - bDelta
+ return a.id.localeCompare(b.id)
+ })
+}
+
+function nextContinuationChild(parent: ConversationSessionRow, byId: Map, childrenByParent: Map): ConversationSessionRow | null {
+ if (parent.end_reason !== 'compression') return null
+ const candidates = continuationCandidates(parent, byId, childrenByParent)
+ if (candidates.length === 1) return candidates[0]
+
+ const exactPreviewMatches = candidates.filter(child => {
+ const childPreview = normalizeText(child.preview)
+ const parentPreview = normalizeText(parent.preview)
+ return !!childPreview && childPreview === parentPreview
+ })
+
+ if (exactPreviewMatches.length === 1) return exactPreviewMatches[0]
+ return null
+}
+
+function collectConversationChain(rootId: string, byId: Map, childrenByParent: Map): ConversationSessionRow[] {
+ const chain: ConversationSessionRow[] = []
+ const seen = new Set()
+ let current = byId.get(rootId) || null
+ while (current && !seen.has(current.id)) {
+ chain.push(current)
+ seen.add(current.id)
+ current = nextContinuationChild(current, byId, childrenByParent)
+ }
+ return chain
+}
+
+function toSummary(session: ConversationSessionRow): ConversationSummary {
+ return {
+ id: session.id,
+ source: safeText(session.source),
+ model: safeText(session.model),
+ title: session.title ?? null,
+ started_at: Number(session.started_at || 0),
+ ended_at: session.ended_at ?? null,
+ last_active: session.last_active,
+ message_count: Number(session.message_count || 0),
+ tool_call_count: Number(session.tool_call_count || 0),
+ input_tokens: Number(session.input_tokens || 0),
+ output_tokens: Number(session.output_tokens || 0),
+ cache_read_tokens: Number(session.cache_read_tokens || 0),
+ cache_write_tokens: Number(session.cache_write_tokens || 0),
+ reasoning_tokens: Number(session.reasoning_tokens || 0),
+ billing_provider: session.billing_provider ?? null,
+ estimated_cost_usd: Number(session.estimated_cost_usd || 0),
+ actual_cost_usd: session.actual_cost_usd ?? null,
+ cost_status: safeText(session.cost_status),
+ preview: session.preview,
+ is_active: session.is_active,
+ thread_session_count: 1,
+ }
+}
+
+function aggregateSummary(rootId: string, byId: Map, childrenByParent: Map): ConversationSummary | null {
+ const chain = collectConversationChain(rootId, byId, childrenByParent)
+ if (!chain.length || !chain.some(session => session.has_visible_messages)) return null
+ const root = chain[0]
+ const last = chain[chain.length - 1]
+ const firstPreview = chain.map(session => session.preview).find(Boolean) || ''
+ const costStatuses = Array.from(new Set(chain.map(session => safeText(session.cost_status)).filter(Boolean)))
+
+ return {
+ ...toSummary(root),
+ title: root.title || firstPreview || null,
+ preview: root.preview || firstPreview,
+ model: safeText(last?.model || root.model),
+ ended_at: last?.ended_at ?? null,
+ last_active: Math.max(...chain.map(session => session.last_active)),
+ is_active: chain.some(session => session.is_active),
+ billing_provider: last?.billing_provider ?? root.billing_provider ?? null,
+ cost_status: costStatuses.length === 1 ? costStatuses[0] : 'mixed',
+ thread_session_count: chain.length,
+ message_count: chain.reduce((sum, session) => sum + Number(session.message_count || 0), 0),
+ tool_call_count: chain.reduce((sum, session) => sum + Number(session.tool_call_count || 0), 0),
+ input_tokens: chain.reduce((sum, session) => sum + Number(session.input_tokens || 0), 0),
+ output_tokens: chain.reduce((sum, session) => sum + Number(session.output_tokens || 0), 0),
+ cache_read_tokens: chain.reduce((sum, session) => sum + Number(session.cache_read_tokens || 0), 0),
+ cache_write_tokens: chain.reduce((sum, session) => sum + Number(session.cache_write_tokens || 0), 0),
+ reasoning_tokens: chain.reduce((sum, session) => sum + Number(session.reasoning_tokens || 0), 0),
+ estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0),
+ actual_cost_usd: chain.reduce((sum, session) => {
+ const actual = session.actual_cost_usd
+ if (actual == null) return sum
+ return (sum || 0) + Number(actual)
+ }, null),
+ }
+}
+
+function normalizeVisibleMessage(message: { id: number | string, session_id: string, role: string, content: unknown, timestamp: number }, fallbackTimestamp: number): ConversationMessage | null {
+ const role = safeText(message.role)
+ const content = textFromContent(message.content).trim()
+ if (!content) return null
+ if (role !== 'user' && role !== 'assistant') return null
+ if (role === 'user' && isSyntheticUserText(content)) return null
+
+ return {
+ id: message.id,
+ session_id: message.session_id,
+ role,
+ content,
+ timestamp: Number.isFinite(Number(message.timestamp)) && Number(message.timestamp) > 0
+ ? Number(message.timestamp)
+ : fallbackTimestamp,
+ }
+}
+
+async function openConversationDb() {
+ if (!SQLITE_AVAILABLE) {
+ throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
+ }
+
+ const { DatabaseSync } = await import('node:sqlite')
+ return new DatabaseSync(conversationDbPath(), { open: true, readOnly: true })
+}
+
+function buildConversationSessionSql(source?: string): { sql: string, params: any[] } {
+ const sql = `
+ SELECT
+ s.id,
+ s.source,
+ COALESCE(s.user_id, '') AS user_id,
+ COALESCE(s.model, '') AS model,
+ COALESCE(s.title, '') AS title,
+ s.parent_session_id AS parent_session_id,
+ COALESCE(s.started_at, 0) AS started_at,
+ s.ended_at AS ended_at,
+ COALESCE(s.end_reason, '') AS end_reason,
+ COALESCE(s.message_count, 0) AS message_count,
+ COALESCE(s.tool_call_count, 0) AS tool_call_count,
+ COALESCE(s.input_tokens, 0) AS input_tokens,
+ COALESCE(s.output_tokens, 0) AS output_tokens,
+ COALESCE(s.cache_read_tokens, 0) AS cache_read_tokens,
+ COALESCE(s.cache_write_tokens, 0) AS cache_write_tokens,
+ COALESCE(s.reasoning_tokens, 0) AS reasoning_tokens,
+ COALESCE(s.billing_provider, '') AS billing_provider,
+ COALESCE(s.estimated_cost_usd, 0) AS estimated_cost_usd,
+ s.actual_cost_usd AS actual_cost_usd,
+ COALESCE(s.cost_status, '') AS cost_status,
+ COALESCE(
+ (
+ SELECT SUBSTR(REPLACE(REPLACE(m.content, CHAR(10), ' '), CHAR(13), ' '), 1, 80)
+ FROM messages m
+ WHERE m.session_id = s.id
+ AND ${VISIBLE_HUMAN_MESSAGE_SQL}
+ ORDER BY m.timestamp, m.id
+ LIMIT 1
+ ),
+ ''
+ ) AS preview,
+ COALESCE((SELECT MAX(m2.timestamp) FROM messages m2 WHERE m2.session_id = s.id), s.started_at) AS last_active,
+ CASE WHEN EXISTS (
+ SELECT 1
+ FROM messages m
+ WHERE m.session_id = s.id
+ AND ${VISIBLE_HUMAN_MESSAGE_SQL}
+ ) THEN 1 ELSE 0 END AS has_visible_messages
+ FROM sessions s
+ WHERE s.source != 'tool'
+ ${source ? 'AND s.source = ?' : ''}
+ ORDER BY s.started_at DESC
+ `
+
+ return { sql, params: source ? [source] : [] }
+}
+
+async function loadConversationSessions(source?: string): Promise {
+ const db = await openConversationDb()
+ try {
+ const { sql, params } = buildConversationSessionSql(source)
+ const rows = db.prepare(sql).all(...params) as Record[]
+ const nowSeconds = Date.now() / 1000
+ return rows.map(row => mapSessionRow(row, nowSeconds))
+ } finally {
+ db.close()
+ }
+}
+
+export async function listConversationSummariesFromDb(options: ConversationListOptions = {}): Promise {
+ const humanOnly = options.humanOnly !== false
+ const limit = options.limit && options.limit > 0 ? options.limit : DEFAULT_CONVERSATION_LIMIT
+ const sessions = await loadConversationSessions(options.source)
+ const byId = new Map(sessions.map(session => [session.id, session]))
+ const childrenByParent = new Map()
+ for (const session of sessions) {
+ const key = session.parent_session_id ?? null
+ const siblings = childrenByParent.get(key) || []
+ siblings.push(session.id)
+ childrenByParent.set(key, siblings)
+ }
+
+ if (!humanOnly) {
+ return sortByRecency(sessions.map(toSummary)).slice(0, limit)
+ }
+
+ const summaries = sessions
+ .filter(session => isVisibleRoot(session, byId))
+ .map(session => aggregateSummary(session.id, byId, childrenByParent))
+ .filter((summary): summary is ConversationSummary => !!summary)
+
+ return sortByRecency(summaries).slice(0, limit)
+}
+
+export async function getConversationDetailFromDb(sessionId: string, options: ConversationListOptions = {}): Promise {
+ const humanOnly = options.humanOnly !== false
+ const sessions = await loadConversationSessions(options.source)
+ const byId = new Map(sessions.map(session => [session.id, session]))
+ const childrenByParent = new Map()
+ for (const session of sessions) {
+ const key = session.parent_session_id ?? null
+ const siblings = childrenByParent.get(key) || []
+ siblings.push(session.id)
+ childrenByParent.set(key, siblings)
+ }
+
+ let chain: ConversationSessionRow[] = []
+ if (!humanOnly) {
+ const session = byId.get(sessionId)
+ if (!session || session.source === 'tool') return null
+ chain = [session]
+ } else {
+ const root = byId.get(sessionId)
+ if (!isVisibleRoot(root, byId)) return null
+ chain = collectConversationChain(sessionId, byId, childrenByParent)
+ }
+
+ if (!chain.length) return null
+
+ const db = await openConversationDb()
+ try {
+ const ids = chain.map(session => session.id)
+ const placeholders = ids.map(() => '?').join(', ')
+ const rows = db.prepare(`
+ SELECT id, session_id, role, content, timestamp
+ FROM messages
+ WHERE session_id IN (${placeholders})
+ AND role IN ('user', 'assistant')
+ AND content IS NOT NULL
+ AND content != ''
+ ORDER BY timestamp, id
+ `).all(...ids) as Array>
+
+ const sessionById = new Map(chain.map(session => [session.id, session]))
+ const messages = rows
+ .map(row => {
+ const session = sessionById.get(String(row.session_id || ''))
+ return normalizeVisibleMessage({
+ id: row.id as number | string,
+ session_id: String(row.session_id || ''),
+ role: String(row.role || ''),
+ content: row.content,
+ timestamp: normalizeNumber(row.timestamp),
+ }, session?.last_active || session?.started_at || 0)
+ })
+ .filter((message): message is ConversationMessage => !!message)
+ .sort((a, b) => {
+ if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
+ return String(a.id).localeCompare(String(b.id))
+ })
+
+ if (!messages.length) {
+ return humanOnly
+ ? null
+ : {
+ session_id: sessionId,
+ messages: [],
+ visible_count: 0,
+ thread_session_count: chain.length,
+ }
+ }
+ return {
+ session_id: sessionId,
+ messages,
+ visible_count: messages.length,
+ thread_session_count: chain.length,
+ }
+ } finally {
+ db.close()
+ }
+}
diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts
index 93b6c1c..dba73e7 100644
--- a/packages/server/src/db/hermes/sessions-db.ts
+++ b/packages/server/src/db/hermes/sessions-db.ts
@@ -159,6 +159,38 @@ function containsCjk(text: string): boolean {
return false
}
+function isNumericQuery(text: string): boolean {
+ return /^\d+(?:\s+\d+)*$/.test(text.trim())
+}
+
+function runLikeContentSearch(
+ db: { prepare: (sql: string) => { all: (...params: any[]) => Record[] } },
+ source: string | undefined,
+ query: string,
+): Record[] {
+ const likeBase = buildBaseSessionSql(source)
+ const likeSql = `
+ WITH base AS (
+ ${likeBase.sql}
+ )
+ SELECT
+ base.*,
+ m.id AS matched_message_id,
+ substr(
+ m.content,
+ max(1, instr(m.content, ?) - 40),
+ 120
+ ) AS snippet,
+ 0 AS rank
+ FROM base
+ JOIN messages m ON m.session_id = base.id
+ WHERE m.content LIKE ?
+ ORDER BY base.last_active DESC, m.timestamp DESC
+ `
+ const likeStatement = db.prepare(likeSql)
+ return likeStatement.all(...likeBase.params, query, `%${query}%`) as Record[]
+}
+
function sanitizeFtsQuery(query: string): string {
const quotedParts: string[] = []
@@ -246,6 +278,7 @@ export async function searchSessionSummaries(
const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true })
const normalized = sanitizeFtsQuery(trimmed)
const prefixQuery = toPrefixQuery(normalized)
+ let titleRows: Record[] = []
try {
const titleBase = buildBaseSessionSql(source)
@@ -270,7 +303,7 @@ export async function searchSessionSummaries(
`
const titleStatement = db.prepare(titleSql)
- const titleRows = titleStatement.all(...titleBase.params, `%${trimmed.toLowerCase()}%`, limit) as Record[]
+ titleRows = titleStatement.all(...titleBase.params, `%${trimmed.toLowerCase()}%`, limit) as Record[]
const contentSql = `
WITH base AS (
@@ -312,28 +345,9 @@ export async function searchSessionSummaries(
})
return items.slice(0, limit)
} catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
if (containsCjk(normalized)) {
- const likeBase = buildBaseSessionSql(source)
- const likeSql = `
- WITH base AS (
- ${likeBase.sql}
- )
- SELECT
- base.*,
- m.id AS matched_message_id,
- substr(
- m.content,
- max(1, instr(m.content, ?) - 40),
- 120
- ) AS snippet,
- 0 AS rank
- FROM base
- JOIN messages m ON m.session_id = base.id
- WHERE m.content LIKE ?
- ORDER BY base.last_active DESC, m.timestamp DESC
- `
- const likeStatement = db.prepare(likeSql)
- const likeRows = likeStatement.all(...likeBase.params, trimmed, `%${trimmed}%`) as Record[]
+ const likeRows = runLikeContentSearch(db, source, trimmed)
const merged = new Map()
for (const row of likeRows) {
const mapped = mapSearchRow(row)
@@ -344,7 +358,22 @@ export async function searchSessionSummaries(
return [...merged.values()].slice(0, limit)
}
- const message = err instanceof Error ? err.message : String(err)
+ if (message.includes('no such table: messages_fts') && isNumericQuery(trimmed)) {
+ const likeRows = runLikeContentSearch(db, source, trimmed)
+ const merged = new Map()
+ for (const row of titleRows) {
+ const mapped = mapSearchRow(row)
+ merged.set(mapped.id, mapped)
+ }
+ for (const row of likeRows) {
+ const mapped = mapSearchRow(row)
+ if (!merged.has(mapped.id)) {
+ merged.set(mapped.id, mapped)
+ }
+ }
+ return [...merged.values()].slice(0, limit)
+ }
+
throw new Error(`Failed to search sessions: ${message}`)
} finally {
db.close()
diff --git a/tests/client/chat-panel.test.ts b/tests/client/chat-panel.test.ts
index 1567447..1e69671 100644
--- a/tests/client/chat-panel.test.ts
+++ b/tests/client/chat-panel.test.ts
@@ -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')
diff --git a/tests/client/chat-store.test.ts b/tests/client/chat-store.test.ts
index a47e8eb..4a61787 100644
--- a/tests/client/chat-store.test.ts
+++ b/tests/client/chat-store.test.ts
@@ -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()
diff --git a/tests/server/conversations-db.test.ts b/tests/server/conversations-db.test.ts
new file mode 100644
index 0000000..14bc253
--- /dev/null
+++ b/tests/server/conversations-db.test.ts
@@ -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) {
+ 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) {
+ 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,
+ })
+ })
+})
diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts
new file mode 100644
index 0000000..04c1175
--- /dev/null
+++ b/tests/server/sessions-controller.test.ts
@@ -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 })
+ })
+})
diff --git a/tests/server/sessions-db.test.ts b/tests/server/sessions-db.test.ts
index 495c0b3..504b117 100644
--- a/tests/server/sessions-db.test.ts
+++ b/tests/server/sessions-db.test.ts
@@ -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()
+ })
})