import type { TranscriptRecord, MessageRecord, SessionRecord, SummaryRecord, ToolCallRecord, ToolResultRecord, WorkflowAgentRecord, WorkflowRecord, } from './providers/types.ts'; type WithoutKind = Omit; export interface SessionDetailMessage { [key: string]: unknown; uuid: string; type: string | null; timestamp: string | null; text: string | null; content_type: string | null; is_meta: 0 | 1; turn_duration_ms: number | null; } export type SessionDetailToolResult = WithoutKind; export interface SessionDetailWorkflowAgent { agent_id: string; phase: string | null; label: string | null; state: string | null; tokens: number | null; duration_ms: number | null; } export interface SessionDetailWorkflow { [key: string]: unknown; run_id: string; parent_tool_use_id: string | null; workflow_name: string | null; status: string | null; duration_ms: number | null; total_tokens: number | null; agent_count: number | null; agents: SessionDetailWorkflowAgent[]; } export interface AssembledToolCall { id: string; name: string; presentation: 'default' | 'skill'; input_json: string | null; result: SessionDetailToolResult | null; subagent?: { agent_id: string; agent_type: string | null; description: string | null; }; workflow?: SessionDetailWorkflow; } export interface AssembledMessage extends SessionDetailMessage { [key: string]: unknown; tool_calls?: AssembledToolCall[]; _thinking?: string; _skillMd?: string; } export interface SessionDetailSnapshot { session: SessionDetailSession | null; messages: AssembledMessage[]; workflows: SessionDetailWorkflow[]; summaries: SessionDetailSummary[]; } export type SessionDetailSummary = WithoutKind & Record; export type SessionDetailSession = Omit, 'countMode'>; export interface SessionDetailSessionRow { [key: string]: unknown; id: string; title?: string | null; project?: string | null; started_at?: string | null; ended_at?: string | null; git_branch?: string | null; version?: string | null; message_count?: number | null; jsonl_path?: string | null; source?: string | null; } export interface SessionMessageRow { [key: string]: unknown; uuid: string; session_id?: string | null; type?: string | null; role?: string | null; parent_uuid?: string | null; timestamp?: string | null; text?: string | null; content_type?: string | null; is_meta?: number | boolean | null; visibility?: string | null; } export interface SessionToolResultRow { [key: string]: unknown; tool_use_id: string; message_uuid?: string | null; session_id?: string | null; content?: string | null; } export interface SessionToolCallRow { [key: string]: unknown; id: string; message_uuid: string; session_id?: string | null; name: string; presentation?: string | null; input_json?: string | null; } export interface SessionSubagentRow { [key: string]: unknown; agent_id: string; session_id?: string | null; parent_tool_use_id?: string | null; agent_type?: string | null; description?: string | null; } export interface SessionWorkflowAgentRow { [key: string]: unknown; agent_id: string; run_id?: string | null; session_id?: string | null; phase?: string | null; label?: string | null; state?: string | null; tokens?: number | null; duration_ms?: number | null; } export interface SessionWorkflowRow { [key: string]: unknown; run_id: string; session_id?: string | null; parent_tool_use_id?: string | null; workflow_name?: string | null; status?: string | null; duration_ms?: number | null; total_tokens?: number | null; agent_count?: number | null; agents?: SessionWorkflowAgentRow[] | null; } export interface SessionSummaryRow { [key: string]: unknown; id: string | number; session_id?: string | null; } export interface SessionDetailRows { session?: SessionDetailSessionRow | null; messages?: SessionMessageRow[]; toolCalls?: SessionToolCallRow[]; toolResults?: SessionToolResultRow[]; subagents?: SessionSubagentRow[]; workflows?: SessionWorkflowRow[]; summaries?: SessionSummaryRow[]; } function withoutKind(record: T): WithoutKind { const { kind: _kind, ...value } = record; return value; } function assembleMessages( messages: SessionDetailMessage[], toolCalls: ToolCallRecord[], toolResults: ToolResultRecord[], subagents: Extract[], workflows: SessionDetailWorkflow[], ): AssembledMessage[] { const resultsByCallId = new Map(); for (const result of toolResults) resultsByCallId.set(result.tool_use_id, withoutKind(result)); const subagentsByCallId = new Map>(); for (const subagent of subagents) { if (subagent.parent_tool_use_id) subagentsByCallId.set(subagent.parent_tool_use_id, subagent); } const callsByMessageUuid = new Map(); const workflowsByCallId = new Map( workflows .filter((workflow) => workflow.parent_tool_use_id) .map((workflow) => [workflow.parent_tool_use_id as string, workflow]), ); for (const toolCall of toolCalls) { const call: AssembledToolCall = { id: toolCall.id, name: toolCall.name, presentation: toolCall.presentation, input_json: toolCall.input_json, result: resultsByCallId.get(toolCall.id) ?? null, }; const subagent = subagentsByCallId.get(toolCall.id); if (subagent) { call.subagent = { agent_id: subagent.agent_id, agent_type: subagent.agent_type ?? null, description: subagent.description ?? null, }; } const workflow = workflowsByCallId.get(toolCall.id); if (workflow) call.workflow = workflow; const calls = callsByMessageUuid.get(toolCall.message_uuid) ?? []; calls.push(call); callsByMessageUuid.set(toolCall.message_uuid, calls); } const raw = messages.map((message): AssembledMessage => { const assembled: AssembledMessage = { ...message }; const calls = callsByMessageUuid.get(message.uuid); if (calls?.length) assembled.tool_calls = calls; return assembled; }); const output: AssembledMessage[] = []; for (let index = 0; index < raw.length; index++) { const message = raw[index]; if (message.content_type === 'tool_result') continue; if (message.type === 'assistant' && message.content_type === 'thinking') { const thinkingParts = [message.text ?? '']; let nextIndex = index + 1; while ( nextIndex < raw.length && raw[nextIndex].type === 'assistant' && raw[nextIndex].content_type === 'thinking' ) { thinkingParts.push(raw[nextIndex].text ?? ''); nextIndex++; } if ( nextIndex < raw.length && raw[nextIndex].type === 'assistant' && raw[nextIndex].content_type !== 'thinking' ) { raw[nextIndex]._thinking = thinkingParts.join('\n\n'); index = nextIndex - 1; continue; } output.push({ ...message, text: thinkingParts.join('\n\n'), content_type: 'thinking' }); index = nextIndex - 1; continue; } if (message.type === 'assistant' && message.content_type === 'tool_use') { const merged: AssembledMessage = { ...message, tool_calls: [...(message.tool_calls ?? [])], }; const mergedCalls = merged.tool_calls ?? []; if (message._thinking) merged._thinking = message._thinking; const skillOnly = mergedCalls.length === 1 && mergedCalls[0].presentation === 'skill'; let nextIndex = index + 1; while (nextIndex < raw.length) { const next = raw[nextIndex]; if (next.content_type === 'tool_result') { nextIndex++; continue; } if (next.content_type === 'skill_instructions' && next.text) { merged._skillMd = next.text; nextIndex++; continue; } if (!skillOnly && next.type === 'assistant' && next.content_type === 'tool_use') { if (next.tool_calls) mergedCalls.push(...next.tool_calls); if (next.text && !merged.text) merged.text = next.text; nextIndex++; continue; } break; } output.push(merged); index = nextIndex - 1; continue; } const assembled: AssembledMessage = { ...message }; if (message._thinking) assembled._thinking = message._thinking; if (message.type === 'assistant' && message.content_type !== 'tool_use' && message.content_type !== 'thinking') { if (!assembled.tool_calls) assembled.tool_calls = []; let nextIndex = index + 1; while (nextIndex < raw.length) { const next = raw[nextIndex]; if (next.content_type === 'tool_result') { nextIndex++; continue; } if (next.type === 'assistant' && next.content_type === 'tool_use') { if (next.tool_calls) assembled.tool_calls.push(...next.tool_calls); nextIndex++; continue; } break; } if (!assembled.tool_calls.length) delete assembled.tool_calls; index = nextIndex - 1; } output.push(assembled); } return output; } /** * Project one canonical provider record stream into the app's session detail. * Provider-specific wire semantics must already be resolved before this seam. */ function assembleTranscriptRecords(records: Iterable): SessionDetailSnapshot { let session: SessionDetailSession | null = null; const messages: SessionDetailMessage[] = []; const messagesByUuid = new Map(); const mainMessageUuids = new Set(); const toolCalls: ToolCallRecord[] = []; const toolResults: ToolResultRecord[] = []; const subagents: Extract[] = []; const workflows: WorkflowRecord[] = []; const workflowAgentsById = new Map(); const summaries: SessionDetailSummary[] = []; for (const record of records) { switch (record.kind) { case 'session': if (record.countMode === 'delta') { throw new Error('Direct session detail assembly requires a fresh full parse (cursor = null), not a provider delta'); } session = { id: record.id, title: record.title, project: record.project, started_at: record.started_at, ended_at: record.ended_at, git_branch: record.git_branch, version: record.version, message_count: record.message_count, jsonl_path: record.jsonl_path, source: record.source, }; break; case 'message': { if (record.visibility === 'hidden') break; const message: SessionDetailMessage = { uuid: record.uuid, type: record.type || record.role, timestamp: record.timestamp, text: record.text, content_type: record.content_type, is_meta: record.is_meta, turn_duration_ms: typeof (record as MessageRecord & { turn_duration_ms?: unknown }).turn_duration_ms === 'number' ? (record as MessageRecord & { turn_duration_ms: number }).turn_duration_ms : null, }; messages.push(message); messagesByUuid.set(message.uuid, message); if (record.agent_id === null) mainMessageUuids.add(message.uuid); break; } case 'tool_call': toolCalls.push(record); break; case 'tool_result': toolResults.push(record); break; case 'subagent': subagents.push(record); break; case 'workflow': workflows.push(record); break; case 'workflow_agent': workflowAgentsById.set(record.agent_id, { ...(workflowAgentsById.get(record.agent_id) ?? record), ...Object.fromEntries( Object.entries(record).filter(([, value]) => value !== null && value !== undefined), ), } as WorkflowAgentRecord); break; case 'summary': summaries.push(withoutKind(record)); break; case 'message-turn-duration': { const message = messagesByUuid.get(record.uuid); if (message) message.turn_duration_ms = record.turn_duration_ms; break; } case 'delete-session': break; } } const assembledWorkflows: SessionDetailWorkflow[] = workflows.map((workflow) => { const agents = [...workflowAgentsById.values()] .filter((agent) => agent.run_id === workflow.run_id) .map((agent) => ({ agent_id: agent.agent_id, phase: agent.phase ?? null, label: agent.label ?? null, state: agent.state ?? null, tokens: agent.tokens ?? null, duration_ms: agent.duration_ms ?? null, })); return { run_id: workflow.run_id, parent_tool_use_id: workflow.parent_tool_use_id ?? null, workflow_name: workflow.workflow_name, status: workflow.status, duration_ms: workflow.duration_ms, total_tokens: workflow.total_tokens, agent_count: workflow.agent_count ?? agents.length, agents, }; }); const detailMessages = session === null ? messages : messages.filter((message) => mainMessageUuids.has(message.uuid)); detailMessages.sort((left, right) => { const leftTimestamp = left.timestamp ?? ''; const rightTimestamp = right.timestamp ?? ''; if (leftTimestamp !== rightTimestamp) return leftTimestamp < rightTimestamp ? -1 : 1; return left.uuid < right.uuid ? -1 : left.uuid > right.uuid ? 1 : 0; }); return { session, messages: assembleMessages(detailMessages, toolCalls, toolResults, subagents, assembledWorkflows), workflows: assembledWorkflows, summaries, }; } /** Adapt persisted rows back into the same canonical record language providers emit. */ function sessionDetailRecordsFromRows(input: SessionDetailRows): TranscriptRecord[] { const records: TranscriptRecord[] = []; if (input.session) { const session = input.session; records.push({ kind: 'session', id: session.id, title: typeof session.title === 'string' ? session.title : null, project: typeof session.project === 'string' ? session.project : null, started_at: typeof session.started_at === 'string' ? session.started_at : null, ended_at: typeof session.ended_at === 'string' ? session.ended_at : null, git_branch: typeof session.git_branch === 'string' ? session.git_branch : null, version: typeof session.version === 'string' ? session.version : null, message_count: typeof session.message_count === 'number' ? session.message_count : 0, countMode: 'total', jsonl_path: typeof session.jsonl_path === 'string' ? session.jsonl_path : '', source: typeof session.source === 'string' ? session.source : '', }); } for (const message of input.messages ?? []) { records.push({ ...message, kind: 'message', uuid: message.uuid, session_id: typeof message.session_id === 'string' ? message.session_id : '', type: typeof message.type === 'string' ? message.type : typeof message.role === 'string' ? message.role : '', parent_uuid: typeof message.parent_uuid === 'string' ? message.parent_uuid : null, timestamp: typeof message.timestamp === 'string' ? message.timestamp : null, role: typeof message.role === 'string' ? message.role : null, text: typeof message.text === 'string' ? message.text : null, content_type: typeof message.content_type === 'string' ? message.content_type : null, is_meta: message.is_meta ? 1 : 0, visibility: message.visibility === 'hidden' ? 'hidden' : 'visible', model: typeof message.model === 'string' ? message.model : null, is_sidechain: message.is_sidechain ? 1 : 0, agent_id: typeof message.agent_id === 'string' ? message.agent_id : null, input_tokens: typeof message.input_tokens === 'number' ? message.input_tokens : null, output_tokens: typeof message.output_tokens === 'number' ? message.output_tokens : null, cwd: typeof message.cwd === 'string' ? message.cwd : null, skill: typeof message.skill === 'string' ? message.skill : null, source: typeof message.source === 'string' ? message.source : '', }); } for (const toolCall of input.toolCalls ?? []) { records.push({ ...toolCall, kind: 'tool_call', id: toolCall.id, message_uuid: toolCall.message_uuid, session_id: typeof toolCall.session_id === 'string' ? toolCall.session_id : '', name: toolCall.name, presentation: toolCall.presentation === 'skill' ? 'skill' : 'default', input_json: typeof toolCall.input_json === 'string' ? toolCall.input_json : '', file_path: typeof toolCall.file_path === 'string' ? toolCall.file_path : null, }); } for (const result of input.toolResults ?? []) { records.push({ ...result, kind: 'tool_result', tool_use_id: result.tool_use_id, message_uuid: typeof result.message_uuid === 'string' ? result.message_uuid : '', session_id: typeof result.session_id === 'string' ? result.session_id : '', content: typeof result.content === 'string' ? result.content : '', file_path: typeof result.file_path === 'string' ? result.file_path : null, is_error: result.is_error ? 1 : 0, }); } for (const subagent of input.subagents ?? []) { records.push({ ...subagent, kind: 'subagent', agent_id: subagent.agent_id, session_id: typeof subagent.session_id === 'string' ? subagent.session_id : '', parent_tool_use_id: typeof subagent.parent_tool_use_id === 'string' ? subagent.parent_tool_use_id : null, agent_type: typeof subagent.agent_type === 'string' ? subagent.agent_type : null, description: typeof subagent.description === 'string' ? subagent.description : null, duration_ms: typeof subagent.duration_ms === 'number' ? subagent.duration_ms : null, total_tokens: typeof subagent.total_tokens === 'number' ? subagent.total_tokens : null, }); } for (const workflow of input.workflows ?? []) { records.push({ ...workflow, kind: 'workflow', run_id: workflow.run_id, session_id: typeof workflow.session_id === 'string' ? workflow.session_id : '', parent_tool_use_id: typeof workflow.parent_tool_use_id === 'string' ? workflow.parent_tool_use_id : null, task_id: typeof workflow.task_id === 'string' ? workflow.task_id : null, script: typeof workflow.script === 'string' ? workflow.script : null, result_json: typeof workflow.result_json === 'string' ? workflow.result_json : null, timestamp: typeof workflow.timestamp === 'string' ? workflow.timestamp : null, agent_count: typeof workflow.agent_count === 'number' ? workflow.agent_count : 0, duration_ms: typeof workflow.duration_ms === 'number' ? workflow.duration_ms : null, total_tokens: typeof workflow.total_tokens === 'number' ? workflow.total_tokens : null, status: typeof workflow.status === 'string' ? workflow.status : null, workflow_name: typeof workflow.workflow_name === 'string' ? workflow.workflow_name : null, }); for (const agent of workflow.agents ?? []) { records.push({ ...agent, kind: 'workflow_agent', agent_id: agent.agent_id, run_id: typeof agent.run_id === 'string' ? agent.run_id : workflow.run_id, session_id: typeof agent.session_id === 'string' ? agent.session_id : typeof workflow.session_id === 'string' ? workflow.session_id : '', ...(typeof agent.agent_type === 'string' ? { agent_type: agent.agent_type } : {}), ...(typeof agent.description === 'string' ? { description: agent.description } : {}), ...(typeof agent.phase === 'string' ? { phase: agent.phase } : {}), ...(typeof agent.label === 'string' ? { label: agent.label } : {}), ...(typeof agent.model === 'string' ? { model: agent.model } : {}), ...(typeof agent.state === 'string' ? { state: agent.state } : {}), ...(typeof agent.duration_ms === 'number' ? { duration_ms: agent.duration_ms } : {}), ...(typeof agent.tokens === 'number' ? { tokens: agent.tokens } : {}), ...(typeof agent.tool_calls === 'number' ? { tool_calls: agent.tool_calls } : {}), }); } } for (const summary of input.summaries ?? []) { records.push({ ...summary, kind: 'summary', id: String(summary.id), session_id: typeof summary.session_id === 'string' ? summary.session_id : '', timestamp: typeof summary.timestamp === 'string' ? summary.timestamp : null, source: typeof summary.source === 'string' ? summary.source : '', content: typeof summary.content === 'string' ? summary.content : '', }); } return records; } /** * Assemble detail from either a provider's complete canonical transcript stream * (a fresh parse with cursor = null) or the same records after a persistence * round-trip. This is the only presentation seam. */ export function assembleSessionDetail( input: Iterable | SessionDetailRows, ): SessionDetailSnapshot { const records = Symbol.iterator in input ? input as Iterable : sessionDetailRecordsFromRows(input as SessionDetailRows); return assembleTranscriptRecords(records); }