traceFixture.ts6.8 KBView on GitHub
/**
 * A hand-built `ExecutionTree` shaped like a real morning fan-out.
 *
 *   root (0 → 10000)
 *   ├─ agenda-review-a      (100 → 4100)        ── overlaps b: parallel
 *   │  └─ draft-subagent-a  (200 → 3200)
 *   │     └─ tool t-a1      (300)               completion-only
 *   ├─ agenda-review-b      (150 → 5150)
 *   │  └─ draft-subagent-b  (300 → 4300)  FAILED
 *   │     └─ tool t-b1      (900)               completion-only
 *   ├─ agenda-review-c      (5500 → 8500)       ── the instrumented branch
 *   │  ├─ tool t-c1         (5600 → 6500)       measured, 900ms
 *   │  ├─ tool t-c3         (5700 → 5703)       measured, 3ms: sub-pixel
 *   │  ├─ tool t-c4         (6600)              measured, 0ms: genuinely instant
 *   │  └─ tool t-c2         (7500)              completion-only
 *   └─ agenda-review-d      (9000 → 9002)       ── 2ms: sub-pixel at any width
 *
 * The MIX is the point. `agent_tool_calls.started_at` is nullable forever, so a
 * trace carrying both instrumented and historical rows is the normal case, not
 * an edge case — one fixture has to exercise both.
 *
 * Two deliberate traps are baked into review-c:
 *   - t-c1 STARTS before t-c3 but FINISHES after it, so anything sorting by
 *     completion time puts them in the wrong order;
 *   - t-c4 is a measured 0ms call, which must not render like the unmeasured
 *     t-c2 even though both carry `durationMs: 0`.
 *
 * The root's window is 10000ms, so every offset in ms is also 1/100th of a
 * percent — which keeps the geometry assertions readable.
 */

import type { ExecutionTree, ExecutionTreeNode, ToolCallSpan } from '@zero/server/execution-tree';

const ROOT_STARTED_AT = new Date('2026-08-09T09:00:00.000Z');

function at(offsetMs: number): Date {
  return new Date(ROOT_STARTED_AT.getTime() + offsetMs);
}

/**
 * A historical row. `logToolCall` runs after the tool returned, so `created_at`
 * is the COMPLETION time and it is the only timestamp that exists — hence
 * `durationMs: 0` meaning UNKNOWN, and an offset that is where it ENDED.
 */
function completionOnlyToolCall(
  id: string,
  runId: string,
  toolName: string,
  completionOffsetMs: number,
  overrides: Partial<ToolCallSpan> = {},
): ToolCallSpan {
  return {
    kind: 'tool',
    id,
    runId,
    toolName,
    arguments: { conversationId: 'conv-1' },
    result: { ok: true },
    startedAt: null,
    createdAt: at(completionOffsetMs),
    startOffsetMs: completionOffsetMs,
    durationMs: 0,
    timingSource: 'completion-only',
    ...overrides,
  };
}

/** An instrumented row: `started_at` is real, so the span is a true interval. */
function measuredToolCall(
  id: string,
  runId: string,
  toolName: string,
  startOffsetMs: number,
  durationMs: number,
  overrides: Partial<ToolCallSpan> = {},
): ToolCallSpan {
  return {
    kind: 'tool',
    id,
    runId,
    toolName,
    arguments: { conversationId: 'conv-1' },
    result: { ok: true },
    startedAt: at(startOffsetMs),
    createdAt: at(startOffsetMs + durationMs),
    startOffsetMs,
    durationMs,
    timingSource: 'measured',
    ...overrides,
  };
}

function execution(
  runId: string,
  overrides: Partial<ExecutionTreeNode> & Pick<ExecutionTreeNode, 'depth' | 'startOffsetMs'>,
): ExecutionTreeNode {
  const durationMs = overrides.durationMs === undefined ? 1000 : overrides.durationMs;
  return {
    kind: overrides.depth === 0 ? 'execution' : 'subagent',
    runId,
    parentRunId: null,
    status: 'completed',
    userId: 'user-1',
    conversationId: null,
    agentId: null,
    agentName: null,
    prompt: null,
    summary: null,
    eventType: null,
    source: null,
    createdAt: at(overrides.startOffsetMs),
    completedAt: durationMs === null ? null : at(overrides.startOffsetMs + durationMs),
    childRunIds: [],
    toolCalls: [],
    ...overrides,
    durationMs,
  };
}

export function buildTraceFixture(): ExecutionTree {
  const nodes: ExecutionTreeNode[] = [
    execution('root', {
      depth: 0,
      startOffsetMs: 0,
      durationMs: 10_000,
      source: 'background-sync',
      eventType: 'slack',
      childRunIds: ['review-a', 'review-b', 'review-c', 'review-d'],
    }),
    execution('review-a', {
      depth: 1,
      startOffsetMs: 100,
      durationMs: 4000,
      parentRunId: 'root',
      agentName: 'review-conversation-a',
      conversationId: 'conv-a',
      prompt: 'Flagged: no reply in 6 days',
      childRunIds: ['subagent-a'],
    }),
    execution('subagent-a', {
      depth: 2,
      startOffsetMs: 200,
      durationMs: 3000,
      parentRunId: 'review-a',
      agentName: 'draft-email',
      toolCalls: [completionOnlyToolCall('t-a1', 'subagent-a', 'draft-email', 300)],
    }),
    execution('review-b', {
      depth: 1,
      startOffsetMs: 150,
      durationMs: 5000,
      parentRunId: 'root',
      agentName: 'review-conversation-b',
      childRunIds: ['subagent-b'],
    }),
    execution('subagent-b', {
      depth: 2,
      startOffsetMs: 300,
      durationMs: 4000,
      parentRunId: 'review-b',
      agentName: 'enrich-contact',
      status: 'failed',
      toolCalls: [
        completionOnlyToolCall('t-b1', 'subagent-b', 'search-crm', 900, {
          result: { error: 'CRM timed out' },
        }),
      ],
    }),
    // The instrumented branch. Its tool calls sit at depth 2 but are LEAVES, so
    // they stay on screen under the default collapse rule — the shapes below are
    // what a reader actually sees without a click.
    execution('review-c', {
      depth: 1,
      startOffsetMs: 5500,
      durationMs: 3000,
      parentRunId: 'root',
      agentName: 'review-conversation-c',
      toolCalls: [
        // Listed out of start order on purpose: the viewer must sort them, and
        // must sort them by START, not by the completion time they used to use.
        measuredToolCall('t-c3', 'review-c', 'tiny-lookup', 5700, 3),
        measuredToolCall('t-c1', 'review-c', 'fetch-thread', 5600, 900),
        completionOnlyToolCall('t-c2', 'review-c', 'search-crm-legacy', 7500),
        measuredToolCall('t-c4', 'review-c', 'instant-cache-hit', 6600, 0),
      ],
    }),
    execution('review-d', {
      depth: 1,
      startOffsetMs: 9000,
      durationMs: 2,
      parentRunId: 'root',
      agentName: 'review-conversation-d',
    }),
  ];

  return {
    requestedRunId: 'root',
    rootRunId: 'root',
    rootStartedAt: ROOT_STARTED_AT,
    totalDurationMs: 10_000,
    nodes,
    nodeCount: nodes.length,
    toolCallCount: nodes.reduce((sum, node) => sum + node.toolCalls.length, 0),
    maxDepth: 2,
    truncated: false,
    truncationReasons: [],
  };
}

/** Parse a percentage style value written by the waterfall (`'1.5%'` → `1.5`). */
export function pct(value: string): number {
  return Number.parseFloat(value.replace('%', ''));
}