Controller, claim store, live-session guard, engine link and seal, turn tracker, cohort force stop and recovery, client library, transcript and mediated terminal, with the fake engine and tests. Fixtures only; no live cutover. Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694) approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files). Suites on an export: conversation 152/152, control-board 124, webui 14, seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green. Follow-ups for I3 are in DEFERRED. Gate E stays with Jason. Co-Authored-By: Claude Opus 5.5 <[email protected]>
305 lines
11 KiB
JavaScript
305 lines
11 KiB
JavaScript
// 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") : "…";
|
||
}
|