| 1 | import { isShellToolName } from "./shellToolIdentity"; |
| 2 | import { historyToolStatus } from "./historyToolStatus"; |
| 3 | // historyItems converts durable HistoryMessage rows (and legacy HistoryPage |
| 4 | // payloads) into transcript Items for the single-shot hydration path. The |
| 5 | // windowed counterpart lives in transcriptStore; both projections must agree |
| 6 | // on item identity and tool call/result folding. |
| 7 | import { asArray } from "./array"; |
| 8 | import { historicalResultNotice } from "./completionResultState"; |
| 9 | import { appendNoticeItem, deliveryReadinessDetail, readinessMissingIds } from "./controllerNotices"; |
| 10 | import { appendHistoryAttachmentRefs } from "./historyAttachmentRefs"; |
| 11 | import { createUniqueItemIDAllocator } from "./historyItemIds"; |
| 12 | import { t } from "./i18n"; |
| 13 | import { upsertReadPause } from "./readPause"; |
| 14 | import { historySearchAndAnswer } from "./searchTranscript"; |
| 15 | import { fileDiffFromWire, summarizeFileDiff } from "./tools"; |
| 16 | import type { HistoryMessage, HistoryPage, MemoryCitation } from "./types"; |
| 17 | import type { Item } from "./useController"; |
| 18 | |
| 19 | /** Mirrors Go backend's ReadOnly() hints. */ |
| 20 | export function isReadOnlyTool(name: string): boolean { |
| 21 | switch (name) { |
| 22 | case "read_file": |
| 23 | case "ls": |
| 24 | case "grep": |
| 25 | case "glob": |
| 26 | case "web_fetch": |
| 27 | case "web_search": |
| 28 | case "code_index": |
| 29 | case "bash_output": |
| 30 | case "waitJob": |
| 31 | case "todo_write": |
| 32 | case "read_skill": |
| 33 | return true; |
| 34 | default: |
| 35 | return false; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } { |
| 40 | const resultByID = new Map<string, HistoryMessage>(); |
| 41 | for (const m of messages) { |
| 42 | if (m.role === "tool" && m.toolCallId && !resultByID.has(m.toolCallId)) { |
| 43 | resultByID.set(m.toolCallId, m); |
| 44 | } |
| 45 | } |
| 46 | const positionalResults = positionalToolResults(messages); |
| 47 | const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index)); |
| 48 | |
| 49 | let items: Item[] = []; |
| 50 | let seq = startSeq; |
| 51 | const consumedToolIDs = new Set<string>(); |
| 52 | const uniqueItemID = createUniqueItemIDAllocator(); |
| 53 | for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { |
| 54 | const m = messages[messageIndex]; |
| 55 | const recordItemId = m.recordId ? `record:${m.recordId}` : `${idPrefix}${seq}`; |
| 56 | if (m.role === "system") continue; |
| 57 | if (m.role === "phase") { |
| 58 | if (m.content.trim() !== "") { |
| 59 | items.push({ kind: "phase", id: recordItemId, text: m.content }); |
| 60 | seq++; |
| 61 | } |
| 62 | continue; |
| 63 | } |
| 64 | if (m.role === "notice") { |
| 65 | if (m.code === "read_completion") { |
| 66 | const next = appendNoticeItem(items, seq, recordItemId, "info", m.content, m.detail, m.code); |
| 67 | items = next.items; |
| 68 | seq = next.seq; |
| 69 | continue; |
| 70 | } |
| 71 | if (m.code === "incomplete_read") { |
| 72 | items = upsertReadPause(items, m.readPause, recordItemId); |
| 73 | seq++; |
| 74 | continue; |
| 75 | } |
| 76 | if (m.completionReceipt || m.completionSummary) { |
| 77 | const result = historicalResultNotice(m, recordItemId); |
| 78 | if (result) { items.push(result); seq++; } |
| 79 | continue; |
| 80 | } |
| 81 | if (m.code === "protocol_recovery" && m.pending && m.protocolRecovery?.id) { |
| 82 | items.push({kind:"notice",id:recordItemId,level:"info",code:m.code,text:t("notice.protocolRecoveryBody"),action:"recover_context",recoveryId:m.protocolRecovery.id}); |
| 83 | seq++; |
| 84 | continue; |
| 85 | } |
| 86 | if (m.code === "final_readiness" && m.pending) { |
| 87 | items.push({ |
| 88 | kind: "notice", |
| 89 | id: recordItemId, |
| 90 | level: "info", |
| 91 | variant: "delivery", |
| 92 | title: t("notice.deliveryIncompleteTitle"), |
| 93 | text: t("notice.deliveryIncompleteBody"), |
| 94 | detail: deliveryReadinessDetail(m.readiness), |
| 95 | action: "continue_delivery", |
| 96 | missing: readinessMissingIds(m.readiness), |
| 97 | }); |
| 98 | seq++; |
| 99 | continue; |
| 100 | } |
| 101 | if (m.content.trim() !== "" || m.decisionReceipt) { |
| 102 | const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt); |
| 103 | items = next.items; |
| 104 | seq = next.seq; |
| 105 | } |
| 106 | continue; |
| 107 | } |
| 108 | if (m.role === "compaction") { |
| 109 | items.push({ |
| 110 | kind: "compaction", |
| 111 | id: recordItemId, |
| 112 | pending: Boolean(m.pending), |
| 113 | trigger: m.trigger ?? "", |
| 114 | messages: m.messages ?? 0, |
| 115 | summary: m.summary ?? "", |
| 116 | archive: m.archive ?? "", |
| 117 | }); |
| 118 | seq++; |
| 119 | continue; |
| 120 | } |
| 121 | if (m.role === "user") { |
| 122 | if (m.content.trim() === "") continue; |
| 123 | items.push({ kind: "user", id: m.messageId ? `m:${m.messageId}` : recordItemId, messageId: m.messageId, submissionId: m.submissionId, turnId: m.turnId, text: appendHistoryAttachmentRefs(m.content, m.attachments), submitText: m.submitText, createdAt: m.createdAt, checkpointTurn: m.checkpointTurn, historyTurn: m.historyTurn }); |
| 124 | seq++; |
| 125 | continue; |
| 126 | } |
| 127 | if (m.role === "assistant") { |
| 128 | const memoryCitations = asArray<MemoryCitation>(m.memoryCitations); |
| 129 | const messageItemId = m.messageId ? `m:${m.messageId}` : m.recordId ? recordItemId : undefined; |
| 130 | const built = historySearchAndAnswer(messageItemId ?? `${idPrefix}${seq}`, { |
| 131 | content: m.content, |
| 132 | reasoning: m.reasoning, |
| 133 | workDurationMs: m.workDurationMs, |
| 134 | turnDurationMs: m.turnDurationMs, |
| 135 | turnUsage: m.turnUsage, |
| 136 | createdAt: m.createdAt, |
| 137 | memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined, |
| 138 | serverSearch: m.serverSearch, |
| 139 | }); |
| 140 | for (const item of built) { |
| 141 | item.turnId = m.turnId; |
| 142 | if (item.kind === "assistant") { item.id = messageItemId ?? `${idPrefix}${seq}`; item.streaming = Boolean(m.pending); } |
| 143 | items.push(item); |
| 144 | seq++; |
| 145 | } |
| 146 | if (m.pending && !built.some((item) => item.kind === "assistant")) { |
| 147 | items.push({ kind: "assistant", id: messageItemId ?? recordItemId, text: m.content, reasoning: m.reasoning ?? "", streaming: true, createdAt: m.createdAt }); |
| 148 | seq++; |
| 149 | } |
| 150 | const toolCalls = m.toolCalls ?? []; |
| 151 | for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) { |
| 152 | const tc = toolCalls[callIndex]; |
| 153 | const positionalResult = tc.id ? undefined : positionalResults.get(positionalToolResultKey(messageIndex, callIndex)); |
| 154 | const result = tc.id ? resultByID.get(tc.id) : positionalResult?.message; |
| 155 | if (tc.id) consumedToolIDs.add(tc.id); |
| 156 | const archived = Boolean(tc.argumentsArchived || result?.toolResultArchived); |
| 157 | const output = result?.toolResultArchived ? undefined : result?.content ?? ""; |
| 158 | const error = result?.toolResultError || (output ? historyToolError(output) : undefined); |
| 159 | const fileDiff = fileDiffFromWire(tc); |
| 160 | items.push({ |
| 161 | kind: "tool", |
| 162 | id: uniqueItemID(tc.id || "", m.recordId ? `${recordItemId}:tc${callIndex}` : `${idPrefix}tool${seq}`), |
| 163 | messageId: m.messageId, |
| 164 | parentId: tc.parentId, |
| 165 | argChars: tc.argChars, |
| 166 | startedAt: tc.startedAt, |
| 167 | name: tc.name, |
| 168 | args: tc.arguments ?? "", |
| 169 | readOnly: typeof tc.resolvedReadOnly === "boolean" ? tc.resolvedReadOnly : isReadOnlyTool(tc.name), |
| 170 | resolvedName: tc.resolvedName, |
| 171 | capabilityId: tc.capabilityId, |
| 172 | status: historyToolStatus(result, tc, error), |
| 173 | contentState: result && !result.toolResultArchived ? "ready" : "unloaded", |
| 174 | output, |
| 175 | error, |
| 176 | dataArchived: archived || undefined, |
| 177 | subject: tc.subject, |
| 178 | summary: summarizeFileDiff(fileDiff) || tc.summary, |
| 179 | fileDiff, |
| 180 | isShell: isShellToolName(tc.name) || (tc.id || "").startsWith("shell-"), |
| 181 | execution: result?.execution, |
| 182 | presentedFiles: result?.presentedFiles, |
| 183 | }); |
| 184 | seq++; |
| 185 | } |
| 186 | continue; |
| 187 | } |
| 188 | if (m.role === "tool") { |
| 189 | if ((m.toolCallId && consumedToolIDs.has(m.toolCallId)) || consumedPositionalToolIndexes.has(messageIndex)) continue; |
| 190 | const output = m.toolResultArchived ? undefined : m.content; |
| 191 | const error = m.toolResultError || (output ? historyToolError(output) : undefined); |
| 192 | items.push({ |
| 193 | kind: "tool", |
| 194 | id: uniqueItemID(m.toolCallId || "", m.recordId ? `${recordItemId}:tool` : `${idPrefix}tool${seq}`), |
| 195 | name: m.toolName || "tool", |
| 196 | args: "", |
| 197 | readOnly: isReadOnlyTool(m.toolName || "tool"), |
| 198 | status: error ? "error" : "done", |
| 199 | output, |
| 200 | error, |
| 201 | dataArchived: m.toolResultArchived || undefined, |
| 202 | isShell: isShellToolName(m.toolName || "") || (m.toolCallId || "").startsWith("shell-"), |
| 203 | execution: m.execution, |
| 204 | presentedFiles: m.presentedFiles, |
| 205 | }); |
| 206 | seq++; |
| 207 | continue; |
| 208 | } |
| 209 | } |
| 210 | return { items, seq }; |
| 211 | } |
| 212 | |
| 213 | export function applyTurnCheckpoint(items: Item[], submissionId: string | undefined, turn: number | undefined): Item[] { |
| 214 | if (!submissionId) return items; |
| 215 | const validTurn = turn !== undefined && Number.isInteger(turn) && turn >= 0; |
| 216 | let changed = false; |
| 217 | const next = items.map((item) => { |
| 218 | if (item.kind !== "user" || item.submissionId !== submissionId) return item; |
| 219 | changed = true; |
| 220 | return { ...item, submissionId: undefined, checkpointTurn: item.checkpointTurn ?? (validTurn ? turn : undefined) }; |
| 221 | }); |
| 222 | return changed ? next : items; |
| 223 | } |
| 224 | |
| 225 | export function historyPageItems(page: HistoryPage): { items: Item[]; seq: number; firstTurn: number } { |
| 226 | const converted = historyMessagesToItems(asArray(page.messages), `h${page.startTurn}-`, 0); |
| 227 | let historyTurn = page.startTurn + 1; |
| 228 | const items = converted.items.map((item) => { |
| 229 | if (item.kind !== "user") return item; |
| 230 | const user = { ...item, historyTurn }; |
| 231 | historyTurn += 1; |
| 232 | return user; |
| 233 | }); |
| 234 | return { |
| 235 | items, |
| 236 | seq: converted.seq, |
| 237 | // Legacy HistoryPage is 0-based while HistorySlice is 1-based. State uses |
| 238 | // the HistorySlice coordinate so every transcript consumer sees one model. |
| 239 | firstTurn: page.totalTurns > 0 ? page.startTurn + 1 : 0, |
| 240 | }; |
| 241 | } |
| 242 | |
| 243 | function positionalToolResults(messages: HistoryMessage[]): Map<string, { message: HistoryMessage; index: number }> { |
| 244 | const out = new Map<string, { message: HistoryMessage; index: number }>(); |
| 245 | const consumed = new Set<number>(); |
| 246 | for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) { |
| 247 | const message = messages[messageIndex]; |
| 248 | const toolCalls = message.role === "assistant" ? message.toolCalls ?? [] : []; |
| 249 | if (toolCalls.length === 0) continue; |
| 250 | let resultIndex = messageIndex + 1; |
| 251 | for (let callIndex = 0; callIndex < toolCalls.length; callIndex += 1) { |
| 252 | if (toolCalls[callIndex].id) continue; |
| 253 | let matched = false; |
| 254 | while (resultIndex < messages.length) { |
| 255 | const candidate = messages[resultIndex]; |
| 256 | if (candidate.role !== "tool") break; |
| 257 | const candidateIndex = resultIndex; |
| 258 | resultIndex += 1; |
| 259 | if (candidate.toolCallId || consumed.has(candidateIndex)) continue; |
| 260 | consumed.add(candidateIndex); |
| 261 | out.set(positionalToolResultKey(messageIndex, callIndex), { message: candidate, index: candidateIndex }); |
| 262 | matched = true; |
| 263 | break; |
| 264 | } |
| 265 | if (!matched) break; |
| 266 | } |
| 267 | } |
| 268 | return out; |
| 269 | } |
| 270 | |
| 271 | function positionalToolResultKey(messageIndex: number, callIndex: number): string { |
| 272 | return `${messageIndex}:${callIndex}`; |
| 273 | } |
| 274 | |
| 275 | export function historyToolError(output: string): string | undefined { |
| 276 | const trimmed = output.trimStart(); |
| 277 | if ( |
| 278 | trimmed.startsWith("[error") || |
| 279 | trimmed.startsWith("Error:") || |
| 280 | trimmed.startsWith("error:") || |
| 281 | trimmed.startsWith("blocked:") |
| 282 | ) { |
| 283 | return output; |
| 284 | } |
| 285 | return undefined; |
| 286 | } |
| 287 |