/** * Shared pure OpenCode /event frame interpreters (design §1.1, §4.2). * * Channel renderers (Discord §4.1, Slack §4.3, …) all consume the SAME native * OpenCode `Event` union from the guardian's filtered /event stream and must * correlate frames identically: by `sessionID` AND the channel-generated * `messageID` (§4.2), with the same turn-end / tool-update / permission-ask * interpretation (§1.1). That logic is platform-agnostic and pure, so it lives * here (channels-sdk) and is reused by every renderer rather than duplicated per * channel — mirroring the "shared pure logic in channels-sdk" placement (§2.2). * * These functions are PURE: no I/O, deterministic, fully unit-testable. They * narrow the OpenCode wire shape defensively (`event.type` + property probing) * and tolerate unknown event shapes for graceful OpenCode-version degrade (§5). * Platform rendering (Discord embeds, Slack Block Kit) stays in each adapter. */ /** A minimally-narrowed OpenCode event frame: `{ type, properties }` (§1.1). */ export interface RawEvent { type: string; properties?: Record; } /** Coerce any value into a RawEvent shape (defensive — runtime surface is the contract). */ export function asRaw(ev: unknown): RawEvent { const e = ev as RawEvent; return { type: typeof e?.type === "string" ? e.type : "", properties: (e?.properties ?? {}) as Record, }; } function propStr(props: Record | undefined, key: string): string | undefined { const v = props?.[key]; return typeof v === "string" ? v : undefined; } /** * If this frame is a part SNAPSHOT (`message.part.updated`), return its * `{ partID, type }` so the renderer can learn a part's type. Used to identify * REASONING parts (whose token deltas must NOT be shown to the user). A * `message.part.delta` only carries `{partID, field:"text", delta}` with NO type * — and reasoning AND answer parts both stream `field:"text"` — so the type can * only come from the snapshot. Track reasoning partIDs and pass them to * extractTextDelta to filter the chain-of-thought out of the channel output. */ export function partSnapshotType(e: RawEvent): { partID: string; type: string } | null { if (e.type !== "message.part.updated") return null; const part = e.properties?.part as { id?: unknown; type?: unknown } | undefined; if (part && typeof part.id === "string" && typeof part.type === "string") { return { partID: part.id, type: part.type }; } return null; } /** * Extract a text delta from any supported delta event family, correlated to our * session — EXCLUDING reasoning (chain-of-thought). Returns null if the frame is * for a different session, is not a text delta, carries no delta, or belongs to a * known reasoning part. Prefers the fine-grained 1.15.13 `session.next.text.delta` * (reasoning uses the distinct `session.next.reasoning.delta`, never matched * here), falling back to `message.part.delta` on a text field (§1.1). * * `reasoningPartIds` is the set of partIDs the renderer has seen typed as * "reasoning" (via partSnapshotType). A `message.part.delta` for one of those is * dropped — without this, the model's reasoning leaks into the channel output as * if it were the answer (both stream `field:"text"`). * * Correlation is **by sessionID only — NOT by the client-supplied messageID.** * Live capture (2026-06-04) proved the assistant's reply deltas carry a * SERVER-generated messageID, not the id the client passed to `prompt_async`, so * messageID filtering would drop the whole stream; turns are serialized per * session and the guardian ownership-filters `/event` by session (§4.2). */ export function extractTextDelta(e: RawEvent, sessionId: string, reasoningPartIds?: ReadonlySet): string | null { const props = e.properties ?? {}; if (propStr(props, "sessionID") !== sessionId) return null; // Preferred: fine-grained 1.15.13 stream (reasoning uses session.next.reasoning.delta — excluded). if (e.type === "session.next.text.delta") { return propStr(props, "delta") ?? propStr(props, "text") ?? null; } // Fallback: message.part.delta on a text field — but NOT for a reasoning part. if (e.type === "message.part.delta") { if (propStr(props, "field") && propStr(props, "field") !== "text") return null; const partID = propStr(props, "partID"); if (partID && reasoningPartIds?.has(partID)) return null; // drop chain-of-thought return propStr(props, "delta") ?? null; } return null; } /** * The explicit `session.status` values that mean "this turn is over" (§1.1). * * IMPORTANT: only an EXPLICIT idle status ends a turn. A `session.status` frame * whose `status` is missing/empty/unknown is an intermediate or partial frame * and must NOT be treated as turn-end — doing so cuts the render off mid-stream * (the §4.2 "pin the exact end-of-turn condition empirically" open question). * `session.idle` remains the unambiguous fallback. The exact 1.15.13 idle marker * is being pinned against a live stream; add any observed terminal value here. */ export const TURN_IDLE_STATUSES: ReadonlySet = new Set(["idle", "completed", "done"]); /** * The `session.status` `status` field on a live OpenCode 1.15.13 server is an * OBJECT `{ type: "busy" | "idle" }` — NOT a bare string. (Verified against a * live 1.15.13 /event stream, 2026-06-04.) Older/other shapes may carry a bare * string, so read either: an object's `.type`, or the string itself. Returns the * status name, or undefined if absent/unreadable (→ NOT turn-end). */ export function statusName(status: unknown): string | undefined { if (typeof status === "string") return status; if (status && typeof status === "object" && typeof (status as { type?: unknown }).type === "string") { return (status as { type: string }).type; } return undefined; } /** Is this the turn-end signal for our session? (§1.1 — session.status idle, fallback session.idle.) */ export function isTurnEnd(e: RawEvent, sessionId: string): boolean { if (propStr(e.properties, "sessionID") !== sessionId) return false; if (e.type === "session.idle") return true; if (e.type === "session.status") { // status is `{type:"idle"}` on live 1.15.13 (or a bare string elsewhere). // Only an explicit idle status ends the turn (see TURN_IDLE_STATUSES note). const name = statusName(e.properties?.status); return name !== undefined && TURN_IDLE_STATUSES.has(name); } return false; } /** A tool-part update for our session: `{ callID, tool, state:{status,…} }` (§1.1). */ export interface ToolUpdate { callID: string; tool: string; status: string; title?: string; error?: string; } export function extractToolUpdate(e: RawEvent, sessionId: string): ToolUpdate | null { if (propStr(e.properties, "sessionID") !== sessionId) return null; const part = (e.properties?.part ?? e.properties?.tool) as Record | undefined; if (e.type === "message.part.updated" && part && (part.type === "tool" || part.state)) { const state = (part.state ?? {}) as Record; return { callID: String(part.callID ?? part.id ?? ""), tool: String(part.tool ?? "tool"), status: String(state.status ?? "running"), title: typeof state.title === "string" ? state.title : undefined, error: typeof state.error === "string" ? state.error : undefined, }; } // session.next.tool.* family. if (e.type.startsWith("session.next.tool.")) { return { callID: propStr(e.properties, "callID") ?? "", tool: propStr(e.properties, "tool") ?? "tool", status: e.type === "session.next.tool.called" ? "running" : (propStr(e.properties, "status") ?? "running"), title: propStr(e.properties, "title"), }; } return null; } /** A `permission.asked` request for our session (§1.1 — properties IS the PermissionRequest). */ export interface PermissionAsk { requestID: string; permission: string; patterns: string[]; } export function extractPermissionAsk(e: RawEvent, sessionId: string): PermissionAsk | null { if (e.type !== "permission.asked") return null; if (propStr(e.properties, "sessionID") !== sessionId) return null; const id = propStr(e.properties, "id"); if (!id) return null; const patterns = Array.isArray(e.properties?.patterns) ? (e.properties!.patterns as unknown[]).filter((p): p is string => typeof p === "string") : []; return { requestID: id, permission: propStr(e.properties, "permission") ?? "tool", patterns }; } /** Is this the guardian's synthetic upstream-reset frame for our session? (§3.2 restart handling.) */ export function isSessionError(e: RawEvent, sessionId: string): boolean { return e.type === "session.error" && propStr(e.properties, "sessionID") === sessionId; } /** One option offered for a question (OpenCode QuestionOption). */ export interface QuestionOption { label: string; description: string; } /** One question in a `question.asked` request (OpenCode QuestionInfo). */ export interface QuestionInfo { question: string; header: string; options: QuestionOption[]; } /** * A `question.asked` request for our session. The OpenCode `question` tool is the * interactive-question parallel of permissions: it pauses the turn (tool stuck * `running`) until answered via `POST /question/{requestID}/reply` (§ question * tool). `properties` IS the QuestionRequest, whose `id` (`que_…`) is the * requestID and `questions[]` carries each prompt + its options. */ export interface QuestionAsk { requestID: string; questions: QuestionInfo[]; } export function extractQuestionAsk(e: RawEvent, sessionId: string): QuestionAsk | null { if (e.type !== "question.asked") return null; if (propStr(e.properties, "sessionID") !== sessionId) return null; const id = propStr(e.properties, "id"); if (!id) return null; const rawQuestions = Array.isArray(e.properties?.questions) ? (e.properties!.questions as unknown[]) : []; const questions: QuestionInfo[] = []; for (const q of rawQuestions) { const qo = q as { question?: unknown; header?: unknown; options?: unknown }; const question = typeof qo.question === "string" ? qo.question : ""; const header = typeof qo.header === "string" ? qo.header : ""; const options: QuestionOption[] = Array.isArray(qo.options) ? (qo.options as unknown[]) .map((o) => o as { label?: unknown; description?: unknown }) .filter((o) => typeof o.label === "string") .map((o) => ({ label: o.label as string, description: typeof o.description === "string" ? o.description : "" })) : []; questions.push({ question, header, options }); } if (questions.length === 0) return null; return { requestID: id, questions }; }