elasticdash-test 0.1.12-hotfix → 0.1.13-alpha

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.
@@ -24,6 +24,9 @@ function loadSnapshot(cwd, snapshotId) {
24
24
  return null;
25
25
  }
26
26
  }
27
+ function isDenoProject(dir) {
28
+ return existsSync(path.join(dir, 'deno.json')) || existsSync(path.join(dir, 'deno.jsonc'));
29
+ }
27
30
  function resolveRuntimeModule(cwd, baseName) {
28
31
  for (const ext of ['.ts', '.tsx', '.js', '.jsx']) {
29
32
  const candidate = path.join(cwd, `${baseName}${ext}`);
@@ -164,12 +167,21 @@ function formatError(error) {
164
167
  }
165
168
  function runToolInSubprocess(toolsModulePath, toolName, args) {
166
169
  return new Promise((resolve) => {
170
+ const startMs = Date.now();
167
171
  const workerScript = new URL('./tool-runner-worker.js', import.meta.url).pathname;
168
- // Forward NODE_OPTIONS so tsx/esm transpiles TypeScript in the child process
172
+ const projectDir = path.dirname(toolsModulePath);
173
+ const denoProject = isDenoProject(projectDir);
174
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
175
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
169
176
  const nodeOptions = process.env.NODE_OPTIONS ?? '';
170
- const childEnv = { ...process.env, NODE_OPTIONS: nodeOptions };
171
- const child = spawn(process.execPath, [workerScript], {
177
+ const tsxFlag = '--import tsx';
178
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
179
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
180
+ const runtime = denoProject ? 'deno' : process.execPath;
181
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
182
+ const child = spawn(runtime, runtimeArgs, {
172
183
  env: childEnv,
184
+ cwd: projectDir,
173
185
  stdio: ['pipe', 'pipe', 'pipe'],
174
186
  });
175
187
  const RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
@@ -191,17 +203,21 @@ function runToolInSubprocess(toolsModulePath, toolName, args) {
191
203
  process.stderr.write(chunk);
192
204
  });
193
205
  child.on('close', () => {
206
+ const currentDurationMs = Date.now() - startMs;
194
207
  if (resultLine) {
195
208
  try {
196
- resolve(JSON.parse(resultLine));
209
+ resolve({ ...JSON.parse(resultLine), currentDurationMs });
197
210
  return;
198
211
  }
199
212
  catch { /* fall through */ }
200
213
  }
201
- resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.' });
214
+ resolve({ ok: false, error: stderr.trim() || 'Tool subprocess produced no output.', currentDurationMs });
202
215
  });
203
216
  child.on('error', (err) => {
204
- resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}` });
217
+ const hint = denoProject && err.code === 'ENOENT'
218
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
219
+ : '';
220
+ resolve({ ok: false, error: `Failed to spawn tool subprocess: ${err.message}${hint}`, currentDurationMs: Date.now() - startMs });
205
221
  });
206
222
  // Always use absolute file URL for toolsModulePath
207
223
  const payload = JSON.stringify({
@@ -216,17 +232,40 @@ function runToolInSubprocess(toolsModulePath, toolName, args) {
216
232
  function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, args, input, options) {
217
233
  return new Promise((resolve) => {
218
234
  const workerScript = new URL('./workflow-runner-worker.js', import.meta.url).pathname;
219
- // Forward NODE_OPTIONS so tsx/esm transpiles TypeScript in the child process
235
+ const projectDir = path.dirname(workflowsModulePath);
236
+ const denoProject = isDenoProject(projectDir);
237
+ // For Deno projects use `deno run --allow-all` so that https:// imports and
238
+ // TypeScript are handled natively. For Node projects keep the existing tsx path.
220
239
  const nodeOptions = process.env.NODE_OPTIONS ?? '';
221
- const childEnv = { ...process.env, NODE_OPTIONS: nodeOptions };
222
- const child = spawn(process.execPath, [workerScript], {
240
+ const tsxFlag = '--import tsx';
241
+ const childNodeOptions = nodeOptions.includes('tsx') ? nodeOptions : `${nodeOptions} ${tsxFlag}`.trim();
242
+ const childEnv = { ...process.env, NODE_OPTIONS: denoProject ? nodeOptions : childNodeOptions };
243
+ const runtime = denoProject ? 'deno' : process.execPath;
244
+ const runtimeArgs = denoProject ? ['run', '--allow-all', workerScript] : [workerScript];
245
+ const child = spawn(runtime, runtimeArgs, {
223
246
  env: childEnv,
247
+ cwd: projectDir,
224
248
  stdio: ['pipe', 'pipe', 'pipe', 'pipe'],
225
249
  });
226
250
  let fd3Data = '';
227
251
  let stderr = '';
252
+ // Line-buffer stdout so that large result JSON lines split across multiple
253
+ // data events are reassembled before processing.
254
+ const WORKFLOW_RESULT_PREFIX = '__ELASTICDASH_RESULT__:';
255
+ let stdoutBuf = '';
228
256
  child.stdout.on('data', (chunk) => {
229
- process.stdout.write(chunk);
257
+ stdoutBuf += chunk.toString();
258
+ const lines = stdoutBuf.split('\n');
259
+ stdoutBuf = lines.pop() ?? ''; // keep last (possibly incomplete) line
260
+ for (const line of lines) {
261
+ if (line.startsWith(WORKFLOW_RESULT_PREFIX)) {
262
+ // Stdout fallback channel (used by Deno when fd3 is unavailable)
263
+ fd3Data += line.slice(WORKFLOW_RESULT_PREFIX.length);
264
+ }
265
+ else if (line) {
266
+ process.stdout.write(line + '\n');
267
+ }
268
+ }
230
269
  });
231
270
  child.stderr.on('data', (chunk) => {
232
271
  stderr += chunk.toString();
@@ -237,6 +276,13 @@ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowN
237
276
  fd3Data += chunk.toString();
238
277
  });
239
278
  child.on('close', () => {
279
+ // Flush any remaining buffered stdout line (e.g. result with no trailing newline)
280
+ if (stdoutBuf.startsWith(WORKFLOW_RESULT_PREFIX)) {
281
+ fd3Data += stdoutBuf.slice(WORKFLOW_RESULT_PREFIX.length);
282
+ }
283
+ else if (stdoutBuf) {
284
+ process.stdout.write(stdoutBuf + '\n');
285
+ }
240
286
  if (fd3Data) {
241
287
  try {
242
288
  resolve(JSON.parse(fd3Data));
@@ -247,7 +293,10 @@ function runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowN
247
293
  resolve({ ok: false, error: stderr.trim() || 'Workflow subprocess produced no output.' });
248
294
  });
249
295
  child.on('error', (err) => {
250
- resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}` });
296
+ const hint = denoProject && err.code === 'ENOENT'
297
+ ? ' (Deno project detected — ensure "deno" is installed and available in PATH)'
298
+ : '';
299
+ resolve({ ok: false, error: `Failed to spawn workflow subprocess: ${err.message}${hint}` });
251
300
  });
252
301
  // Always use absolute file URL for workflowsModulePath and toolsModulePath
253
302
  const payload = JSON.stringify({
@@ -301,8 +350,8 @@ async function runGenerationObservation(observation) {
301
350
  const model = observation.model;
302
351
  const temperature = typeof observation.modelParameters?.temperature === 'number' ? observation.modelParameters.temperature : 0;
303
352
  const maxTokens = typeof observation.modelParameters?.max_tokens === 'number' ? observation.modelParameters.max_tokens : 512;
304
- const output = await callProviderLLM(prompt, { provider, model }, systemPrompt ?? 'You are a helpful assistant.', maxTokens, temperature);
305
- return { ok: true, currentOutput: output };
353
+ const result = await callProviderLLM(prompt, { provider, model }, systemPrompt ?? 'You are a helpful assistant.', maxTokens, temperature);
354
+ return { ok: true, currentOutput: result.content, currentDurationMs: result.durationMs, currentUsage: result.usage };
306
355
  }
307
356
  catch (error) {
308
357
  return { ok: false, error: `Generation rerun failed: ${formatError(error)}` };
@@ -418,64 +467,21 @@ function toObservationFromWorkflowEvent(event) {
418
467
  const inp = event.input;
419
468
  const out = event.output;
420
469
  const provider = inp?.provider ?? '';
421
- let completion = '';
422
- if (out) {
423
- if (out.streamed === true && typeof out.completion === 'string') {
424
- completion = out.completion;
425
- }
426
- else if (provider === 'gemini') {
427
- const candidates = out.candidates;
428
- if (Array.isArray(candidates) && candidates.length > 0) {
429
- const first = candidates[0];
430
- if (first.content && typeof first.content === 'object') {
431
- const parts = first.content.parts;
432
- if (Array.isArray(parts) && parts.length > 0) {
433
- completion = String(parts[0].text ?? '');
434
- }
435
- }
436
- }
437
- }
438
- else {
439
- const choices = out.choices;
440
- if (Array.isArray(choices) && choices.length > 0) {
441
- const first = choices[0];
442
- if (first.message && typeof first.message === 'object') {
443
- completion = String(first.message.content ?? '');
444
- }
445
- else if (typeof first.text === 'string') {
446
- completion = first.text;
447
- }
448
- }
449
- // Embedding response: data[].embedding
450
- if (!completion) {
451
- const data = out.data;
452
- if (Array.isArray(data) && data.length > 0) {
453
- const first = data[0];
454
- if (Array.isArray(first?.embedding)) {
455
- completion = `[${data.length} embedding(s), ${first.embedding.length} dimensions]`;
456
- }
457
- }
458
- }
459
- // Gemini embedding response: embeddings[].values
460
- if (!completion) {
461
- const embeddings = out.embeddings;
462
- if (Array.isArray(embeddings) && embeddings.length > 0) {
463
- const first = embeddings[0];
464
- if (Array.isArray(first?.values)) {
465
- completion = `[${embeddings.length} embedding(s), ${first.values.length} dimensions]`;
466
- }
467
- }
468
- }
469
- }
470
+ // For streaming events, out is { streamed: true, completion } — extract text for fallback
471
+ let streamedCompletion;
472
+ if (out?.streamed === true && typeof out.completion === 'string') {
473
+ streamedCompletion = out.completion;
470
474
  }
471
475
  return {
472
476
  type: 'GENERATION',
473
477
  name: provider || 'llm',
474
478
  provider: provider || undefined,
475
479
  model: inp?.model ?? event.name,
476
- input: inp?.prompt,
477
- output: completion,
480
+ input: inp?.messages ?? inp?.prompt,
481
+ output: streamedCompletion !== undefined ? streamedCompletion : out,
478
482
  startTime: normalizeStartTime(event.timestamp),
483
+ durationMs: event.durationMs,
484
+ usage: event.usage,
479
485
  workflowEventId: event.id,
480
486
  ...agentFields,
481
487
  };
@@ -487,6 +493,7 @@ function toObservationFromWorkflowEvent(event) {
487
493
  input: event.input,
488
494
  output: event.output,
489
495
  startTime: normalizeStartTime(event.timestamp),
496
+ durationMs: event.durationMs,
490
497
  workflowEventId: event.id,
491
498
  ...agentFields,
492
499
  };
@@ -499,6 +506,7 @@ function toObservationFromWorkflowEvent(event) {
499
506
  input: event.input,
500
507
  output: event.output,
501
508
  startTime: normalizeStartTime(event.timestamp),
509
+ durationMs: event.durationMs,
502
510
  workflowEventId: event.id,
503
511
  ...agentFields,
504
512
  };
@@ -510,6 +518,7 @@ function toObservationFromWorkflowEvent(event) {
510
518
  input: event.input,
511
519
  output: event.output,
512
520
  startTime: normalizeStartTime(event.timestamp),
521
+ durationMs: event.durationMs,
513
522
  workflowEventId: event.id,
514
523
  ...agentFields,
515
524
  };
@@ -520,6 +529,7 @@ function toObservationFromWorkflowEvent(event) {
520
529
  input: event.input,
521
530
  output: event.output,
522
531
  startTime: normalizeStartTime(event.timestamp),
532
+ durationMs: event.durationMs,
523
533
  workflowEventId: event.id,
524
534
  ...agentFields,
525
535
  };
@@ -571,6 +581,22 @@ function buildValidationObservations(workflowName, workflowInput, workflowOutput
571
581
  }
572
582
  }
573
583
  }
584
+ // Compute total duration and aggregate token usage for the container observation
585
+ if (workflowTrace && workflowTrace.events.length > 0) {
586
+ const endTime = workflowTrace.events.reduce((max, e) => Math.max(max, e.timestamp + e.durationMs), workflowStartTime);
587
+ observations[0].durationMs = endTime - workflowStartTime;
588
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
589
+ for (const e of workflowTrace.events) {
590
+ if (e.type === 'ai' && e.usage) {
591
+ inputTokens += e.usage.inputTokens ?? 0;
592
+ outputTokens += e.usage.outputTokens ?? 0;
593
+ totalTokens += e.usage.totalTokens ?? 0;
594
+ }
595
+ }
596
+ if (totalTokens > 0) {
597
+ observations[0].usage = { inputTokens, outputTokens, totalTokens };
598
+ }
599
+ }
574
600
  // Sort all observations except the workflow entry (index 0) by startTime
575
601
  const [workflowEntry, ...rest] = observations;
576
602
  rest.sort((a, b) => (a.startTime ?? 0) - (b.startTime ?? 0));
@@ -1067,7 +1093,7 @@ function getDashboardHtml() {
1067
1093
  <div class="trace-section-title">Observations</div>
1068
1094
  <div class="observation-table-wrap">
1069
1095
  <table class="observation-table">
1070
- <thead id="observationTableHead"><tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th></tr></thead>
1096
+ <thead id="observationTableHead"><tr><th style="width: 40px;">Check</th><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
1071
1097
  <tbody id="observationTableBody"></tbody>
1072
1098
  </table>
1073
1099
  </div>
@@ -1098,6 +1124,47 @@ function getDashboardHtml() {
1098
1124
  } catch {}
1099
1125
  let step5RunMeta = { loading: false, error: '', runCount: 0, sequential: false };
1100
1126
 
1127
+ function computeDurationMs(obs) {
1128
+ if (obs.durationMs != null) return obs.durationMs;
1129
+ if (obs.latency != null && obs.latency > 0) return Math.round(obs.latency * 1000);
1130
+ if (obs.startTime && obs.endTime) {
1131
+ const diff = new Date(obs.endTime).getTime() - new Date(obs.startTime).getTime();
1132
+ if (Number.isFinite(diff) && diff >= 0) return diff;
1133
+ }
1134
+ if (obs.latency != null) return 0;
1135
+ return null;
1136
+ }
1137
+
1138
+ function formatDuration(ms) {
1139
+ if (ms == null) return '—';
1140
+ if (ms < 1000) return ms + ' ms';
1141
+ return (ms / 1000).toFixed(2) + ' s';
1142
+ }
1143
+
1144
+ function extractUsage(obs) {
1145
+ if (obs.usage && (obs.usage.inputTokens != null || obs.usage.outputTokens != null)) {
1146
+ return obs.usage;
1147
+ }
1148
+ if (obs.usageDetails && (obs.usageDetails.input != null || obs.usageDetails.output != null)) {
1149
+ return { inputTokens: obs.usageDetails.input, outputTokens: obs.usageDetails.output, totalTokens: obs.usageDetails.total };
1150
+ }
1151
+ if (obs.inputUsage != null || obs.outputUsage != null) {
1152
+ return { inputTokens: obs.inputUsage, outputTokens: obs.outputUsage, totalTokens: obs.totalUsage };
1153
+ }
1154
+ return null;
1155
+ }
1156
+
1157
+ function renderUsage(obs) {
1158
+ const u = extractUsage(obs);
1159
+ if (!u || !(u.inputTokens > 0 || u.outputTokens > 0 || u.totalTokens > 0)) return '';
1160
+ const lines = [];
1161
+ if (u.inputTokens != null) lines.push('Input tokens: ' + u.inputTokens);
1162
+ if (u.outputTokens != null) lines.push('Output tokens: ' + u.outputTokens);
1163
+ if (u.totalTokens != null) lines.push('Total tokens: ' + u.totalTokens);
1164
+ if (!lines.length) return '';
1165
+ return \`<div class="detail-section"><div class="detail-title">Usage</div><pre class="detail-pre">\${lines.join('\\n')}</pre></div>\`;
1166
+ }
1167
+
1101
1168
  function persistTraces() {
1102
1169
  try {
1103
1170
  // Store compact version — strip bulky workflowTrace.events (snapshot is on server)
@@ -1202,8 +1269,12 @@ function getDashboardHtml() {
1202
1269
  // Extract unique tool names and their call details from the uploaded trace observations
1203
1270
  const toolCalls = {};
1204
1271
  currentObservations.forEach(function(obs, i) {
1205
- if (obs.type !== 'TOOL') return;
1206
- const name = obs.name || '(unknown)';
1272
+ const isToolByType = obs.type === 'TOOL';
1273
+ const isToolByName = typeof obs.name === 'string' && obs.name.startsWith('tool-');
1274
+ if (!isToolByType && !isToolByName) return;
1275
+ const name = isToolByName && obs.type !== 'TOOL'
1276
+ ? obs.name.slice(5)
1277
+ : (obs.name || '(unknown)');
1207
1278
  if (!toolCalls[name]) toolCalls[name] = [];
1208
1279
  toolCalls[name].push({ index: toolCalls[name].length + 1, obsIndex: i, input: obs.input, output: obs.output });
1209
1280
  });
@@ -1643,6 +1714,27 @@ function getDashboardHtml() {
1643
1714
  const timeB = new Date(b.startTime || 0).getTime();
1644
1715
  return timeA - timeB;
1645
1716
  });
1717
+ // Aggregate token usage onto the workflow container SPAN if it has none
1718
+ if (selectedWorkflow?.name) {
1719
+ const containerIdx = obs.findIndex(
1720
+ o => o.type === 'SPAN' && o.name === selectedWorkflow.name
1721
+ );
1722
+ const _existingUsage = extractUsage(obs[containerIdx]);
1723
+ if (containerIdx >= 0 && (!_existingUsage || !(_existingUsage.totalTokens > 0))) {
1724
+ let inputTokens = 0, outputTokens = 0, totalTokens = 0;
1725
+ for (const o of obs) {
1726
+ if (o.type !== 'GENERATION') continue;
1727
+ const u = extractUsage(o);
1728
+ if (!u) continue;
1729
+ inputTokens += u.inputTokens ?? 0;
1730
+ outputTokens += u.outputTokens ?? 0;
1731
+ totalTokens += u.totalTokens ?? 0;
1732
+ }
1733
+ if (totalTokens > 0) {
1734
+ obs[containerIdx].usage = { inputTokens, outputTokens, totalTokens };
1735
+ }
1736
+ }
1737
+ }
1646
1738
  currentObservations = obs;
1647
1739
  selectedObservationIndex = -1;
1648
1740
  checkedObservations.clear();
@@ -1709,7 +1801,7 @@ function getDashboardHtml() {
1709
1801
  observationsTable += \`<div class="trace-section-title">Observations</div>
1710
1802
  <div class="observation-table-wrap">
1711
1803
  <table class="observation-table">
1712
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1804
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
1713
1805
  <tbody>\`;
1714
1806
  observationsTable += currentObservations.map((obs, j) => {
1715
1807
  const isSelected = j === window.step5SelectedObservation;
@@ -1718,7 +1810,7 @@ function getDashboardHtml() {
1718
1810
  const typeClass = type === "TOOL" ? "tool" : "ai";
1719
1811
  const agentBadge = obs.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${obs.agentTaskIndex + 1}</span>\` : '';
1720
1812
  const rowClass = obs.agentTaskIndex != null ? 'agent-task-row' : '';
1721
- return \`<tr class="\${isSelected ? "selected" : ""} \${rowClass}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td></tr>\`;
1813
+ return \`<tr class="\${isSelected ? "selected" : ""} \${rowClass}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td></tr>\`;
1722
1814
  }).join("");
1723
1815
  observationsTable += \`</tbody></table></div>\`;
1724
1816
  // Details for selected observation
@@ -1730,9 +1822,12 @@ function getDashboardHtml() {
1730
1822
  const filePathHtml = selObs.type === "GENERATION"
1731
1823
  ? '<div class="file-path-placeholder"></div>'
1732
1824
  : renderFilePath(getObsFilePath(selObs));
1825
+ const _dur5orig = computeDurationMs(selObs);
1733
1826
  detailsSection = \`<div class="detail-sections" id="\${detailId}">
1734
1827
  \${filePathHtml}
1735
1828
  \${renderModel(selObs)}
1829
+ \${_dur5orig != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur5orig)}</pre></div>\` : ''}
1830
+ \${renderUsage(selObs)}
1736
1831
  <div class="detail-section">
1737
1832
  <div class="detail-title">Input</div>
1738
1833
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -1753,7 +1848,7 @@ function getDashboardHtml() {
1753
1848
  observationsTable += \`<div class="trace-section-title">Observations</div>
1754
1849
  <div class="observation-table-wrap">
1755
1850
  <table class="observation-table">
1756
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1851
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
1757
1852
  <tbody>\`;
1758
1853
  observationsTable += actions.map((action, j) => {
1759
1854
  const isSelected = j === window.step5SelectedObservation;
@@ -1767,7 +1862,7 @@ function getDashboardHtml() {
1767
1862
  action.agentTaskIndex != null ? 'agent-task-row' : '',
1768
1863
  action.isFrozen ? 'frozen-row' : '',
1769
1864
  ].filter(Boolean).join(' ');
1770
- return \`<tr class="\${rowClasses}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}\${frozenBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td></tr>\`;
1865
+ return \`<tr class="\${rowClasses}" onclick="window.step5SelectedObservation=\${j};renderObservationTable();"><td>\${esc(name)}\${agentBadge}\${frozenBadge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(action))}</td></tr>\`;
1771
1866
  }).join("");
1772
1867
  observationsTable += \`</tbody></table></div>\`;
1773
1868
  // Details for selected observation
@@ -1799,10 +1894,13 @@ function getDashboardHtml() {
1799
1894
  } else if (obs.workflowEventId != null) {
1800
1895
  runFromBpHtml = \`<div class="detail-section" style="padding:8px 12px;"><button class="run-from-bp-btn" onclick="runFromBreakpoint(\${traceIdx},\${window.step5SelectedObservation},event)">&#9654; Run from here</button></div>\`;
1801
1896
  }
1897
+ const _dur5live = computeDurationMs(obs);
1802
1898
  detailsSection = \`<div class="detail-sections" id="\${detailId}">
1803
1899
  \${filePathHtml}
1804
1900
  \${runFromBpHtml}
1805
1901
  \${renderModel(obs)}
1902
+ \${_dur5live != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur5live)}</pre></div>\` : ''}
1903
+ \${renderUsage(obs)}
1806
1904
  <div class="detail-section">
1807
1905
  <div class="detail-title">Input</div>
1808
1906
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -1860,7 +1958,7 @@ function getDashboardHtml() {
1860
1958
  let col1 = \`<div class="trace-section-title">Steps</div>
1861
1959
  <div class="observation-table-wrap">
1862
1960
  <table class="observation-table">
1863
- <thead><tr><th>Name</th><th>Type</th></tr></thead>
1961
+ <thead><tr><th>Name</th><th>Type</th><th style="width:80px;">Duration</th></tr></thead>
1864
1962
  <tbody>\`;
1865
1963
  col1 += obsIndices.map(idx => {
1866
1964
  const obs = currentObservations[idx];
@@ -1875,7 +1973,7 @@ function getDashboardHtml() {
1875
1973
  : latest.ok ? ' <span class="rerun-status success">✓</span>'
1876
1974
  : ' <span class="rerun-status error">✗</span>')
1877
1975
  : '';
1878
- return \`<tr class="\${isSelected ? 'selected' : ''}" onclick="window.step4SelectObservation(\${idx})"><td>\${esc(name)}\${badge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td></tr>\`;
1976
+ return \`<tr class="\${isSelected ? 'selected' : ''}" onclick="window.step4SelectObservation(\${idx})"><td>\${esc(name)}\${badge}</td><td><span class="obs-type \${typeClass}">\${esc(type)}</span></td><td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td></tr>\`;
1879
1977
  }).join('');
1880
1978
  col1 += \`</tbody></table></div>\`;
1881
1979
 
@@ -1945,9 +2043,24 @@ function getDashboardHtml() {
1945
2043
  } else {
1946
2044
  inputSection += \`<div class="detail-section"><div class="detail-title">Input</div><pre class="detail-pre">\${esc(toDisplayText(obs.input, obs.type))}</pre></div>\`;
1947
2045
  }
2046
+ const _dur4 = computeDurationMs(obs);
2047
+ const currentDurSection = (run && !run.running && run.ok && run.currentDurationMs != null)
2048
+ ? \`<div class="detail-section"><div class="detail-title">Current Duration</div><pre class="detail-pre">\${formatDuration(run.currentDurationMs)}</pre></div>\`
2049
+ : '';
2050
+ const currentUsageSection = (run && !run.running && run.ok && run.currentUsage && run.currentUsage.totalTokens > 0)
2051
+ ? \`<div class="detail-section"><div class="detail-title">Current Usage</div><pre class="detail-pre">\${[
2052
+ run.currentUsage.inputTokens != null ? 'Input tokens: ' + run.currentUsage.inputTokens : null,
2053
+ run.currentUsage.outputTokens != null ? 'Output tokens: ' + run.currentUsage.outputTokens : null,
2054
+ run.currentUsage.totalTokens != null ? 'Total tokens: ' + run.currentUsage.totalTokens : null,
2055
+ ].filter(Boolean).join('\\n')}</pre></div>\`
2056
+ : '';
1948
2057
  col3 = \`<div class="detail-sections" id="\${detailId}">
1949
2058
  \${filePathHtml}
1950
2059
  \${renderModel(obs)}
2060
+ \${_dur4 != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur4)}</pre></div>\` : ''}
2061
+ \${currentDurSection}
2062
+ \${renderUsage(obs)}
2063
+ \${currentUsageSection}
1951
2064
  \${inputSection}
1952
2065
  <div class="detail-section"><div class="detail-title">Output</div><pre class="detail-pre">\${esc(outputText)}</pre></div>
1953
2066
  \${currentOutputSection}
@@ -2013,6 +2126,7 @@ function getDashboardHtml() {
2013
2126
  <td style="width: 40px;"><input type="checkbox" class="obs-checkbox" value="\${actualIndex}" \${isChecked ? "checked" : ""}></td>
2014
2127
  <td onclick="selectObservation(\${actualIndex})">\${esc(name)}</td>
2015
2128
  <td><span class="obs-type \${typeClass}">\${esc(type)}</span></td>
2129
+ <td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td>
2016
2130
  </tr>\`;
2017
2131
  }).join("");
2018
2132
 
@@ -2061,9 +2175,12 @@ function getDashboardHtml() {
2061
2175
  ? '<div class="file-path-placeholder"></div>'
2062
2176
  : renderFilePath(getObsFilePath(obs));
2063
2177
 
2178
+ const _dur3 = computeDurationMs(obs);
2064
2179
  observationDetail.innerHTML = \`<div class="detail-sections" id="\${detailId}">
2065
2180
  \${filePathHtml}
2066
2181
  \${renderModel(obs)}
2182
+ \${_dur3 != null ? \`<div class="detail-section"><div class="detail-title">Duration</div><pre class="detail-pre">\${formatDuration(_dur3)}</pre></div>\` : ''}
2183
+ \${renderUsage(obs)}
2067
2184
  <div class="detail-section">
2068
2185
  <div class="detail-title">Input</div>
2069
2186
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -2105,6 +2222,8 @@ function getDashboardHtml() {
2105
2222
  if (response.ok && data.ok) {
2106
2223
  newRun.ok = true;
2107
2224
  newRun.output = data.currentOutput;
2225
+ newRun.currentDurationMs = data.currentDurationMs ?? null;
2226
+ newRun.currentUsage = data.currentUsage ?? null;
2108
2227
  } else {
2109
2228
  newRun.ok = false;
2110
2229
  newRun.error = data.error || 'Rerun failed.';
@@ -2402,16 +2521,46 @@ function getDashboardHtml() {
2402
2521
  }
2403
2522
  }
2404
2523
 
2405
- // Handle GENERATION output format: {role, content, ...}
2406
- if (type === "GENERATION" && value.role && value.content) {
2407
- return stripMarkdownCodeFence(value.content || "No content");
2524
+ // GENERATION input: raw messages array
2525
+ if (type === "GENERATION" && Array.isArray(value)) {
2526
+ return JSON.stringify(value, null, 2);
2408
2527
  }
2409
-
2410
- // Handle GENERATION input format: {messages: [...]}
2528
+
2529
+ // GENERATION input wrapped in {messages:[...]} (legacy / Langfuse format)
2411
2530
  if (type === "GENERATION" && value.messages) {
2412
2531
  return JSON.stringify(value.messages, null, 2);
2413
2532
  }
2414
-
2533
+
2534
+ // GENERATION output: assistant message object
2535
+ if (type === "GENERATION" && value.role) {
2536
+ const parts = [];
2537
+ // Text content
2538
+ if (value.content && typeof value.content === 'string') {
2539
+ parts.push(stripMarkdownCodeFence(value.content));
2540
+ } else if (Array.isArray(value.content)) {
2541
+ // Anthropic / Gemini content block array
2542
+ const text = value.content
2543
+ .filter(b => b && (b.type === 'text' || b.text))
2544
+ .map(b => b.text || b.value || '')
2545
+ .join('');
2546
+ if (text) parts.push(stripMarkdownCodeFence(text));
2547
+ // Anthropic tool_use blocks
2548
+ const toolUses = value.content.filter(b => b && b.type === 'tool_use');
2549
+ if (toolUses.length) parts.push('Tool calls:\\n' + JSON.stringify(toolUses, null, 2));
2550
+ } else if (value.parts && Array.isArray(value.parts)) {
2551
+ // Gemini parts array
2552
+ const text = value.parts.filter(p => p.text).map(p => p.text).join('');
2553
+ if (text) parts.push(stripMarkdownCodeFence(text));
2554
+ const fnCalls = value.parts.filter(p => p.functionCall);
2555
+ if (fnCalls.length) parts.push('Function calls:\\n' + JSON.stringify(fnCalls, null, 2));
2556
+ }
2557
+ // OpenAI tool_calls array
2558
+ if (Array.isArray(value.tool_calls) && value.tool_calls.length) {
2559
+ parts.push('Tool calls:\\n' + JSON.stringify(value.tool_calls, null, 2));
2560
+ }
2561
+ return parts.length ? parts.join('\\n\\n') : 'No content';
2562
+ }
2563
+
2415
2564
  // For other objects, stringify them
2416
2565
  try {
2417
2566
  return JSON.stringify(value, null, 2);