| 1 | import type { PersistentMessage } from "../generated/desktopContract.generated"; |
| 2 | import { HistoryPreparingError } from "./historyPreparation"; |
| 3 | import { canonicalUserDisplay } from "./canonicalUserDisplay"; |
| 4 | import type { HistoryContentChunk, HistoryContentRef, HistoryEntry, HistoryMessage, HistorySlice, HistorySliceRequest, HistoryWindowPageView, HistoryWindowRequestView, MemoryCitation } from "./types"; |
| 5 | |
| 6 | const contentRecovery = new Map<string, () => void>(); |
| 7 | export function registerTranscriptContentRecovery(tabId: string, recover: () => void): () => void { |
| 8 | contentRecovery.set(tabId, recover); |
| 9 | return () => { if (contentRecovery.get(tabId) === recover) contentRecovery.delete(tabId); }; |
| 10 | } |
| 11 | |
| 12 | function asWireObject(value: unknown): Record<string, unknown> { |
| 13 | return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; |
| 14 | } |
| 15 | |
| 16 | export function canonicalMessage(message: PersistentMessage, body: unknown): HistoryMessage { |
| 17 | const raw = asWireObject(body); |
| 18 | if (raw.role === "notice") return { role: "notice", messageId: String(raw.id ?? message.messageId), content: String(raw.content ?? ""), detail: typeof raw.detail === "string" ? raw.detail : undefined, code: typeof raw.code === "string" ? raw.code : undefined, level: raw.level === "warn" ? "warn" : "info" }; |
| 19 | const decisionReceipt = asWireObject(raw.decision_receipt); |
| 20 | if (Object.keys(decisionReceipt).length > 0) { |
| 21 | return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "decision_receipt", level: "info", decisionReceipt: decisionReceipt as unknown as HistoryMessage["decisionReceipt"] }; |
| 22 | } |
| 23 | const readPause = asWireObject(raw.read_pause); |
| 24 | if (Boolean(raw.local_only) && Object.keys(readPause).length > 0) { |
| 25 | return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "incomplete_read", level: "info", readPause: readPause as unknown as HistoryMessage["readPause"] }; |
| 26 | } |
| 27 | const readiness = asWireObject(raw.final_readiness_recovery); |
| 28 | if (Boolean(raw.local_only) && readiness.pending === true) { |
| 29 | return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "Final checks are still required before this task is complete.", code: "historical_checks", level: "info", readiness: { missing: Array.isArray(readiness.missing) ? readiness.missing.map(String) : undefined } }; |
| 30 | } |
| 31 | const protocolRecovery = asWireObject(raw.protocol_recovery); |
| 32 | if (Boolean(raw.local_only) && protocolRecovery.state === "pending" && typeof protocolRecovery.id === "string") { |
| 33 | return { role: "notice", messageId: String(raw.id ?? message.messageId), content: "", code: "protocol_recovery", level: "info", pending: true, protocolRecovery: { id: protocolRecovery.id } }; |
| 34 | } |
| 35 | const toolCalls = (Array.isArray(raw.tool_calls) ? raw.tool_calls as Record<string, unknown>[] : []).map(call => ({ |
| 36 | resultObservation: message.toolObservations?.[String(call.id ?? "")], |
| 37 | id: String(call.id ?? ""), name: String(call.name ?? ""), arguments: String(call.arguments ?? ""), |
| 38 | resolvedName: typeof call.resolved_name === "string" ? call.resolved_name : undefined, |
| 39 | capabilityId: typeof call.capability_id === "string" ? call.capability_id : undefined, |
| 40 | resolvedReadOnly: typeof call.resolved_read_only === "boolean" ? call.resolved_read_only : undefined, |
| 41 | diff: typeof call.diff === "string" ? call.diff : undefined, |
| 42 | added: typeof call.added === "number" ? call.added : undefined, |
| 43 | removed: typeof call.removed === "number" ? call.removed : undefined, |
| 44 | })); |
| 45 | const presented = asWireObject(raw.presented_files); |
| 46 | const role = Boolean(raw.local_only) ? "assistant" : String(raw.role ?? message.role); |
| 47 | const display = role === "user" ? canonicalUserDisplay(raw, message.preview ?? "") : { role, content: String(raw.content ?? raw.raw_content ?? message.preview ?? "") }; |
| 48 | return { |
| 49 | turnFinal: message.turnFinal ?? false, |
| 50 | samplingCount: message.samplingCount ?? undefined, |
| 51 | toolCount: message.toolCount ?? undefined, |
| 52 | turnDurationMs: message.turnDurationMs, |
| 53 | role: display.role, |
| 54 | messageId: String(raw.id ?? message.messageId), |
| 55 | submissionId: message.submissionId, |
| 56 | content: display.content, |
| 57 | reasoning: typeof raw.reasoning_content === "string" ? raw.reasoning_content : undefined, |
| 58 | createdAt: typeof raw.createdAt === "number" ? raw.createdAt : undefined, |
| 59 | workDurationMs: typeof raw.workDurationMs === "number" ? raw.workDurationMs : undefined, |
| 60 | toolCalls: toolCalls.length > 0 ? toolCalls : undefined, |
| 61 | toolCallId: typeof raw.tool_call_id === "string" ? raw.tool_call_id : undefined, |
| 62 | toolName: typeof raw.name === "string" ? raw.name : undefined, |
| 63 | memoryCitations: Array.isArray(raw.memoryCitations) ? raw.memoryCitations as MemoryCitation[] : undefined, |
| 64 | serverSearch: Array.isArray(raw.server_search) ? raw.server_search as HistoryMessage["serverSearch"] : undefined, |
| 65 | execution: Object.keys(asWireObject(raw.tool_execution)).length > 0 ? raw.tool_execution as HistoryMessage["execution"] : undefined, |
| 66 | presentedFiles: Array.isArray(presented.files) ? presented.files as HistoryMessage["presentedFiles"] : undefined, |
| 67 | readCompletion: Object.keys(asWireObject(raw.read_completion)).length > 0 ? raw.read_completion as HistoryMessage["readCompletion"] : undefined, |
| 68 | }; |
| 69 | } |
| 70 | |
| 71 | export function resolvedHistoryField(message: HistoryMessage, field: string): string | undefined { |
| 72 | switch (field) { |
| 73 | case "content": return message.content; |
| 74 | case "reasoning": return message.reasoning; |
| 75 | case "submitText": return message.submitText; |
| 76 | case "detail": return message.detail; |
| 77 | case "code": return message.code; |
| 78 | case "summary": return message.summary; |
| 79 | case "archive": return message.archive; |
| 80 | case "toolResultError": return message.toolResultError; |
| 81 | default: return message.content; |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | // ── binding identity ──────────────────────────────────────────────────────── |
| 86 | // A tab's history comes from exactly one place: the host that owns its |
| 87 | // binding. Crossing over on a failed call would let a transient local error |
| 88 | // (busy, conflict, timeout) be answered by a different service holding |
| 89 | // different data, so routing is decided by identity before the request, never |
| 90 | // by the outcome of one. |
| 91 | export type TranscriptBindingIdentity = "local" | "remote"; |
| 92 | |
| 93 | let bindingIdentityFor: ((tabId: string) => TranscriptBindingIdentity) | undefined; |
| 94 | |
| 95 | /** Installed by the app layer, which is where tab metadata lives. */ |
| 96 | export function setTranscriptBindingIdentity(resolver: (tabId: string) => TranscriptBindingIdentity): void { |
| 97 | bindingIdentityFor = resolver; |
| 98 | } |
| 99 | |
| 100 | // An unregistered identity is a tab with no remote binding, which is what a |
| 101 | // local session is. This is a default, not a fallback: it never moves a |
| 102 | // request to the other service because the first one answered badly. |
| 103 | function identityFor(tabId: string): TranscriptBindingIdentity { |
| 104 | try { |
| 105 | return bindingIdentityFor?.(tabId) ?? "local"; |
| 106 | } catch { |
| 107 | return "local"; |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | export function entriesFor(messages: PersistentMessage[], snapshotSequence: number): HistoryEntry[] { |
| 112 | return messages.map(persistent => { |
| 113 | const entryId = `m:${persistent.messageId}`; |
| 114 | return { |
| 115 | entryId, turn: persistent.visibleTurn ?? 0, order: persistent.position, |
| 116 | message: canonicalMessage(persistent, persistent.inline), |
| 117 | refs: persistent.contentRef ? [{ |
| 118 | entryId, field: "canonicalMessage", size: persistent.contentRef.bytes, |
| 119 | chunks: Math.max(1, Math.ceil(persistent.contentRef.bytes / (1 << 20))), |
| 120 | revision: snapshotSequence, revKnown: true, digest: persistent.contentRef.digest, |
| 121 | canonicalRef: persistent.contentRef, |
| 122 | }] : [], |
| 123 | }; |
| 124 | }); |
| 125 | } |
| 126 | |
| 127 | export async function canonicalHistoryWindow(tabId: string, req: HistoryWindowRequestView): Promise<HistoryWindowPageView> { |
| 128 | const remote = identityFor(tabId) === "remote"; |
| 129 | const { readCanonicalHistoryWindow } = await import("./canonicalHistoryWindow"); |
| 130 | return readCanonicalHistoryWindow(tabId, req, remote); |
| 131 | } |
| 132 | |
| 133 | function staleSlice(): HistorySlice { |
| 134 | return { entries: [], nextCursor: "", hasOlder: false, hasNewer: false, newerCursor: "", totalTurns: 0, startTurn: 0, endTurn: 0, stale: true, revision: 0 }; |
| 135 | } |
| 136 | |
| 137 | /** A window page in the page-shaped form the resident store already consumes. */ |
| 138 | function sliceFromWindow(window: HistoryWindowPageView, source: string): HistorySlice { |
| 139 | return { |
| 140 | entries: window.entries, |
| 141 | nextCursor: window.olderCursor, |
| 142 | hasOlder: window.hasOlder, |
| 143 | newerCursor: window.newerCursor, |
| 144 | hasNewer: window.hasNewer, |
| 145 | totalTurns: window.totalTurns, |
| 146 | startTurn: window.startTurn, |
| 147 | endTurn: window.endTurn, |
| 148 | stale: false, |
| 149 | revision: window.revision, |
| 150 | revisionKnown: window.revisionKnown, |
| 151 | digest: window.digest, |
| 152 | source, |
| 153 | }; |
| 154 | } |
| 155 | |
| 156 | // turnWindowStatus maps a window status onto the page contract the store |
| 157 | // already understands. Empty pages alone are never an error, and a stale |
| 158 | // cursor is an answer rather than a failure. |
| 159 | function requireReadyWindow(window: HistoryWindowPageView): HistorySlice | undefined { |
| 160 | switch (window.status) { |
| 161 | case "ready": return sliceFromWindow(window, "window"); |
| 162 | case "stale_cursor": return staleSlice(); |
| 163 | case "preparing": throw new HistoryPreparingError(); |
| 164 | case "failed": throw new Error("Session history is failed"); |
| 165 | case "not_found": throw new Error("Session history is unavailable for this session"); |
| 166 | default: return undefined; |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | export async function canonicalHistorySlice(tabId: string, req: HistorySliceRequest): Promise<HistorySlice> { |
| 171 | const cursor = req.cursor ?? ""; |
| 172 | const limit = Math.min(100, Math.max(1, req.entries ?? 32)); |
| 173 | if (cursor.startsWith("reasonix:message:")) { |
| 174 | const [id, sequence, generation] = cursor.slice("reasonix:message:".length).split(":"); |
| 175 | const messageId = decodeURIComponent(id); |
| 176 | const snapshotSequence = Number(sequence); |
| 177 | if (!Number.isSafeInteger(snapshotSequence) || snapshotSequence < 0 || generation === undefined) return staleSlice(); |
| 178 | const window = await canonicalHistoryWindow(tabId, { anchor: "message", messageId, snapshotSequence, |
| 179 | generation: decodeURIComponent(generation), direction: req.newer ? "newer" : "older", limit }); |
| 180 | const ready = requireReadyWindow(window); |
| 181 | if (!ready) throw new Error("Transcript v2 requires an updated Desktop and Serve"); |
| 182 | return ready; |
| 183 | } |
| 184 | // A cursor names a position inside a fixed canonical history window. |
| 185 | if (cursor !== "" || req.newer) { |
| 186 | const window = await canonicalHistoryWindow(tabId, { |
| 187 | anchor: "cursor", |
| 188 | cursor, |
| 189 | direction: req.newer ? "newer" : "older", |
| 190 | limit, |
| 191 | }); |
| 192 | const ready = requireReadyWindow(window); |
| 193 | if (ready) return ready; |
| 194 | throw new Error("Transcript v2 requires an updated Desktop and Serve"); |
| 195 | } |
| 196 | // The newest page carries the cursor for the same bidirectional window. |
| 197 | const window = await canonicalHistoryWindow(tabId, { anchor: "newest", direction: "older", limit }); |
| 198 | const ready = requireReadyWindow(window); |
| 199 | if (ready) return { ...ready, source: "recent" }; |
| 200 | throw new Error("Transcript v2 requires an updated Desktop and Serve"); |
| 201 | } |
| 202 | |
| 203 | /** Deferred bodies are loaded only after a transcript requests their content. */ |
| 204 | export async function canonicalHistoryContent(tabID: string, ref: HistoryContentRef, chunkIndex: number): Promise<HistoryContentChunk> { |
| 205 | const remote = identityFor(tabID) === "remote"; |
| 206 | const recover = contentRecovery.get(tabID); |
| 207 | const { readCanonicalHistoryContent } = await import("./canonicalHistoryContent"); |
| 208 | return readCanonicalHistoryContent(tabID, ref, chunkIndex, remote, () => { |
| 209 | if (contentRecovery.get(tabID) === recover) recover?.(); |
| 210 | }); |
| 211 | } |
| 212 |