elasticdash-test 0.1.12 → 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
  });
@@ -1430,8 +1501,9 @@ function getDashboardHtml() {
1430
1501
  const count = parseInt(document.getElementById('liveValidationCount').value, 10);
1431
1502
  const sequential = document.getElementById('liveValidationSequential').checked;
1432
1503
  if (count >= 1) {
1433
- // Build the tool mock config from UI state
1504
+ // Build the tool mock config from UI state and persist for "Run from here"
1434
1505
  const toolMockConfig = buildToolMockConfigFromUI();
1506
+ window._toolMockConfig = toolMockConfig;
1435
1507
  const submitBtn = document.getElementById('submitLiveValidation');
1436
1508
  submitBtn.disabled = true;
1437
1509
  submitBtn.textContent = 'Validating...';
@@ -1642,6 +1714,27 @@ function getDashboardHtml() {
1642
1714
  const timeB = new Date(b.startTime || 0).getTime();
1643
1715
  return timeA - timeB;
1644
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
+ }
1645
1738
  currentObservations = obs;
1646
1739
  selectedObservationIndex = -1;
1647
1740
  checkedObservations.clear();
@@ -1708,7 +1801,7 @@ function getDashboardHtml() {
1708
1801
  observationsTable += \`<div class="trace-section-title">Observations</div>
1709
1802
  <div class="observation-table-wrap">
1710
1803
  <table class="observation-table">
1711
- <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>
1712
1805
  <tbody>\`;
1713
1806
  observationsTable += currentObservations.map((obs, j) => {
1714
1807
  const isSelected = j === window.step5SelectedObservation;
@@ -1717,7 +1810,7 @@ function getDashboardHtml() {
1717
1810
  const typeClass = type === "TOOL" ? "tool" : "ai";
1718
1811
  const agentBadge = obs.agentTaskIndex != null ? \`<span class="agent-task-badge">T\${obs.agentTaskIndex + 1}</span>\` : '';
1719
1812
  const rowClass = obs.agentTaskIndex != null ? 'agent-task-row' : '';
1720
- 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>\`;
1721
1814
  }).join("");
1722
1815
  observationsTable += \`</tbody></table></div>\`;
1723
1816
  // Details for selected observation
@@ -1729,9 +1822,12 @@ function getDashboardHtml() {
1729
1822
  const filePathHtml = selObs.type === "GENERATION"
1730
1823
  ? '<div class="file-path-placeholder"></div>'
1731
1824
  : renderFilePath(getObsFilePath(selObs));
1825
+ const _dur5orig = computeDurationMs(selObs);
1732
1826
  detailsSection = \`<div class="detail-sections" id="\${detailId}">
1733
1827
  \${filePathHtml}
1734
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)}
1735
1831
  <div class="detail-section">
1736
1832
  <div class="detail-title">Input</div>
1737
1833
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -1752,7 +1848,7 @@ function getDashboardHtml() {
1752
1848
  observationsTable += \`<div class="trace-section-title">Observations</div>
1753
1849
  <div class="observation-table-wrap">
1754
1850
  <table class="observation-table">
1755
- <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>
1756
1852
  <tbody>\`;
1757
1853
  observationsTable += actions.map((action, j) => {
1758
1854
  const isSelected = j === window.step5SelectedObservation;
@@ -1766,7 +1862,7 @@ function getDashboardHtml() {
1766
1862
  action.agentTaskIndex != null ? 'agent-task-row' : '',
1767
1863
  action.isFrozen ? 'frozen-row' : '',
1768
1864
  ].filter(Boolean).join(' ');
1769
- 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>\`;
1770
1866
  }).join("");
1771
1867
  observationsTable += \`</tbody></table></div>\`;
1772
1868
  // Details for selected observation
@@ -1798,10 +1894,13 @@ function getDashboardHtml() {
1798
1894
  } else if (obs.workflowEventId != null) {
1799
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>\`;
1800
1896
  }
1897
+ const _dur5live = computeDurationMs(obs);
1801
1898
  detailsSection = \`<div class="detail-sections" id="\${detailId}">
1802
1899
  \${filePathHtml}
1803
1900
  \${runFromBpHtml}
1804
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)}
1805
1904
  <div class="detail-section">
1806
1905
  <div class="detail-title">Input</div>
1807
1906
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -1859,7 +1958,7 @@ function getDashboardHtml() {
1859
1958
  let col1 = \`<div class="trace-section-title">Steps</div>
1860
1959
  <div class="observation-table-wrap">
1861
1960
  <table class="observation-table">
1862
- <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>
1863
1962
  <tbody>\`;
1864
1963
  col1 += obsIndices.map(idx => {
1865
1964
  const obs = currentObservations[idx];
@@ -1874,7 +1973,7 @@ function getDashboardHtml() {
1874
1973
  : latest.ok ? ' <span class="rerun-status success">✓</span>'
1875
1974
  : ' <span class="rerun-status error">✗</span>')
1876
1975
  : '';
1877
- 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>\`;
1878
1977
  }).join('');
1879
1978
  col1 += \`</tbody></table></div>\`;
1880
1979
 
@@ -1944,9 +2043,24 @@ function getDashboardHtml() {
1944
2043
  } else {
1945
2044
  inputSection += \`<div class="detail-section"><div class="detail-title">Input</div><pre class="detail-pre">\${esc(toDisplayText(obs.input, obs.type))}</pre></div>\`;
1946
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
+ : '';
1947
2057
  col3 = \`<div class="detail-sections" id="\${detailId}">
1948
2058
  \${filePathHtml}
1949
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}
1950
2064
  \${inputSection}
1951
2065
  <div class="detail-section"><div class="detail-title">Output</div><pre class="detail-pre">\${esc(outputText)}</pre></div>
1952
2066
  \${currentOutputSection}
@@ -2012,6 +2126,7 @@ function getDashboardHtml() {
2012
2126
  <td style="width: 40px;"><input type="checkbox" class="obs-checkbox" value="\${actualIndex}" \${isChecked ? "checked" : ""}></td>
2013
2127
  <td onclick="selectObservation(\${actualIndex})">\${esc(name)}</td>
2014
2128
  <td><span class="obs-type \${typeClass}">\${esc(type)}</span></td>
2129
+ <td style="color:#888;font-size:12px;">\${formatDuration(computeDurationMs(obs))}</td>
2015
2130
  </tr>\`;
2016
2131
  }).join("");
2017
2132
 
@@ -2060,9 +2175,12 @@ function getDashboardHtml() {
2060
2175
  ? '<div class="file-path-placeholder"></div>'
2061
2176
  : renderFilePath(getObsFilePath(obs));
2062
2177
 
2178
+ const _dur3 = computeDurationMs(obs);
2063
2179
  observationDetail.innerHTML = \`<div class="detail-sections" id="\${detailId}">
2064
2180
  \${filePathHtml}
2065
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)}
2066
2184
  <div class="detail-section">
2067
2185
  <div class="detail-title">Input</div>
2068
2186
  <pre class="detail-pre">\${esc(inputText)}</pre>
@@ -2104,6 +2222,8 @@ function getDashboardHtml() {
2104
2222
  if (response.ok && data.ok) {
2105
2223
  newRun.ok = true;
2106
2224
  newRun.output = data.currentOutput;
2225
+ newRun.currentDurationMs = data.currentDurationMs ?? null;
2226
+ newRun.currentUsage = data.currentUsage ?? null;
2107
2227
  } else {
2108
2228
  newRun.ok = false;
2109
2229
  newRun.error = data.error || 'Rerun failed.';
@@ -2134,6 +2254,7 @@ function getDashboardHtml() {
2134
2254
  checkpoint: obs.workflowEventId,
2135
2255
  snapshotId: liveTrace.snapshotId,
2136
2256
  observations: currentObservations,
2257
+ toolMockConfig: window._toolMockConfig || {},
2137
2258
  };
2138
2259
  const response = await fetch('/api/run-from-breakpoint', {
2139
2260
  method: 'POST',
@@ -2188,6 +2309,7 @@ function getDashboardHtml() {
2188
2309
  resumeFromTaskIndex: taskIndex,
2189
2310
  },
2190
2311
  snapshotId: liveTrace.snapshotId,
2312
+ toolMockConfig: window._toolMockConfig || {},
2191
2313
  };
2192
2314
  const response = await fetch('/api/resume-agent-from-task', {
2193
2315
  method: 'POST',
@@ -2399,16 +2521,46 @@ function getDashboardHtml() {
2399
2521
  }
2400
2522
  }
2401
2523
 
2402
- // Handle GENERATION output format: {role, content, ...}
2403
- if (type === "GENERATION" && value.role && value.content) {
2404
- 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);
2405
2527
  }
2406
-
2407
- // Handle GENERATION input format: {messages: [...]}
2528
+
2529
+ // GENERATION input wrapped in {messages:[...]} (legacy / Langfuse format)
2408
2530
  if (type === "GENERATION" && value.messages) {
2409
2531
  return JSON.stringify(value.messages, null, 2);
2410
2532
  }
2411
-
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
+
2412
2564
  // For other objects, stringify them
2413
2565
  try {
2414
2566
  return JSON.stringify(value, null, 2);
@@ -2726,12 +2878,15 @@ export async function startDashboardServer(cwd, options = {}) {
2726
2878
  const workflowArgs = resolvedInput.args ?? [];
2727
2879
  const workflowInput = resolvedInput.input ?? null;
2728
2880
  const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
2881
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
2882
+ ? body.toolMockConfig
2883
+ : undefined;
2729
2884
  const frozenEventIds = new Set(history
2730
2885
  .filter((event) => (event.id <= checkpoint
2731
2886
  && (event.type === 'ai' || event.type === 'tool' || event.type === 'http' || event.type === 'db')))
2732
2887
  .map((event) => event.id));
2733
2888
  console.log(`[elasticdash] Run from breakpoint: workflow="${workflowName}" checkpoint=${checkpoint} historyLen=${history.length}`);
2734
- const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, { replayMode: true, checkpoint, history });
2889
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, workflowArgs, workflowInput, { replayMode: true, checkpoint, history, ...(toolMockConfig ? { toolMockConfig } : {}) });
2735
2890
  const traceStub = {
2736
2891
  getSteps: () => (result.steps ?? []),
2737
2892
  getLLMSteps: () => (result.llmSteps ?? []),
@@ -2799,8 +2954,11 @@ export async function startDashboardServer(cwd, options = {}) {
2799
2954
  return;
2800
2955
  }
2801
2956
  const toolsModulePath = resolveRuntimeModule(cwd, 'ed_tools') ?? null;
2957
+ const toolMockConfig = body.toolMockConfig && typeof body.toolMockConfig === 'object' && !Array.isArray(body.toolMockConfig)
2958
+ ? body.toolMockConfig
2959
+ : undefined;
2802
2960
  console.log(`[elasticdash] Resume agent from task: workflow="${workflowName}" taskIndex=${taskIndex}`);
2803
- const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, [], null, { replayMode: history.length > 0, checkpoint: 0, history, agentState });
2961
+ const result = await runWorkflowInSubprocess(workflowsModulePath, toolsModulePath, workflowName, [], null, { replayMode: history.length > 0, checkpoint: 0, history, agentState, ...(toolMockConfig ? { toolMockConfig } : {}) });
2804
2962
  const traceStub = {
2805
2963
  getSteps: () => (result.steps ?? []),
2806
2964
  getLLMSteps: () => (result.llmSteps ?? []),