| 1 | import { isShellToolName } from "./shellToolIdentity"; |
| 2 | import { historyToolStatus } from "./historyToolStatus"; |
| 3 | import { asArray } from "./array"; |
| 4 | import { canonicalMessage } from "./canonicalTranscriptBackend"; |
| 5 | import { historicalResultNotice } from "./completionResultState"; |
| 6 | import { historyNoticeItems } from "./controllerNotices"; |
| 7 | import { appendHistoryAttachmentRefs } from "./historyAttachmentRefs"; |
| 8 | import { historySearchAndAnswer } from "./searchTranscript"; |
| 9 | import { fileDiffFromWire, summarizeFileDiff } from "./tools"; |
| 10 | import { historyToolError, isReadOnlyTool, type Item } from "./useController"; |
| 11 | import type { HistoryContentRef, HistoryEntry, HistoryMessage, MemoryCitation } from "./types"; |
| 12 | import { recordBytes } from "./transcriptRecordBytes"; |
| 13 | |
| 14 | export interface TranscriptRecord { |
| 15 | entryId: string; |
| 16 | turn: number; |
| 17 | order: number; |
| 18 | message: HistoryMessage; |
| 19 | refs: HistoryContentRef[]; |
| 20 | resolved?: Record<string, string>; |
| 21 | staleRefs?: Record<string, true>; |
| 22 | bytes: number; |
| 23 | } |
| 24 | |
| 25 | export interface RecordConversion { |
| 26 | items: Item[]; |
| 27 | claims: string[]; |
| 28 | unresolvedIds: string[]; |
| 29 | pendingPositional: number[]; |
| 30 | matches: Map<number, string>; |
| 31 | } |
| 32 | |
| 33 | export function entryToRecord(entry: HistoryEntry): TranscriptRecord { |
| 34 | return { |
| 35 | entryId: entry.entryId, |
| 36 | turn: entry.turn, |
| 37 | order: entry.order, |
| 38 | message: entry.message, |
| 39 | refs: asArray<HistoryContentRef>(entry.refs), |
| 40 | bytes: recordBytes(entry.message), |
| 41 | }; |
| 42 | } |
| 43 | |
| 44 | export function itemIdForToolCall(toolCallId: string, fallback: string): string { |
| 45 | return toolCallId || fallback; |
| 46 | } |
| 47 | |
| 48 | /** Converts one record against the complete resident window. */ |
| 49 | export function convertRecord( |
| 50 | rec: TranscriptRecord, |
| 51 | view: { records: TranscriptRecord[]; indexOf: Map<string, number>; toolResultOwners: Map<string, string> }, |
| 52 | consumed: Set<string>, |
| 53 | priorMatches?: Map<number, string>, |
| 54 | ): RecordConversion { |
| 55 | const converted = convertRecordBody(rec, view, consumed, priorMatches); |
| 56 | if (rec.message.turnId) converted.items = converted.items.map(item => ({ ...item, turnId: rec.message.turnId })); |
| 57 | return converted; |
| 58 | } |
| 59 | |
| 60 | function convertRecordBody( |
| 61 | rec: TranscriptRecord, |
| 62 | view: { records: TranscriptRecord[]; indexOf: Map<string, number>; toolResultOwners: Map<string, string> }, |
| 63 | consumed: Set<string>, |
| 64 | priorMatches?: Map<number, string>, |
| 65 | ): RecordConversion { |
| 66 | const items: Item[] = []; |
| 67 | const claims: string[] = []; |
| 68 | const unresolvedIds: string[] = []; |
| 69 | const pendingPositional: number[] = []; |
| 70 | const matches = new Map<number, string>(priorMatches); |
| 71 | const message = rec.message; |
| 72 | const id = message.messageId && (message.role === "assistant" || message.role === "user") ? `m:${message.messageId}` : `he:${rec.entryId}`; |
| 73 | |
| 74 | if (message.role === "system") return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 75 | if (message.role === "phase") { |
| 76 | if (message.content.trim() !== "") items.push({ kind: "phase", id, text: message.content }); |
| 77 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 78 | } |
| 79 | if (message.role === "notice") { |
| 80 | if (message.completionReceipt || message.completionSummary) { |
| 81 | const result = historicalResultNotice(message, id); |
| 82 | if (result) items.push(result); |
| 83 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 84 | } |
| 85 | return { items: historyNoticeItems(message, id), claims, unresolvedIds, pendingPositional, matches }; |
| 86 | } |
| 87 | if (message.role === "compaction") { |
| 88 | items.push({ |
| 89 | kind: "compaction", id, pending: Boolean(message.pending), trigger: message.trigger ?? "", |
| 90 | messages: message.messages ?? 0, summary: message.summary ?? "", archive: message.archive ?? "", |
| 91 | }); |
| 92 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 93 | } |
| 94 | if (message.role === "user") { |
| 95 | if (message.content.trim() !== "") { |
| 96 | items.push({ kind: "user", id, messageId: message.messageId, submissionId: message.submissionId, |
| 97 | text: appendHistoryAttachmentRefs(message.content, message.attachments), submitText: message.submitText, createdAt: message.createdAt, |
| 98 | checkpointTurn: message.checkpointTurn, historyTurn: rec.turn > 0 ? rec.turn : undefined }); |
| 99 | } |
| 100 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 101 | } |
| 102 | if (message.role === "assistant") { |
| 103 | const memoryCitations = asArray<MemoryCitation>(message.memoryCitations); |
| 104 | items.push(...historySearchAndAnswer(id, { |
| 105 | content: message.content, reasoning: message.reasoning, workDurationMs: message.workDurationMs, |
| 106 | turnFinal: message.turnFinal, samplingCount: message.samplingCount, toolCount: message.toolCount, turnDurationMs: message.turnDurationMs, turnUsage: message.turnUsage, createdAt: message.createdAt, |
| 107 | memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined, serverSearch: message.serverSearch, |
| 108 | }, rec.refs.length > 0)); |
| 109 | const toolCalls = message.toolCalls ?? []; |
| 110 | let scan = (view.indexOf.get(rec.entryId) ?? -1) + 1; |
| 111 | for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) { |
| 112 | const toolCall = toolCalls[callIndex]; |
| 113 | let result: HistoryMessage | undefined; |
| 114 | let resultEntryId: string | undefined; |
| 115 | const prior = matches.get(callIndex); |
| 116 | if (prior) { |
| 117 | resultEntryId = prior; |
| 118 | result = view.records[view.indexOf.get(prior) ?? -1]?.message; |
| 119 | } else if (toolCall.id) { |
| 120 | const owner = view.toolResultOwners.get(toolCall.id); |
| 121 | if (owner) { |
| 122 | resultEntryId = owner; |
| 123 | result = view.records[view.indexOf.get(owner) ?? -1]?.message; |
| 124 | } else unresolvedIds.push(toolCall.id); |
| 125 | } else { |
| 126 | while (scan < view.records.length) { |
| 127 | const candidate = view.records[scan]; |
| 128 | if (candidate.message.role !== "tool") break; |
| 129 | scan += 1; |
| 130 | if (candidate.message.toolCallId || consumed.has(candidate.entryId)) continue; |
| 131 | resultEntryId = candidate.entryId; |
| 132 | result = candidate.message; |
| 133 | break; |
| 134 | } |
| 135 | if (!resultEntryId) pendingPositional.push(callIndex); |
| 136 | } |
| 137 | if (resultEntryId) { |
| 138 | matches.set(callIndex, resultEntryId); |
| 139 | claims.push(resultEntryId); |
| 140 | consumed.add(resultEntryId); |
| 141 | } |
| 142 | const archived = Boolean(toolCall.argumentsArchived || result?.toolResultArchived || (!result && toolCall.resultObservation?.contentRef)); |
| 143 | const output = result?.toolResultArchived ? undefined : result?.content ?? ""; |
| 144 | const error = result?.toolResultError || (output ? historyToolError(output) : undefined); |
| 145 | const fileDiff = fileDiffFromWire(toolCall); |
| 146 | items.push({ |
| 147 | kind: "tool", id: itemIdForToolCall(toolCall.id, `he:${rec.entryId}:tc${callIndex}`), name: toolCall.name, |
| 148 | args: toolCall.arguments ?? "", readOnly: typeof toolCall.resolvedReadOnly === "boolean" ? toolCall.resolvedReadOnly : isReadOnlyTool(toolCall.name), |
| 149 | resolvedName: toolCall.resolvedName, capabilityId: toolCall.capabilityId, |
| 150 | status: historyToolStatus(result, toolCall, error), contentState: !result || archived ? "unloaded" : "ready", resultMissing: !result && !toolCall.resultObservation?.messageId || undefined, output, error, dataArchived: archived || undefined, |
| 151 | subject: toolCall.subject, summary: summarizeFileDiff(fileDiff) || toolCall.summary, fileDiff, |
| 152 | isShell: isShellToolName(toolCall.name) || (toolCall.id || "").startsWith("shell-"), execution: result?.execution, |
| 153 | presentedFiles: result?.presentedFiles, |
| 154 | }); |
| 155 | } |
| 156 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 157 | } |
| 158 | if (message.role === "tool") { |
| 159 | if (consumed.has(rec.entryId)) return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 160 | const output = message.toolResultArchived ? undefined : message.content; |
| 161 | const error = message.toolResultError || (output ? historyToolError(output) : undefined); |
| 162 | items.push({ |
| 163 | kind: "tool", id: itemIdForToolCall(message.toolCallId ?? "", id), name: message.toolName || "tool", args: "", |
| 164 | readOnly: isReadOnlyTool(message.toolName || "tool"), status: error ? "error" : "done", output, error, |
| 165 | dataArchived: message.toolResultArchived || undefined, isShell: isShellToolName(message.toolName || "") || (message.toolCallId || "").startsWith("shell-"), |
| 166 | execution: message.execution, presentedFiles: message.presentedFiles, |
| 167 | }); |
| 168 | } |
| 169 | return { items, claims, unresolvedIds, pendingPositional, matches }; |
| 170 | } |
| 171 | |
| 172 | export function applyResolvedField(rec: TranscriptRecord, ref: HistoryContentRef, data: string): boolean { |
| 173 | const message = rec.message; |
| 174 | switch (ref.field) { |
| 175 | case "canonicalMessage": { |
| 176 | const bytes = Uint8Array.from(data, character => character.charCodeAt(0)); |
| 177 | const decoded = canonicalMessage( |
| 178 | { messageId: rec.entryId, submissionId: message.submissionId, position: rec.turn, version: 1, role: message.role, eventSequence: 0, visibleTurn: rec.turn, turnFinal: message.turnFinal, toolObservations: Object.fromEntries((message.toolCalls ?? []).flatMap(call => call.resultObservation ? [[call.id, call.resultObservation]] : [])), turnDurationMs: message.turnDurationMs, samplingCount: message.samplingCount, toolCount: message.toolCount }, |
| 179 | JSON.parse(new TextDecoder().decode(bytes)), |
| 180 | ); |
| 181 | rec.message = { ...message, ...decoded }; |
| 182 | return true; |
| 183 | } |
| 184 | case "content": rec.message = { ...message, content: data }; return true; |
| 185 | case "reasoning": rec.message = { ...message, reasoning: data }; return true; |
| 186 | case "submitText": rec.message = { ...message, submitText: data }; return true; |
| 187 | case "detail": rec.message = { ...message, detail: data }; return true; |
| 188 | case "code": rec.message = { ...message, code: data }; return true; |
| 189 | case "summary": rec.message = { ...message, summary: data }; return true; |
| 190 | case "archive": rec.message = { ...message, archive: data }; return true; |
| 191 | case "toolResultError": rec.message = { ...message, toolResultError: data }; return true; |
| 192 | case "toolArguments": |
| 193 | case "toolSubject": |
| 194 | case "toolSummary": |
| 195 | case "toolDiff": { |
| 196 | rec.message = { ...message, toolCalls: (message.toolCalls ?? []).map((toolCall) => { |
| 197 | if (toolCall.id !== ref.toolCallId) return toolCall; |
| 198 | if (ref.field === "toolArguments") return { ...toolCall, arguments: data }; |
| 199 | if (ref.field === "toolSubject") return { ...toolCall, subject: data }; |
| 200 | if (ref.field === "toolSummary") return { ...toolCall, summary: data }; |
| 201 | return { ...toolCall, diff: data }; |
| 202 | }) }; |
| 203 | return true; |
| 204 | } |
| 205 | default: return false; |
| 206 | } |
| 207 | } |
| 208 |