/** * 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 => 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, key: string): string => typeof raw[key] === 'string' ? (raw[key] as string) : ''; const numField = (raw: Record, key: string): number | undefined => typeof raw[key] === 'number' && Number.isFinite(raw[key]) ? (raw[key] as number) : undefined; const intField = (raw: Record, 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): 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, 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): 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) => `provider_retry:${intField(raw, 'attempt') ?? '?'}:${intField(raw, 'status') ?? 'net'}:${strField(raw, 'reason')}`; const errorId = (raw: Record) => `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): 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, 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, 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): 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): 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): 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): 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): 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): 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): 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): 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): 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); }