feat(chat): add direct Live badge and harden Live monitor backend (#138)

* feat(chat): add direct live badge to session rows

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

* docs: add chat vs live monitor direction plan

* fix(search): avoid numeric session search 500 without FTS table
This commit is contained in:
Zhicheng Han
2026-04-23 04:49:00 +02:00
committed by GitHub
parent 32dc084b66
commit 5f40ae6258
12 changed files with 1435 additions and 24 deletions
@@ -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.
@@ -605,6 +605,8 @@ async function handleRenameConfirm() {
:deep(.session-item-title) { :deep(.session-item-title) {
display: block; display: block;
flex: 1 1 auto;
min-width: 0;
font-size: 13px; font-size: 13px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -624,6 +626,25 @@ async function handleRenameConfirm() {
filter: drop-shadow(0 0 6px rgba(var(--accent-primary-rgb), 0.35)); 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) { :deep(.session-item-pin) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -45,6 +45,7 @@ const { t } = useI18n()
</svg> </svg>
</span> </span>
<span class="session-item-title">{{ session.title }}</span> <span class="session-item-title">{{ session.title }}</span>
<span v-if="live" class="session-item-live-badge">{{ t('chat.liveMode') }}</span>
</span> </span>
<span class="session-item-meta"> <span class="session-item-meta">
<span v-if="session.model" class="session-item-model">{{ session.model }}</span> <span v-if="session.model" class="session-item-model">{{ session.model }}</span>
+10 -1
View File
@@ -40,6 +40,8 @@ export interface Session {
messageCount?: number messageCount?: number
inputTokens?: number inputTokens?: number
outputTokens?: number outputTokens?: number
endedAt?: number | null
lastActiveAt?: number
} }
function uid(): string { function uid(): string {
@@ -155,6 +157,8 @@ function mapHermesSession(s: SessionSummary): Session {
model: s.model, model: s.model,
provider: (s as any).billing_provider || '', provider: (s as any).billing_provider || '',
messageCount: s.message_count, 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 IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
const POLL_INTERVAL_MS = 2000 const POLL_INTERVAL_MS = 2000
const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished
const LIVE_BADGE_WINDOW_MS = 5 * 60 * 1000
// 获取当前 profile 名称,用于隔离缓存。 // 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取, // 从 profiles store 的 activeProfileName(同步 localStorage)读取,
@@ -324,7 +329,11 @@ export const useChatStore = defineStore('chat', () => {
const messages = computed<Message[]>(() => activeSession.value?.messages || []) const messages = computed<Message[]>(() => activeSession.value?.messages || [])
function isSessionLive(sessionId: string): boolean { 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() { function persistSessionsList() {
@@ -1,5 +1,9 @@
import * as hermesCli from '../../services/hermes/hermes-cli' import * as hermesCli from '../../services/hermes/hermes-cli'
import { getConversationDetail, listConversationSummaries } from '../../services/hermes/conversations' import { getConversationDetail, listConversationSummaries } from '../../services/hermes/conversations'
import {
getConversationDetailFromDb,
listConversationSummariesFromDb,
} from '../../db/hermes/conversations-db'
import { listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db' import { listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db'
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store' import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context' 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 source = (ctx.query.source as string) || undefined
const humanOnly = parseHumanOnly(ctx.query.humanOnly) const humanOnly = parseHumanOnly(ctx.query.humanOnly)
const limit = parseLimit(ctx.query.limit) 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 }) const sessions = await listConversationSummaries({ source, humanOnly, limit })
ctx.body = { sessions } ctx.body = { sessions }
} }
@@ -27,6 +40,20 @@ export async function listConversations(ctx: any) {
export async function getConversationMessages(ctx: any) { export async function getConversationMessages(ctx: any) {
const source = (ctx.query.source as string) || undefined const source = (ctx.query.source as string) || undefined
const humanOnly = parseHumanOnly(ctx.query.humanOnly) 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 }) const detail = await getConversationDetail(ctx.params.id, { source, humanOnly })
if (!detail) { if (!detail) {
ctx.status = 404 ctx.status = 404
@@ -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<string, unknown>
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<string, unknown>, 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<T extends { last_active: number; started_at: number; id: string }>(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<string, ConversationSessionRow>): 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<string, ConversationSessionRow>): boolean {
if (!session || session.source === 'tool') return false
return session.parent_session_id == null || isBranchRoot(session, byId)
}
function continuationCandidates(parent: ConversationSessionRow, byId: Map<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): 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<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): 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<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): ConversationSessionRow[] {
const chain: ConversationSessionRow[] = []
const seen = new Set<string>()
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<string, ConversationSessionRow>, childrenByParent: Map<string | null, string[]>): 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<number | null>((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<ConversationSessionRow[]> {
const db = await openConversationDb()
try {
const { sql, params } = buildConversationSessionSql(source)
const rows = db.prepare(sql).all(...params) as Record<string, unknown>[]
const nowSeconds = Date.now() / 1000
return rows.map(row => mapSessionRow(row, nowSeconds))
} finally {
db.close()
}
}
export async function listConversationSummariesFromDb(options: ConversationListOptions = {}): Promise<ConversationSummary[]> {
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<string | null, string[]>()
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<ConversationDetail | null> {
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<string | null, string[]>()
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<Record<string, unknown>>
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()
}
}
+52 -23
View File
@@ -159,6 +159,38 @@ function containsCjk(text: string): boolean {
return false return false
} }
function isNumericQuery(text: string): boolean {
return /^\d+(?:\s+\d+)*$/.test(text.trim())
}
function runLikeContentSearch(
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
source: string | undefined,
query: string,
): Record<string, unknown>[] {
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<string, unknown>[]
}
function sanitizeFtsQuery(query: string): string { function sanitizeFtsQuery(query: string): string {
const quotedParts: string[] = [] const quotedParts: string[] = []
@@ -246,6 +278,7 @@ export async function searchSessionSummaries(
const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true }) const db = new DatabaseSync(sessionDbPath(), { open: true, readOnly: true })
const normalized = sanitizeFtsQuery(trimmed) const normalized = sanitizeFtsQuery(trimmed)
const prefixQuery = toPrefixQuery(normalized) const prefixQuery = toPrefixQuery(normalized)
let titleRows: Record<string, unknown>[] = []
try { try {
const titleBase = buildBaseSessionSql(source) const titleBase = buildBaseSessionSql(source)
@@ -270,7 +303,7 @@ export async function searchSessionSummaries(
` `
const titleStatement = db.prepare(titleSql) const titleStatement = db.prepare(titleSql)
const titleRows = titleStatement.all(...titleBase.params, `%${trimmed.toLowerCase()}%`, limit) as Record<string, unknown>[] titleRows = titleStatement.all(...titleBase.params, `%${trimmed.toLowerCase()}%`, limit) as Record<string, unknown>[]
const contentSql = ` const contentSql = `
WITH base AS ( WITH base AS (
@@ -312,28 +345,9 @@ export async function searchSessionSummaries(
}) })
return items.slice(0, limit) return items.slice(0, limit)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (containsCjk(normalized)) { if (containsCjk(normalized)) {
const likeBase = buildBaseSessionSql(source) const likeRows = runLikeContentSearch(db, source, trimmed)
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<string, unknown>[]
const merged = new Map<string, HermesSessionSearchRow>() const merged = new Map<string, HermesSessionSearchRow>()
for (const row of likeRows) { for (const row of likeRows) {
const mapped = mapSearchRow(row) const mapped = mapSearchRow(row)
@@ -344,7 +358,22 @@ export async function searchSessionSummaries(
return [...merged.values()].slice(0, limit) 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<string, HermesSessionSearchRow>()
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}`) throw new Error(`Failed to search sessions: ${message}`)
} finally { } finally {
db.close() db.close()
+4
View File
@@ -149,6 +149,10 @@ describe('ChatPanel session list', () => {
const liveRow = wrapper.findAll('.session-item').find(node => node.text().includes('Discord Active')) 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?.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') await wrapper.findAll('.session-item').find(node => node.text().includes('Slack Selected'))!.trigger('click')
+24
View File
@@ -169,6 +169,30 @@ describe('Chat Store', () => {
expect(window.localStorage.getItem(legacySessionMessagesKey('legacy-1'))).toBeNull() 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 () => { it('silently refreshes from server on SSE error instead of appending a fake error bubble', async () => {
vi.useFakeTimers() vi.useFakeTimers()
+360
View File
@@ -0,0 +1,360 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const profileDirState = vi.hoisted(() => ({ value: '' }))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: () => profileDirState.value,
}))
function ensureSqliteAvailable() {
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 5)) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
}
}
function createSchema(db: any) {
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
parent_session_id TEXT,
started_at REAL NOT NULL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
cost_source TEXT,
pricing_version TEXT,
title TEXT,
api_call_count INTEGER DEFAULT 0,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
reasoning_content TEXT
);
`)
}
function insertSession(db: any, session: Record<string, unknown>) {
db.prepare(`
INSERT INTO sessions (
id, source, user_id, model, model_config, system_prompt, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
reasoning_tokens, billing_provider, billing_base_url, billing_mode,
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
pricing_version, title, api_call_count
) VALUES (
@id, @source, @user_id, @model, @model_config, @system_prompt, @parent_session_id,
@started_at, @ended_at, @end_reason, @message_count, @tool_call_count,
@input_tokens, @output_tokens, @cache_read_tokens, @cache_write_tokens,
@reasoning_tokens, @billing_provider, @billing_base_url, @billing_mode,
@estimated_cost_usd, @actual_cost_usd, @cost_status, @cost_source,
@pricing_version, @title, @api_call_count
)
`).run({
user_id: null,
model_config: null,
system_prompt: null,
billing_base_url: null,
billing_mode: null,
cost_source: null,
pricing_version: null,
api_call_count: 0,
...session,
})
}
function insertMessage(db: any, message: Record<string, unknown>) {
db.prepare(`
INSERT INTO messages (
id, session_id, role, content, tool_call_id, tool_calls, tool_name,
timestamp, token_count, finish_reason, reasoning, reasoning_details,
codex_reasoning_items, reasoning_content
) VALUES (
@id, @session_id, @role, @content, @tool_call_id, @tool_calls, @tool_name,
@timestamp, @token_count, @finish_reason, @reasoning, @reasoning_details,
@codex_reasoning_items, @reasoning_content
)
`).run({
tool_call_id: null,
tool_calls: null,
tool_name: null,
token_count: null,
finish_reason: null,
reasoning: null,
reasoning_details: null,
codex_reasoning_items: null,
reasoning_content: null,
...message,
})
}
describe('conversation DB service', () => {
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-conversations-db-'))
})
afterEach(() => {
vi.useRealTimers()
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
})
it('aggregates a compression continuation without using full CLI export', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 110,
end_reason: 'compression',
message_count: 2,
tool_call_count: 0,
input_tokens: 5,
output_tokens: 8,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.1,
actual_cost_usd: 0.1,
cost_status: 'estimated',
})
insertSession(db, {
id: 'root-cont',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Continuation',
started_at: 110,
ended_at: 111,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.2,
actual_cost_usd: 0.2,
cost_status: 'final',
})
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 })
insertMessage(db, { id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 })
insertMessage(db, { id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 })
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
expect(summaries).toHaveLength(1)
expect(summaries[0]).toEqual(expect.objectContaining({
id: 'root',
thread_session_count: 2,
ended_at: 111,
cost_status: 'mixed',
actual_cost_usd: 0.30000000000000004,
}))
const detail = await mod.getConversationDetailFromDb('root', { humanOnly: true })
expect(detail?.thread_session_count).toBe(2)
expect(detail?.messages.map((message: any) => message.content)).toEqual([
'Start here',
'Assistant reply',
'Continue with more detail',
'Continued answer',
])
})
it('treats branched children as their own visible conversations', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Root',
started_at: 100,
ended_at: 200,
end_reason: 'branched',
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertSession(db, {
id: 'branch-child',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Branch child',
started_at: 201,
ended_at: 210,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 })
insertMessage(db, { id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 })
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
const detail = await mod.getConversationDetailFromDb('branch-child', { humanOnly: true })
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
})
it('excludes synthetic-only roots from human-only summaries and details', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'synthetic-root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 101,
end_reason: null,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
insertMessage(db, {
id: 1,
session_id: 'synthetic-root',
role: 'user',
content: "You've reached the maximum number of tool-calling iterations allowed.",
timestamp: 100,
})
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const summaries = await mod.listConversationSummariesFromDb({ humanOnly: true })
const detail = await mod.getConversationDetailFromDb('synthetic-root', { humanOnly: true })
expect(summaries).toEqual([])
expect(detail).toBeNull()
})
it('returns an empty detail payload for non-human-only sessions with no visible messages', async () => {
ensureSqliteAvailable()
const { DatabaseSync } = await import('node:sqlite')
const db = new DatabaseSync(join(profileDirState.value, 'state.db'))
createSchema(db)
insertSession(db, {
id: 'assistant-empty',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Empty detail',
started_at: 200,
ended_at: null,
end_reason: null,
message_count: 0,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
})
db.close()
const mod = await import('../../packages/server/src/db/hermes/conversations-db')
const detail = await mod.getConversationDetailFromDb('assistant-empty', { humanOnly: false })
expect(detail).toEqual({
session_id: 'assistant-empty',
messages: [],
visible_count: 0,
thread_session_count: 1,
})
})
})
+107
View File
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const listConversationSummariesFromDbMock = vi.fn()
const getConversationDetailFromDbMock = vi.fn()
const listConversationSummariesMock = vi.fn()
const getConversationDetailMock = vi.fn()
const loggerWarnMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({
listConversationSummariesFromDb: listConversationSummariesFromDbMock,
getConversationDetailFromDb: getConversationDetailFromDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/conversations', () => ({
listConversationSummaries: listConversationSummariesMock,
getConversationDetail: getConversationDetailMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
warn: loggerWarnMock,
error: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
listSessions: vi.fn(),
getSession: vi.fn(),
deleteSession: vi.fn(),
renameSession: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
listSessionSummaries: vi.fn(),
searchSessionSummaries: vi.fn(),
}))
vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({
deleteUsage: vi.fn(),
getUsage: vi.fn(),
getUsageBatch: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: vi.fn(),
}))
describe('session conversations controller', () => {
beforeEach(() => {
vi.resetModules()
listConversationSummariesFromDbMock.mockReset()
getConversationDetailFromDbMock.mockReset()
listConversationSummariesMock.mockReset()
getConversationDetailMock.mockReset()
loggerWarnMock.mockReset()
})
it('prefers the DB-backed conversations summary path', async () => {
listConversationSummariesFromDbMock.mockResolvedValue([{ id: 'db-conversation' }])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'true', limit: '5' }, body: null }
await mod.listConversations(ctx)
expect(listConversationSummariesFromDbMock).toHaveBeenCalledWith({ source: undefined, humanOnly: true, limit: 5 })
expect(listConversationSummariesMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ sessions: [{ id: 'db-conversation' }] })
})
it('falls back to the CLI-export conversations summary path when the DB query fails', async () => {
listConversationSummariesFromDbMock.mockRejectedValue(new Error('db unavailable'))
listConversationSummariesMock.mockResolvedValue([{ id: 'fallback-conversation' }])
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { query: { humanOnly: 'false' }, body: null }
await mod.listConversations(ctx)
expect(loggerWarnMock).toHaveBeenCalled()
expect(listConversationSummariesMock).toHaveBeenCalledWith({ source: undefined, humanOnly: false, limit: undefined })
expect(ctx.body).toEqual({ sessions: [{ id: 'fallback-conversation' }] })
})
it('prefers the DB-backed conversation detail path', async () => {
getConversationDetailFromDbMock.mockResolvedValue({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'true' }, body: null }
await mod.getConversationMessages(ctx)
expect(getConversationDetailFromDbMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: true })
expect(getConversationDetailMock).not.toHaveBeenCalled()
expect(ctx.body).toEqual({ session_id: 'root', messages: [], visible_count: 0, thread_session_count: 1 })
})
it('falls back to the CLI-export conversation detail path when the DB query throws', async () => {
getConversationDetailFromDbMock.mockRejectedValue(new Error('db unavailable'))
getConversationDetailMock.mockResolvedValue({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
const ctx: any = { params: { id: 'root' }, query: { humanOnly: 'false' }, body: null }
await mod.getConversationMessages(ctx)
expect(loggerWarnMock).toHaveBeenCalled()
expect(getConversationDetailMock).toHaveBeenCalledWith('root', { source: undefined, humanOnly: false })
expect(ctx.body).toEqual({ session_id: 'root', messages: [{ id: 1 }], visible_count: 1, thread_session_count: 1 })
})
})
+185
View File
@@ -231,6 +231,163 @@ describe('session DB summaries', () => {
expect(rows[1].snippet).toContain('docker') 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 () => { it('falls back to LIKE search for CJK queries', async () => {
titleAllMock.mockReturnValue([]) titleAllMock.mockReturnValue([])
contentAllMock.mockImplementation(() => { contentAllMock.mockImplementation(() => {
@@ -273,4 +430,32 @@ describe('session DB summaries', () => {
expect(rows[0].id).toBe('cjk-1') expect(rows[0].id).toBe('cjk-1')
expect(rows[0].snippet).toContain('记忆断裂') 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()
})
}) })