import type { OpencodeCompactionSource, OpencodeCompactionTimelineEvent, OpencodeNativePartKind, OpencodeNativeToolState, OpencodeNormalizedSessionMessage, OpencodeNormalizedSessionPart, OpencodeNormalizedToolState, OpencodeSessionExecutionState, OpencodeSessionTranscriptState, } from '@/types/opencode'; export interface OpenCodeEventEnvelope { type: string; payload: unknown; } export interface OpencodeCompactionMatch { id?: string; nativePartID?: string; nativeEventID?: string; runID?: string; generation?: number; } export interface OpencodePendingCompactionInput extends OpencodeCompactionMatch { source: OpencodeCompactionSource; anchorMessageID?: string; anchorPartID?: string; startedAt?: number; } export interface OpencodeCompactionCompletion { nativePartID?: string; nativeEventID?: string; anchorMessageID?: string; anchorPartID?: string; runID?: string; generation?: number; completedAt?: number; } const PART_TYPES = new Set([ 'text', 'reasoning', 'tool', 'step-start', 'step-finish', 'retry', 'subtask', 'agent', 'patch', 'file', 'snapshot', 'compaction', ]); function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; } function nonEmptyString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined; } function getSessionId(value: unknown): string | undefined { const record = asRecord(value); if (!record) return undefined; return nonEmptyString(record?.sessionID) ?? nonEmptyString(record?.sessionId) ?? getSessionId(record?.part) ?? getSessionId(record?.info) ?? getSessionId(record?.message); } function getDeltaText(value: unknown): string { if (typeof value === 'string') return value; const record = asRecord(value); if (!record) return ''; for (const key of ['text', 'reasoning', 'reasoning_content', 'reasoning_text']) { const text = nonEmptyString(record[key]); if (text) return text; } return ''; } function getMessageId(value: unknown): string | undefined { const record = asRecord(value); return nonEmptyString(record?.messageID) ?? nonEmptyString(record?.messageId) ?? nonEmptyString(asRecord(record?.info)?.id) ?? nonEmptyString(asRecord(record?.message)?.id) ?? nonEmptyString(asRecord(record?.part)?.messageID); } function getPartId(value: unknown): string | undefined { const record = asRecord(value); return nonEmptyString(record?.partID) ?? nonEmptyString(record?.partId) ?? nonEmptyString(asRecord(record?.part)?.id) ?? nonEmptyString(asRecord(record?.part)?.partID); } function getNativeEventId(value: unknown): string | undefined { const record = asRecord(value); const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence) ? String(record.sequence) : nonEmptyString(record?.sequence); return nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence; } function getRunId(value: unknown): string | undefined { const record = asRecord(value); return nonEmptyString(record?.runID) ?? nonEmptyString(record?.runId) ?? nonEmptyString(asRecord(record?.part)?.runID) ?? nonEmptyString(asRecord(record?.part)?.runId); } function getGeneration(value: unknown): number | undefined { const record = asRecord(value); const generation = record?.generation ?? asRecord(record?.part)?.generation; return typeof generation === 'number' && Number.isFinite(generation) ? generation : undefined; } function toMilliseconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value; } function getTime(value: unknown): { start?: number; end?: number } | undefined { const record = asRecord(value); if (!record) return undefined; const start = toMilliseconds(record.start); const end = toMilliseconds(record.end); return start === undefined && end === undefined ? undefined : { start, end }; } function stringifyDetail(value: unknown, limit = 8_000): string | undefined { if (value === undefined || value === null) return undefined; const text = typeof value === 'string' ? value : (() => { try { return JSON.stringify(value, null, 2); } catch { return String(value); } })(); const trimmed = text.trim(); if (!trimmed) return undefined; return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n…` : trimmed; } function normalizeToolState(value: unknown): OpencodeNormalizedToolState | undefined { const record = asRecord(value); if (!record) return undefined; const rawStatus = nonEmptyString(record.status); const status: OpencodeNativeToolState = rawStatus === 'pending' || rawStatus === 'running' || rawStatus === 'completed' || rawStatus === 'error' ? rawStatus : 'running'; const time = getTime(record.time); return { status, ...(record.input !== undefined ? { input: record.input } : {}), ...(record.output !== undefined ? { output: record.output } : {}), ...(record.error !== undefined ? { error: record.error } : {}), ...(asRecord(record.metadata) ? { metadata: asRecord(record.metadata)! } : {}), ...(time ? { time } : {}), }; } function normalizePartType(rawType: string): OpencodeNativePartKind { return PART_TYPES.has(rawType as OpencodeNativePartKind) ? rawType as OpencodeNativePartKind : 'unknown'; } function pickPartRecord(value: unknown): Record | null { const record = asRecord(value); return asRecord(record?.part) ?? record; } function pickMessageInfo(value: unknown): Record | null { const record = asRecord(value); return asRecord(record?.info) ?? asRecord(record?.message) ?? record; } function normalizeMessageRole(value: unknown): string { const role = nonEmptyString(value); return role === 'user' || role === 'assistant' || role === 'system' || role === 'tool' ? role : 'assistant'; } function normalizePart( input: unknown, sessionID: string, messageID: string, fallbackId?: string, ): OpencodeNormalizedSessionPart | null { const record = pickPartRecord(input); if (!record) return null; const id = nonEmptyString(record.id) ?? nonEmptyString(record.partID) ?? fallbackId; const rawType = nonEmptyString(record.type) ?? 'unknown'; if (!id) return null; const type = normalizePartType(rawType); const state = normalizeToolState(record.state); const time = getTime(record.time) ?? state?.time; const text = typeof record.text === 'string' ? record.text : typeof record.reasoning === 'string' ? record.reasoning : typeof record.reasoning_content === 'string' ? record.reasoning_content : typeof record.reasoning_text === 'string' ? record.reasoning_text : undefined; const detail = stringifyDetail( type === 'tool' ? state?.output ?? state?.error ?? state?.input : type === 'retry' ? record.message ?? record.error ?? record : type === 'step-finish' ? { reason: record.reason, cost: record.cost, tokens: record.tokens, } : type === 'text' || type === 'reasoning' ? undefined : record, ); return { id, sessionID, messageID, type, rawType, ...(text !== undefined ? { text } : {}), ...(record.synthetic === true ? { synthetic: true } : {}), ...(typeof record.tool === 'string' ? { tool: record.tool } : {}), ...(typeof record.callID === 'string' ? { callID: record.callID } : {}), ...(state ? { state } : {}), ...(detail ? { detail } : {}), ...(typeof record.parentID === 'string' ? { parentID: record.parentID } : {}), ...(typeof record.sessionID === 'string' ? { childSessionID: record.sessionID } : {}), ...(time ? { time } : {}), raw: record, }; } function createEmptyState(sessionID: string): OpencodeSessionTranscriptState { return { sessionID, status: 'idle', messagesById: {}, messageOrder: [], partsById: {}, partOrderByMessageId: {}, compactionsById: {}, compactionOrder: [], }; } function nextCompactionOrder(state: OpencodeSessionTranscriptState): number { return state.compactionOrder.reduce((highest, id) => ( Math.max(highest, state.compactionsById[id]?.order ?? -1) ), -1) + 1; } function matchesCompaction( event: OpencodeCompactionTimelineEvent, match: OpencodeCompactionMatch, ): boolean { const entries = Object.entries(match).filter(([, value]) => value !== undefined); return entries.length > 0 && entries.every(([key, value]) => ( event[key as keyof OpencodeCompactionMatch] === value )); } function findCompactionId( state: OpencodeSessionTranscriptState, match: OpencodeCompactionMatch, runningOnly = false, ): string | undefined { return [...state.compactionOrder].reverse().find((id) => { const event = state.compactionsById[id]; return event && (!runningOnly || event.status === 'running') && matchesCompaction(event, match); }); } function replaceCompaction( state: OpencodeSessionTranscriptState, event: OpencodeCompactionTimelineEvent, ): OpencodeSessionTranscriptState { return { ...state, compactionsById: { ...state.compactionsById, [event.id]: event }, compactionOrder: state.compactionOrder.includes(event.id) ? state.compactionOrder : [...state.compactionOrder, event.id], }; } /** Add a renderer-owned running event before its native Part is available. */ export function createPendingCompaction( state: OpencodeSessionTranscriptState, input: OpencodePendingCompactionInput, ): OpencodeSessionTranscriptState { const match: OpencodeCompactionMatch = { ...(input.id ? { id: input.id } : {}), ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), ...(input.runID ? { runID: input.runID } : {}), ...(input.generation !== undefined ? { generation: input.generation } : {}), }; const existingID = findCompactionId(state, match, true); if (existingID) return state; const order = nextCompactionOrder(state); const id = input.id ?? `compaction:${state.sessionID}:${input.runID ?? input.generation ?? 'pending'}:${order}`; if (state.compactionsById[id]) return state; return replaceCompaction(state, { id, sessionID: state.sessionID, source: input.source, status: 'running', order, ...(input.anchorMessageID ? { anchorMessageID: input.anchorMessageID } : {}), ...(input.anchorPartID ? { anchorPartID: input.anchorPartID } : {}), ...(input.nativePartID ? { nativePartID: input.nativePartID } : {}), ...(input.nativeEventID ? { nativeEventID: input.nativeEventID } : {}), ...(input.runID ? { runID: input.runID } : {}), ...(input.generation !== undefined ? { generation: input.generation } : {}), ...(input.startedAt !== undefined ? { startedAt: input.startedAt } : {}), }); } /** Complete the precisely matched event, or the latest running event if no match is supplied. */ export function completeCompaction( state: OpencodeSessionTranscriptState, match: OpencodeCompactionMatch = {}, completion: OpencodeCompactionCompletion = {}, ): OpencodeSessionTranscriptState { const hasMatch = Object.values(match).some((value) => value !== undefined); const id = hasMatch ? findCompactionId(state, match, true) : [...state.compactionOrder].reverse().find((candidateID) => ( state.compactionsById[candidateID]?.status === 'running' )); if (!id) return state; const current = state.compactionsById[id]; return replaceCompaction(state, { ...current, status: 'completed', ...(completion.nativePartID ? { nativePartID: completion.nativePartID } : {}), ...(completion.nativeEventID ? { nativeEventID: completion.nativeEventID } : {}), ...(completion.anchorMessageID ? { anchorMessageID: completion.anchorMessageID } : {}), ...(completion.anchorPartID ? { anchorPartID: completion.anchorPartID } : {}), ...(completion.runID ? { runID: completion.runID } : {}), ...(completion.generation !== undefined ? { generation: completion.generation } : {}), ...(completion.completedAt !== undefined ? { completedAt: completion.completedAt } : {}), }); } /** Remove a failed/cancelled event without relying on its mutable native correlation. */ export function removeCompaction( state: OpencodeSessionTranscriptState, match: OpencodeCompactionMatch, ): OpencodeSessionTranscriptState { const id = findCompactionId(state, match); if (!id) return state; const compactionsById = { ...state.compactionsById }; delete compactionsById[id]; return { ...state, compactionsById, compactionOrder: state.compactionOrder.filter((candidateID) => candidateID !== id), }; } function compactionSource(part: OpencodeNormalizedSessionPart): OpencodeCompactionSource { const raw = part.raw ?? {}; return raw.source === 'manual' || raw.auto === false ? 'manual' : 'automatic'; } function upsertCompactionPart( state: OpencodeSessionTranscriptState, part: OpencodeNormalizedSessionPart, nativeEventID?: string, ): OpencodeSessionTranscriptState { if (part.type !== 'compaction') return state; const raw = part.raw ?? {}; const runID = getRunId(raw); const generation = getGeneration(raw); const source = compactionSource(part); const correlatedID = findCompactionId(state, { nativePartID: part.id }); let id = correlatedID; if (!id && source === 'manual') { id = [...state.compactionOrder].reverse().find((candidateID) => { const candidate = state.compactionsById[candidateID]; return candidate?.source === 'manual' && candidate.status === 'running' && !candidate.nativePartID && (runID === undefined || candidate.runID === undefined || candidate.runID === runID) && (generation === undefined || candidate.generation === undefined || candidate.generation === generation); }); } if (!id) { id = `compaction:${state.sessionID}:part:${part.id}`; } let next = state; if (!correlatedID) { for (const candidateID of state.compactionOrder) { if (candidateID === id) continue; if (state.compactionsById[candidateID]?.status !== 'running') continue; next = completeCompaction(next, { id: candidateID }); } } const existing = next.compactionsById[id]; const endedAt = part.time?.end; return replaceCompaction(next, { id, sessionID: state.sessionID, source: existing?.source ?? source, status: endedAt !== undefined ? 'completed' : existing?.status ?? 'running', order: existing?.order ?? nextCompactionOrder(next), anchorMessageID: part.messageID, anchorPartID: part.id, nativePartID: part.id, ...(nativeEventID ? { nativeEventID } : existing?.nativeEventID ? { nativeEventID: existing.nativeEventID } : {}), ...(runID ? { runID } : existing?.runID ? { runID: existing.runID } : {}), ...(generation !== undefined ? { generation } : existing?.generation !== undefined ? { generation: existing.generation } : {}), ...(part.time?.start !== undefined ? { startedAt: part.time.start } : existing?.startedAt !== undefined ? { startedAt: existing.startedAt } : {}), ...(endedAt !== undefined ? { completedAt: endedAt } : existing?.completedAt !== undefined ? { completedAt: existing.completedAt } : {}), }); } function ensureMessage( state: OpencodeSessionTranscriptState, input: unknown, sessionID: string, messageID: string, ): OpencodeSessionTranscriptState { const info = pickMessageInfo(input) ?? {}; const existing = state.messagesById[messageID]; const next: OpencodeNormalizedSessionMessage = { id: messageID, sessionID, role: normalizeMessageRole(info.role), ...(toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) !== undefined ? { createdAt: toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) } : existing?.createdAt !== undefined ? { createdAt: existing.createdAt } : {}), ...(toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) !== undefined ? { completedAt: toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) } : existing?.completedAt !== undefined ? { completedAt: existing.completedAt } : {}), partIDs: existing?.partIDs ?? [], }; return { ...state, messagesById: { ...state.messagesById, [messageID]: next }, messageOrder: state.messageOrder.includes(messageID) ? state.messageOrder : [...state.messageOrder, messageID], partOrderByMessageId: state.partOrderByMessageId[messageID] ? state.partOrderByMessageId : { ...state.partOrderByMessageId, [messageID]: [] }, }; } function upsertPart( state: OpencodeSessionTranscriptState, part: OpencodeNormalizedSessionPart, nativeEventID?: string, ): OpencodeSessionTranscriptState { const message = state.messagesById[part.messageID]; const currentOrder = state.partOrderByMessageId[part.messageID] ?? []; const next = { ...state, partsById: { ...state.partsById, [part.id]: part, }, messagesById: message ? { ...state.messagesById, [part.messageID]: { ...message, partIDs: message.partIDs.includes(part.id) ? message.partIDs : [...message.partIDs, part.id], }, } : state.messagesById, partOrderByMessageId: { ...state.partOrderByMessageId, [part.messageID]: currentOrder.includes(part.id) ? currentOrder : [...currentOrder, part.id], }, }; return upsertCompactionPart(next, part, nativeEventID); } function removePart( state: OpencodeSessionTranscriptState, partID: string, ): OpencodeSessionTranscriptState { const part = state.partsById[partID]; if (!part) return state; const message = state.messagesById[part.messageID]; const nextParts = { ...state.partsById }; delete nextParts[partID]; const next = { ...state, partsById: nextParts, messagesById: message ? { ...state.messagesById, [part.messageID]: { ...message, partIDs: message.partIDs.filter((id) => id !== partID), }, } : state.messagesById, partOrderByMessageId: { ...state.partOrderByMessageId, [part.messageID]: (state.partOrderByMessageId[part.messageID] ?? []) .filter((id) => id !== partID), }, }; return part.type === 'compaction' ? removeCompaction(next, { nativePartID: partID }) : next; } function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined { const record = asRecord(event.payload); const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence) ? String(record.sequence) : nonEmptyString(record?.sequence); const id = nonEmptyString(record?.eventID) ?? nonEmptyString(record?.eventId) ?? sequence // Message/Part ids identify the resource, not the event. They must not be // treated as a delta event id or every later chunk would be discarded. ?? (event.type === 'message.part.delta' ? undefined : nonEmptyString(record?.id)); return id ? `${sessionID ?? getSessionId(event.payload) ?? ''}:${event.type}:${id}` : undefined; } function rememberEventKey( state: OpencodeSessionTranscriptState, key: string | undefined, ): OpencodeSessionTranscriptState { if (!key) return state; const previous = state.seenEventKeys ?? []; if (previous.includes(key)) return state; return { ...state, lastEventKey: key, seenEventKeys: [...previous, key].slice(-512), }; } function applyDelta( state: OpencodeSessionTranscriptState, event: OpenCodeEventEnvelope, ): OpencodeSessionTranscriptState { const record = asRecord(event.payload); const delta = getDeltaText(record?.delta); const sessionID = getSessionId(event.payload) ?? state.sessionID; const messageID = getMessageId(event.payload); const partID = getPartId(event.payload); if (!delta || !messageID || !partID) return state; const field = nonEmptyString(record?.field) ?? nonEmptyString(asRecord(record?.part)?.field); const signature = `${messageID}:${partID}:${field ?? 'text'}:${delta}`; // Without a runtime event identity, only suppress an immediately repeated // signature. With an identity, the reducer-level event cache is the source // of truth and equal consecutive chunks remain valid output. if (!eventKey(event, sessionID) && state.lastDeltaSignature === signature) return state; const existing = state.partsById[partID]; const isReasoning = field === 'reasoning' || field === 'thinking' || asRecord(record?.part)?.type === 'reasoning' || existing?.type === 'reasoning'; const nextPart: OpencodeNormalizedSessionPart = { ...(existing ?? { id: partID, sessionID, messageID, type: isReasoning ? 'reasoning' : 'text', rawType: isReasoning ? 'reasoning' : 'text', }), text: `${existing?.text ?? ''}${delta}`, }; return { ...upsertPart(ensureMessage(state, record?.part ?? record, sessionID, messageID), nextPart), lastDeltaSignature: signature, }; } export function normalizeOpenCodeMessage( input: unknown, fallbackSessionID?: string, ): OpencodeNormalizedSessionMessage | null { const record = asRecord(input); if (!record) return null; const info = pickMessageInfo(record); const sessionID = getSessionId(record) ?? fallbackSessionID; const id = nonEmptyString(info?.id) ?? nonEmptyString(record.id); if (!sessionID || !id) return null; const parts = Array.isArray(record.parts) ? record.parts : []; const normalizedParts = parts .map((part) => normalizePart(part, sessionID, id)) .filter((part): part is OpencodeNormalizedSessionPart => Boolean(part)); return { id, sessionID, role: normalizeMessageRole(info?.role), ...(toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) !== undefined ? { createdAt: toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) } : {}), ...(toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) !== undefined ? { completedAt: toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) } : {}), partIDs: normalizedParts.map((part) => part.id), }; } export function hydrateOpenCodeSession( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { const state = createEmptyState(sessionID); if (!Array.isArray(messages)) return { ...state, status }; let next = state; for (const messageInput of messages) { const message = normalizeOpenCodeMessage(messageInput, sessionID); if (!message) continue; next = ensureMessage(next, messageInput, sessionID, message.id); const record = asRecord(messageInput); const parts = Array.isArray(record?.parts) ? record.parts : []; for (const partInput of parts) { const part = normalizePart(partInput, sessionID, message.id); if (part) next = upsertPart(next, part); } } const snapshotOrder = [...next.compactionOrder]; snapshotOrder.forEach((id, index) => { const snapshotEvent = next.compactionsById[id]; const existingID = current ? findCompactionId(current, { id: snapshotEvent.id }) ?? (snapshotEvent.nativePartID ? findCompactionId(current, { nativePartID: snapshotEvent.nativePartID }) : undefined) ?? (snapshotEvent.anchorPartID ? [...current.compactionOrder].reverse().find((candidateID) => { const candidate = current.compactionsById[candidateID]; return candidate?.anchorPartID === snapshotEvent.anchorPartID && candidate.anchorMessageID === snapshotEvent.anchorMessageID; }) : undefined) : undefined; const pendingID = !existingID && snapshotEvent.source === 'manual' ? [...(current?.compactionOrder ?? [])].reverse().find((candidateID) => { const candidate = current?.compactionsById[candidateID]; return candidate?.source === 'manual' && candidate.status === 'running' && !candidate.nativePartID && (snapshotEvent.runID === undefined || candidate.runID === undefined || candidate.runID === snapshotEvent.runID) && (snapshotEvent.generation === undefined || candidate.generation === undefined || candidate.generation === snapshotEvent.generation); }) : undefined; const preservedID = existingID ?? pendingID; const existing = preservedID ? current?.compactionsById[preservedID] : undefined; const idToUse = preservedID ?? id; const completed = existing?.status === 'completed' || snapshotEvent.completedAt !== undefined || status === 'idle' || existing?.status !== 'running'; if (idToUse !== id) { const compactionsById = { ...next.compactionsById }; delete compactionsById[id]; next = { ...next, compactionsById, compactionOrder: next.compactionOrder.map((candidateID) => ( candidateID === id ? idToUse : candidateID )), }; } next = replaceCompaction(next, { ...snapshotEvent, ...existing, id: idToUse, status: completed ? 'completed' : 'running', order: index, anchorMessageID: snapshotEvent.anchorMessageID, anchorPartID: snapshotEvent.anchorPartID, nativePartID: snapshotEvent.nativePartID, ...(snapshotEvent.nativeEventID ? { nativeEventID: snapshotEvent.nativeEventID } : {}), ...(snapshotEvent.runID ? { runID: snapshotEvent.runID } : {}), ...(snapshotEvent.generation !== undefined ? { generation: snapshotEvent.generation } : {}), ...(snapshotEvent.startedAt !== undefined ? { startedAt: snapshotEvent.startedAt } : {}), ...(snapshotEvent.completedAt !== undefined ? { completedAt: snapshotEvent.completedAt } : {}), }); }); if (status !== 'idle' && current) { for (const id of current.compactionOrder) { const pending = current.compactionsById[id]; if (!pending || pending.status !== 'running' || pending.nativePartID) continue; if (next.compactionsById[id]) continue; next = replaceCompaction(next, { ...pending, order: nextCompactionOrder(next) }); } } return { ...next, status }; } /** Rebuild the canonical transcript from the server's message/Part snapshot. */ export function hydrateSessionSnapshot( sessionID: string, messages: unknown, status: OpencodeSessionExecutionState = 'idle', current?: OpencodeSessionTranscriptState, ): OpencodeSessionTranscriptState { return hydrateOpenCodeSession(sessionID, messages, status, current); } export function reduceOpenCodeEvent( current: OpencodeSessionTranscriptState | undefined, event: OpenCodeEventEnvelope, ): OpencodeSessionTranscriptState | undefined { const sessionID = getSessionId(event.payload) ?? current?.sessionID; if (!sessionID) return current; if (current && current.sessionID !== sessionID) return current; let state = current ?? createEmptyState(sessionID); const key = eventKey(event, sessionID); if (key && state.seenEventKeys?.includes(key)) return state; state = rememberEventKey(state, key); if (event.type === 'message.part.delta') { return applyDelta(state, event); } if (event.type === 'message.part.removed') { const partID = getPartId(event.payload); return partID ? removePart(state, partID) : state; } if (event.type === 'message.updated') { const messageInput = asRecord(event.payload)?.message ?? event.payload; const message = normalizeOpenCodeMessage(messageInput, sessionID); if (!message) return state; let next = ensureMessage(state, messageInput, sessionID, message.id); const parts = Array.isArray(asRecord(messageInput)?.parts) ? asRecord(messageInput)?.parts as unknown[] : []; for (const partInput of parts) { const part = normalizePart(partInput, sessionID, message.id); if (part) next = upsertPart(next, part); } return next; } if (event.type === 'message.part.updated') { const record = asRecord(event.payload); const part = normalizePart(record?.part ?? event.payload, sessionID, getMessageId(event.payload) ?? ''); const messageID = part?.messageID || getMessageId(event.payload); if (!part || !messageID) return state; const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID); return upsertPart(withMessage, { ...part, messageID }, getNativeEventId(event.payload)); } if (event.type === 'session.status') { const type = nonEmptyString(asRecord(event.payload)?.status && asRecord(asRecord(event.payload)?.status)?.type) ?? nonEmptyString(asRecord(event.payload)?.type); return { ...state, status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status, }; } if (event.type === 'session.idle') { return { ...completeCompaction(state), status: 'idle' }; } if (event.type === 'session.compacted') { const payload = asRecord(event.payload); const match: OpencodeCompactionMatch = { ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), ...(getGeneration(event.payload) !== undefined ? { generation: getGeneration(event.payload) } : {}), }; const completedAt = toMilliseconds( asRecord(payload?.time)?.completed ?? asRecord(payload?.time)?.end ?? payload?.completedAt ?? payload?.timestamp, ); return completeCompaction(state, match, { ...(getNativeEventId(event.payload) ? { nativeEventID: getNativeEventId(event.payload) } : {}), ...(getRunId(event.payload) ? { runID: getRunId(event.payload) } : {}), ...(getGeneration(event.payload) !== undefined ? { generation: getGeneration(event.payload) } : {}), ...(completedAt !== undefined ? { completedAt } : {}), }); } if (event.type === 'session.error') { const payload = asRecord(event.payload); const error = payload?.error; let next = state; for (const id of state.compactionOrder) { if (next.compactionsById[id]?.status !== 'running') continue; next = removeCompaction(next, { id }); } return { ...next, status: 'idle', lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000), }; } return state; } export function getOrderedSessionParts( state: OpencodeSessionTranscriptState | undefined, messageID: string, ): OpencodeNormalizedSessionPart[] { if (!state) return []; return (state.partOrderByMessageId[messageID] ?? []) .map((partID) => state.partsById[partID]) .filter((part): part is OpencodeNormalizedSessionPart => Boolean(part)); } export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number { return state ? Object.keys(state.partsById).length : 0; } export function getOrderedSessionCompactions( state: OpencodeSessionTranscriptState | undefined, ): OpencodeCompactionTimelineEvent[] { if (!state) return []; return state.compactionOrder .map((id) => state.compactionsById[id]) .filter((event): event is OpencodeCompactionTimelineEvent => Boolean(event)) .sort((left, right) => left.order - right.order); }