fix(sessions): 修复压缩续接会话详情为空 (#218)

Session detail now prefers DB-backed reconstruction for compressed continuation chains, with CLI fallback preserved and pending-deletion guard covered by tests.
This commit is contained in:
Zhicheng Han
2026-04-25 16:23:33 +02:00
committed by GitHub
parent 3993dda013
commit d2ab2bca08
4 changed files with 569 additions and 2 deletions
@@ -4,7 +4,7 @@ import {
getConversationDetailFromDb,
listConversationSummariesFromDb,
} from '../../db/hermes/conversations-db'
import { listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db'
import { getSessionDetailFromDb, listSessionSummaries, searchSessionSummaries } from '../../db/hermes/sessions-db'
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
import { getModelContextLength } from '../../services/hermes/model-context'
import type { ConversationDetail, ConversationSummary } from '../../services/hermes/conversations'
@@ -48,6 +48,16 @@ function hasPendingDeletedConversation(detail: ConversationDetail): boolean {
return detail.messages.some(message => pendingIds.has(message.session_id))
}
function hasPendingDeletedSessionDetail(session: { id: string; messages?: Array<{ session_id?: string | null }> }): boolean {
const pendingIds = getPendingDeletedSessionIds()
if (pendingIds.size === 0) return false
if (pendingIds.has(session.id)) return true
return (session.messages || []).some(message => {
const messageSessionId = message.session_id || session.id
return pendingIds.has(messageSessionId)
})
}
function getGroupChatStorage() {
return getGroupChatServer()?.getStorage() || null
}
@@ -135,6 +145,21 @@ export async function get(ctx: any) {
return
}
try {
const session = await getSessionDetailFromDb(ctx.params.id)
if (session) {
if (hasPendingDeletedSessionDetail(session)) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
return
}
} catch (err) {
logger.warn(err, 'Hermes Session DB: detail query failed, falling back to CLI')
}
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
@@ -5,6 +5,8 @@ const SQLITE_AVAILABLE = (() => {
return major > 22 || (major === 22 && minor >= 5)
})()
const LINEAGE_TOLERANCE_SECONDS = 3
export interface HermesSessionRow {
id: string
source: string
@@ -35,6 +37,32 @@ export interface HermesSessionSearchRow extends HermesSessionRow {
rank: number
}
export interface HermesMessageRow {
id: number | string
session_id: string
role: string
content: string
tool_call_id: string | null
tool_calls: any[] | null
tool_name: string | null
timestamp: number
token_count: number | null
finish_reason: string | null
reasoning: string | null
reasoning_details?: string | null
codex_reasoning_items?: string | null
reasoning_content?: string | null
}
export interface HermesSessionDetailRow extends HermesSessionRow {
messages: HermesMessageRow[]
thread_session_count: number
}
interface HermesSessionInternalRow extends HermesSessionRow {
parent_session_id: string | null
}
function sessionDbPath(): string {
return `${getActiveProfileDir()}/state.db`
}
@@ -292,6 +320,212 @@ function mapSearchRow(row: Record<string, unknown>): HermesSessionSearchRow {
}
}
function mapInternalSessionRow(row: Record<string, unknown>): HermesSessionInternalRow {
return {
...mapRow(row),
parent_session_id: normalizeNullableString(row.parent_session_id),
}
}
function parseToolCalls(value: unknown): any[] | null {
if (value == null || value === '') return null
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function normalizeMessageId(value: unknown): number | string {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'bigint') return Number(value)
const asNumber = Number(value)
if (Number.isInteger(asNumber)) return asNumber
return String(value || '')
}
function mapMessageRow(row: Record<string, unknown>): HermesMessageRow {
const reasoning = normalizeNullableString(row.reasoning) || normalizeNullableString(row.reasoning_content)
return {
id: normalizeMessageId(row.id),
session_id: String(row.session_id || ''),
role: String(row.role || ''),
content: row.content == null ? '' : String(row.content),
tool_call_id: normalizeNullableString(row.tool_call_id),
tool_calls: parseToolCalls(row.tool_calls),
tool_name: normalizeNullableString(row.tool_name),
timestamp: normalizeNumber(row.timestamp),
token_count: normalizeNullableNumber(row.token_count),
finish_reason: normalizeNullableString(row.finish_reason),
reasoning,
reasoning_details: normalizeNullableString(row.reasoning_details),
codex_reasoning_items: normalizeNullableString(row.codex_reasoning_items),
reasoning_content: normalizeNullableString(row.reasoning_content),
}
}
function timingMatchesParent(parent: HermesSessionInternalRow | undefined, child: HermesSessionInternalRow | 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 continuationCandidates(
parent: HermesSessionInternalRow,
byId: Map<string, HermesSessionInternalRow>,
childrenByParent: Map<string | null, string[]>,
): HermesSessionInternalRow[] {
return (childrenByParent.get(parent.id) || [])
.map(childId => byId.get(childId))
.filter((child): child is HermesSessionInternalRow => !!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 normalizeComparableText(value: unknown): string {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()
}
function nextContinuationChild(
parent: HermesSessionInternalRow,
byId: Map<string, HermesSessionInternalRow>,
childrenByParent: Map<string | null, string[]>,
): HermesSessionInternalRow | 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 = normalizeComparableText(child.preview)
const parentPreview = normalizeComparableText(parent.preview)
return !!childPreview && childPreview === parentPreview
})
return exactPreviewMatches.length === 1 ? exactPreviewMatches[0] : null
}
function collectSessionChain(
rootId: string,
byId: Map<string, HermesSessionInternalRow>,
childrenByParent: Map<string | null, string[]>,
): HermesSessionInternalRow[] {
const chain: HermesSessionInternalRow[] = []
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 aggregateSessionDetail(chain: HermesSessionInternalRow[], messages: HermesMessageRow[]): HermesSessionDetailRow {
const root = chain[0]
const last = chain[chain.length - 1] || root
const costStatuses = Array.from(new Set(chain.map(session => String(session.cost_status || '')).filter(Boolean)))
const actualCosts = chain
.map(session => session.actual_cost_usd)
.filter((value): value is number => value != null)
const firstPreview = chain.map(session => session.preview).find(Boolean) || root.preview
return {
...root,
title: root.title || (firstPreview ? (firstPreview.length > 40 ? `${firstPreview.slice(0, 40)}...` : firstPreview) : null),
preview: root.preview || firstPreview || '',
model: last.model || root.model,
ended_at: last.ended_at,
end_reason: last.end_reason,
last_active: Math.max(...chain.map(session => session.last_active || session.started_at || 0)),
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),
billing_provider: last.billing_provider ?? root.billing_provider,
estimated_cost_usd: chain.reduce((sum, session) => sum + Number(session.estimated_cost_usd || 0), 0),
actual_cost_usd: actualCosts.length ? actualCosts.reduce((sum, value) => sum + Number(value || 0), 0) : null,
cost_status: costStatuses.length === 1 ? costStatuses[0] : (costStatuses.length > 1 ? 'mixed' : ''),
messages,
thread_session_count: chain.length,
}
}
async function openSessionDb() {
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(sessionDbPath(), { open: true, readOnly: true })
}
export async function getSessionDetailFromDb(sessionId: string): Promise<HermesSessionDetailRow | null> {
const db = await openSessionDb()
try {
const rows = db.prepare(`
SELECT
${SESSION_SELECT},
s.parent_session_id AS parent_session_id
FROM sessions s
WHERE s.source != 'tool'
`).all() as Record<string, unknown>[]
const sessions = rows.map(mapInternalSessionRow)
const byId = new Map(sessions.map(session => [session.id, session]))
const root = byId.get(sessionId)
if (!root) return null
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)
}
const chain = collectSessionChain(sessionId, byId, childrenByParent)
if (!chain.length) return null
const ids = chain.map(session => session.id)
const placeholders = ids.map(() => '?').join(', ')
const messageRows = db.prepare(`
SELECT
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
FROM messages
WHERE session_id IN (${placeholders})
ORDER BY timestamp, id
`).all(...ids) as Record<string, unknown>[]
const messages = messageRows.map(mapMessageRow)
return aggregateSessionDetail(chain, messages)
} finally {
db.close()
}
}
export async function listSessionSummaries(source?: string, limit = 2000): Promise<HermesSessionRow[]> {
if (!SQLITE_AVAILABLE) {
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)