| 1 | import { asArray } from "./array"; |
| 2 | import { t, type DictKey } from "./i18n"; |
| 3 | import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./types"; |
| 4 | import type { Item } from "./useController"; |
| 5 | import { readPauseItem } from "./readPause"; |
| 6 | |
| 7 | export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } { |
| 8 | if (quietTranscriptNoticeKey(rawText, code)) return { items, seq }; |
| 9 | const text = localizedNoticeText(rawText, code); |
| 10 | if (quietTranscriptNoticeKey(text, code)) return { items, seq }; |
| 11 | const trimmedDetail = detail?.trim(); |
| 12 | return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 }; |
| 13 | } |
| 14 | |
| 15 | export function errorMessage(err: unknown): string { |
| 16 | if (err instanceof Error) return err.message; |
| 17 | if (typeof err === "string") return err; |
| 18 | return String(err || ""); |
| 19 | } |
| 20 | |
| 21 | const noticeCodeKeys: Record<string, DictKey> = { |
| 22 | final_readiness: "notice.finalReadiness", |
| 23 | historical_checks: "notice.historicalChecks", |
| 24 | search_sources_not_provided: "sources.notProvided", |
| 25 | empty_final: "notice.emptyFinal", |
| 26 | executor_handoff: "notice.executorHandoff", |
| 27 | tool_budget: "notice.toolBudget", |
| 28 | prompt_queued: "notice.promptQueued", |
| 29 | loop_guard: "notice.loopGuard", |
| 30 | workspace_lease: "notice.workspaceLease", |
| 31 | cancelled_turn_display: "notice.cancelledTurnDisplay", |
| 32 | protocol_recovery: "notice.protocolRecoveryBody", |
| 33 | recovery_paused: "notice.recoveryPausedBody", |
| 34 | completion_uncertain: "notice.completionUncertainBody", |
| 35 | session_recovery_forked: "recovery.noticeSavedCopy", |
| 36 | session_recovery_adopted: "recovery.noticeAdopted", |
| 37 | session_recovery_adopted_covered: "recovery.noticeAdoptedCovered", |
| 38 | session_recovery_depth_cap: "recovery.noticeKeptCurrent", |
| 39 | session_shutdown_recovery_forked: "recovery.noticeSavedCopy", |
| 40 | session_concurrent_writer: "recovery.noticeConcurrentWriter", |
| 41 | session_head_switched: "recovery.noticeHeadSwitched", |
| 42 | session_head_selected: "recovery.noticeHeadSelected", |
| 43 | decision_receipt: "notice.decisionReceiptTitle", |
| 44 | context_editing_fallback: "notice.contextEditingFallback", |
| 45 | turn_stalled: "notice.turnStalled", |
| 46 | }; |
| 47 | |
| 48 | const streamInterruptReasonCodeKeys: Record<string, DictKey> = { |
| 49 | stream_interrupted_idle_timeout: "notice.streamInterruptReason.idleTimeout", |
| 50 | stream_interrupted_premature_eof: "notice.streamInterruptReason.prematureEof", |
| 51 | stream_interrupted_connection_reset: "notice.streamInterruptReason.connectionReset", |
| 52 | }; |
| 53 | |
| 54 | export function localizedNoticeText(text: string, code?: string): string { |
| 55 | if (text === "Model reported the goal complete.") return t("notice.goalModelComplete"); |
| 56 | if (code === "unapplied_steer") { |
| 57 | const separator = text.indexOf("\n"); |
| 58 | const guidance = separator >= 0 ? text.slice(separator + 1) : text; |
| 59 | return t("notice.unappliedSteer", { guidance }); |
| 60 | } |
| 61 | const streamReasonKey = code ? streamInterruptReasonCodeKeys[code] : undefined; |
| 62 | if (streamReasonKey) { |
| 63 | return t("notice.streamInterruptReason", { reason: t(streamReasonKey) }); |
| 64 | } |
| 65 | const key = code ? noticeCodeKeys[code] : undefined; |
| 66 | return key ? t(key) : localizedBackendNoticeText(text); |
| 67 | } |
| 68 | |
| 69 | const deliveryRequirementKeys: Record<string, DictKey> = { |
| 70 | project_check: "notice.deliveryRequirementProjectCheck", |
| 71 | todo: "notice.deliveryRequirementTodo", |
| 72 | criteria: "notice.deliveryRequirementCriteria", |
| 73 | verification: "notice.deliveryRequirementVerification", |
| 74 | review: "notice.deliveryRequirementReview", |
| 75 | signoff: "notice.deliveryRequirementSignoff", |
| 76 | action: "notice.deliveryRequirementAction", |
| 77 | mutation: "notice.deliveryRequirementMutation", |
| 78 | task: "notice.deliveryRequirementTask", |
| 79 | capability: "notice.deliveryRequirementCapability", |
| 80 | }; |
| 81 | |
| 82 | export function readinessMissingIds(readiness: WireFinalReadiness | undefined): string[] { |
| 83 | return asArray(readiness?.missing).map((id) => String(id)); |
| 84 | } |
| 85 | |
| 86 | export function deliveryReadinessDetail(readiness: WireFinalReadiness | undefined, fallback = ""): string { |
| 87 | const labels = asArray(readiness?.missing) |
| 88 | .map((id) => deliveryRequirementKeys[id]) |
| 89 | .filter((key): key is DictKey => Boolean(key)) |
| 90 | .map((key) => t(key)); |
| 91 | return labels.length === 0 |
| 92 | ? fallback |
| 93 | : t("notice.deliveryIncompleteMissing", { items: labels.join(t("notice.deliveryRequirementSeparator")) }); |
| 94 | } |
| 95 | |
| 96 | function localizedSessionAction(action: string): string { |
| 97 | switch (action.trim()) { |
| 98 | case "changing model": return t("status.actionChangingModel"); |
| 99 | case "changing effort": return t("status.actionChangingEffort"); |
| 100 | case "rebuilding settings": return t("status.actionRebuildingSettings"); |
| 101 | case "switching sessions": return t("status.actionSwitchingSessions"); |
| 102 | case "switching tabs": return t("status.actionSwitchingTabs"); |
| 103 | case "autosave": return t("status.actionAutosave"); |
| 104 | default: return action.trim() || t("status.actionCurrentSession"); |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | function backendNoticeKey(msg: string): DictKey | "" { |
| 109 | switch (msg) { |
| 110 | case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.": return "notice.finalReadiness"; |
| 111 | case "No visible answer was produced; asking the assistant to respond again.": return "notice.emptyFinal"; |
| 112 | case "The assistant answered before taking action; asking it to use the required tools.": return "notice.executorHandoff"; |
| 113 | case "Tool round limit reached; asking the assistant to summarize progress.": return "notice.toolBudget"; |
| 114 | case "The assistant is stuck retrying a blocked action; asking it to change approach.": return "notice.loopGuard"; |
| 115 | case "Context is getting large; preserving cache until cleanup is needed.": return "notice.contextLarge"; |
| 116 | case "Context cleanup skipped for now.": return "notice.contextCleanupSkipped"; |
| 117 | case "Automatic context cleanup paused because the context window is too small.": return "notice.contextCleanupPaused"; |
| 118 | case "Context was compacted without a generated summary.": return "notice.compactionNoSummary"; |
| 119 | case "Goal is not ready to complete yet; continuing the remaining work.": return "notice.goalNotReady"; |
| 120 | case "Goal still has unfinished task state; continuing the remaining work.": return "notice.goalUnfinished"; |
| 121 | case "Job artifact migration failed.": return "notice.jobArtifactMigrationFailed"; |
| 122 | case "Background job teardown timed out.": return "notice.jobTeardownTimeout"; |
| 123 | case "Some plan-mode tool settings were ignored.": return "notice.planModeToolSettingsIgnored"; |
| 124 | case "Some plan-mode command settings were ignored.": return "notice.planModeCommandSettingsIgnored"; |
| 125 | case "Config migration did not complete.": return "notice.configMigrationIncomplete"; |
| 126 | case "Provider connection settings were repaired.": return "notice.providerConnectionRepaired"; |
| 127 | case "Selected model is missing its API key.": return "notice.modelMissingApiKey"; |
| 128 | case "An MCP server failed to start.": return "notice.mcpServerFailed"; |
| 129 | case "Some MCP servers failed to start; run /mcp for details.": return "notice.mcpServersFailed"; |
| 130 | case "Guardian was disabled because its model was not found.": return "notice.guardianModelMissing"; |
| 131 | case "Guardian was disabled because it could not start.": return "notice.guardianStartFailed"; |
| 132 | default: return ""; |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | export function localizedBackendNoticeText(text: string): string { |
| 137 | const msg = text.trim(); |
| 138 | const autosave = /^Session autosave failed: (.+)$/s.exec(msg); |
| 139 | if (autosave) return t("status.sessionAutosaveFailed", { err: autosave[1] }); |
| 140 | const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg); |
| 141 | if (saveBefore) return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] }); |
| 142 | const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg); |
| 143 | if (modelFallback) return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] }); |
| 144 | const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg); |
| 145 | if (backgroundJob) return t("notice.backgroundJobFailed", { kind: backgroundJob[1] }); |
| 146 | const canonical = backendNoticeKey(msg); |
| 147 | if (canonical) return t(canonical); |
| 148 | if (/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) || /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)) return t("recovery.noticeSavedCopy"); |
| 149 | if (/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) || /^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) || /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)) return t("recovery.noticeKeptCurrent"); |
| 150 | if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) return t("recovery.noticeAdoptedCovered"); |
| 151 | if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) return t("recovery.noticeAdopted"); |
| 152 | return msg; |
| 153 | } |
| 154 | |
| 155 | function recoveryNoticeDedupeKey(text: string, code?: string): string { |
| 156 | switch (code) { |
| 157 | case "session_recovery_forked": |
| 158 | case "session_shutdown_recovery_forked": return "recovery:saved-copy"; |
| 159 | case "session_recovery_depth_cap": return "recovery:kept-current"; |
| 160 | case "session_recovery_adopted_covered": return "recovery:adopted-covered"; |
| 161 | case "session_recovery_adopted": return "recovery:adopted"; |
| 162 | } |
| 163 | const msg = text.trim(); |
| 164 | if (/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) || /^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) || msg === t("recovery.noticeSavedCopy")) return "recovery:saved-copy"; |
| 165 | if (/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) || /^repeated save conflicts were detected; saved the current conflict copy in an isolated recovery branch$/i.test(msg) || /^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) || msg === t("recovery.noticeKeptCurrent")) return "recovery:kept-current"; |
| 166 | if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) || msg === t("recovery.noticeAdoptedCovered")) return "recovery:adopted-covered"; |
| 167 | if (/^session changed on disk; adopted the newer transcript$/i.test(msg) || msg === t("recovery.noticeAdopted")) return "recovery:adopted"; |
| 168 | return ""; |
| 169 | } |
| 170 | |
| 171 | export function quietTranscriptNoticeKey(text: string, code?: string): string { |
| 172 | const recovery = recoveryNoticeDedupeKey(text, code); |
| 173 | if (recovery) return recovery; |
| 174 | const msg = text.trim(); |
| 175 | if (/^guardian enabled · model=.+$/i.test(msg)) return "startup:guardian-enabled"; |
| 176 | if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) return "startup:mcp-failures"; |
| 177 | const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg); |
| 178 | if (directMCPFailure && !["add", "auth", "config", "connect", "import", "mode", "remove"].includes(directMCPFailure[1].toLowerCase())) return "startup:mcp-failure"; |
| 179 | if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) return "startup:plugin-demote"; |
| 180 | if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) return "settings:deferred-refresh-applied"; |
| 181 | return ""; |
| 182 | } |
| 183 | // Windowed history and live read pauses share presentation without growing |
| 184 | // the transcript record store's unrelated paging and cache responsibilities. |
| 185 | export function historyNoticeItems(m: HistoryMessage, id: string): Item[] { |
| 186 | if (m.code === "incomplete_read") return [readPauseItem(m.readPause, id)]; |
| 187 | if (m.content.trim() === "" && !m.decisionReceipt) return []; |
| 188 | if (quietTranscriptNoticeKey(m.content, m.code)) return []; |
| 189 | const text = localizedNoticeText(m.content, m.code); |
| 190 | if (quietTranscriptNoticeKey(text, m.code)) return []; |
| 191 | const detail = m.detail?.trim(); |
| 192 | return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text, |
| 193 | ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }]; |
| 194 | } |
| 195 |