Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685/** * transcript — the typed normalized web transcript view model + centralized raw-event * normalization (saga vr7kgx.3 “Complete web transcript event parity”). * * The web client renders a flat `TranscriptEntry[]`. Before this saga that union was an * ad-hoc inline declaration in `./connection` and the raw-event → entry conversion was * duplicated (and drifted) between the legacy agent-first `reduceConnection` and the * session-first `sessionLiveDelta`. This module is the SINGLE source of truth for both: * * 1. The named/exported typed entries — message, tool event (with `RenderDescriptor`, * `ToolResultContent`, explicit `exitCode`/`durationMs`, streamed `argsText` while * incomplete), and structured system entry variants (turn start/end with `turnId` + * `usage`, compaction with counts/range, provider retry with attempt/max/status/ * reason, error with code/message/fatal). * 2. `liveEventToEntries` — a PURE raw-event → typed-delta normalizer shared by both * `reduceConnection` and `sessionLiveDelta` so they cannot drift. It correctly * handles `message_delta` by nonnegative-integer `block_index`, reconciles streamed * tool args by `tool_call_id` (fallback from message/block), accumulates full args * fragments without truncation/duplication, lets `message_complete` authoritatively * replace the partial message AND enrich matching tool rows from complete tool-call * blocks, and emits structured system rows with DETERMINISTIC ids (no `Date.now` / * `Math.random`). * 3. `mergeToolEvents` — the shared tool-row merge (call ↔ result ↔ completion) used by * the live merge, preserving complementary fields without erasure. * 4. Bounded-output helpers so raw tool output / streamed args cannot explode the page. * * Boundary: imports only TYPES from `@/types` / `@/contracts`. Pure, deterministic, no * browser globals, no module-level mutable state (the saga explicitly forbids hidden * connection-only mutable state — usage folding lives in the pure state snapshot, not * here). Fully testable in isolation. */import type { Message } from '@/types/message';import type { ToolResultContent } from '@/types/tool-result';import type { Usage } from '@/types/usage';import type { RenderDescriptor } from '@/contracts/render-descriptor';import { normalizePending, type PendingRequest } from './permission';
// ── bounded inline output ─────────────────────────────────────────────────────────// Raw tool output and streamed args can dominate the page. Bounding is a RENDER concern// (the full data stays on the entry for the dedicated viewer / copy); these helpers// produce a bounded preview + an explicit "more" affordance. Expansion stays client-local.
/** Default inline caps for raw tool output / streamed args (lines then characters). */export const INLINE_LINE_CAP = 24;export const INLINE_CHAR_CAP = 8000;
export interface BoundedPreview { preview: string; truncated: boolean; moreLines: number; moreChars: number;}
/** Bound `text` to `lineCap` lines / `charCap` characters with a deterministic preview. */export function boundPreview(text: string, lineCap = INLINE_LINE_CAP, charCap = INLINE_CHAR_CAP): BoundedPreview { const lines = text.split('\n'); const lineBounded = lines.length > lineCap ? lines.slice(0, lineCap).join('\n') : text; const preview = lineBounded.slice(0, charCap); return { preview, truncated: preview.length < text.length, moreLines: Math.max(0, lines.length - lineCap), moreChars: Math.max(0, text.length - preview.length), };}
/** Render a `ToolResultContent` body as a flat string for inline display. */export function resultBody(content: ToolResultContent | undefined): string { if (content === undefined) return ''; switch (content.kind) { case 'text': return content.text; case 'error': return content.message; case 'json': { try { return JSON.stringify(content.value); } catch { return String(content.value); } } default: return ''; }}
// ── typed entries ─────────────────────────────────────────────────────────────────
/** A user/assistant `Message` row, optionally carrying its durable replay entry id. * `local` marks a browser echo that has been command-accepted but not replay-confirmed. */export interface TranscriptMessage extends Message { sessionEntryId?: string; local?: true; /** Durable message-entry anchor that preceded a local echo, used to reconcile replay * without consuming an older identical prompt. */ localAfterEntryId?: string;}
/** * The flat tool-call/result row the web components render. Carries the FULL typed * metadata: parsed `args` (authoritative once `complete`), streamed `argsText` fragments * while incomplete (untruncated — bounding is a render concern), `content`/`render`/ * `isError` from the result, explicit `exitCode`/`durationMs` (preserved for EVERY tool, * not folded into a descriptor), and the `read_only` class bit. `complete` marks the args * as authoritative (a `tool_call` event or a `message_complete` tool-call block landed). */export interface ToolEvent { kind: 'tool_event'; id: string; role: 'tool'; toolCallId: string; name: string; args?: unknown; argsText?: string; content?: ToolResultContent; render?: RenderDescriptor; isError?: boolean; exitCode?: number; durationMs?: number; read_only?: boolean; complete?: boolean; /** Streaming coordinate used to reconcile a late `tool_call_id` with its earlier * message/block fallback identity. */ messageId?: string; blockIndex?: number;}
export interface TurnStartEntry { kind: 'turn_start'; id: string; role: 'system'; turnId?: string; message: string;}export interface TurnEndEntry { kind: 'turn_end'; id: string; role: 'system'; turnId?: string; message: string; usage?: Usage;}export interface CompactionSystemEntry { kind: 'compaction'; id: string; role: 'system'; message: string; tokensBefore?: number; tokensAfter?: number; range?: readonly [string, string];}export interface ProviderRetryEntry { kind: 'provider_retry'; id: string; role: 'system'; message: string; attempt?: number; maxRetries?: number; status?: number; reason?: string;}export interface ErrorEntry { kind: 'error'; id: string; role: 'system'; message: string; code?: string; fatal?: boolean;}
/** A non-block system row: turn marker / compaction / provider_retry / error. */export type SystemEntry = | TurnStartEntry | TurnEndEntry | CompactionSystemEntry | ProviderRetryEntry | ErrorEntry;
export type TranscriptEntry = TranscriptMessage | ToolEvent | SystemEntry;
/** * A pending permission/UI request surfaced as a live dialog. This is the VALIDATED * discriminated model from `./permission` (saga vr7kgx.4) — the open-ended wire bag was * replaced so malformed structured choices fail closed at the boundary instead of flowing * unvalidated into the UI. `PendingWireRequest` is retained as a transition alias. */export type PendingWireRequest = PendingRequest;export type { PendingRequest } from './permission';
/** A resolution event for a pending request, carrying enough identity to retire the right * entry: `request_id` plus the optional owner (`agent_id`). A resolve retires a pending * request when the request_id matches AND (the resolve's agent_id is absent OR matches the * request's owner) — so a delegated-child resolve can't retire a same-id own prompt. */export interface PendingResolve { request_id: string; agent_id?: string;}
/** The typed delta `liveEventToEntries` produces for one raw event. */export interface LiveEventDelta { entries: TranscriptEntry[]; pendingAdd?: PendingRequest[]; pendingResolve?: PendingResolve[]; appendMessageIds?: string[]; /** Latest valid `usage` event awaiting fold-into the next turn-end (session-first state * stores this on the tab; legacy stores it on `WebConnectionState`). */ pendingUsage?: Usage;}
// ── guards / coercion ─────────────────────────────────────────────────────────────
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
export const validTranscriptMessage = (v: unknown): v is Message => isRecord(v) && typeof v.id === 'string' && (v.role === 'user' || v.role === 'assistant') && Array.isArray(v.content) && v.content.every(validContentBlock) && optionalString(v.provider) && optionalString(v.api) && optionalString(v.model);
function validContentBlock(value: unknown): boolean { if (!isRecord(value) || typeof value.type !== 'string') return false; if (value.type === 'text') return typeof value.text === 'string'; if (value.type === 'thinking') return typeof value.text === 'string' && optionalString(value.thinkingSignature) && (value.redacted === undefined || typeof value.redacted === 'boolean'); if (value.type === 'tool_call') return typeof value.id === 'string' && typeof value.name === 'string' && isRecord(value.args); return value.type === 'tool_result' && typeof value.tool_call_id === 'string' && validToolContent(value.content) && (value.is_error === undefined || typeof value.is_error === 'boolean');}
function validToolContent(value: unknown): value is ToolResultContent { if (!isRecord(value)) return false; return (value.kind === 'text' && typeof value.text === 'string') || (value.kind === 'error' && typeof value.message === 'string') || value.kind === 'json';}
function validRender(value: unknown): value is RenderDescriptor { if (!isRecord(value)) return false; switch (value.kind) { case 'text': return typeof value.text === 'string'; case 'kv': return Array.isArray(value.pairs) && value.pairs.every((pair) => isRecord(pair) && typeof pair.k === 'string' && typeof pair.v === 'string'); case 'diff': return typeof value.path === 'string' && Array.isArray(value.hunks) && value.hunks.every(validHunk); case 'tree': return validTree(value.root); case 'progress': return typeof value.label === 'string' && Number.isInteger(value.current) && (value.total === undefined || Number.isInteger(value.total)); case 'status': return typeof value.label === 'string' && ['info', 'warn', 'error'].includes(String(value.tone)); case 'command': return typeof value.command === 'string' && optionalInteger(value.exitCode) && optionalInteger(value.durationMs); case 'path_list': return Array.isArray(value.paths) && value.paths.every(validPath); case 'code': return typeof value.text === 'string' && optionalString(value.path) && optionalString(value.lang) && optionalString(value.summary); default: return false; }}
const optionalInteger = (value: unknown) => value === undefined || Number.isInteger(value);const optionalString = (value: unknown) => value === undefined || typeof value === 'string';const validHunk = (value: unknown) => isRecord(value) && ['oldStart', 'oldLines', 'newStart', 'newLines'].every((key) => Number.isInteger(value[key])) && Array.isArray(value.lines) && value.lines.every((line) => typeof line === 'string');const validPath = (value: unknown) => isRecord(value) && typeof value.path === 'string' && (value.kind === 'file' || value.kind === 'dir') && optionalString(value.hint);function validTree(value: unknown): boolean { return isRecord(value) && typeof value.name === 'string' && (value.children === undefined || (Array.isArray(value.children) && value.children.every(validTree)));}
const strField = (raw: Record<string, unknown>, key: string): string => typeof raw[key] === 'string' ? (raw[key] as string) : '';
const numField = (raw: Record<string, unknown>, key: string): number | undefined => typeof raw[key] === 'number' && Number.isFinite(raw[key]) ? (raw[key] as number) : undefined;
const intField = (raw: Record<string, unknown>, key: string): number | undefined => { const v = raw[key]; return typeof v === 'number' && Number.isInteger(v) ? v : undefined;};
/** A valid nonnegative-integer `block_index` from the wire. Missing → `0` (lenient: the * common single-block case); present-but-invalid (negative / non-integer) → `undefined`, * which the caller treats as a malformed delta to ignore (saga vr7kgx.3). */function blockIndex(raw: Record<string, unknown>): number | undefined { const v = raw.block_index; if (v === undefined) return 0; if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) return undefined; return v;}
/** Deterministic `tool_call_id` for a streamed tool delta: explicit id, else message:block. */function toolCallIdOf(raw: Record<string, unknown>, idx: number | undefined): string { const explicit = strField(raw, 'tool_call_id'); if (explicit !== '') return explicit; const message = strField(raw, 'message_id'); return idx !== undefined ? `${message}:${idx}` : message;}
/** Build a `PendingResolve` from a `permission_resolved`/`ui_resolved` event: request_id + * optional owner (agent_id), so a delegated-child resolve can't retire a same-id own prompt. */function resolveFrom(raw: Record<string, unknown>): PendingResolve { const resolve: PendingResolve = { request_id: raw.request_id as string }; if (typeof raw.agent_id === 'string' && raw.agent_id.length > 0) resolve.agent_id = raw.agent_id; return resolve;}
// ── deterministic system-row ids ──────────────────────────────────────────────────// No `Date.now` / `Math.random`: ids derive from protocol correlation fields (turn_id,// compaction index) or, for otherwise-unkeyed retries/errors, from a deterministic// combination of their typed fields. Distinct occurrences get distinct keys (different// attempt / code / message / status → different ids); only EXACT duplicate events dedup,// which is the desired overlap behavior. This never collapses ALL occurrences.
const turnStartId = (turnId: string) => (turnId !== '' ? `turn_start:${turnId}` : 'turn_start');const turnEndId = (turnId: string) => (turnId !== '' ? `turn_end:${turnId}` : 'turn_end');const compactionId = (index: number | undefined) => index !== undefined ? `compaction:${index}` : 'compaction';const retryId = (raw: Record<string, unknown>) => `provider_retry:${intField(raw, 'attempt') ?? '?'}:${intField(raw, 'status') ?? 'net'}:${strField(raw, 'reason')}`;const errorId = (raw: Record<string, unknown>) => `error:${strField(raw, 'code') || '?'}:${strField(raw, 'message').slice(0, 80)}`;
// ── message_delta normalization ────────────────────────────────────────────────────
/** Normalize a `message_delta` into message/tool deltas keyed by `block_index`/`tool_call_id`. */function normalizeMessageDelta(raw: Record<string, unknown>): LiveEventDelta { const messageId = strField(raw, 'message_id'); if (messageId === '') return EMPTY_DELTA; const kind = strField(raw, 'kind'); if (kind === 'tool_call') return normalizeToolCallDelta(raw, messageId); if (kind === 'thinking' || kind === 'text') return normalizeTextDelta(raw, messageId, kind); return EMPTY_DELTA; // unknown kind — ignore (malformed)}
/** A streamed text/thinking fragment → a sparse message entry at `block_index`. */function normalizeTextDelta(raw: Record<string, unknown>, messageId: string, kind: string): LiveEventDelta { const idx = blockIndex(raw); if (idx === undefined) return EMPTY_DELTA; // malformed block_index — ignore const text = strField(raw, 'text_delta'); if (text === '') return EMPTY_DELTA; // no fragment — nothing to append const block = kind === 'thinking' ? { type: 'thinking' as const, text, ...(raw.redacted === true ? { redacted: true } : {}) } : { type: 'text' as const, text }; const content: Message['content'] = []; content[idx] = block; const entry: TranscriptMessage = { id: messageId, role: 'assistant', content }; return { entries: [entry], appendMessageIds: [messageId] };}
/** A streamed tool_call open / args_delta → a tool-event row keyed by `tool_call_id`. */function normalizeToolCallDelta(raw: Record<string, unknown>, messageId: string): LiveEventDelta { const idx = blockIndex(raw); if (idx === undefined) return EMPTY_DELTA; const toolCallId = toolCallIdOf(raw, idx); const name = strField(raw, 'name'); const argsDelta = strField(raw, 'args_delta'); const entry: ToolEvent = { kind: 'tool_event', id: `tool-${toolCallId}`, role: 'tool', toolCallId, name: name !== '' ? name : 'tool', messageId, blockIndex: idx, ...(argsDelta !== '' ? { argsText: argsDelta } : {}), }; return { entries: [entry] };}
// ── message_complete normalization ────────────────────────────────────────────────
/** * `message_complete` authoritatively replaces the partial message AND emits a complete * tool-event row for each tool-call block so the shared merge enriches matching streamed * rows (parsed args, name) rather than duplicating them. Pure & defensive. */function normalizeMessageComplete(raw: Record<string, unknown>): LiveEventDelta { const message = raw.message; if (!validTranscriptMessage(message)) return EMPTY_DELTA; const messageId = strField(raw, 'message_id') || (typeof message.id === 'string' ? message.id : ''); const msg: TranscriptMessage = messageId !== '' ? { ...(message as Message), id: messageId } : (message as Message); const entries: TranscriptEntry[] = [msg]; for (let index = 0; index < msg.content.length; index++) { const block = msg.content[index]; if (block !== null && typeof block === 'object' && block.type === 'tool_call') { const id = typeof block.id === 'string' && block.id !== '' ? block.id : `${messageId}:${index}`; const name = typeof block.name === 'string' ? block.name : 'tool'; const args = block.args; const tool: ToolEvent = { kind: 'tool_event', id: `tool-${id}`, role: 'tool', toolCallId: id, name, messageId, blockIndex: index, ...(args !== undefined ? { args } : {}), complete: true, }; entries.push(tool); } } return { entries, appendMessageIds: [] };}
// ── standalone tool_call / tool_result normalization ───────────────────────────────
function normalizeToolCall(raw: Record<string, unknown>): LiveEventDelta { const toolCallId = strField(raw, 'tool_call_id'); const name = strField(raw, 'name'); if (toolCallId === '' || name === '' || !isRecord(raw.args)) return EMPTY_DELTA; const args = raw.args; const render = validRender(raw.render) ? raw.render : undefined; const entry: ToolEvent = { kind: 'tool_event', id: `tool-${toolCallId}`, role: 'tool', toolCallId, name: name !== '' ? name : 'tool', ...(args !== undefined ? { args } : {}), ...(render !== undefined ? { render } : {}), ...(typeof raw.read_only === 'boolean' ? { read_only: raw.read_only } : {}), complete: true, }; return { entries: [entry] };}
function normalizeToolResult(raw: Record<string, unknown>): LiveEventDelta { const toolCallId = strField(raw, 'tool_call_id'); if (toolCallId === '' || !validToolContent(raw.content) || typeof raw.is_error !== 'boolean') return EMPTY_DELTA; const content = raw.content; const render = validRender(raw.render) ? raw.render : undefined; const exitCode = intField(raw, 'exit_code'); const durationMs = intField(raw, 'duration_ms'); const entry: ToolEvent = { kind: 'tool_event', id: `tool-${toolCallId}`, role: 'tool', toolCallId, name: strField(raw, 'name') || 'tool', ...(content !== undefined ? { content } : {}), ...(render !== undefined ? { render } : {}), ...(typeof raw.is_error === 'boolean' ? { isError: raw.is_error } : {}), ...(exitCode !== undefined ? { exitCode } : {}), ...(durationMs !== undefined ? { durationMs } : {}), }; return { entries: [entry] };}
// ── system-row normalization ───────────────────────────────────────────────────────
function normalizeTurnStart(raw: Record<string, unknown>): LiveEventDelta { const turnId = strField(raw, 'turn_id'); if (turnId === '') return EMPTY_DELTA; return { entries: [{ kind: 'turn_start', id: turnStartId(turnId), role: 'system', turnId, message: 'Turn started' }] };}
function normalizeTurnEnd(raw: Record<string, unknown>): LiveEventDelta { const turnId = strField(raw, 'turn_id'); if (turnId === '') return EMPTY_DELTA; const usage = usageFrom(raw.usage); const entry: TurnEndEntry = { kind: 'turn_end', id: turnEndId(turnId), role: 'system', ...(turnId !== '' ? { turnId } : {}), message: 'Turn ended', ...(usage !== undefined ? { usage } : {}), }; return { entries: [entry] };}
function normalizeCompaction(raw: Record<string, unknown>): LiveEventDelta { const index = intField(raw, 'index'); const before = intField(raw, 'tokens_before'); const after = intField(raw, 'tokens_after'); const range = Array.isArray(raw.message_range) && raw.message_range.length === 2 && typeof (raw.message_range as unknown[])[0] === 'string' && typeof (raw.message_range as unknown[])[1] === 'string' ? (raw.message_range as [string, string]) : undefined; const kept = strField(raw, 'first_kept_entry_id'); if (index === undefined || before === undefined || after === undefined || range === undefined || kept === '') return EMPTY_DELTA; const entry: CompactionSystemEntry = { kind: 'compaction', id: compactionId(index), role: 'system', message: `Context compacted (${before}→${after} tokens${kept !== '' ? `, kept from ${kept}` : ''})`, tokensBefore: before, tokensAfter: after, range, }; return { entries: [entry] };}
function normalizeProviderRetry(raw: Record<string, unknown>): LiveEventDelta { const attempt = intField(raw, 'attempt'); const max = intField(raw, 'max_retries'); const status = intField(raw, 'status'); const reason = strField(raw, 'reason'); if (attempt === undefined || max === undefined || reason === '') return EMPTY_DELTA; const tail = ` — ${reason}`; const label = `Provider retry${attempt !== undefined ? ` ${attempt}${max !== undefined ? `/${max}` : ''}` : ''}${status !== undefined ? ` (HTTP ${status})` : ''}${tail}`; const entry: ProviderRetryEntry = { kind: 'provider_retry', id: retryId(raw), role: 'system', message: label, ...(attempt !== undefined ? { attempt } : {}), ...(max !== undefined ? { maxRetries: max } : {}), ...(status !== undefined ? { status } : {}), ...(reason !== '' ? { reason } : {}), }; return { entries: [entry] };}
function normalizeError(raw: Record<string, unknown>): LiveEventDelta { const code = strField(raw, 'code'); const message = strField(raw, 'message'); if (code === '' || message === '') return EMPTY_DELTA; const entry: ErrorEntry = { kind: 'error', id: errorId(raw), role: 'system', message: `${code}: ${message}`, code, ...(typeof raw.fatal === 'boolean' ? { fatal: raw.fatal } : {}), }; return { entries: [entry] };}
function normalizeUsage(raw: Record<string, unknown>): LiveEventDelta { const usage = usageFrom(raw); return usage === undefined ? EMPTY_DELTA : { entries: [], pendingUsage: usage };}
function usageFrom(value: unknown): Usage | undefined { if (!isRecord(value)) return undefined; const input = intField(value, 'input'); const output = intField(value, 'output'); if (input === undefined || output === undefined) return undefined; const cacheRead = intField(value, 'cache_read'); const cacheWrite = intField(value, 'cache_write'); const contextTokens = intField(value, 'context_tokens'); const cost = numField(value, 'cost'); const model = strField(value, 'model'); const provider = strField(value, 'provider'); return { input, output, ...(cacheRead !== undefined ? { cache_read: cacheRead } : {}), ...(cacheWrite !== undefined ? { cache_write: cacheWrite } : {}), ...(cost !== undefined ? { cost } : {}), ...(contextTokens !== undefined ? { context_tokens: contextTokens } : {}), ...(model !== '' ? { model } : {}), ...(provider !== '' ? { provider } : {}), };}
const EMPTY_DELTA: LiveEventDelta = { entries: [] };
/** * Convert one raw wire event into the typed transcript/pending/usage deltas. The single * shared normalizer for both `reduceConnection` (legacy agent-first) and `sessionLiveDelta` * (session-first) so they cannot drift. Pure & defensive — malformed events yield an empty * delta (never throw, never corrupt state). */export function liveEventToEntries(raw: unknown): LiveEventDelta { if (!isRecord(raw) || typeof raw.type !== 'string') return EMPTY_DELTA; const type = raw.type; switch (type) { case 'permission_request': case 'ui_request': { // Validate at the boundary (saga vr7kgx.4): malformed requests fail closed (empty // delta) rather than flowing unvalidated into the UI. The discriminated model from // `./permission` carries only server-authoritative, validated fields. const req = normalizePending(raw); return req === null ? EMPTY_DELTA : { entries: [], pendingAdd: [req] }; } case 'permission_resolved': case 'ui_resolved': if (typeof raw.request_id !== 'string') return EMPTY_DELTA; return { entries: [], pendingResolve: [resolveFrom(raw)] }; case 'message_delta': return normalizeMessageDelta(raw); case 'message_complete': return normalizeMessageComplete(raw); case 'tool_call': return normalizeToolCall(raw); case 'tool_result': return normalizeToolResult(raw); case 'turn_start': return normalizeTurnStart(raw); case 'turn_end': return normalizeTurnEnd(raw); case 'compaction': return normalizeCompaction(raw); case 'provider_retry': return normalizeProviderRetry(raw); case 'error': return normalizeError(raw); case 'usage': return normalizeUsage(raw); default: return EMPTY_DELTA; }}
// ── shared tool-row merge ─────────────────────────────────────────────────────────
/** `true` for a flat tool-call/result row (`kind: 'tool_event'`). */export function isToolEvent(e: TranscriptEntry): e is ToolEvent { return 'kind' in e && (e as { kind?: string }).kind === 'tool_event';}/** `true` for a user/assistant `Message` (has `role` user|assistant, no `kind`). */export function isMessageEntry(e: TranscriptEntry): e is TranscriptMessage { return 'role' in e && (e.role === 'user' || e.role === 'assistant');}/** `true` for a system row (turn marker / compaction / provider_retry / error). */export function isSystemEntry(e: TranscriptEntry): e is SystemEntry { return 'kind' in e && (e as { kind?: string }).kind !== 'tool_event';}
/** * Whether the transcript's tail is a tool call still awaiting its result — the agent is * mid-turn, blocked on a tool (saga vr7kgx.17). A `ToolEvent` with `content === undefined` * and no `isError` has received its call/args but no result yet. This is the "waiting on a * tool call" sub-state of `streaming` — distinct from generating text output. */export function hasInflightTool(entries: readonly TranscriptEntry[]): boolean { if (entries.length === 0) return false; const last = entries[entries.length - 1]; return last !== undefined && isToolEvent(last) && last.content === undefined && !last.isError;}
/** * Combine duplicate tool rows without losing call/result/completion data. Result fields * (content/render/isError/exit/duration) are authoritative from the incoming row * (fill-when-absent on the current); call fields (name/args) are NOT erased — an * authoritative completion (`complete: true`) upgrades the streamed args (parsed args * replace the accumulated `argsText`, which is cleared); a streamed fragment appends to * `argsText`. `read_only` fills from either side. Pure. */export function mergeToolEvents(current: ToolEvent, incoming: ToolEvent): ToolEvent { const authoritative = incoming.complete === true; const complete = authoritative || current.complete === true; const argsText = authoritative ? undefined : (current.argsText ?? '') + (incoming.argsText ?? ''); const name = authoritative && incoming.name !== 'tool' ? incoming.name : current.name !== 'tool' ? current.name : incoming.name; const args = authoritative ? incoming.args : current.args ?? incoming.args; const incomingHasStableId = incoming.toolCallId !== fallbackToolId(incoming); const toolCallId = incomingHasStableId ? incoming.toolCallId : current.toolCallId; const content = incoming.content ?? current.content; const render = incoming.render ?? current.render; const isError = incoming.isError ?? current.isError; const exitCode = incoming.exitCode ?? current.exitCode; const durationMs = incoming.durationMs ?? current.durationMs; const readOnly = incoming.read_only ?? current.read_only; const messageId = incoming.messageId ?? current.messageId; const index = incoming.blockIndex ?? current.blockIndex; return { kind: 'tool_event', id: `tool-${toolCallId}`, role: 'tool', toolCallId, name, ...(args !== undefined ? { args } : {}), ...(argsText !== undefined && argsText !== '' ? { argsText } : {}), ...(content !== undefined ? { content } : {}), ...(render !== undefined ? { render } : {}), ...(isError !== undefined ? { isError } : {}), ...(exitCode !== undefined ? { exitCode } : {}), ...(durationMs !== undefined ? { durationMs } : {}), ...(readOnly !== undefined ? { read_only: readOnly } : {}), ...(complete ? { complete: true } : {}), ...(messageId !== undefined ? { messageId } : {}), ...(index !== undefined ? { blockIndex: index } : {}), };}
function fallbackToolId(entry: ToolEvent): string | undefined { return entry.messageId !== undefined && entry.blockIndex !== undefined ? `${entry.messageId}:${entry.blockIndex}` : undefined;}
/** Streaming coordinate equality used when a later delta first supplies `tool_call_id`. */export function sameToolCoordinate(a: ToolEvent, b: ToolEvent): boolean { return a.messageId !== undefined && b.messageId !== undefined && a.blockIndex !== undefined && b.blockIndex !== undefined && a.messageId === b.messageId && a.blockIndex === b.blockIndex;}
/** Close any call that survived to `turn_end` without a result. This mirrors the TUI's * defensive reconciliation and prevents a permanently "running" row after a protocol gap. */export function finishUnresolvedTools(entries: readonly TranscriptEntry[]): TranscriptEntry[] { return entries.map((entry) => isToolEvent(entry) && entry.content === undefined ? { ...entry, content: { kind: 'error', message: 'No result received before turn ended' }, isError: true } : entry);}