Files
stack/packages/conversation/src/events.mjs
T
jason.woltjeandClaude Opus 5.5 243e153c8b feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)
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]>
2026-10-04 15:47:53 -05:00

122 lines
5.8 KiB
JavaScript

// Pi RPC events to CHAT-01 events (#1507, CHAT-03 §3 "Events").
//
// Pinned Pi 0.85.1 (docs/rpc.md, rpc-types.d.ts) emits JSON lines. Mapped:
//
// message_start -> message-start
// message_update text_delta -> text-delta (append)
// message_update thinking_delta -> thinking-delta (append)
// tool_execution_start -> tool-start
// tool_execution_update -> tool-update (replace)
// tool_execution_end -> tool-end (replace)
// message_end -> message-end (replace, one or more parts)
// agent_settled -> run-settled (never cohort termination)
//
// Known and deliberately not shown: turn_start, turn_end, agent_start,
// agent_end, queue_update, compaction_*, auto_retry_*, summarization_retry_*,
// bash_execution_update, extension_error, extension_ui_request (P3 has its own notice), and the
// message_update subtypes that message-end supersedes (text_start, text_end,
// thinking_start, thinking_end, toolcall_*, start, done, error). The
// controller counts them in its evidence. Anything else is an unknown event:
// no client event, counted with its type and byte size, and never passed
// through raw.
//
// Pi's stream carries no entry ID. `entry` on message-end is a stream-local
// ID, not a session entry ID, so the seam between a history page and the
// stream can't be deduplicated by ID (E4).
import { fragments, safeId, LIMITS } from "./parts.mjs";
export const KNOWN_UNSHOWN = Object.freeze(new Set([
"turn_start", "turn_end", "agent_start", "agent_end", "queue_update", "compaction_start", "compaction_end",
"auto_retry_start", "auto_retry_end", "summarization_retry_scheduled", "summarization_retry_attempt_start",
"summarization_retry_finished", "bash_execution_update", "extension_error", "extension_ui_request", "response",
]));
export const MAPPED = Object.freeze(new Set(["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "agent_settled"]));
const FOLDED_UPDATES = new Set(["start", "text_start", "text_end", "thinking_start", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end", "done", "error"]);
export const DIALOG_METHODS = Object.freeze(new Set(["select", "confirm", "input", "editor"]));
export const NOTIFY_METHODS = Object.freeze(new Set(["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"]));
export function roleOf(message) {
switch (message?.role) {
case "user":
return "user";
case "assistant":
return "assistant";
case "toolResult":
return "tool";
case "compactionSummary":
return "compaction";
default:
return "notice";
}
}
const textOf = (content) => {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("");
};
function pushText(out, type, text, block, extra = {}) {
const parts = fragments(text);
parts.forEach((t, i) => out.push({ type, ...extra, text: t, block, fragment: i, lastFragment: i === parts.length - 1 }));
}
// Every content block of a finished native message, in CHAT-01 form.
export function messageBlocks(message) {
const out = [];
const role = roleOf(message);
if (role === "tool") {
pushText(out, "tool-result", textOf(message.content), 0, { call: safeId(message.toolCallId), isError: message.isError === true });
return out;
}
if (role === "compaction") {
const parts = fragments(typeof message.summary === "string" ? message.summary : "");
parts.forEach((t, i) => out.push({ type: "compaction", summary: t, nativeEntry: safeId(message.firstKeptEntryId ?? "compaction"), block: 0, fragment: i, lastFragment: i === parts.length - 1 }));
return out;
}
const content = typeof message?.content === "string" ? [{ type: "text", text: message.content }] : Array.isArray(message?.content) ? message.content : [];
content.forEach((c, block) => {
if (!c || typeof c !== "object") return;
if (c.type === "text") pushText(out, "text", String(c.text ?? ""), block);
else if (c.type === "thinking") {
if (c.redacted) out.push({ type: "thinking", text: "", visibility: "unavailable", block, fragment: 0, lastFragment: true });
else pushText(out, "thinking", String(c.thinking ?? ""), block, { visibility: "permitted-visible" });
} else if (c.type === "toolCall") {
const parts = fragments(JSON.stringify(c.arguments ?? {}));
parts.forEach((t, i) => out.push({ type: "tool-call", call: safeId(c.id), name: safeId(c.name), argumentsText: t, block, fragment: i, lastFragment: i === parts.length - 1 }));
} else if (c.type === "image") {
out.push({ type: "attachment", attachment: safeId(`image-${block}`), block, fragment: 0, lastFragment: true });
} else {
pushText(out, "text", `[${String(c.type).slice(0, 40)} block not shown]`, block);
}
});
return out;
}
// Splits blocks into message-end parts of at most LIMITS.blocks blocks.
export function partsOf(blocks) {
if (blocks.length === 0) return [[]];
const out = [];
for (let i = 0; i < blocks.length; i += LIMITS.blocks) out.push(blocks.slice(i, i + LIMITS.blocks));
return out;
}
export function deltaBlocks(kind, delta, contentIndex) {
const out = [];
const block = Number.isInteger(contentIndex) && contentIndex >= 0 ? contentIndex : 0;
if (kind === "thinking") pushText(out, "thinking", String(delta ?? ""), block, { visibility: "permitted-visible" });
else pushText(out, "text", String(delta ?? ""), block);
return out;
}
export function isFoldedUpdate(type) {
return FOLDED_UPDATES.has(type);
}
export function toolResultText(result) {
if (result && typeof result === "object") return textOf(result.content);
return typeof result === "string" ? result : "";
}