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() + }) })