@golba98/codexa 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +320 -0
  2. package/bin/codexa.js +445 -0
  3. package/package.json +45 -0
  4. package/scripts/audit-codexa-capabilities.mjs +466 -0
  5. package/scripts/smoke-terminal-bench.mjs +35 -0
  6. package/src/app.tsx +4561 -0
  7. package/src/appRenderStability.test.ts +131 -0
  8. package/src/commands/handler.test.ts +643 -0
  9. package/src/commands/handler.ts +875 -0
  10. package/src/config/launchArgs.test.ts +158 -0
  11. package/src/config/launchArgs.ts +186 -0
  12. package/src/config/layeredConfig.test.ts +143 -0
  13. package/src/config/layeredConfig.ts +836 -0
  14. package/src/config/persistence.test.ts +110 -0
  15. package/src/config/persistence.ts +311 -0
  16. package/src/config/runtimeConfig.test.ts +218 -0
  17. package/src/config/runtimeConfig.ts +554 -0
  18. package/src/config/settings.test.ts +155 -0
  19. package/src/config/settings.ts +401 -0
  20. package/src/config/toml-serialize.ts +98 -0
  21. package/src/config/trustStore.test.ts +29 -0
  22. package/src/config/trustStore.ts +68 -0
  23. package/src/core/attachments.test.ts +155 -0
  24. package/src/core/attachments.ts +71 -0
  25. package/src/core/auth/codexAuth.test.ts +68 -0
  26. package/src/core/auth/codexAuth.ts +359 -0
  27. package/src/core/cleanupFastFail.test.ts +76 -0
  28. package/src/core/cleanupFastFail.ts +67 -0
  29. package/src/core/clipboard.ts +24 -0
  30. package/src/core/codex.ts +124 -0
  31. package/src/core/codexExecArgs.test.ts +195 -0
  32. package/src/core/codexExecArgs.ts +152 -0
  33. package/src/core/codexLaunch.test.ts +205 -0
  34. package/src/core/codexLaunch.ts +162 -0
  35. package/src/core/codexPrompt.test.ts +252 -0
  36. package/src/core/codexPrompt.ts +428 -0
  37. package/src/core/executables/claudeExecutable.ts +63 -0
  38. package/src/core/executables/codexExecutable.test.ts +212 -0
  39. package/src/core/executables/codexExecutable.ts +159 -0
  40. package/src/core/executables/executableResolver.test.ts +129 -0
  41. package/src/core/executables/executableResolver.ts +138 -0
  42. package/src/core/executables/geminiExecutable.test.ts +116 -0
  43. package/src/core/executables/geminiExecutable.ts +78 -0
  44. package/src/core/executables/pathSanityScan.test.ts +47 -0
  45. package/src/core/githubDiagnostics.test.ts +92 -0
  46. package/src/core/githubDiagnostics.ts +222 -0
  47. package/src/core/hollowResponseFormat.test.ts +58 -0
  48. package/src/core/hollowResponseFormat.ts +39 -0
  49. package/src/core/inputDebug.ts +51 -0
  50. package/src/core/launchContext.test.ts +157 -0
  51. package/src/core/launchContext.ts +266 -0
  52. package/src/core/models/codexCapabilities.test.ts +45 -0
  53. package/src/core/models/codexCapabilities.ts +95 -0
  54. package/src/core/models/codexModelCapabilities.test.ts +246 -0
  55. package/src/core/models/codexModelCapabilities.ts +571 -0
  56. package/src/core/models/modelSpecs.test.ts +283 -0
  57. package/src/core/models/modelSpecs.ts +300 -0
  58. package/src/core/perf/profiler.ts +125 -0
  59. package/src/core/perf/renderDebug.test.ts +230 -0
  60. package/src/core/perf/renderDebug.ts +373 -0
  61. package/src/core/planStorage.test.ts +143 -0
  62. package/src/core/planStorage.ts +141 -0
  63. package/src/core/process/CommandRunner.test.ts +105 -0
  64. package/src/core/process/CommandRunner.ts +269 -0
  65. package/src/core/process/processValidation.ts +101 -0
  66. package/src/core/projectInstructions.test.ts +50 -0
  67. package/src/core/projectInstructions.ts +54 -0
  68. package/src/core/providerLauncher/launcher.test.ts +238 -0
  69. package/src/core/providerLauncher/launcher.ts +203 -0
  70. package/src/core/providerLauncher/registry.test.ts +324 -0
  71. package/src/core/providerLauncher/registry.ts +253 -0
  72. package/src/core/providerLauncher/types.ts +84 -0
  73. package/src/core/providerLauncher/workspaceConfig.test.ts +638 -0
  74. package/src/core/providerLauncher/workspaceConfig.ts +407 -0
  75. package/src/core/providerRuntime/anthropic.test.ts +1120 -0
  76. package/src/core/providerRuntime/anthropic.ts +576 -0
  77. package/src/core/providerRuntime/capabilityProfile.test.ts +311 -0
  78. package/src/core/providerRuntime/capabilityProfile.ts +288 -0
  79. package/src/core/providerRuntime/claudeCodeDiscovery.ts +446 -0
  80. package/src/core/providerRuntime/contextMetadata.test.ts +468 -0
  81. package/src/core/providerRuntime/contextMetadata.ts +409 -0
  82. package/src/core/providerRuntime/gemini.test.ts +437 -0
  83. package/src/core/providerRuntime/gemini.ts +784 -0
  84. package/src/core/providerRuntime/lmstudio.test.ts +168 -0
  85. package/src/core/providerRuntime/lmstudio.ts +118 -0
  86. package/src/core/providerRuntime/local.test.ts +787 -0
  87. package/src/core/providerRuntime/local.ts +754 -0
  88. package/src/core/providerRuntime/models.ts +150 -0
  89. package/src/core/providerRuntime/reasoning.ts +17 -0
  90. package/src/core/providerRuntime/registry.test.ts +233 -0
  91. package/src/core/providerRuntime/registry.ts +203 -0
  92. package/src/core/providerRuntime/types.ts +103 -0
  93. package/src/core/providers/codexJsonStream.test.ts +148 -0
  94. package/src/core/providers/codexJsonStream.ts +305 -0
  95. package/src/core/providers/codexSubprocess.test.ts +68 -0
  96. package/src/core/providers/codexSubprocess.ts +372 -0
  97. package/src/core/providers/codexTranscript.test.ts +284 -0
  98. package/src/core/providers/codexTranscript.ts +695 -0
  99. package/src/core/providers/openaiNative.ts +13 -0
  100. package/src/core/providers/registry.ts +21 -0
  101. package/src/core/providers/types.ts +59 -0
  102. package/src/core/terminal/terminalCapabilities.test.ts +93 -0
  103. package/src/core/terminal/terminalCapabilities.ts +100 -0
  104. package/src/core/terminal/terminalControl.test.ts +75 -0
  105. package/src/core/terminal/terminalControl.ts +147 -0
  106. package/src/core/terminal/terminalSanitize.test.ts +22 -0
  107. package/src/core/terminal/terminalSanitize.ts +147 -0
  108. package/src/core/terminal/terminalSelection.test.ts +42 -0
  109. package/src/core/terminal/terminalSelection.ts +66 -0
  110. package/src/core/terminal/terminalTitle.test.ts +328 -0
  111. package/src/core/terminal/terminalTitle.ts +483 -0
  112. package/src/core/workspaceActivity.test.ts +163 -0
  113. package/src/core/workspaceActivity.ts +380 -0
  114. package/src/core/workspaceGuard.test.ts +151 -0
  115. package/src/core/workspaceGuard.ts +288 -0
  116. package/src/core/workspaceRoot.test.ts +23 -0
  117. package/src/core/workspaceRoot.ts +47 -0
  118. package/src/exec.test.ts +13 -0
  119. package/src/exec.ts +72 -0
  120. package/src/headless/execArgs.test.ts +147 -0
  121. package/src/headless/execArgs.ts +294 -0
  122. package/src/headless/execRunner.test.ts +434 -0
  123. package/src/headless/execRunner.ts +304 -0
  124. package/src/index.test.tsx +618 -0
  125. package/src/index.tsx +296 -0
  126. package/src/session/appSession.test.ts +897 -0
  127. package/src/session/appSession.ts +761 -0
  128. package/src/session/chatLifecycle.test.ts +64 -0
  129. package/src/session/chatLifecycle.ts +951 -0
  130. package/src/session/liveRenderScheduler.test.ts +201 -0
  131. package/src/session/liveRenderScheduler.ts +214 -0
  132. package/src/session/planFlow.test.ts +103 -0
  133. package/src/session/planFlow.ts +149 -0
  134. package/src/session/planTranscript.test.ts +65 -0
  135. package/src/session/planTranscript.ts +15 -0
  136. package/src/session/promptRunSchedule.test.ts +36 -0
  137. package/src/session/promptRunSchedule.ts +26 -0
  138. package/src/session/types.ts +228 -0
  139. package/src/test/runtimeTestUtils.ts +14 -0
  140. package/src/types/react-dom.d.ts +3 -0
  141. package/src/ui/ActionRequiredBlock.tsx +38 -0
  142. package/src/ui/ActivityBars.tsx +68 -0
  143. package/src/ui/ActivityIndicator.test.tsx +58 -0
  144. package/src/ui/ActivityIndicator.tsx +58 -0
  145. package/src/ui/AgentBlock.test.ts +6 -0
  146. package/src/ui/AgentBlock.tsx +130 -0
  147. package/src/ui/AnimatedStatusText.test.ts +16 -0
  148. package/src/ui/AnimatedStatusText.tsx +69 -0
  149. package/src/ui/AppShell.test.tsx +1739 -0
  150. package/src/ui/AppShell.tsx +698 -0
  151. package/src/ui/AttachmentImportPanel.test.tsx +204 -0
  152. package/src/ui/AttachmentImportPanel.tsx +98 -0
  153. package/src/ui/AuthPanel.tsx +113 -0
  154. package/src/ui/BackendPicker.tsx +28 -0
  155. package/src/ui/BottomComposer.test.ts +674 -0
  156. package/src/ui/BottomComposer.tsx +1028 -0
  157. package/src/ui/CodexLogo.tsx +55 -0
  158. package/src/ui/DashCard.tsx +82 -0
  159. package/src/ui/Markdown.test.ts +157 -0
  160. package/src/ui/Markdown.tsx +310 -0
  161. package/src/ui/ModePicker.tsx +27 -0
  162. package/src/ui/ModelPicker.tsx +31 -0
  163. package/src/ui/ModelPickerProviderScope.test.tsx +411 -0
  164. package/src/ui/ModelPickerScreen.test.tsx +99 -0
  165. package/src/ui/ModelPickerScreen.tsx +416 -0
  166. package/src/ui/ModelPickerState.test.tsx +151 -0
  167. package/src/ui/ModelReasoningPicker.test.tsx +447 -0
  168. package/src/ui/ModelReasoningPicker.tsx +458 -0
  169. package/src/ui/Panel.tsx +51 -0
  170. package/src/ui/PermissionsPanel.tsx +78 -0
  171. package/src/ui/PlanActionPicker.tsx +119 -0
  172. package/src/ui/PlanReviewPanel.test.tsx +267 -0
  173. package/src/ui/PlanReviewPanel.tsx +212 -0
  174. package/src/ui/PromptCardBorder.test.tsx +161 -0
  175. package/src/ui/ProviderPicker.test.tsx +289 -0
  176. package/src/ui/ProviderPicker.tsx +321 -0
  177. package/src/ui/ProviderShortcut.test.tsx +143 -0
  178. package/src/ui/ReasoningPicker.tsx +46 -0
  179. package/src/ui/RunFooter.tsx +65 -0
  180. package/src/ui/SelectionPanel.tsx +67 -0
  181. package/src/ui/SettingsPanel.test.tsx +233 -0
  182. package/src/ui/SettingsPanel.tsx +156 -0
  183. package/src/ui/Spinner.tsx +25 -0
  184. package/src/ui/StaticIntroItem.tsx +54 -0
  185. package/src/ui/StaticTranscriptItem.tsx +56 -0
  186. package/src/ui/TextEntryPanel.tsx +139 -0
  187. package/src/ui/ThemePicker.tsx +31 -0
  188. package/src/ui/ThinkingBlock.tsx +100 -0
  189. package/src/ui/Timeline.test.ts +2067 -0
  190. package/src/ui/Timeline.tsx +1472 -0
  191. package/src/ui/TimelineNavigation.test.tsx +201 -0
  192. package/src/ui/TopHeader.test.tsx +239 -0
  193. package/src/ui/TopHeader.tsx +257 -0
  194. package/src/ui/TurnGroup.test.tsx +365 -0
  195. package/src/ui/TurnGroup.tsx +657 -0
  196. package/src/ui/busyStatusAnimation.test.ts +30 -0
  197. package/src/ui/busyStatusAnimation.ts +11 -0
  198. package/src/ui/commandNormalize.test.ts +142 -0
  199. package/src/ui/commandNormalize.ts +66 -0
  200. package/src/ui/diffRenderer.test.ts +102 -0
  201. package/src/ui/diffRenderer.ts +116 -0
  202. package/src/ui/focus.ts +61 -0
  203. package/src/ui/focusFlow.test.tsx +1098 -0
  204. package/src/ui/inputBuffer.test.ts +151 -0
  205. package/src/ui/inputBuffer.ts +203 -0
  206. package/src/ui/layout.test.ts +145 -0
  207. package/src/ui/layout.ts +287 -0
  208. package/src/ui/modeDisplay.test.ts +42 -0
  209. package/src/ui/modeDisplay.ts +52 -0
  210. package/src/ui/outputPipeline.ts +64 -0
  211. package/src/ui/progressEntries.ts +156 -0
  212. package/src/ui/runActivityView.test.ts +89 -0
  213. package/src/ui/runActivityView.ts +37 -0
  214. package/src/ui/runLifecycleView.test.tsx +237 -0
  215. package/src/ui/slashCommands.ts +41 -0
  216. package/src/ui/statusRenderIsolation.test.tsx +654 -0
  217. package/src/ui/terminalAnswerFormat.test.ts +19 -0
  218. package/src/ui/terminalAnswerFormat.ts +128 -0
  219. package/src/ui/textLayout.test.ts +18 -0
  220. package/src/ui/textLayout.ts +338 -0
  221. package/src/ui/theme.tsx +395 -0
  222. package/src/ui/themeFlow.test.ts +53 -0
  223. package/src/ui/themeFlow.ts +41 -0
  224. package/src/ui/timelineMeasure.ts +3088 -0
  225. package/src/ui/timelineMeasureCache.test.ts +986 -0
  226. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,951 @@
1
+ import { MAX_CHAT_LINES } from "../config/settings.js";
2
+ import type { AvailableBackend } from "../config/settings.js";
3
+ import type { ResolvedRuntimeConfig } from "../config/runtimeConfig.js";
4
+ import type { BackendProgressUpdate } from "../core/providers/types.js";
5
+ import { summarizeRunActivity, type RunFileActivity } from "../core/workspaceActivity.js";
6
+ import * as renderDebug from "../core/perf/renderDebug.js";
7
+ import type {
8
+ RunEvent,
9
+ RunPlanBlock,
10
+ RunProgressBlock,
11
+ RunProgressEntry,
12
+ RunResponseSegment,
13
+ RunStreamItem,
14
+ RunToolActivity,
15
+ SystemEvent,
16
+ ErrorEvent,
17
+ TimelineEvent,
18
+ UIState,
19
+ } from "./types.js";
20
+
21
+ // ─── Types & constants ────────────────────────────────────────────────────────
22
+
23
+ export const RUN_OUTPUT_TRUNCATION_NOTICE = "Older output was truncated to keep the UI responsive.";
24
+ const ACTION_REQUIRED_BLOCK_PATTERN = /\*{0,2}=+\*{0,2}\s*\n\*{0,2}\[ACTION REQUIRED\]\*{0,2}\s*\n\*{0,2}Verification Question:\*{0,2}\s*\n([\s\S]*?)\n\*{0,2}=+\*{0,2}/i;
25
+
26
+ export type ConfigMutationKind = "backend" | "model" | "mode" | "reasoning" | "permissions" | "theme";
27
+ export type UIStateAction =
28
+ | { type: "PROMPT_RUN_STARTED"; turnId: number }
29
+ | { type: "FIRST_ASSISTANT_DELTA"; turnId: number }
30
+ | { type: "FINAL_ANSWER_VISIBLE"; turnId: number }
31
+ | { type: "RUN_COMPLETED"; turnId: number }
32
+ | { type: "RUN_FAILED"; turnId: number; message: string }
33
+ | { type: "RUN_CANCELED"; turnId: number }
34
+ | { type: "AWAITING_USER_ACTION"; turnId: number; question: string }
35
+ | { type: "DISMISS_TRANSIENT" }
36
+ | { type: "SHELL_STARTED"; shellId: number }
37
+ | { type: "SHELL_FINISHED"; shellId: number };
38
+
39
+ const BUSY_NOTICE_BY_KIND: Record<ConfigMutationKind, string> = {
40
+ backend: "Finish the current run before changing the backend.",
41
+ model: "Finish the current run before changing the model.",
42
+ mode: "Finish the current run before changing the mode.",
43
+ reasoning: "Finish the current run before changing the reasoning level.",
44
+ permissions: "Finish the current run before changing permissions.",
45
+ theme: "Finish the current run before changing the theme.",
46
+ };
47
+
48
+ // ─── Agent question detection ─────────────────────────────────────────────────
49
+ // Called on the final response text after a run completes.
50
+ // Returns the question string if detected, null otherwise.
51
+ //
52
+ // Detection order:
53
+ // 1. Explicit marker [QUESTION]: <text>
54
+ //
55
+ // Ordinary assistant prose must never enter blocking-question mode just because it
56
+ // ends with a question mark. Only explicit hard-block markers should do that.
57
+
58
+ export function detectAgentQuestion(text: string): string | null {
59
+ const explicit = text.match(/\[QUESTION\]:\s*(.+)/);
60
+ if (explicit) return explicit[1]!.trim();
61
+
62
+ return null;
63
+ }
64
+
65
+ function stripBoldMarkers(text: string): string {
66
+ return text.replace(/\*\*/g, "").trim();
67
+ }
68
+
69
+ export function extractAssistantActionRequired(text: string): { content: string; question: string | null } {
70
+ const normalized = text ?? "";
71
+ const blockMatch = ACTION_REQUIRED_BLOCK_PATTERN.exec(normalized);
72
+
73
+ if (blockMatch) {
74
+ const question = stripBoldMarkers(blockMatch[1] ?? "")
75
+ .split("\n")
76
+ .map((line) => line.trim())
77
+ .filter(Boolean)
78
+ .join("\n")
79
+ .trim();
80
+ const content = normalized.replace(blockMatch[0], "").trim();
81
+ return {
82
+ content,
83
+ question: question || null,
84
+ };
85
+ }
86
+
87
+ return {
88
+ content: normalized,
89
+ question: detectAgentQuestion(normalized),
90
+ };
91
+ }
92
+
93
+ export function buildFollowUpPrompt(params: {
94
+ originalPrompt: string;
95
+ assistantQuestion: string;
96
+ userAnswer: string;
97
+ }): string {
98
+ return [
99
+ "You are continuing an earlier task after pausing for one genuinely blocking clarification.",
100
+ "",
101
+ "Original task:",
102
+ params.originalPrompt,
103
+ "",
104
+ "Your blocking question:",
105
+ params.assistantQuestion,
106
+ "",
107
+ "User answer:",
108
+ params.userAnswer,
109
+ "",
110
+ "Continue the task using that answer.",
111
+ "Default to best-effort continuation instead of stopping for clarification.",
112
+ "If another detail is missing but non-critical, make the most reasonable assumption and state it briefly.",
113
+ "Only ask another blocking question if proceeding would likely use the wrong file, wrong command, destructive behavior, or produce fundamentally incorrect output.",
114
+ "If you are still truly blocked on one critical missing fact, end the response with exactly one [QUESTION]: line.",
115
+ ].join("\n");
116
+ }
117
+
118
+ // ─── UI state reducer ────────────────────────────────────────────────────────
119
+
120
+ function stateMatchesTurn(state: UIState, turnId: number): boolean {
121
+ return "turnId" in state && state.turnId === turnId;
122
+ }
123
+
124
+ export function reduceUIState(state: UIState, action: UIStateAction): UIState {
125
+ switch (action.type) {
126
+ case "PROMPT_RUN_STARTED":
127
+ return { kind: "THINKING", turnId: action.turnId };
128
+ case "FIRST_ASSISTANT_DELTA":
129
+ if (state.kind === "THINKING" && state.turnId === action.turnId) {
130
+ return { kind: "RESPONDING", turnId: action.turnId };
131
+ }
132
+ return state;
133
+ case "FINAL_ANSWER_VISIBLE":
134
+ if (stateMatchesTurn(state, action.turnId)) {
135
+ return { kind: "ANSWER_VISIBLE", turnId: action.turnId };
136
+ }
137
+ return state;
138
+ case "RUN_COMPLETED":
139
+ if (stateMatchesTurn(state, action.turnId)) {
140
+ return { kind: "IDLE" };
141
+ }
142
+ return state;
143
+ case "RUN_FAILED":
144
+ if (stateMatchesTurn(state, action.turnId)) {
145
+ return { kind: "ERROR", turnId: action.turnId, message: action.message };
146
+ }
147
+ return state;
148
+ case "RUN_CANCELED":
149
+ if (stateMatchesTurn(state, action.turnId)) {
150
+ return { kind: "IDLE" };
151
+ }
152
+ return state;
153
+ case "AWAITING_USER_ACTION":
154
+ return { kind: "AWAITING_USER_ACTION", turnId: action.turnId, question: action.question };
155
+ case "DISMISS_TRANSIENT":
156
+ if (state.kind === "ERROR" || state.kind === "AWAITING_USER_ACTION") {
157
+ return { kind: "IDLE" };
158
+ }
159
+ return state;
160
+ case "SHELL_STARTED":
161
+ return { kind: "SHELL_RUNNING", shellId: action.shellId };
162
+ case "SHELL_FINISHED":
163
+ if (state.kind === "SHELL_RUNNING" && state.shellId === action.shellId) {
164
+ return { kind: "IDLE" };
165
+ }
166
+ return state;
167
+ default:
168
+ return state;
169
+ }
170
+ }
171
+
172
+ // ─── Run event creation ───────────────────────────────────────────────────────
173
+
174
+ export function createRunEvent(params: {
175
+ id: number;
176
+ backendId: AvailableBackend;
177
+ backendLabel: string;
178
+ runtime: ResolvedRuntimeConfig;
179
+ prompt: string;
180
+ turnId: number;
181
+ startedAtMs?: number;
182
+ approvedPlan?: string;
183
+ responsePresentation?: "assistant" | "plan";
184
+ }): RunEvent {
185
+ const now = params.startedAtMs ?? Date.now();
186
+ const plan: RunPlanBlock | null = params.approvedPlan
187
+ ? {
188
+ id: `plan-${params.id}`,
189
+ streamSeq: 1,
190
+ chunks: [params.approvedPlan],
191
+ status: "completed",
192
+ startedAt: now,
193
+ }
194
+ : null;
195
+ return {
196
+ id: params.id,
197
+ type: "run",
198
+ createdAt: now,
199
+ startedAt: now,
200
+ durationMs: null,
201
+ backendId: params.backendId,
202
+ backendLabel: params.backendLabel,
203
+ runtime: params.runtime,
204
+ prompt: params.prompt,
205
+ progressEntries: [],
206
+ status: "running",
207
+ summary: "starting...",
208
+ truncatedOutput: false,
209
+ toolActivities: [],
210
+ activity: [],
211
+ touchedFileCount: 0,
212
+ errorMessage: null,
213
+ turnId: params.turnId,
214
+ streamItems: plan
215
+ ? [{ streamSeq: plan.streamSeq, kind: "plan" as const, refId: plan.id }]
216
+ : [],
217
+ responseSegments: [],
218
+ lastStreamSeq: plan ? plan.streamSeq : 0,
219
+ activeResponseSegmentId: null,
220
+ plan,
221
+ approvedPlan: params.approvedPlan,
222
+ };
223
+ }
224
+
225
+ function appendStreamItem(items: RunStreamItem[], item: RunStreamItem): RunStreamItem[] {
226
+ return [...items, item];
227
+ }
228
+
229
+ function maxStreamSeq(event: RunEvent, items: RunStreamItem[] = event.streamItems ?? []): number {
230
+ return Math.max(event.lastStreamSeq ?? 0, ...items.map((item) => item.streamSeq));
231
+ }
232
+
233
+ function createPlanBlock(event: RunEvent, text = "", status: RunPlanBlock["status"] = "active"): RunPlanBlock {
234
+ const streamSeq = maxStreamSeq(event) + 1;
235
+ return {
236
+ id: `plan-${event.id}`,
237
+ streamSeq,
238
+ chunks: text ? [text] : [],
239
+ status,
240
+ startedAt: Date.now(),
241
+ };
242
+ }
243
+
244
+ export function appendRunPlanChunk(event: RunEvent, chunk: string): RunEvent {
245
+ if (!chunk) return event;
246
+
247
+ if (event.plan) {
248
+ return {
249
+ ...event,
250
+ plan: {
251
+ ...event.plan,
252
+ chunks: [...event.plan.chunks, chunk],
253
+ status: "active",
254
+ },
255
+ activeResponseSegmentId: null,
256
+ summary: "planning...",
257
+ };
258
+ }
259
+
260
+ const plan = createPlanBlock(event, chunk);
261
+ return {
262
+ ...event,
263
+ plan,
264
+ streamItems: appendStreamItem(event.streamItems ?? [], {
265
+ streamSeq: plan.streamSeq,
266
+ kind: "plan",
267
+ refId: plan.id,
268
+ }),
269
+ lastStreamSeq: plan.streamSeq,
270
+ activeResponseSegmentId: null,
271
+ summary: "planning...",
272
+ };
273
+ }
274
+
275
+ export function finalizePlanBlock(event: RunEvent, finalPlan?: string): RunEvent {
276
+ const text = finalPlan ?? event.plan?.chunks.join("") ?? "";
277
+
278
+ if (event.plan) {
279
+ const streamItemsWithoutPlan = (event.streamItems ?? []).filter((item) =>
280
+ !(item.kind === "plan" && item.refId === event.plan?.id)
281
+ );
282
+ const streamSeq = maxStreamSeq(event, streamItemsWithoutPlan) + 1;
283
+ return {
284
+ ...event,
285
+ plan: {
286
+ ...event.plan,
287
+ streamSeq,
288
+ chunks: text ? [text] : event.plan.chunks,
289
+ status: "completed",
290
+ },
291
+ streamItems: appendStreamItem(streamItemsWithoutPlan, {
292
+ streamSeq,
293
+ kind: "plan",
294
+ refId: event.plan.id,
295
+ }),
296
+ lastStreamSeq: streamSeq,
297
+ activeResponseSegmentId: null,
298
+ };
299
+ }
300
+
301
+ if (!text.trim()) {
302
+ return { ...event, activeResponseSegmentId: null };
303
+ }
304
+
305
+ const plan = createPlanBlock(event, text, "completed");
306
+ return {
307
+ ...event,
308
+ plan,
309
+ streamItems: appendStreamItem(event.streamItems ?? [], {
310
+ streamSeq: plan.streamSeq,
311
+ kind: "plan",
312
+ refId: plan.id,
313
+ }),
314
+ lastStreamSeq: plan.streamSeq,
315
+ activeResponseSegmentId: null,
316
+ };
317
+ }
318
+
319
+ // ─── Tool activities ─────────────────────────────────────────────────────────
320
+
321
+ export function upsertRunToolActivity(event: RunEvent, activity: RunToolActivity): RunEvent {
322
+ const existingIndex = event.toolActivities.findIndex((item) => item.id === activity.id);
323
+ if (existingIndex < 0) {
324
+ const streamSeq = (event.lastStreamSeq ?? 0) + 1;
325
+ const enriched: RunToolActivity = { ...activity, streamSeq };
326
+ renderDebug.traceEvent("action", "normalized", {
327
+ runId: event.id,
328
+ turnId: event.turnId,
329
+ actionId: enriched.id,
330
+ status: enriched.status,
331
+ streamSeq,
332
+ operation: "insert",
333
+ stableKeyPreserved: true,
334
+ });
335
+ return {
336
+ ...event,
337
+ toolActivities: [...event.toolActivities, enriched],
338
+ streamItems: appendStreamItem(event.streamItems ?? [], {
339
+ streamSeq,
340
+ kind: "action",
341
+ refId: enriched.id,
342
+ }),
343
+ lastStreamSeq: streamSeq,
344
+ // A new tool interrupts the current response segment; the next assistant
345
+ // delta will start a fresh segment with a larger streamSeq.
346
+ activeResponseSegmentId: null,
347
+ };
348
+ }
349
+
350
+ const existing = event.toolActivities[existingIndex]!;
351
+ const merged: RunToolActivity = {
352
+ ...existing,
353
+ ...activity,
354
+ streamSeq: existing.streamSeq, // preserve original assignment
355
+ };
356
+ renderDebug.traceEvent("action", "normalized", {
357
+ runId: event.id,
358
+ turnId: event.turnId,
359
+ actionId: merged.id,
360
+ previousStatus: existing.status,
361
+ status: merged.status,
362
+ streamSeq: merged.streamSeq,
363
+ operation: "merge",
364
+ stableKeyPreserved: existing.streamSeq === merged.streamSeq,
365
+ });
366
+ const nextToolActivities = [...event.toolActivities];
367
+ nextToolActivities[existingIndex] = merged;
368
+ return {
369
+ ...event,
370
+ toolActivities: nextToolActivities,
371
+ };
372
+ }
373
+
374
+ function finalizePendingToolActivities(
375
+ toolActivities: RunToolActivity[],
376
+ finalStatus: "completed" | "failed" | "canceled",
377
+ ): RunToolActivity[] {
378
+ const fallbackStatus = finalStatus === "completed" ? "completed" : "failed";
379
+ const fallbackSummary = finalStatus === "completed"
380
+ ? "Completed"
381
+ : finalStatus === "canceled"
382
+ ? "Canceled"
383
+ : "Failed";
384
+
385
+ return toolActivities.map((item) => (
386
+ item.status === "running"
387
+ ? {
388
+ ...item,
389
+ status: fallbackStatus,
390
+ completedAt: item.completedAt ?? Date.now(),
391
+ summary: item.summary ?? fallbackSummary,
392
+ }
393
+ : item
394
+ ));
395
+ }
396
+
397
+ function mergeRunActivity(existing: RunFileActivity[], additions: RunFileActivity[]): RunFileActivity[] {
398
+ const merged = [...existing];
399
+
400
+ for (const item of additions) {
401
+ const last = merged[merged.length - 1];
402
+ if (
403
+ last &&
404
+ last.path === item.path &&
405
+ last.operation === item.operation &&
406
+ item.operation === "modified"
407
+ ) {
408
+ merged[merged.length - 1] = item;
409
+ continue;
410
+ }
411
+
412
+ merged.push(item);
413
+ }
414
+
415
+ return merged;
416
+ }
417
+
418
+ export function appendRunActivity(event: RunEvent, additions: RunFileActivity[]): RunEvent {
419
+ if (additions.length === 0) return event;
420
+
421
+ const activity = mergeRunActivity(event.activity, additions);
422
+ const touchedFileCount = new Set(activity.map((item) => item.path)).size;
423
+
424
+ return {
425
+ ...event,
426
+ activity,
427
+ touchedFileCount,
428
+ activitySummary: summarizeRunActivity(activity),
429
+ summary: touchedFileCount > 0
430
+ ? `${touchedFileCount} file${touchedFileCount === 1 ? "" : "s"} modified`
431
+ : "working...",
432
+ };
433
+ }
434
+
435
+ // ─── Progress blocks ─────────────────────────────────────────────────────────
436
+
437
+ function trimProgressText(text: string): string {
438
+ return text
439
+ .replace(/\r\n/g, "\n")
440
+ .replace(/\r/g, "\n")
441
+ .replace(/[ \t]+\n/g, "\n");
442
+ }
443
+
444
+ function createProgressBlock(entryId: string, sequence: number, createdAt: number): RunProgressBlock {
445
+ return {
446
+ id: `${entryId}-block-${sequence}`,
447
+ text: "",
448
+ sequence,
449
+ createdAt,
450
+ updatedAt: createdAt,
451
+ status: "active",
452
+ };
453
+ }
454
+
455
+ // Split streaming progress blocks at readable sentence and list boundaries.
456
+ const MIN_PROGRESS_BLOCK_CHARS = 36;
457
+ const TRANSITION_PHRASE_PATTERN = String.raw`(?:I(?:'|’)m going to|I(?:'|’)ll|I found|I(?:'|’)m checking|I'm checking|I am checking|Next(?:,|\s)|The highest-value improvements|The highest value improvements)`;
458
+ const INLINE_TRANSITION_PATTERN = new RegExp(String.raw`[.!?]\s+(?=${TRANSITION_PHRASE_PATTERN}\b)`, "i");
459
+ const NEWLINE_TRANSITION_PATTERN = new RegExp(String.raw`\n(?=${TRANSITION_PHRASE_PATTERN}\b)`, "i");
460
+ const LIST_MARKER_PATTERN = /^\s*(?:[-*+]\s+|\d+[.)]\s+)/;
461
+ const NEWLINE_LIST_MARKER_PATTERN = /\n(?=\s*(?:[-*+]\s+|\d+[.)]\s+))/;
462
+
463
+ function hasCommittedBlockText(blocks: RunProgressBlock[]): boolean {
464
+ return blocks.some((block) => block.text.length > 0);
465
+ }
466
+
467
+ function hasMeaningfulBlockText(text: string): boolean {
468
+ return text.trim().length >= MIN_PROGRESS_BLOCK_CHARS;
469
+ }
470
+
471
+ function findReadableBoundary(text: string): { splitAt: number; trimLeft: boolean } | null {
472
+ if (!hasMeaningfulBlockText(text)) {
473
+ return null;
474
+ }
475
+
476
+ const transitionMatch = INLINE_TRANSITION_PATTERN.exec(text);
477
+ if (transitionMatch && transitionMatch.index + transitionMatch[0].length < text.length) {
478
+ const splitAt = transitionMatch.index + transitionMatch[0].length;
479
+ if (hasMeaningfulBlockText(text.slice(0, splitAt))) {
480
+ return { splitAt, trimLeft: false };
481
+ }
482
+ }
483
+
484
+ const newlineTransitionMatch = NEWLINE_TRANSITION_PATTERN.exec(text);
485
+ if (newlineTransitionMatch && newlineTransitionMatch.index > 0) {
486
+ const splitAt = newlineTransitionMatch.index;
487
+ if (hasMeaningfulBlockText(text.slice(0, splitAt))) {
488
+ return { splitAt, trimLeft: true };
489
+ }
490
+ }
491
+
492
+ const newlineListMatch = NEWLINE_LIST_MARKER_PATTERN.exec(text);
493
+ if (newlineListMatch && newlineListMatch.index > 0) {
494
+ const before = text.slice(0, newlineListMatch.index);
495
+ const after = text.slice(newlineListMatch.index + 1);
496
+ if (
497
+ hasMeaningfulBlockText(before)
498
+ && !LIST_MARKER_PATTERN.test(before.split("\n").findLast((line) => line.trim()) ?? "")
499
+ && LIST_MARKER_PATTERN.test(after)
500
+ ) {
501
+ return { splitAt: newlineListMatch.index, trimLeft: true };
502
+ }
503
+ }
504
+
505
+ return null;
506
+ }
507
+
508
+ function appendDeltaToProgressEntry(
509
+ entry: RunProgressEntry,
510
+ delta: string,
511
+ updatedAt: number,
512
+ ): Pick<RunProgressEntry, "blocks" | "pendingNewlineCount"> {
513
+ let blocks = entry.blocks.slice();
514
+ let pendingNewlineCount = entry.pendingNewlineCount;
515
+
516
+ const ensureActiveBlock = (): number => {
517
+ const last = blocks[blocks.length - 1];
518
+ if (last?.status === "active") {
519
+ return blocks.length - 1;
520
+ }
521
+
522
+ const nextSequence = last?.sequence ?? 0;
523
+ blocks.push(createProgressBlock(entry.id, nextSequence + 1, updatedAt));
524
+ return blocks.length - 1;
525
+ };
526
+
527
+ const updateBlock = (index: number, updater: (block: RunProgressBlock) => RunProgressBlock) => {
528
+ blocks[index] = updater(blocks[index]!);
529
+ };
530
+
531
+ const splitActiveBlockAtReadableBoundary = () => {
532
+ let activeIndex = -1;
533
+ for (let index = blocks.length - 1; index >= 0; index -= 1) {
534
+ if (blocks[index]?.status === "active") {
535
+ activeIndex = index;
536
+ break;
537
+ }
538
+ }
539
+ if (activeIndex < 0) return;
540
+
541
+ const block = blocks[activeIndex]!;
542
+ const boundary = findReadableBoundary(block.text);
543
+ if (!boundary) return;
544
+
545
+ const completedText = block.text.slice(0, boundary.splitAt).trimEnd();
546
+ const activeText = block.text.slice(boundary.splitAt + (boundary.trimLeft ? 1 : 0)).trimStart();
547
+ if (!completedText || !activeText) return;
548
+
549
+ blocks[activeIndex] = {
550
+ ...block,
551
+ text: completedText,
552
+ status: "completed",
553
+ updatedAt,
554
+ };
555
+ blocks.splice(activeIndex + 1, 0, {
556
+ ...createProgressBlock(entry.id, block.sequence + 1, updatedAt),
557
+ text: activeText,
558
+ });
559
+ blocks = blocks.map((candidate, index) => ({
560
+ ...candidate,
561
+ sequence: index + 1,
562
+ id: `${entry.id}-block-${index + 1}`,
563
+ }));
564
+ };
565
+
566
+ for (const char of delta) {
567
+ if (char === "\n") {
568
+ pendingNewlineCount += 1;
569
+ continue;
570
+ }
571
+
572
+ if (pendingNewlineCount >= 2) {
573
+ const last = blocks[blocks.length - 1];
574
+ if (last?.status === "active") {
575
+ updateBlock(blocks.length - 1, (block) => ({ ...block, status: "completed", updatedAt }));
576
+ }
577
+ } else if (pendingNewlineCount === 1 && hasCommittedBlockText(blocks)) {
578
+ const activeIndex = ensureActiveBlock();
579
+ updateBlock(activeIndex, (block) => ({
580
+ ...block,
581
+ text: `${block.text}\n`,
582
+ updatedAt,
583
+ }));
584
+ }
585
+
586
+ pendingNewlineCount = 0;
587
+ const activeIndex = ensureActiveBlock();
588
+ updateBlock(activeIndex, (block) => ({
589
+ ...block,
590
+ text: `${block.text}${char}`,
591
+ updatedAt,
592
+ }));
593
+ splitActiveBlockAtReadableBoundary();
594
+ }
595
+
596
+ if (pendingNewlineCount >= 2) {
597
+ const last = blocks[blocks.length - 1];
598
+ if (last?.status === "active") {
599
+ updateBlock(blocks.length - 1, (block) => ({ ...block, status: "completed", updatedAt }));
600
+ }
601
+ }
602
+
603
+ return { blocks, pendingNewlineCount };
604
+ }
605
+
606
+ function materializeProgressEntry(
607
+ entry: RunProgressEntry,
608
+ nextText: string,
609
+ updatedAt: number,
610
+ source: BackendProgressUpdate["source"],
611
+ ): RunProgressEntry {
612
+ if (nextText === entry.text) {
613
+ return {
614
+ ...entry,
615
+ source,
616
+ updatedAt,
617
+ };
618
+ }
619
+
620
+ if (nextText.startsWith(entry.text)) {
621
+ const delta = nextText.slice(entry.text.length);
622
+ const next = appendDeltaToProgressEntry(entry, delta, updatedAt);
623
+ return {
624
+ ...entry,
625
+ source,
626
+ text: nextText,
627
+ updatedAt,
628
+ blocks: next.blocks,
629
+ pendingNewlineCount: next.pendingNewlineCount,
630
+ };
631
+ }
632
+
633
+ const rebuiltSeed: RunProgressEntry = {
634
+ ...entry,
635
+ source,
636
+ text: "",
637
+ updatedAt,
638
+ blocks: [],
639
+ pendingNewlineCount: 0,
640
+ };
641
+ const rebuilt = appendDeltaToProgressEntry(rebuiltSeed, nextText, updatedAt);
642
+
643
+ const blocks = rebuilt.blocks.map((block, index) => {
644
+ const existing = entry.blocks[index];
645
+ if (
646
+ existing
647
+ && existing.sequence === block.sequence
648
+ && existing.text === block.text
649
+ && existing.status === block.status
650
+ ) {
651
+ return existing;
652
+ }
653
+
654
+ return {
655
+ ...block,
656
+ id: existing?.id ?? block.id,
657
+ createdAt: existing?.createdAt ?? block.createdAt,
658
+ };
659
+ });
660
+
661
+ return {
662
+ ...entry,
663
+ source,
664
+ text: nextText,
665
+ updatedAt,
666
+ blocks,
667
+ pendingNewlineCount: rebuilt.pendingNewlineCount,
668
+ };
669
+ }
670
+
671
+ /** Sources that surface as user-facing thinking items. */
672
+ const THINKING_SOURCES = new Set(["reasoning", "todo", "transcript"]);
673
+
674
+ export function appendRunThinking(event: RunEvent, updates: BackendProgressUpdate[]): RunEvent {
675
+ if (updates.length === 0) return event;
676
+ renderDebug.traceEvent("transcript", "progressBatchNormalize", {
677
+ runId: event.id,
678
+ turnId: event.turnId,
679
+ updateCount: updates.length,
680
+ previousProgressEntries: event.progressEntries.length,
681
+ });
682
+
683
+ let nextSequence = event.progressEntries[event.progressEntries.length - 1]?.sequence ?? 0;
684
+ let progressEntries = [...event.progressEntries];
685
+ let truncatedOutput = event.truncatedOutput;
686
+
687
+ for (const update of updates) {
688
+ const text = trimProgressText(update.text ?? "");
689
+ if (!text.trim()) continue;
690
+ const updatedAt = Date.now();
691
+
692
+ const existingIndex = progressEntries.findIndex((entry) => entry.id === update.id);
693
+ if (existingIndex >= 0) {
694
+ const existing = progressEntries[existingIndex]!;
695
+ progressEntries[existingIndex] = materializeProgressEntry(existing, text, updatedAt, update.source);
696
+ continue;
697
+ }
698
+
699
+ nextSequence += 1;
700
+ const createdAt = updatedAt;
701
+ const seed: RunProgressEntry = {
702
+ id: update.id,
703
+ source: update.source,
704
+ sequence: nextSequence,
705
+ createdAt,
706
+ updatedAt,
707
+ text: "",
708
+ blocks: [],
709
+ pendingNewlineCount: 0,
710
+ };
711
+ progressEntries.push(materializeProgressEntry(seed, text, updatedAt, update.source));
712
+ }
713
+
714
+ // Assign turn-global streamSeq to newly visible thinking blocks. Only
715
+ // reasoning/todo entries surface as thinking — tool/stderr/transcript
716
+ // text is diagnostic and never becomes a stream item.
717
+ let streamItems = event.streamItems ?? [];
718
+ let lastStreamSeq = event.lastStreamSeq ?? 0;
719
+ let activeResponseSegmentId = event.activeResponseSegmentId ?? null;
720
+ let anyVisibleNewBlock = false;
721
+
722
+ progressEntries = progressEntries.map((entry) => {
723
+ if (!THINKING_SOURCES.has(entry.source)) return entry;
724
+ let entryChanged = false;
725
+ const blocks = entry.blocks.map((block) => {
726
+ if (block.streamSeq != null) return block;
727
+ if (block.text.trim().length === 0) return block;
728
+ anyVisibleNewBlock = true;
729
+ lastStreamSeq += 1;
730
+ const next: RunProgressBlock = { ...block, streamSeq: lastStreamSeq };
731
+ streamItems = appendStreamItem(streamItems, {
732
+ streamSeq: lastStreamSeq,
733
+ kind: "thinking",
734
+ refId: block.id,
735
+ });
736
+ entryChanged = true;
737
+ return next;
738
+ });
739
+ return entryChanged ? { ...entry, blocks } : entry;
740
+ });
741
+
742
+ if (anyVisibleNewBlock) {
743
+ activeResponseSegmentId = null;
744
+ }
745
+
746
+ if (progressEntries.length > MAX_CHAT_LINES) {
747
+ progressEntries = progressEntries.slice(-MAX_CHAT_LINES);
748
+ truncatedOutput = true;
749
+ }
750
+
751
+ return {
752
+ ...event,
753
+ progressEntries,
754
+ truncatedOutput,
755
+ streamItems,
756
+ lastStreamSeq,
757
+ activeResponseSegmentId,
758
+ summary: "processing...",
759
+ };
760
+ }
761
+
762
+ // ─── Assistant response segments ─────────────────────────────────────────────
763
+
764
+ /**
765
+ * Append a response chunk. Extends the currently active response segment if
766
+ * one exists (`activeResponseSegmentId`); otherwise opens a new segment with
767
+ * a freshly allocated streamSeq, becoming the new active segment.
768
+ *
769
+ * A thinking or action event between deltas clears `activeResponseSegmentId`,
770
+ * which is what produces correct response → action → response interleaving.
771
+ */
772
+ export function appendRunResponseChunk(event: RunEvent, chunk: string): RunEvent {
773
+ if (!chunk) return event;
774
+
775
+ const segments = event.responseSegments ?? [];
776
+ const activeId = event.activeResponseSegmentId ?? null;
777
+
778
+ if (activeId) {
779
+ const index = segments.findIndex((segment) => segment.id === activeId);
780
+ if (index >= 0) {
781
+ const existing = segments[index]!;
782
+ const updated: RunResponseSegment = { ...existing, chunks: [...existing.chunks, chunk] };
783
+ const nextSegments = [...segments];
784
+ nextSegments[index] = updated;
785
+ return { ...event, responseSegments: nextSegments };
786
+ }
787
+ }
788
+
789
+ const streamSeq = (event.lastStreamSeq ?? 0) + 1;
790
+ const segmentId = `response-${event.id}-${streamSeq}`;
791
+ const newSegment: RunResponseSegment = {
792
+ id: segmentId,
793
+ streamSeq,
794
+ chunks: [chunk],
795
+ status: "active",
796
+ startedAt: Date.now(),
797
+ };
798
+
799
+ return {
800
+ ...event,
801
+ responseSegments: [...segments, newSegment],
802
+ streamItems: appendStreamItem(event.streamItems ?? [], {
803
+ streamSeq,
804
+ kind: "response",
805
+ refId: segmentId,
806
+ }),
807
+ lastStreamSeq: streamSeq,
808
+ activeResponseSegmentId: segmentId,
809
+ };
810
+ }
811
+
812
+ /**
813
+ * Mark all response segments completed. Optionally rewrite the trailing
814
+ * segment's chunks to an authoritative final response (e.g. when the
815
+ * backend returns a different response than what was streamed).
816
+ */
817
+ export function finalizeResponseSegments(event: RunEvent, finalResponse?: string): RunEvent {
818
+ renderDebug.traceEvent("action", "regroupedForFinalize", {
819
+ runId: event.id,
820
+ turnId: event.turnId,
821
+ toolActivities: event.toolActivities.length,
822
+ streamItems: event.streamItems?.length ?? 0,
823
+ responseSegments: event.responseSegments?.length ?? 0,
824
+ hasFinalResponseOverride: Boolean(finalResponse),
825
+ });
826
+ const segments = event.responseSegments ?? [];
827
+ if (segments.length === 0) {
828
+ if (!finalResponse) return event;
829
+ const seq = (event.lastStreamSeq ?? 0) + 1;
830
+ const id = `response-final-${event.id}-${seq}`;
831
+ return {
832
+ ...event,
833
+ responseSegments: [{
834
+ id,
835
+ streamSeq: seq,
836
+ chunks: [finalResponse],
837
+ status: "completed",
838
+ startedAt: Date.now(),
839
+ }],
840
+ streamItems: appendStreamItem(event.streamItems ?? [], {
841
+ streamSeq: seq,
842
+ kind: "response",
843
+ refId: id,
844
+ }),
845
+ lastStreamSeq: seq,
846
+ activeResponseSegmentId: null,
847
+ };
848
+ }
849
+
850
+ const nextSegments = segments.map((segment, index) => {
851
+ if (index === segments.length - 1 && finalResponse != null) {
852
+ return { ...segment, chunks: [finalResponse], status: "completed" as const };
853
+ }
854
+ return segment.status === "active" ? { ...segment, status: "completed" as const } : segment;
855
+ });
856
+ return { ...event, responseSegments: nextSegments, activeResponseSegmentId: null };
857
+ }
858
+
859
+ export function markResponseSegmentsCompleted(event: RunEvent, finalResponse?: string): RunEvent {
860
+ return finalizeResponseSegments(event, finalResponse);
861
+ }
862
+
863
+ export const appendRunOutput = appendRunThinking;
864
+
865
+ // ─── Run lifecycle ────────────────────────────────────────────────────────────
866
+
867
+ export function completeRunEvent(event: RunEvent, durationMs = Date.now() - event.startedAt): RunEvent {
868
+ const touchedSuffix = event.touchedFileCount > 0
869
+ ? ` · ${event.touchedFileCount} file${event.touchedFileCount === 1 ? "" : "s"} touched`
870
+ : "";
871
+
872
+ return {
873
+ ...event,
874
+ status: "completed",
875
+ durationMs,
876
+ activitySummary: summarizeRunActivity(event.activity),
877
+ toolActivities: finalizePendingToolActivities(event.toolActivities, "completed"),
878
+ errorMessage: null,
879
+ summary: event.progressEntries.length > 0 || event.activity.length > 0
880
+ ? `Run completed successfully${touchedSuffix}`
881
+ : "Run completed with no visible output",
882
+ };
883
+ }
884
+
885
+ export function failRunEvent(
886
+ event: RunEvent,
887
+ summary = "Run failed",
888
+ errorMessage?: string,
889
+ durationMs = Date.now() - event.startedAt,
890
+ ): RunEvent {
891
+ return {
892
+ ...event,
893
+ status: "failed",
894
+ durationMs,
895
+ activitySummary: summarizeRunActivity(event.activity),
896
+ toolActivities: finalizePendingToolActivities(event.toolActivities, "failed"),
897
+ errorMessage: errorMessage ?? summary,
898
+ summary: event.touchedFileCount > 0
899
+ ? `${summary} · ${event.touchedFileCount} file${event.touchedFileCount === 1 ? "" : "s"} touched`
900
+ : summary,
901
+ };
902
+ }
903
+
904
+ export function cancelRunEvent(event: RunEvent, durationMs = Date.now() - event.startedAt): RunEvent {
905
+ return {
906
+ ...event,
907
+ status: "canceled",
908
+ durationMs,
909
+ activitySummary: summarizeRunActivity(event.activity),
910
+ toolActivities: finalizePendingToolActivities(event.toolActivities, "canceled"),
911
+ errorMessage: null,
912
+ summary: event.touchedFileCount > 0
913
+ ? `Run canceled · ${event.touchedFileCount} file${event.touchedFileCount === 1 ? "" : "s"} touched`
914
+ : "Run canceled",
915
+ };
916
+ }
917
+
918
+ // ─── Event routing ───────────────────────────────────────────────────────────
919
+
920
+ export function appendStaticEvents(events: TimelineEvent[], additions: TimelineEvent[]): TimelineEvent[] {
921
+ const result = [...events];
922
+ for (const addition of additions) {
923
+ const last = result[result.length - 1];
924
+ let isDuplicate = false;
925
+ if (last && last.type === addition.type) {
926
+ if (addition.type === "system" || addition.type === "error") {
927
+ const lastTyped = last as SystemEvent | ErrorEvent;
928
+ const additionTyped = addition as SystemEvent | ErrorEvent;
929
+ isDuplicate = lastTyped.title === additionTyped.title && lastTyped.content === additionTyped.content;
930
+ }
931
+ }
932
+
933
+ if (!isDuplicate) {
934
+ result.push(addition);
935
+ }
936
+ }
937
+ return result;
938
+ }
939
+
940
+ export function trimStaticEvents(events: TimelineEvent[]): TimelineEvent[] {
941
+ return events;
942
+ }
943
+
944
+ export function guardConfigMutation(kind: ConfigMutationKind, busy: boolean): { allowed: boolean; message?: string } {
945
+ if (!busy) return { allowed: true };
946
+ return { allowed: false, message: BUSY_NOTICE_BY_KIND[kind] };
947
+ }
948
+
949
+ export function isCurrentRun(activeRunId: number | null, runId: number): boolean {
950
+ return activeRunId === runId;
951
+ }