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:
@@ -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) {
|
||||
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;
|
||||
|
||||
@@ -45,6 +45,7 @@ const { t } = useI18n()
|
||||
</svg>
|
||||
</span>
|
||||
<span class="session-item-title">{{ session.title }}</span>
|
||||
<span v-if="live" class="session-item-live-badge">{{ t('chat.liveMode') }}</span>
|
||||
</span>
|
||||
<span class="session-item-meta">
|
||||
<span v-if="session.model" class="session-item-model">{{ session.model }}</span>
|
||||
|
||||
@@ -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<Message[]>(() => 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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<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 {
|
||||
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<string, unknown>[] = []
|
||||
|
||||
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<string, unknown>[]
|
||||
titleRows = titleStatement.all(...titleBase.params, `%${trimmed.toLowerCase()}%`, limit) as Record<string, unknown>[]
|
||||
|
||||
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<string, unknown>[]
|
||||
const likeRows = runLikeContentSearch(db, source, trimmed)
|
||||
const merged = new Map<string, HermesSessionSearchRow>()
|
||||
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<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}`)
|
||||
} finally {
|
||||
db.close()
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user