From d2ab2bca0822a8eb24706e82f5c182065bd6b367 Mon Sep 17 00:00:00 2001 From: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:23:33 +0200 Subject: [PATCH] =?UTF-8?q?fix(sessions):=20=E4=BF=AE=E5=A4=8D=E5=8E=8B?= =?UTF-8?q?=E7=BC=A9=E7=BB=AD=E6=8E=A5=E4=BC=9A=E8=AF=9D=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E4=B8=BA=E7=A9=BA=20(#218)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session detail now prefers DB-backed reconstruction for compressed continuation chains, with CLI fallback preserved and pending-deletion guard covered by tests. --- .../server/src/controllers/hermes/sessions.ts | 27 +- packages/server/src/db/hermes/sessions-db.ts | 234 ++++++++++++++++++ tests/server/session-detail-db.test.ts | 221 +++++++++++++++++ tests/server/sessions-controller.test.ts | 89 ++++++- 4 files changed, 569 insertions(+), 2 deletions(-) create mode 100644 tests/server/session-detail-db.test.ts diff --git a/packages/server/src/controllers/hermes/sessions.ts b/packages/server/src/controllers/hermes/sessions.ts index ac21dd86..26f8c421 100644 --- a/packages/server/src/controllers/hermes/sessions.ts +++ b/packages/server/src/controllers/hermes/sessions.ts @@ -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 diff --git a/packages/server/src/db/hermes/sessions-db.ts b/packages/server/src/db/hermes/sessions-db.ts index 950d973f..1f9789c2 100644 --- a/packages/server/src/db/hermes/sessions-db.ts +++ b/packages/server/src/db/hermes/sessions-db.ts @@ -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): HermesSessionSearchRow { } } +function mapInternalSessionRow(row: Record): 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): 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, + childrenByParent: Map, +): 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, + childrenByParent: Map, +): 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, + childrenByParent: Map, +): HermesSessionInternalRow[] { + const chain: HermesSessionInternalRow[] = [] + const seen = new Set() + let current = byId.get(rootId) || null + while (current && !seen.has(current.id)) { + chain.push(current) + seen.add(current.id) + current = nextContinuationChild(current, byId, childrenByParent) + } + return chain +} + +function 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 { + 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[] + + 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() + 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[] + + const messages = messageRows.map(mapMessageRow) + return aggregateSessionDetail(chain, messages) + } finally { + db.close() + } +} + export async function listSessionSummaries(source?: string, limit = 2000): Promise { if (!SQLITE_AVAILABLE) { throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) diff --git a/tests/server/session-detail-db.test.ts b/tests/server/session-detail-db.test.ts new file mode 100644 index 00000000..18e56fc0 --- /dev/null +++ b/tests/server/session-detail-db.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' + +const profileDirState = vi.hoisted(() => ({ value: '' })) + +vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + getActiveProfileDir: () => profileDirState.value, +})) + +function ensureSqliteAvailable() { + const [major, minor] = process.versions.node.split('.').map(Number) + if (major < 22 || (major === 22 && minor < 5)) { + throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`) + } +} + +function createSchema(db: any) { + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + user_id TEXT, + model TEXT, + model_config TEXT, + system_prompt TEXT, + parent_session_id TEXT, + started_at REAL NOT NULL, + ended_at REAL, + end_reason TEXT, + message_count INTEGER DEFAULT 0, + tool_call_count INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cache_read_tokens INTEGER DEFAULT 0, + cache_write_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + billing_provider TEXT, + billing_base_url TEXT, + billing_mode TEXT, + estimated_cost_usd REAL, + actual_cost_usd REAL, + cost_status TEXT, + cost_source TEXT, + pricing_version TEXT, + title TEXT, + api_call_count INTEGER DEFAULT 0, + FOREIGN KEY (parent_session_id) REFERENCES sessions(id) + ); + + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id), + role TEXT NOT NULL, + content TEXT, + tool_call_id TEXT, + tool_calls TEXT, + tool_name TEXT, + timestamp REAL NOT NULL, + token_count INTEGER, + finish_reason TEXT, + reasoning TEXT, + reasoning_details TEXT, + codex_reasoning_items TEXT, + reasoning_content TEXT + ); + `) +} + +function insertSession(db: any, session: Record) { + db.prepare(` + INSERT INTO sessions ( + id, source, user_id, model, model_config, system_prompt, parent_session_id, + started_at, ended_at, end_reason, message_count, tool_call_count, + input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, billing_provider, billing_base_url, billing_mode, + estimated_cost_usd, actual_cost_usd, cost_status, cost_source, + pricing_version, title, api_call_count + ) VALUES ( + @id, @source, @user_id, @model, @model_config, @system_prompt, @parent_session_id, + @started_at, @ended_at, @end_reason, @message_count, @tool_call_count, + @input_tokens, @output_tokens, @cache_read_tokens, @cache_write_tokens, + @reasoning_tokens, @billing_provider, @billing_base_url, @billing_mode, + @estimated_cost_usd, @actual_cost_usd, @cost_status, @cost_source, + @pricing_version, @title, @api_call_count + ) + `).run({ + user_id: null, + model_config: null, + system_prompt: null, + parent_session_id: null, + 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: null, + billing_base_url: null, + billing_mode: null, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: '', + cost_source: null, + pricing_version: null, + title: null, + api_call_count: 0, + ...session, + }) +} + +function insertMessage(db: any, message: Record) { + db.prepare(` + INSERT INTO messages ( + id, session_id, role, content, tool_call_id, tool_calls, tool_name, + timestamp, token_count, finish_reason, reasoning, reasoning_details, + codex_reasoning_items, reasoning_content + ) VALUES ( + @id, @session_id, @role, @content, @tool_call_id, @tool_calls, @tool_name, + @timestamp, @token_count, @finish_reason, @reasoning, @reasoning_details, + @codex_reasoning_items, @reasoning_content + ) + `).run({ + tool_call_id: null, + tool_calls: null, + tool_name: null, + token_count: null, + finish_reason: null, + reasoning: null, + reasoning_details: null, + codex_reasoning_items: null, + reasoning_content: null, + ...message, + }) +} + +describe('session DB detail', () => { + beforeEach(() => { + vi.resetModules() + profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-session-detail-db-')) + }) + + afterEach(() => { + if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true }) + }) + + it('reconstructs compressed continuation messages for session detail', async () => { + ensureSqliteAvailable() + const { DatabaseSync } = await import('node:sqlite') + const db = new DatabaseSync(join(profileDirState.value, 'state.db')) + createSchema(db) + + insertSession(db, { + id: 'root', + source: 'cli', + model: 'gpt-5.5', + title: 'Root title', + started_at: 100, + ended_at: 110, + end_reason: 'compression', + message_count: 2, + tool_call_count: 1, + input_tokens: 10, + output_tokens: 20, + actual_cost_usd: 0.1, + cost_status: 'estimated', + }) + insertSession(db, { + id: 'root-cont', + parent_session_id: 'root', + source: 'cli', + model: 'gpt-5.5', + started_at: 110, + ended_at: 120, + end_reason: null, + message_count: 2, + tool_call_count: 0, + input_tokens: 3, + output_tokens: 4, + actual_cost_usd: 0.2, + cost_status: 'final', + }) + + insertMessage(db, { id: 1, session_id: 'root', role: 'user', content: 'before compression', timestamp: 101 }) + insertMessage(db, { + id: 2, + session_id: 'root', + role: 'assistant', + content: '', + tool_calls: JSON.stringify([{ id: 'call-1', type: 'function', function: { name: 'terminal', arguments: '{"command":"pwd"}' } }]), + finish_reason: 'tool_calls', + reasoning_content: 'thinking before tool', + timestamp: 102, + }) + insertMessage(db, { id: 3, session_id: 'root-cont', role: 'tool', content: '{"output":"/tmp"}', tool_call_id: 'call-1', timestamp: 111 }) + insertMessage(db, { id: 4, session_id: 'root-cont', role: 'assistant', content: 'after compression', timestamp: 112 }) + db.close() + + const mod = await import('../../packages/server/src/db/hermes/sessions-db') + const detail = await mod.getSessionDetailFromDb('root') + + expect(detail?.id).toBe('root') + expect(detail?.message_count).toBe(4) + expect(detail?.tool_call_count).toBe(1) + expect(detail?.ended_at).toBe(120) + expect(detail?.cost_status).toBe('mixed') + expect(detail?.actual_cost_usd).toBeCloseTo(0.3) + expect(detail?.messages.map((message: any) => `${message.session_id}:${message.role}:${message.content}`)).toEqual([ + 'root:user:before compression', + 'root:assistant:', + 'root-cont:tool:{"output":"/tmp"}', + 'root-cont:assistant:after compression', + ]) + expect(detail?.messages[1].tool_calls?.[0]?.function?.name).toBe('terminal') + expect(detail?.messages[1].reasoning).toBe('thinking before tool') + }) +}) diff --git a/tests/server/sessions-controller.test.ts b/tests/server/sessions-controller.test.ts index 04c11750..9e5933cf 100644 --- a/tests/server/sessions-controller.test.ts +++ b/tests/server/sessions-controller.test.ts @@ -4,6 +4,9 @@ const listConversationSummariesFromDbMock = vi.fn() const getConversationDetailFromDbMock = vi.fn() const listConversationSummariesMock = vi.fn() const getConversationDetailMock = vi.fn() +const getSessionDetailFromDbMock = vi.fn() +const getSessionMock = vi.fn() +const getGroupChatServerMock = vi.fn() const loggerWarnMock = vi.fn() vi.mock('../../packages/server/src/db/hermes/conversations-db', () => ({ @@ -25,7 +28,7 @@ vi.mock('../../packages/server/src/services/logger', () => ({ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ listSessions: vi.fn(), - getSession: vi.fn(), + getSession: getSessionMock, deleteSession: vi.fn(), renameSession: vi.fn(), })) @@ -33,6 +36,7 @@ vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({ vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({ listSessionSummaries: vi.fn(), searchSessionSummaries: vi.fn(), + getSessionDetailFromDb: getSessionDetailFromDbMock, })) vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({ @@ -41,6 +45,10 @@ vi.mock('../../packages/server/src/db/hermes/usage-store', () => ({ getUsageBatch: vi.fn(), })) +vi.mock('../../packages/server/src/routes/hermes/group-chat', () => ({ + getGroupChatServer: getGroupChatServerMock, +})) + vi.mock('../../packages/server/src/services/hermes/model-context', () => ({ getModelContextLength: vi.fn(), })) @@ -52,6 +60,10 @@ describe('session conversations controller', () => { getConversationDetailFromDbMock.mockReset() listConversationSummariesMock.mockReset() getConversationDetailMock.mockReset() + getSessionDetailFromDbMock.mockReset() + getSessionMock.mockReset() + getGroupChatServerMock.mockReset() + getGroupChatServerMock.mockReturnValue(null) loggerWarnMock.mockReset() }) @@ -104,4 +116,79 @@ describe('session conversations controller', () => { 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 }) }) + + it('serves DB-backed session detail before falling back to CLI export', async () => { + getSessionDetailFromDbMock.mockResolvedValue({ + id: 'compressed-root', + source: 'cli', + user_id: null, + model: 'gpt-5.5', + title: 'Compressed root', + started_at: 100, + ended_at: 120, + end_reason: 'compression', + message_count: 2, + tool_call_count: 0, + input_tokens: 10, + output_tokens: 20, + cache_read_tokens: 0, + cache_write_tokens: 0, + reasoning_tokens: 0, + billing_provider: null, + estimated_cost_usd: 0, + actual_cost_usd: null, + cost_status: '', + preview: 'hello', + last_active: 121, + messages: [ + { id: 1, session_id: 'compressed-root', role: 'user', content: 'hello', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 101, token_count: null, finish_reason: null, reasoning: null }, + { id: 2, session_id: 'compressed-root-cont', role: 'assistant', content: 'world', tool_call_id: null, tool_calls: null, tool_name: null, timestamp: 121, token_count: null, finish_reason: null, reasoning: null }, + ], + }) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { params: { id: 'compressed-root' }, query: {}, body: null } + await mod.get(ctx) + + expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('compressed-root') + expect(getSessionMock).not.toHaveBeenCalled() + expect(ctx.body.session.messages.map((message: any) => message.content)).toEqual(['hello', 'world']) + }) + + it('falls back to CLI session detail when the DB detail path is unavailable', async () => { + getSessionDetailFromDbMock.mockRejectedValue(new Error('db unavailable')) + getSessionMock.mockResolvedValue({ id: 'legacy', messages: [{ id: 1, content: 'from cli' }] }) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { params: { id: 'legacy' }, query: {}, body: null } + await mod.get(ctx) + + expect(loggerWarnMock).toHaveBeenCalled() + expect(getSessionMock).toHaveBeenCalledWith('legacy') + expect(ctx.body).toEqual({ session: { id: 'legacy', messages: [{ id: 1, content: 'from cli' }] } }) + }) + + it('hides DB-backed session detail when a continuation child is pending deletion', async () => { + getGroupChatServerMock.mockReturnValue({ + getStorage: () => ({ + getPendingDeletedSessionIds: () => new Set(['compressed-root-cont']), + }), + }) + getSessionDetailFromDbMock.mockResolvedValue({ + id: 'compressed-root', + messages: [ + { id: 1, session_id: 'compressed-root', role: 'user', content: 'hello', timestamp: 101 }, + { id: 2, session_id: 'compressed-root-cont', role: 'assistant', content: 'hidden', timestamp: 121 }, + ], + }) + + const mod = await import('../../packages/server/src/controllers/hermes/sessions') + const ctx: any = { params: { id: 'compressed-root' }, query: {}, body: null } + await mod.get(ctx) + + expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('compressed-root') + expect(getSessionMock).not.toHaveBeenCalled() + expect(ctx.status).toBe(404) + expect(ctx.body).toEqual({ error: 'Session not found' }) + }) })