// The transcript view shared by the library and the terminal (#1507, CHAT-03 // §9, E1–E7). // // It joins a history page with the live event stream. Replay is unavailable // in CHAT-03, and page entries and events carry different IDs, so overlap at // the seam can't be deduplicated (CHAT-01 lines 104–110). The rule is // reconciliation instead: // // - The controller's seam says where live events start (`fromSequence`) and // whether the cut was quiet: no run visible and no prompt in the slot, so // every earlier message had settled and was persisted. // - A cut that wasn't quiet puts a reconcile marker at the seam. Near it a // message may show twice or be missing. The view re-reads the page after // the next `run-settled` and clears the marker once a read is quiet. // - A sequence gap, a new stream epoch or a repeated event ID with different // bytes also marks the seam and re-reads at once. A gap is never // concatenated across. // - A repeated event ID with identical bytes is dropped (E3). // // Tool progress (`tool-start`, `tool-update`, `tool-end`) shows a call while // it runs. Once a finished tool message carries that call's result, from the // stream or the page, the progress item is hidden, so each message appears // once. export const RECONCILE_MARKER = "-- reconciling: messages near here may repeat or be missing until the run settles --"; const MESSAGE_EVENTS = new Set(["message-start", "text-delta", "thinking-delta", "message-end"]); const TOOL_EVENTS = new Set(["tool-start", "tool-update", "tool-end"]); export class Transcript { // With a client, the view follows it: it reads the page on every welcome // and re-reads when the stream asks for it. constructor({ client = null } = {}) { this.client = client; this.entries = []; this.events = []; this.held = []; this.seen = new Map(); this.seam = null; this.expected = null; this.reconcile = false; this.reason = null; this.refusal = null; this.loads = 0; this.loading = null; this.again = false; this.listeners = new Set(); if (client) client.on((m) => this.#onClient(m)); // Attached to a client that is already connected: no welcome will come, // so read the page now. if (client && !client.closed && client.connection) void this.reload("attach"); } on(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); } #changed() { for (const fn of this.listeners) fn(); } #onClient(m) { if (m.type === "welcome") { void this.reload("connect"); return; } if (m.type !== "push" || m.kind !== "event") return; const why = this.event(m.event); if (why) void this.reload(why); this.#changed(); } // Installs a full read of the page (every entry, in order) and its seam. load(entries, seam) { this.entries = entries.slice(); this.seam = { ...seam }; this.reconcile = seam.quiet !== true; this.reason = this.reconcile ? "cut" : null; this.loads += 1; const kept = [...this.events, ...this.held].filter((e) => e.streamEpoch === seam.streamEpoch && e.sequence >= seam.fromSequence).sort((a, b) => a.sequence - b.sequence); this.events = []; this.held = []; this.expected = seam.fromSequence; for (const e of kept) { if (e.sequence < this.expected) continue; if (e.sequence > this.expected) { this.#mark("gap"); this.held = kept.filter((x) => x.sequence > this.expected); // A run settled past the gap: the next read starts after it. if (this.held.some((x) => x.type === "run-settled")) this.again = true; break; } this.events.push(e); this.expected = e.sequence + 1; } } // Takes one live event. Returns why the page must be re-read, or null. event(e) { const bytes = JSON.stringify(e); const prior = this.seen.get(e.id); if (prior !== undefined) return prior === bytes ? null : this.#mark("conflict"); this.seen.set(e.id, bytes); // Events past a gap or in another epoch are held, not shown, until the // next read; the read keeps those after its seam. if (!this.seam) { this.held.push(e); return null; } // Once something is held a read is already due; a later held event asks // again only when its run settles, when the page has caught up. if (this.held.length) { this.held.push(e); return e.type === "run-settled" ? "settled" : null; } if (e.streamEpoch !== this.seam.streamEpoch) { this.held.push(e); return this.#mark("epoch"); } if (e.sequence < this.expected) return null; if (e.sequence > this.expected) { this.held.push(e); return this.#mark("gap"); } this.events.push(e); this.expected = e.sequence + 1; return e.type === "run-settled" && this.reconcile ? "settled" : null; } #mark(why) { this.reconcile = true; this.reason = why; return why; } // Reads every page and installs it. Overlapping calls coalesce; a call made // while a read is in flight runs one more read after it. reload(why = "manual") { if (!this.client) return Promise.resolve(); if (this.loading) { this.again = true; return this.loading; } this.loading = (async () => { do { this.again = false; const r = await this.#readAll(); if (!r.ok) { this.refusal = r.refusal; break; } this.refusal = null; this.load(r.entries, r.seam); } while (this.again); })().finally(() => { this.loading = null; this.#changed(); }); this.lastReload = why; return this.loading; } async #readAll() { let r = await this.client.observe(); if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome }; const seam = r.data.seam; const entries = [...r.data.page.entries]; while (r.data.page.hasMore) { r = await this.client.observe({ cursor: r.data.page.nextCursor }); if (r.outcome !== "observing") return { ok: false, refusal: r.refusal ?? r.outcome }; entries.push(...r.data.page.entries); } return { ok: true, entries, seam }; } // The view: page messages, the marker when the seam is unreconciled, then // live messages. Each item is {key, role, source, final, text} or // {marker: true, text, reason}. messages() { const items = []; for (const en of this.entries) { const last = items.at(-1); const it = last && last.key === en.message ? last : null; if (it) it.parts.set(en.part, en.content); else items.push({ key: en.message, role: en.role, source: "page", parts: new Map([[en.part, en.content]]), lastPart: null, exec: false }); if (en.lastPart) (it ?? items.at(-1)).lastPart = en.part; } const pageCount = items.length; const live = new Map(); const epoch = this.seam?.streamEpoch; for (const e of this.events) { if (!this.seam || e.streamEpoch !== epoch) continue; if (!MESSAGE_EVENTS.has(e.type) && !TOOL_EVENTS.has(e.type)) continue; let it = live.get(e.message); if (!it) { it = { key: e.message, role: e.role, source: "live", parts: new Map(), lastPart: null, stream: new Map(), exec: TOOL_EVENTS.has(e.type), status: null, call: null }; live.set(e.message, it); items.push(it); } if (e.type === "text-delta" || e.type === "thinking-delta") { for (const b of e.content) { const s = it.stream.get(b.block) ?? { type: b.type, text: "", visibility: b.visibility }; s.text += b.text ?? ""; it.stream.set(b.block, s); } } else if (e.type === "message-end") { it.role = e.role; it.parts.set(e.part, e.content); if (e.lastPart) it.lastPart = e.part; } else if (TOOL_EVENTS.has(e.type)) { it.call = e.content[0]?.call ?? it.call; if (e.type === "tool-start") { it.status = "running"; it.name = e.content[0]?.name ?? null; } else { it.status = e.type === "tool-end" ? "done" : "running"; it.result = e.content; } } } const finals = new Set(); for (const it of items) { if (!isFinal(it) || it.exec) continue; for (const b of blocksOf(it)) if (b.type === "tool-result") finals.add(b.call); } const out = []; items.forEach((it, i) => { if (i === pageCount && this.reconcile) out.push(this.#marker()); if (it.exec && finals.has(it.call)) return; out.push({ key: it.key, role: it.role, source: it.source, final: isFinal(it), text: textOf(it) }); }); if (items.length === pageCount && this.reconcile) out.push(this.#marker()); return out; } #marker() { return { marker: true, text: RECONCILE_MARKER, reason: this.reason }; } // One line per message (a message may span several lines of text). lines() { return this.messages().map((m) => (m.marker ? m.text : `${m.role}: ${m.text}`)); } } function isFinal(it) { if (it.lastPart === null) return false; for (let p = 0; p <= it.lastPart; p++) if (!it.parts.has(p)) return false; return true; } function blocksOf(it) { const out = []; const n = it.lastPart ?? Math.max(-1, ...it.parts.keys()); for (let p = 0; p <= n; p++) out.push(...(it.parts.get(p) ?? [])); return out; } // Joins fragments by block and renders each block. export function renderBlocks(blocks) { const joined = []; for (const b of blocks) { const last = joined.at(-1); if (last && last.block === b.block && last.type === b.type && (b.fragment ?? 0) > 0 && (last.call ?? null) === (b.call ?? null)) { last.text = (last.text ?? "") + (b.text ?? ""); last.argumentsText = (last.argumentsText ?? "") + (b.argumentsText ?? ""); last.summary = (last.summary ?? "") + (b.summary ?? ""); } else joined.push({ ...b }); } return joined.map(renderBlock).join("\n"); } function renderBlock(b) { switch (b.type) { case "text": return b.text ?? ""; case "thinking": return b.visibility === "unavailable" ? "(thinking not shown)" : `(thinking) ${b.text ?? ""}`; case "tool-call": return `[tool-call ${b.name} ${b.argumentsText ?? ""}]`; case "tool-result": return `[tool-result${b.isError ? " error" : ""}] ${b.text ?? ""}`; case "compaction": return `[compaction] ${b.summary ?? ""}`; case "attachment": return `[attachment ${b.attachment}]`; default: return `[${b.type}]`; } } function textOf(it) { if (isFinal(it)) { const blocks = blocksOf(it); return blocks.length ? renderBlocks(blocks) : "(empty)"; } if (it.exec) { const head = `[tool ${it.name ?? it.call} ${it.status}]`; return it.result?.length ? `${head} ${renderBlocks(it.result)}` : head; } const parts = [...it.stream.entries()].sort(([a], [b]) => a - b).map(([, s]) => renderBlock(s)); return parts.length ? parts.join("\n") : "…"; }