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