akribes 0.21.17

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 (100) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +160 -0
  4. package/dist/client.d.ts +240 -0
  5. package/dist/client.d.ts.map +1 -0
  6. package/dist/client.js +272 -0
  7. package/dist/client.js.map +1 -0
  8. package/dist/errors.d.ts +196 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/errors.js +274 -0
  11. package/dist/errors.js.map +1 -0
  12. package/dist/execution/index.d.ts +3 -0
  13. package/dist/execution/index.d.ts.map +1 -0
  14. package/dist/execution/index.js +3 -0
  15. package/dist/execution/index.js.map +1 -0
  16. package/dist/execution/replay.d.ts +37 -0
  17. package/dist/execution/replay.d.ts.map +1 -0
  18. package/dist/execution/replay.js +59 -0
  19. package/dist/execution/replay.js.map +1 -0
  20. package/dist/execution/steps.d.ts +327 -0
  21. package/dist/execution/steps.d.ts.map +1 -0
  22. package/dist/execution/steps.js +1068 -0
  23. package/dist/execution/steps.js.map +1 -0
  24. package/dist/http.d.ts +53 -0
  25. package/dist/http.d.ts.map +1 -0
  26. package/dist/http.js +141 -0
  27. package/dist/http.js.map +1 -0
  28. package/dist/index.d.ts +36 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +38 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/runStream.d.ts +176 -0
  33. package/dist/runStream.d.ts.map +1 -0
  34. package/dist/runStream.js +408 -0
  35. package/dist/runStream.js.map +1 -0
  36. package/dist/sse.d.ts +46 -0
  37. package/dist/sse.d.ts.map +1 -0
  38. package/dist/sse.js +218 -0
  39. package/dist/sse.js.map +1 -0
  40. package/dist/sub/bench.d.ts +182 -0
  41. package/dist/sub/bench.d.ts.map +1 -0
  42. package/dist/sub/bench.js +420 -0
  43. package/dist/sub/bench.js.map +1 -0
  44. package/dist/sub/channels.d.ts +22 -0
  45. package/dist/sub/channels.d.ts.map +1 -0
  46. package/dist/sub/channels.js +32 -0
  47. package/dist/sub/channels.js.map +1 -0
  48. package/dist/sub/clients.d.ts +79 -0
  49. package/dist/sub/clients.d.ts.map +1 -0
  50. package/dist/sub/clients.js +190 -0
  51. package/dist/sub/clients.js.map +1 -0
  52. package/dist/sub/documents.d.ts +113 -0
  53. package/dist/sub/documents.d.ts.map +1 -0
  54. package/dist/sub/documents.js +329 -0
  55. package/dist/sub/documents.js.map +1 -0
  56. package/dist/sub/evals.d.ts +71 -0
  57. package/dist/sub/evals.d.ts.map +1 -0
  58. package/dist/sub/evals.js +86 -0
  59. package/dist/sub/evals.js.map +1 -0
  60. package/dist/sub/events.d.ts +65 -0
  61. package/dist/sub/events.d.ts.map +1 -0
  62. package/dist/sub/events.js +154 -0
  63. package/dist/sub/events.js.map +1 -0
  64. package/dist/sub/executions.d.ts +255 -0
  65. package/dist/sub/executions.d.ts.map +1 -0
  66. package/dist/sub/executions.js +322 -0
  67. package/dist/sub/executions.js.map +1 -0
  68. package/dist/sub/mcp.d.ts +51 -0
  69. package/dist/sub/mcp.d.ts.map +1 -0
  70. package/dist/sub/mcp.js +42 -0
  71. package/dist/sub/mcp.js.map +1 -0
  72. package/dist/sub/projects.d.ts +73 -0
  73. package/dist/sub/projects.d.ts.map +1 -0
  74. package/dist/sub/projects.js +101 -0
  75. package/dist/sub/projects.js.map +1 -0
  76. package/dist/sub/scripts.d.ts +58 -0
  77. package/dist/sub/scripts.d.ts.map +1 -0
  78. package/dist/sub/scripts.js +82 -0
  79. package/dist/sub/scripts.js.map +1 -0
  80. package/dist/sub/tokens.d.ts +126 -0
  81. package/dist/sub/tokens.d.ts.map +1 -0
  82. package/dist/sub/tokens.js +105 -0
  83. package/dist/sub/tokens.js.map +1 -0
  84. package/dist/sub/versions.d.ts +29 -0
  85. package/dist/sub/versions.d.ts.map +1 -0
  86. package/dist/sub/versions.js +52 -0
  87. package/dist/sub/versions.js.map +1 -0
  88. package/dist/tokenSafety.d.ts +15 -0
  89. package/dist/tokenSafety.d.ts.map +1 -0
  90. package/dist/tokenSafety.js +24 -0
  91. package/dist/tokenSafety.js.map +1 -0
  92. package/dist/types.d.ts +1147 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +132 -0
  95. package/dist/types.js.map +1 -0
  96. package/dist/workflowEvents.d.ts +297 -0
  97. package/dist/workflowEvents.d.ts.map +1 -0
  98. package/dist/workflowEvents.js +612 -0
  99. package/dist/workflowEvents.js.map +1 -0
  100. package/package.json +57 -0
@@ -0,0 +1,1068 @@
1
+ import { normalizeSuspendTrigger } from '../workflowEvents';
2
+ function parseSubScriptEnvelope(payload) {
3
+ if (!payload || typeof payload !== 'object')
4
+ return null;
5
+ const p = payload;
6
+ const scriptName = typeof p.script_name === 'string' ? p.script_name : '';
7
+ const parentTask = typeof p.parent_task === 'string' ? p.parent_task : '';
8
+ const frame = { scriptName, parentTask };
9
+ const child = p.child;
10
+ if (!child || typeof child !== 'object') {
11
+ return {
12
+ frame,
13
+ kind: 'leaf',
14
+ effects: { childIsTerminal: false, childIsError: false },
15
+ };
16
+ }
17
+ const c = child;
18
+ const cType = typeof c.type === 'string' ? c.type : '';
19
+ const cPayload = c.payload;
20
+ // Nested sub-script: defer to the caller for one more level of unwrapping.
21
+ if (cType === 'SubScript') {
22
+ return { frame, kind: 'nested', innerPayload: cPayload };
23
+ }
24
+ switch (cType) {
25
+ case 'StateUpdate': {
26
+ // Wire shape: payload is `[name, value]`.
27
+ if (Array.isArray(cPayload) && typeof cPayload[0] === 'string') {
28
+ return {
29
+ frame,
30
+ kind: 'leaf',
31
+ effects: {
32
+ maybeInput: { name: cPayload[0], value: cPayload[1] },
33
+ childIsTerminal: false,
34
+ childIsError: false,
35
+ },
36
+ };
37
+ }
38
+ return { frame, kind: 'leaf', effects: { childIsTerminal: false, childIsError: false } };
39
+ }
40
+ case 'TaskEnd': {
41
+ const obj = cPayload && typeof cPayload === 'object' && !Array.isArray(cPayload)
42
+ ? cPayload
43
+ : null;
44
+ const usage = obj && typeof obj.usage === 'object' && obj.usage !== null
45
+ ? obj.usage
46
+ : undefined;
47
+ const taskName = obj && typeof obj.task === 'string' ? obj.task : '';
48
+ const attempt = obj && typeof obj.attempt === 'number' ? obj.attempt : 1;
49
+ const duration = obj && typeof obj.duration === 'object' && obj.duration !== null
50
+ ? obj.duration
51
+ : null;
52
+ const durationMs = duration
53
+ ? Number(duration.secs ?? 0) * 1000 + Number(duration.nanos ?? 0) / 1_000_000
54
+ : 0;
55
+ const tokens = parseTokens(usage);
56
+ // #871: per-task USD if the engine attached it. The current server
57
+ // doesn't include `cost_usd` on TaskEnd, but the reducer reads it
58
+ // opportunistically so the field surfaces in the UI as soon as the
59
+ // server side starts emitting it.
60
+ const costUsdRaw = obj && typeof obj.cost_usd === 'number' ? obj.cost_usd : undefined;
61
+ const summary = {
62
+ kind: 'task_end',
63
+ taskName,
64
+ durationMs,
65
+ attempt,
66
+ tokens,
67
+ ...(costUsdRaw !== undefined ? { costUsd: costUsdRaw } : {}),
68
+ };
69
+ return {
70
+ frame,
71
+ kind: 'leaf',
72
+ effects: {
73
+ maybeUsage: usage,
74
+ maybeTaskSummary: summary,
75
+ childIsTerminal: false,
76
+ childIsError: false,
77
+ },
78
+ };
79
+ }
80
+ case 'AgentOutput': {
81
+ // Per-task streaming text inside a sub-script. Wire shape mirrors the
82
+ // top-level `AgentOutput` arm: `{ task_name, agent_name, task_id,
83
+ // schema_type, chunk }`. We carry just `task_name` + `chunk` — the
84
+ // task summary keys by name, and the chunk is what the operator wants
85
+ // to read in the drill-in.
86
+ const obj = cPayload && typeof cPayload === 'object' && !Array.isArray(cPayload)
87
+ ? cPayload
88
+ : null;
89
+ const taskName = obj && typeof obj.task_name === 'string' ? obj.task_name : '';
90
+ const chunk = obj && typeof obj.chunk === 'string' ? obj.chunk : '';
91
+ if (!taskName || !chunk) {
92
+ return { frame, kind: 'leaf', effects: { childIsTerminal: false, childIsError: false } };
93
+ }
94
+ return {
95
+ frame,
96
+ kind: 'leaf',
97
+ effects: {
98
+ maybeAgentOutput: { taskName, chunk },
99
+ childIsTerminal: false,
100
+ childIsError: false,
101
+ },
102
+ };
103
+ }
104
+ case 'ValidationFailure': {
105
+ const obj = cPayload && typeof cPayload === 'object' && !Array.isArray(cPayload)
106
+ ? cPayload
107
+ : null;
108
+ const taskName = obj && typeof obj.task_name === 'string' ? obj.task_name : '';
109
+ const attempt = obj && typeof obj.attempt === 'number' ? obj.attempt : 1;
110
+ return {
111
+ frame,
112
+ kind: 'leaf',
113
+ effects: {
114
+ maybeTaskSummary: { kind: 'validation_failure', taskName, attempt },
115
+ childIsTerminal: false,
116
+ childIsError: false,
117
+ },
118
+ };
119
+ }
120
+ case 'WorkflowEnd': {
121
+ // Issue #1173: WorkflowEnd payload may be either the new
122
+ // `{ value, total_input_tokens, ... }` struct or the legacy bare
123
+ // output value. Recover the output via the same disambiguator
124
+ // used in `workflowEvents.ts`'s parseWorkflowEndPayload (presence
125
+ // of `value` + any `total_*` key signals new shape).
126
+ let output = cPayload;
127
+ if (cPayload && typeof cPayload === 'object' && !Array.isArray(cPayload)) {
128
+ const o = cPayload;
129
+ const aggKeys = [
130
+ 'total_input_tokens',
131
+ 'total_output_tokens',
132
+ 'total_cached_input_tokens',
133
+ 'total_thinking_tokens',
134
+ 'total_tool_tokens',
135
+ 'total_cost_usd',
136
+ 'task_count',
137
+ ];
138
+ if ('value' in o && aggKeys.some((k) => k in o)) {
139
+ output = o.value;
140
+ }
141
+ }
142
+ return {
143
+ frame,
144
+ kind: 'leaf',
145
+ effects: {
146
+ maybeOutput: output,
147
+ childIsTerminal: true,
148
+ childIsError: false,
149
+ },
150
+ };
151
+ }
152
+ case 'Error': {
153
+ return {
154
+ frame,
155
+ kind: 'leaf',
156
+ effects: { childIsTerminal: true, childIsError: true },
157
+ };
158
+ }
159
+ default:
160
+ return { frame, kind: 'leaf', effects: { childIsTerminal: false, childIsError: false } };
161
+ }
162
+ }
163
+ /**
164
+ * Unwrap a chain of nested `SubScript` envelopes into:
165
+ * * the ordered stack of frames (`[outermost, …, innermost]`)
166
+ * * the leaf-level effects to apply at the innermost frame
167
+ *
168
+ * Returns `null` only when the outermost payload itself is malformed (which
169
+ * the reducer treats as a no-op, mirroring the v1 behavior).
170
+ */
171
+ function unwrapSubScriptChain(payload) {
172
+ // Issue #993: the new flat wire shape carries the ancestor chain via
173
+ // `parent_path` on the OUTER envelope (frames ordered outermost →
174
+ // immediate parent). Read those first so the chain reads correctly
175
+ // even when the engine emitted a depth-1 (`parent_path` empty) +
176
+ // flat-child envelope. Pre-#993 emissions had no `parent_path` and
177
+ // nested every level via `child`; we still walk that case for
178
+ // back-compat against archived event logs.
179
+ const frames = [];
180
+ if (payload && typeof payload === 'object') {
181
+ const outer = payload;
182
+ if (Array.isArray(outer.parent_path)) {
183
+ for (const f of outer.parent_path) {
184
+ if (!f || typeof f !== 'object')
185
+ continue;
186
+ const ff = f;
187
+ frames.push({
188
+ scriptName: typeof ff.script_name === 'string' ? ff.script_name : '',
189
+ parentTask: typeof ff.parent_task === 'string' ? ff.parent_task : '',
190
+ });
191
+ }
192
+ }
193
+ }
194
+ let current = payload;
195
+ // Hard upper bound to keep the loop honest if a future engine ever produces
196
+ // a cycle (it can't today — `Box<EngineEvent>` is a tree, not a graph).
197
+ for (let depth = 0; depth < 64; depth += 1) {
198
+ const parsed = parseSubScriptEnvelope(current);
199
+ if (!parsed)
200
+ return null;
201
+ frames.push(parsed.frame);
202
+ if (parsed.kind === 'leaf') {
203
+ return { frames, effects: parsed.effects };
204
+ }
205
+ current = parsed.innerPayload;
206
+ }
207
+ // Defensive fallback for an absurdly deep chain — render as a single leaf
208
+ // with no effects rather than crashing the reducer.
209
+ return { frames, effects: { childIsTerminal: false, childIsError: false } };
210
+ }
211
+ function aggregateSubScriptTokens(prev, raw) {
212
+ const input = Number(raw.input_tokens ?? 0);
213
+ const output = Number(raw.output_tokens ?? 0);
214
+ const cachedInput = Number(raw.cached_input_tokens ?? 0);
215
+ const model = typeof raw.model === 'string' ? raw.model : '';
216
+ // Skip empty-usage events (mock provider emits `{}` sometimes).
217
+ if (!input && !output && !cachedInput && !model)
218
+ return prev;
219
+ const base = prev ?? { input: 0, output: 0, cachedInput: 0, models: [] };
220
+ const models = model && !base.models.includes(model) ? [...base.models, model] : base.models;
221
+ return {
222
+ input: base.input + input,
223
+ output: base.output + output,
224
+ cachedInput: base.cachedInput + cachedInput,
225
+ models,
226
+ };
227
+ }
228
+ /**
229
+ * Walk a list of nested sub-script steps and roll their token totals into a
230
+ * single aggregate. Used when a parent sub-script's totals need to be
231
+ * recomputed after a descendant updated.
232
+ */
233
+ function rollupTokensFromChildren(children) {
234
+ let agg = undefined;
235
+ for (const c of children) {
236
+ const t = c.subScript?.subScriptTokens;
237
+ if (!t)
238
+ continue;
239
+ const base = agg ?? { input: 0, output: 0, cachedInput: 0, models: [] };
240
+ const models = [...base.models];
241
+ for (const m of t.models)
242
+ if (!models.includes(m))
243
+ models.push(m);
244
+ agg = {
245
+ input: base.input + t.input,
246
+ output: base.output + t.output,
247
+ cachedInput: base.cachedInput + t.cachedInput,
248
+ models,
249
+ };
250
+ }
251
+ return agg;
252
+ }
253
+ /**
254
+ * Sum the `nestedTaskCount` across a list of children. Used to keep an
255
+ * ancestor's count in sync as descendants accumulate `TaskEnd` envelopes.
256
+ */
257
+ function rollupTaskCountFromChildren(children) {
258
+ let n = 0;
259
+ for (const c of children)
260
+ n += c.subScript?.nestedTaskCount ?? 0;
261
+ return n;
262
+ }
263
+ /**
264
+ * Combine this level's `selfTokens` (tokens from `TaskEnd`s wrapped DIRECTLY
265
+ * at this frame, never from descendants) with the recursive rollup of every
266
+ * child's `subScriptTokens`. The result is the level's recursive
267
+ * `subScriptTokens`. Returns `undefined` only when both inputs are
268
+ * absent/empty so the UI can preserve its "no tokens yet" state.
269
+ *
270
+ * Invariant the SubScript reducer relies on:
271
+ *
272
+ * subScriptTokens = selfTokens + Σ children[i].subScriptTokens
273
+ *
274
+ * Maintaining this invariant explicitly (rather than the previous
275
+ * `prevTokens - prevChildrenRollup` subtraction) avoids the floating-point
276
+ * + model-list drift the old recompute path was vulnerable to when a level
277
+ * accumulated multiple direct `TaskEnd`s in between child updates.
278
+ */
279
+ function combineSelfAndChildrenTokens(self, children) {
280
+ const childRoll = rollupTokensFromChildren(children ?? []);
281
+ if (!self && !childRoll)
282
+ return undefined;
283
+ const merged = {
284
+ input: (self?.input ?? 0) + (childRoll?.input ?? 0),
285
+ output: (self?.output ?? 0) + (childRoll?.output ?? 0),
286
+ cachedInput: (self?.cachedInput ?? 0) + (childRoll?.cachedInput ?? 0),
287
+ models: [],
288
+ };
289
+ for (const m of self?.models ?? [])
290
+ if (!merged.models.includes(m))
291
+ merged.models.push(m);
292
+ for (const m of childRoll?.models ?? [])
293
+ if (!merged.models.includes(m))
294
+ merged.models.push(m);
295
+ if (!merged.input && !merged.output && !merged.cachedInput && merged.models.length === 0) {
296
+ return undefined;
297
+ }
298
+ return merged;
299
+ }
300
+ /**
301
+ * If the summary is a `task_end` and the streaming buffer holds matching
302
+ * text, attach it as `streamingOutput`. The reducer drops the buffer entry
303
+ * after this call so it doesn't keep accumulating across the sub-script's
304
+ * lifetime. Returns `undefined` when there is no summary (caller no-ops).
305
+ */
306
+ function attachStreamingToSummary(summary, streamingByTask) {
307
+ if (!summary)
308
+ return undefined;
309
+ if (summary.kind !== 'task_end')
310
+ return summary;
311
+ const buffered = streamingByTask?.[summary.taskName];
312
+ if (!buffered)
313
+ return summary;
314
+ return { ...summary, streamingOutput: buffered };
315
+ }
316
+ function parseTokens(raw) {
317
+ if (!raw || typeof raw !== 'object')
318
+ return undefined;
319
+ const u = raw;
320
+ const input = Number(u.input_tokens ?? 0);
321
+ const output = Number(u.output_tokens ?? 0);
322
+ const cachedInput = Number(u.cached_input_tokens ?? 0);
323
+ if (!input && !output && !cachedInput)
324
+ return undefined;
325
+ return {
326
+ input,
327
+ output,
328
+ cachedInput,
329
+ model: typeof u.model === 'string' ? u.model : '',
330
+ provider: typeof u.provider === 'string' ? u.provider : '',
331
+ };
332
+ }
333
+ /**
334
+ * Pure reducer: takes current steps + a hub event, returns new steps + side
335
+ * effects. This is the core event-handling logic shared between Studio's
336
+ * live panel and the docs runner.
337
+ */
338
+ export function reduceExecutionEvent(prev, hubEvt, activeLineRef, activeNodeRef) {
339
+ if (hubEvt.type !== 'Execution')
340
+ return { steps: prev, effects: {} };
341
+ const evt = hubEvt.payload.event;
342
+ const evName = evt.type;
343
+ const evPayload = evt.payload;
344
+ const timestamp = Date.now();
345
+ const id = `${evName}-${timestamp}-${Math.random()}`;
346
+ const effects = {};
347
+ // Server-attached fields (Workstream 04 §B). Optional — older servers don't
348
+ // stamp these, so the reducer treats both as undefined-by-default. The UI
349
+ // displays the seq badge only when present.
350
+ const wirePayload = hubEvt.payload;
351
+ const wireSeq = typeof wirePayload.seq === 'number' ? wirePayload.seq : undefined;
352
+ const wireServerTs = typeof wirePayload.at === 'string' ? Date.parse(wirePayload.at) : undefined;
353
+ let steps;
354
+ switch (evName) {
355
+ case 'NodeStart': {
356
+ const [nodeId, span] = evPayload;
357
+ activeLineRef.current = span.line;
358
+ activeNodeRef.current = nodeId;
359
+ effects.setActiveLine = span.line;
360
+ steps = [...prev, { id, line: span.line, type: 'execution', content: `Executing Node ${nodeId}`, status: 'running', timestamp, nodeId, visibility: 'hidden', seq: wireSeq, serverTs: wireServerTs }];
361
+ break;
362
+ }
363
+ case 'TaskPrompt': {
364
+ const [name, prompt] = evPayload;
365
+ // Set `taskName` on the chat step (Workstream 04 \u00a7A.2): without it, the
366
+ // later `TaskEnd` merge \u2014 which keys off `s.taskName === name` \u2014 appends
367
+ // a second step instead of folding into the streaming chat row.
368
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'chat', agent: name, taskName: name, content: 'Generating response\u2026', prompt, timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'hidden', seq: wireSeq, serverTs: wireServerTs }];
369
+ break;
370
+ }
371
+ case 'AgentOutput': {
372
+ const { task_name, agent_name, task_id, schema_type, chunk } = evPayload;
373
+ // First try the per-`task_id` merge (used for repeated chunks once the
374
+ // chat step has captured a task_id).
375
+ const byTaskId = prev.findIndex(s => s.type === 'chat' && s.taskId === task_id);
376
+ // Fall back to the chat step opened by `TaskPrompt` for this task name
377
+ // on the same active node \u2014 that's the row we want to fill in. The
378
+ // TaskPrompt arm sets taskName but no taskId yet.
379
+ const byTaskName = byTaskId === -1
380
+ ? prev.findIndex(s => s.type === 'chat'
381
+ && s.taskName === task_name
382
+ && !s.taskId
383
+ && s.nodeId === activeNodeRef.current)
384
+ : -1;
385
+ const idx = byTaskId !== -1 ? byTaskId : byTaskName;
386
+ if (idx !== -1) {
387
+ const newSteps = [...prev];
388
+ const cur = newSteps[idx];
389
+ newSteps[idx] = {
390
+ ...cur,
391
+ // The TaskPrompt arm seeds with the placeholder "Generating response\u2026";
392
+ // overwrite that on the first chunk instead of concatenating to it.
393
+ content: (cur.content === 'Generating response\u2026' ? '' : cur.content) + chunk,
394
+ taskId: task_id, // absorb so future AgentOutput chunks hit byTaskId
395
+ schemaType: schema_type ?? cur.schemaType,
396
+ visibility: 'inline',
397
+ seq: wireSeq ?? cur.seq,
398
+ serverTs: wireServerTs ?? cur.serverTs,
399
+ };
400
+ steps = newSteps;
401
+ }
402
+ else {
403
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'chat', agent: agent_name || task_name, taskId: task_id, taskName: task_name, schemaType: schema_type, content: chunk, timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'inline', seq: wireSeq, serverTs: wireServerTs }];
404
+ }
405
+ break;
406
+ }
407
+ case 'StateUpdate': {
408
+ const [name, value] = evPayload;
409
+ effects.globalEnvUpdates = { [name]: value };
410
+ // Workstream 04 \u00a7A.3: `StateUpdate` events are panel-only \u2014 the
411
+ // Variables segment shows them; the inline Output stream filters them
412
+ // out so users don't see "Variable updated: x" rows next to the chat
413
+ // step that already produced `x`.
414
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'variable', content: `Variable updated: ${name}`, variables: { [name]: value }, status: 'success', timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'panel-only', seq: wireSeq, serverTs: wireServerTs }];
415
+ break;
416
+ }
417
+ case 'TaskEnd': {
418
+ // TaskEnd payload: { task, on_error_label, value, value_type, duration, attempt, usage }
419
+ const { task: name, value: result, value_type, duration, attempt, usage } = evPayload;
420
+ const durationMs = duration.secs * 1000 + duration.nanos / 1000000;
421
+ const tokens = parseTokens(usage);
422
+ // If we already have a chat step from this task's AgentOutput chunks,
423
+ // merge the structured result into it rather than appending a duplicate.
424
+ // Without this the panel renders the streamed text AND the parsed object
425
+ // as two separate rows with the same timestamp.
426
+ const chatIdx = (() => {
427
+ for (let i = prev.length - 1; i >= 0; i -= 1) {
428
+ const s = prev[i];
429
+ if (s.type === 'chat' && s.taskName === name && s.valueType == null)
430
+ return i;
431
+ }
432
+ return -1;
433
+ })();
434
+ if (chatIdx !== -1) {
435
+ const merged = [...prev];
436
+ merged[chatIdx] = {
437
+ ...merged[chatIdx],
438
+ status: 'success',
439
+ variables: { ...(merged[chatIdx].variables ?? {}), result },
440
+ duration: durationMs,
441
+ tokens,
442
+ valueType: value_type ?? null,
443
+ attempt: typeof attempt === 'number' ? attempt : undefined,
444
+ seq: wireSeq ?? merged[chatIdx].seq,
445
+ serverTs: wireServerTs ?? merged[chatIdx].serverTs,
446
+ };
447
+ steps = merged;
448
+ }
449
+ else {
450
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'execution', content: `Finished task: ${name}`, status: 'success', variables: { result }, duration: durationMs, timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'inline', tokens, valueType: value_type ?? null, attempt: typeof attempt === 'number' ? attempt : undefined, seq: wireSeq, serverTs: wireServerTs }];
451
+ }
452
+ break;
453
+ }
454
+ case 'NodeEnd': {
455
+ // Support object form { node_id, span, target_var, value, duration },
456
+ // 2-tuple [nodeId, duration], and enriched array [nodeId, span, ..., duration].
457
+ let nodeId;
458
+ let durationMs;
459
+ if (typeof evPayload === 'object' && !Array.isArray(evPayload) && 'node_id' in evPayload) {
460
+ nodeId = evPayload.node_id;
461
+ const dur = evPayload.duration;
462
+ durationMs = dur.secs * 1000 + dur.nanos / 1000000;
463
+ }
464
+ else if (Array.isArray(evPayload)) {
465
+ if (evPayload.length === 2) {
466
+ const [nid, duration] = evPayload;
467
+ nodeId = nid;
468
+ durationMs = duration.secs * 1000 + duration.nanos / 1000000;
469
+ }
470
+ else {
471
+ nodeId = evPayload[0];
472
+ const duration = evPayload[evPayload.length - 1];
473
+ durationMs = duration.secs * 1000 + duration.nanos / 1000000;
474
+ }
475
+ }
476
+ else {
477
+ steps = prev;
478
+ break;
479
+ }
480
+ steps = prev.map(s => s.nodeId === nodeId && s.content.startsWith('Executing Node') ? { ...s, status: 'success', duration: durationMs, seq: wireSeq ?? s.seq, serverTs: wireServerTs ?? s.serverTs } : s);
481
+ break;
482
+ }
483
+ case 'WorkflowEnd':
484
+ effects.executionFinished = true;
485
+ effects.refreshHistory = true;
486
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'execution', content: 'Workflow completed', status: 'success', variables: { final_result: evPayload }, timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'inline', seq: wireSeq, serverTs: wireServerTs }];
487
+ break;
488
+ case 'Error': {
489
+ effects.executionFinished = true;
490
+ effects.refreshHistory = true;
491
+ // Error payload can be a bare string (legacy) or the structured
492
+ // envelope `{ message, kind, code, user_message, retry_after_ms,
493
+ // source }`. We forward every field present so the UI doesn't
494
+ // have to re-derive them.
495
+ let message;
496
+ let kind;
497
+ let code;
498
+ let userMessage;
499
+ let retryAfterMs;
500
+ let source;
501
+ if (typeof evPayload === 'string') {
502
+ message = evPayload;
503
+ }
504
+ else if (evPayload && typeof evPayload === 'object') {
505
+ message = typeof evPayload.message === 'string' ? evPayload.message : JSON.stringify(evPayload);
506
+ kind = typeof evPayload.kind === 'string' ? evPayload.kind : undefined;
507
+ code = typeof evPayload.code === 'string' ? evPayload.code : undefined;
508
+ userMessage = typeof evPayload.user_message === 'string' ? evPayload.user_message : undefined;
509
+ retryAfterMs = typeof evPayload.retry_after_ms === 'number' ? evPayload.retry_after_ms : undefined;
510
+ if (evPayload.source && typeof evPayload.source === 'object') {
511
+ const s = evPayload.source;
512
+ source = {
513
+ task: typeof s.task === 'string' ? s.task : undefined,
514
+ agent: typeof s.agent === 'string' ? s.agent : undefined,
515
+ provider: typeof s.provider === 'string' ? s.provider : undefined,
516
+ model: typeof s.model === 'string' ? s.model : undefined,
517
+ toolRef: typeof s.tool_ref === 'string' ? s.tool_ref : undefined,
518
+ script: typeof s.script === 'string' ? s.script : undefined,
519
+ line: typeof s.line === 'number' ? s.line : undefined,
520
+ };
521
+ }
522
+ }
523
+ else {
524
+ message = String(evPayload);
525
+ }
526
+ steps = [...prev, {
527
+ id,
528
+ line: activeLineRef.current || 0,
529
+ type: 'execution',
530
+ content: `Error: ${message}`,
531
+ status: 'error',
532
+ timestamp,
533
+ nodeId: activeNodeRef.current ?? undefined,
534
+ visibility: 'inline',
535
+ errorKind: kind,
536
+ errorCode: code,
537
+ errorUserMessage: userMessage,
538
+ errorRetryAfterMs: retryAfterMs,
539
+ errorSource: source,
540
+ seq: wireSeq,
541
+ serverTs: wireServerTs,
542
+ }];
543
+ break;
544
+ }
545
+ case 'TaskStart': {
546
+ const [name] = evPayload;
547
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'execution', content: `Starting task: ${name}`, status: 'running', timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'panel-only', seq: wireSeq, serverTs: wireServerTs }];
548
+ break;
549
+ }
550
+ case 'TaskCacheHit': {
551
+ // P3: the engine emits `TaskCacheHit { agent, key_prefix }` right
552
+ // before the cached `AgentOutput` + `TaskEnd` arrive. Find the
553
+ // most recent open chat step for this agent and flip its
554
+ // `cached` flag so the renderer can show a "cached" pill before
555
+ // the row settles. The subsequent `TaskEnd` merges into the
556
+ // same step and spreads the rest of the fields without touching
557
+ // `cached` — so the flag survives end-to-end.
558
+ const agent = (evPayload && typeof evPayload === 'object' && 'agent' in evPayload)
559
+ ? String(evPayload.agent)
560
+ : '';
561
+ // Walk newest-first: the most recently-opened chat row for this
562
+ // agent on the active node is the one the upcoming TaskEnd will
563
+ // fold into. We match on `taskName === agent` (the TaskPrompt
564
+ // arm seeds the chat step with `taskName = name` where `name`
565
+ // is the task identifier — which is also what the engine emits
566
+ // as `agent` on the cache-hit event for an agent-bound task).
567
+ let hitIdx = -1;
568
+ for (let i = prev.length - 1; i >= 0; i -= 1) {
569
+ const s = prev[i];
570
+ if (s.type === 'chat' && (s.taskName === agent || s.agent === agent)) {
571
+ hitIdx = i;
572
+ break;
573
+ }
574
+ }
575
+ if (hitIdx === -1) {
576
+ // Defensive: replay edge cases (e.g. event arrives before its
577
+ // TaskPrompt) shouldn't produce a stray step. Forward-only.
578
+ steps = prev;
579
+ }
580
+ else {
581
+ const updated = [...prev];
582
+ updated[hitIdx] = { ...updated[hitIdx], cached: true };
583
+ steps = updated;
584
+ }
585
+ break;
586
+ }
587
+ case 'ValidationFailure': {
588
+ // Workstream 04 §A.4: structured payload from `EngineEvent::ValidationFailure`.
589
+ // `ValidationFailureCard` consumes `step.validationFailure` directly; the
590
+ // detail page re-renders the same card expanded.
591
+ const p = evPayload;
592
+ steps = [...prev, {
593
+ id,
594
+ line: activeLineRef.current || 0,
595
+ type: 'validation_failure',
596
+ content: `Validation failed on ${p.task_name} (attempt ${p.attempt})`,
597
+ status: 'error',
598
+ timestamp,
599
+ seq: wireSeq,
600
+ serverTs: wireServerTs,
601
+ nodeId: activeNodeRef.current ?? undefined,
602
+ visibility: 'inline',
603
+ validationFailure: {
604
+ taskName: p.task_name,
605
+ attempt: p.attempt,
606
+ modelResponse: p.model_response,
607
+ missingFields: p.missing_fields,
608
+ extraFields: p.extra_fields,
609
+ typeErrors: p.type_errors,
610
+ stopReason: p.stop_reason ?? null,
611
+ },
612
+ }];
613
+ break;
614
+ }
615
+ case 'Suspended': {
616
+ const payloadObj = evPayload && typeof evPayload === 'object' && !Array.isArray(evPayload)
617
+ ? evPayload
618
+ : null;
619
+ const checkpointName = payloadObj
620
+ ? (typeof payloadObj.checkpoint_name === 'string' ? payloadObj.checkpoint_name : '')
621
+ : (Array.isArray(evPayload) && typeof evPayload[0] === 'string' ? evPayload[0] : '');
622
+ const suspendToken = payloadObj && typeof payloadObj.token === 'string' ? payloadObj.token : undefined;
623
+ const suspendPrompt = payloadObj && typeof payloadObj.prompt === 'string' ? payloadObj.prompt : undefined;
624
+ const suspendSchema = payloadObj ? payloadObj.schema : undefined;
625
+ const trigger = payloadObj
626
+ ? normalizeSuspendTrigger(payloadObj.trigger)
627
+ : { kind: 'DagPosition' };
628
+ const exhausted = trigger.kind === 'ValidationExhausted'
629
+ ? trigger
630
+ : null;
631
+ const step = {
632
+ id,
633
+ line: activeLineRef.current || 0,
634
+ type: 'execution',
635
+ content: exhausted
636
+ ? `Validation exhausted after ${exhausted.retryCount} attempts on '${exhausted.taskName}' — suspended at ${checkpointName}`
637
+ : `Suspended at checkpoint: ${checkpointName}`,
638
+ status: 'pending',
639
+ timestamp,
640
+ seq: wireSeq,
641
+ serverTs: wireServerTs,
642
+ nodeId: activeNodeRef.current ?? undefined,
643
+ visibility: 'inline',
644
+ checkpointName,
645
+ suspendTrigger: trigger,
646
+ suspendToken,
647
+ suspendPrompt,
648
+ suspendSchema,
649
+ };
650
+ if (exhausted) {
651
+ step.retryCount = exhausted.retryCount;
652
+ step.lastAttempt = exhausted.lastAttempt;
653
+ step.validationErrors = exhausted.validationErrors;
654
+ }
655
+ steps = [...prev, step];
656
+ break;
657
+ }
658
+ case 'Resumed': {
659
+ const payloadObj = evPayload && typeof evPayload === 'object' && !Array.isArray(evPayload)
660
+ ? evPayload
661
+ : null;
662
+ const checkpointName = payloadObj
663
+ ? (typeof payloadObj.checkpoint_name === 'string' ? payloadObj.checkpoint_name : '')
664
+ : (Array.isArray(evPayload) && typeof evPayload[0] === 'string' ? evPayload[0] : '');
665
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'execution', content: `Resumed from checkpoint: ${checkpointName}`, status: 'running', timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'inline', seq: wireSeq, serverTs: wireServerTs }];
666
+ break;
667
+ }
668
+ case 'Breakpoint': {
669
+ const { node_id, span, token, env_snapshot } = evPayload;
670
+ activeLineRef.current = span.line;
671
+ activeNodeRef.current = node_id;
672
+ effects.setActiveLine = span.line;
673
+ effects.breakpoint = { nodeId: node_id, token, envSnapshot: env_snapshot, line: span.line };
674
+ steps = [...prev, { id, line: span.line, type: 'execution', content: `Paused at breakpoint (line ${span.line})`, status: 'running', variables: env_snapshot, timestamp, nodeId: node_id, visibility: 'inline', seq: wireSeq, serverTs: wireServerTs }];
675
+ break;
676
+ }
677
+ case 'BreakpointResumed': {
678
+ const { node_id } = evPayload;
679
+ effects.breakpointResumed = true;
680
+ steps = prev.map(s => s.nodeId === node_id && s.content.startsWith('Paused at breakpoint') ? { ...s, content: `Resumed from breakpoint (line ${s.line})`, status: 'success', seq: wireSeq ?? s.seq, serverTs: wireServerTs ?? s.serverTs } : s);
681
+ break;
682
+ }
683
+ case 'Log': {
684
+ const message = typeof evPayload === 'string' ? evPayload : JSON.stringify(evPayload);
685
+ steps = [...prev, { id, line: activeLineRef.current || 0, type: 'execution', content: message, status: 'success', timestamp, nodeId: activeNodeRef.current ?? undefined, visibility: 'inline', seq: wireSeq, serverTs: wireServerTs }];
686
+ break;
687
+ }
688
+ case 'ToolCallStart': {
689
+ const step = {
690
+ id,
691
+ line: activeLineRef.current || 0,
692
+ type: 'tool_call',
693
+ content: `Calling ${evPayload.tool_name}...`,
694
+ status: 'running',
695
+ timestamp,
696
+ seq: wireSeq,
697
+ serverTs: wireServerTs,
698
+ toolName: evPayload.tool_name,
699
+ serverName: evPayload.server_name,
700
+ toolInput: evPayload.input,
701
+ nodeId: activeNodeRef.current ?? undefined,
702
+ visibility: 'panel-only',
703
+ };
704
+ steps = [...prev, step];
705
+ break;
706
+ }
707
+ case 'SubScript': {
708
+ // Cross-script `call(...)` envelope (akribes-core EngineEvent::SubScript,
709
+ // PR #360). Each event wraps ONE inner sub-engine event; one logical
710
+ // call therefore arrives as a stream of SubScript envelopes that share
711
+ // the same `parent_task`.
712
+ //
713
+ // Strategy:
714
+ // * Maintain ONE `sub_script` step per (parent_task, currently open
715
+ // call). The first envelope for a parent_task with no open call
716
+ // creates the step; subsequent envelopes accumulate into that step
717
+ // until the wrapped child event is `WorkflowEnd` (or `Error`),
718
+ // which closes the call. A later envelope with the same
719
+ // `parent_task` — e.g. the same variable being assigned a fresh
720
+ // `call(...)` later in the workflow — opens a new step.
721
+ //
722
+ // * Nested calls (depth > 1, i.e. A → B → C): the engine wraps each
723
+ // level as a `SubScript` envelope, so a depth-2 grandchild event
724
+ // arrives as `SubScript { child: SubScript { child: <leaf> } }`.
725
+ // `unwrapSubScriptChain` peels off the wrappers into a frame stack
726
+ // `[A, B, C]`. We then walk into the open A-step's `children`,
727
+ // find/open the open B-step, walk into ITS `children`, and apply
728
+ // the leaf effects on the innermost (C) step. The outermost
729
+ // `SubScriptCard` recursively renders nested cards from `children`.
730
+ //
731
+ // * Token rollup: each wrapped `TaskEnd` reports usage at the level
732
+ // that hosts the task (so C's `TaskEnd` lands on the C step). We
733
+ // ALSO add it to every ancestor's `subScriptTokens` so the
734
+ // outermost card keeps showing the run-wide total — preserving the
735
+ // existing v1 "tokens still aggregate correctly" guarantee even
736
+ // across nesting. `nestedTaskCount` is rolled up the same way.
737
+ //
738
+ // Pricing: token totals accumulate; USD is intentionally NOT computed
739
+ // client-side. See the note on `SubScriptTokens` for the rationale.
740
+ const chain = unwrapSubScriptChain(evPayload);
741
+ if (!chain) {
742
+ steps = prev;
743
+ break;
744
+ }
745
+ const { frames, effects } = chain;
746
+ const outermostFrame = frames[0];
747
+ // Walk the chain top-down: at each level, locate (or create) the open
748
+ // sub-script step matching that level's `parent_task`. The leaf-level
749
+ // effects are applied to the innermost frame. Token usage is folded
750
+ // into every ancestor's totals via the explicit
751
+ // `subScriptTokens = selfTokens + Σ child.subScriptTokens` invariant —
752
+ // see `combineSelfAndChildrenTokens`.
753
+ const updateFrame = (steps, frameIndex) => {
754
+ const frame = frames[frameIndex];
755
+ const isLeaf = frameIndex === frames.length - 1;
756
+ const openIdx = (() => {
757
+ for (let i = steps.length - 1; i >= 0; i -= 1) {
758
+ const s = steps[i];
759
+ if (s.type === 'sub_script'
760
+ && s.subScript?.parentTask === frame.parentTask
761
+ && !s.subScript?.closed) {
762
+ return i;
763
+ }
764
+ }
765
+ return -1;
766
+ })();
767
+ if (openIdx === -1) {
768
+ // First envelope for this call at this level — create the step.
769
+ // For a non-leaf frame (an outer level on a nested chain), the
770
+ // creation happens here on the recursion's way down; the leaf
771
+ // effects are applied below when `isLeaf` is true.
772
+ let childrenSteps = [];
773
+ if (!isLeaf) {
774
+ childrenSteps = updateFrame([], frameIndex + 1);
775
+ }
776
+ // selfTokens: only set when a leaf TaskEnd lands at this level on
777
+ // creation (rare — usually the level was opened by an upstream
778
+ // event before its first TaskEnd). Children's tokens are NEVER
779
+ // folded into selfTokens, only into the recursive total.
780
+ const selfTokens = isLeaf && effects.maybeUsage
781
+ ? aggregateSubScriptTokens(undefined, effects.maybeUsage)
782
+ : undefined;
783
+ // Apply AgentOutput streaming buffer at the leaf level on creation.
784
+ let streamingBuffer;
785
+ if (isLeaf && effects.maybeAgentOutput) {
786
+ streamingBuffer = {
787
+ [effects.maybeAgentOutput.taskName]: effects.maybeAgentOutput.chunk,
788
+ };
789
+ }
790
+ // Attach the in-flight streaming text to the new task summary if
791
+ // this creation event IS a TaskEnd.
792
+ const newSummary = isLeaf
793
+ ? attachStreamingToSummary(effects.maybeTaskSummary, streamingBuffer)
794
+ : undefined;
795
+ // Drop the streaming entry once consumed by its TaskEnd so the
796
+ // buffer doesn't grow unbounded across a long-running sub-script.
797
+ if (newSummary && newSummary.kind === 'task_end' && streamingBuffer) {
798
+ const { [newSummary.taskName]: _drop, ...rest } = streamingBuffer;
799
+ streamingBuffer = Object.keys(rest).length > 0 ? rest : undefined;
800
+ }
801
+ const taskCountForThisLevel = isLeaf
802
+ ? (selfTokens ? 1 : 0)
803
+ : rollupTaskCountFromChildren(childrenSteps);
804
+ const next = {
805
+ id: `${id}-l${frameIndex}`,
806
+ line: activeLineRef.current || 0,
807
+ type: 'sub_script',
808
+ content: `call("${frame.scriptName}")`,
809
+ status: isLeaf
810
+ ? effects.childIsTerminal
811
+ ? effects.childIsError
812
+ ? 'error'
813
+ : 'success'
814
+ : 'running'
815
+ : 'running',
816
+ timestamp,
817
+ seq: wireSeq,
818
+ serverTs: wireServerTs,
819
+ nodeId: activeNodeRef.current ?? undefined,
820
+ visibility: 'inline',
821
+ subScript: {
822
+ scriptName: frame.scriptName,
823
+ parentTask: frame.parentTask,
824
+ inputs: isLeaf && effects.maybeInput ? [effects.maybeInput] : [],
825
+ output: isLeaf ? effects.maybeOutput : undefined,
826
+ subScriptTokens: combineSelfAndChildrenTokens(selfTokens, childrenSteps),
827
+ selfTokens,
828
+ nestedTaskCount: taskCountForThisLevel,
829
+ closed: isLeaf ? effects.childIsTerminal : false,
830
+ taskSummaries: newSummary ? [newSummary] : undefined,
831
+ children: isLeaf ? undefined : childrenSteps,
832
+ _streamingByTask: streamingBuffer,
833
+ },
834
+ };
835
+ return [...steps, next];
836
+ }
837
+ const cur = steps[openIdx];
838
+ const curSub = cur.subScript;
839
+ let nextChildren = curSub.children;
840
+ if (!isLeaf) {
841
+ nextChildren = updateFrame(curSub.children ?? [], frameIndex + 1);
842
+ }
843
+ // selfTokens accumulates ONLY at the leaf level. Ancestors keep
844
+ // their existing selfTokens unchanged; their `subScriptTokens` is
845
+ // re-derived from `selfTokens + children` so the descendant update
846
+ // bubbles up cleanly without any subtraction-based bookkeeping.
847
+ const nextSelfTokens = isLeaf && effects.maybeUsage
848
+ ? aggregateSubScriptTokens(curSub.selfTokens, effects.maybeUsage)
849
+ : curSub.selfTokens;
850
+ // Update the streaming buffer for this leaf if AgentOutput arrived.
851
+ let nextStreaming = curSub._streamingByTask;
852
+ if (isLeaf && effects.maybeAgentOutput) {
853
+ const { taskName, chunk } = effects.maybeAgentOutput;
854
+ nextStreaming = {
855
+ ...(nextStreaming ?? {}),
856
+ [taskName]: (nextStreaming?.[taskName] ?? '') + chunk,
857
+ };
858
+ }
859
+ // Build the (possibly enriched) new summary entry.
860
+ const enrichedSummary = isLeaf
861
+ ? attachStreamingToSummary(effects.maybeTaskSummary, nextStreaming)
862
+ : undefined;
863
+ // Drop the streaming entry from the buffer once it has been
864
+ // attached to a finalized `task_end` summary; keeps memory flat.
865
+ if (enrichedSummary && enrichedSummary.kind === 'task_end' && nextStreaming) {
866
+ const { [enrichedSummary.taskName]: _drop, ...rest } = nextStreaming;
867
+ nextStreaming = Object.keys(rest).length > 0 ? rest : undefined;
868
+ }
869
+ const updatedSub = {
870
+ ...curSub,
871
+ inputs: isLeaf && effects.maybeInput
872
+ ? [...curSub.inputs.filter((i) => i.name !== effects.maybeInput.name), effects.maybeInput]
873
+ : curSub.inputs,
874
+ output: isLeaf && effects.maybeOutput !== undefined ? effects.maybeOutput : curSub.output,
875
+ selfTokens: nextSelfTokens,
876
+ subScriptTokens: combineSelfAndChildrenTokens(nextSelfTokens, isLeaf ? curSub.children : nextChildren),
877
+ nestedTaskCount: isLeaf
878
+ ? (curSub.nestedTaskCount ?? 0) + (effects.maybeUsage ? 1 : 0)
879
+ : (curSub.nestedTaskCount ?? 0)
880
+ - rollupTaskCountFromChildren(curSub.children ?? [])
881
+ + rollupTaskCountFromChildren(nextChildren ?? []),
882
+ closed: isLeaf ? (curSub.closed || effects.childIsTerminal) : curSub.closed,
883
+ taskSummaries: enrichedSummary
884
+ ? [...(curSub.taskSummaries ?? []), enrichedSummary]
885
+ : curSub.taskSummaries,
886
+ children: isLeaf ? curSub.children : nextChildren,
887
+ _streamingByTask: nextStreaming,
888
+ };
889
+ const updated = {
890
+ ...cur,
891
+ status: isLeaf
892
+ ? (effects.childIsTerminal
893
+ ? (effects.childIsError ? 'error' : 'success')
894
+ : cur.status)
895
+ : cur.status,
896
+ seq: wireSeq ?? cur.seq,
897
+ serverTs: wireServerTs ?? cur.serverTs,
898
+ subScript: updatedSub,
899
+ };
900
+ const next = [...steps];
901
+ next[openIdx] = updated;
902
+ return next;
903
+ };
904
+ // Sanity: outermost frame must be at the top level of `steps`.
905
+ void outermostFrame; // silence unused-var linter when no debugger active
906
+ steps = updateFrame(prev, 0);
907
+ break;
908
+ }
909
+ case 'LoopStart': {
910
+ // Open a new loop step. Subsequent `LoopTurn`s and the terminal
911
+ // `LoopEnd` will fold back into this step keyed by `loopName`. The
912
+ // step starts with an empty `turns` array; the panel renders a
913
+ // "running…" pulse until LoopEnd flips status to success/error.
914
+ const p = evPayload;
915
+ const loopName = typeof p.name === 'string' ? p.name : '';
916
+ const maxTurns = typeof p.max_turns === 'number' ? p.max_turns : 0;
917
+ steps = [...prev, {
918
+ id,
919
+ line: activeLineRef.current || 0,
920
+ type: 'loop',
921
+ content: `loop ${loopName}`,
922
+ status: 'running',
923
+ timestamp,
924
+ seq: wireSeq,
925
+ serverTs: wireServerTs,
926
+ nodeId: activeNodeRef.current ?? undefined,
927
+ visibility: 'inline',
928
+ loopName,
929
+ maxTurns,
930
+ turns: [],
931
+ }];
932
+ break;
933
+ }
934
+ case 'LoopTurn': {
935
+ // Append a turn summary to the most-recent open (status === 'running')
936
+ // loop step with the matching `loopName`. We scan from the back so a
937
+ // later loop with a name that happens to collide with an earlier one
938
+ // (sequential loops in the same workflow) hits the right step. If no
939
+ // open loop is found we silently no-op — better than mis-attributing.
940
+ const p = evPayload;
941
+ const loopName = typeof p.name === 'string' ? p.name : '';
942
+ const turn = typeof p.turn === 'number' ? p.turn : 0;
943
+ const toolCalls = Array.isArray(p.tool_calls)
944
+ ? p.tool_calls.filter((t) => typeof t === 'string')
945
+ : [];
946
+ const idx = (() => {
947
+ for (let i = prev.length - 1; i >= 0; i -= 1) {
948
+ const s = prev[i];
949
+ if (s.type === 'loop' && s.loopName === loopName && s.status === 'running')
950
+ return i;
951
+ }
952
+ return -1;
953
+ })();
954
+ if (idx === -1) {
955
+ steps = prev;
956
+ break;
957
+ }
958
+ const cur = prev[idx];
959
+ const next = [...prev];
960
+ next[idx] = {
961
+ ...cur,
962
+ turns: [...(cur.turns ?? []), { turn, toolCalls }],
963
+ seq: wireSeq ?? cur.seq,
964
+ serverTs: wireServerTs ?? cur.serverTs,
965
+ };
966
+ steps = next;
967
+ break;
968
+ }
969
+ case 'LoopEnd': {
970
+ // Finalize the matching loop step. Status is 'error' when the value is
971
+ // a `Value::FatalError` envelope (max_turns exhaustion) and 'success'
972
+ // otherwise. The full `value` is carried as `loopResult` so the UI can
973
+ // render it via `AkribesValueViewerWithRawToggle`.
974
+ //
975
+ // Wire shape: `Value` is serialised via `Value::to_wire_json` per the
976
+ // contract in `docs/src/content/docs/reference/engine-events.mdx` —
977
+ // scalars (`Value::String`, `Value::Int`, `Value::Bool`) emit bare JSON
978
+ // values, `Value::Object`/`Value::List` emit clean JSON containers, and
979
+ // `Value::FatalError` emits a `{ "FatalError": <msg>, "error_kind": ...,
980
+ // "code": ..., "error_detail": { ... } }` envelope. We detect the
981
+ // FatalError arm by structural shape so future additions to the
982
+ // FatalError wire envelope (e.g. extending `error_detail`) don't break
983
+ // the check.
984
+ const p = evPayload;
985
+ const loopName = typeof p.name === 'string' ? p.name : '';
986
+ const value = p.value;
987
+ const isFatal = !!value
988
+ && typeof value === 'object'
989
+ && !Array.isArray(value)
990
+ && 'FatalError' in value;
991
+ const idx = (() => {
992
+ for (let i = prev.length - 1; i >= 0; i -= 1) {
993
+ const s = prev[i];
994
+ if (s.type === 'loop' && s.loopName === loopName && s.status === 'running')
995
+ return i;
996
+ }
997
+ return -1;
998
+ })();
999
+ if (idx === -1) {
1000
+ steps = prev;
1001
+ break;
1002
+ }
1003
+ const cur = prev[idx];
1004
+ const next = [...prev];
1005
+ next[idx] = {
1006
+ ...cur,
1007
+ status: isFatal ? 'error' : 'success',
1008
+ loopResult: value,
1009
+ seq: wireSeq ?? cur.seq,
1010
+ serverTs: wireServerTs ?? cur.serverTs,
1011
+ };
1012
+ steps = next;
1013
+ break;
1014
+ }
1015
+ case 'ToolCallEnd': {
1016
+ const toolStepIndex = prev.findLastIndex((s) => s.type === 'tool_call' && s.toolName === evPayload.tool_name && s.status === 'running');
1017
+ if (toolStepIndex >= 0) {
1018
+ const updated = [...prev];
1019
+ const durationMs = evPayload.duration
1020
+ ? evPayload.duration.secs * 1000 + evPayload.duration.nanos / 1000000
1021
+ : undefined;
1022
+ updated[toolStepIndex] = {
1023
+ ...updated[toolStepIndex],
1024
+ status: 'success',
1025
+ content: `${evPayload.tool_name} completed`,
1026
+ toolOutput: evPayload.output,
1027
+ duration: durationMs,
1028
+ seq: wireSeq ?? updated[toolStepIndex].seq,
1029
+ serverTs: wireServerTs ?? updated[toolStepIndex].serverTs,
1030
+ };
1031
+ steps = updated;
1032
+ }
1033
+ else {
1034
+ steps = prev;
1035
+ }
1036
+ break;
1037
+ }
1038
+ default:
1039
+ steps = prev;
1040
+ }
1041
+ return { steps, effects };
1042
+ }
1043
+ /**
1044
+ * Helper to build run-from-line parameters from a previous execution's steps.
1045
+ * Returns null if there's no previous execution to build from.
1046
+ */
1047
+ export function buildRunFromParams(executionSteps, targetLine) {
1048
+ if (executionSteps.length === 0)
1049
+ return null;
1050
+ const upstreamSteps = executionSteps.filter(s => s.line < targetLine && s.line > 0 && s.nodeId != null && s.status === 'success');
1051
+ if (upstreamSteps.length === 0)
1052
+ return null;
1053
+ const seedEnv = {};
1054
+ const skipNodeIds = new Set();
1055
+ for (const step of upstreamSteps) {
1056
+ if (step.nodeId != null) {
1057
+ skipNodeIds.add(step.nodeId);
1058
+ }
1059
+ if (step.variables) {
1060
+ Object.assign(seedEnv, step.variables);
1061
+ }
1062
+ }
1063
+ return {
1064
+ seedEnv,
1065
+ skipNodeIds: Array.from(skipNodeIds),
1066
+ };
1067
+ }
1068
+ //# sourceMappingURL=steps.js.map