diff --git a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx index 4b26d5ddc6..36833f72cd 100644 --- a/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx +++ b/mcpjam-inspector/client/src/components/connection/share-usage/ShareUsageThreadDetail.tsx @@ -19,6 +19,7 @@ import { import { TraceViewer } from "@/components/evals/trace-viewer"; import { BrowserArtifactsView } from "@/components/evals/browser-artifacts-view"; import { hasReplayArtifacts } from "@/components/evals/browser-step-replay"; +import { hydrateTurnTraceSpans } from "@/components/evals/turn-trace-spans"; import { ChatTraceViewModeHeaderBar, type TraceViewMode, @@ -216,28 +217,6 @@ interface ShareUsageThreadDetailProps { */ const PROMOTABLE_SOURCE_TYPES = new Set(["swarm", "scenario"]); -/** - * Fetch span blobs from turn trace URLs and flatten into a single span array. - */ -async function hydrateSpans( - traces: SharedChatTurnTrace[] -): Promise { - const results = await Promise.all( - traces.map(async (trace) => { - if (!trace.spansBlobUrl) return []; - try { - const response = await fetch(trace.spansBlobUrl); - if (!response.ok) return []; - const parsed = await response.json(); - return Array.isArray(parsed) ? (parsed as EvalTraceSpan[]) : []; - } catch { - return []; - } - }) - ); - return results.flat(); -} - export function ShareUsageThreadDetail({ threadId, sessionLink, @@ -312,7 +291,7 @@ export function ShareUsageThreadDetail({ } let isActive = true; - void hydrateSpans(turnTraces).then((spans) => { + void hydrateTurnTraceSpans(turnTraces).then((spans) => { if (isActive) setHydratedSpans(spans); }); return () => { diff --git a/mcpjam-inspector/client/src/components/evals/__tests__/turn-trace-spans.test.ts b/mcpjam-inspector/client/src/components/evals/__tests__/turn-trace-spans.test.ts new file mode 100644 index 0000000000..801ffa59b1 --- /dev/null +++ b/mcpjam-inspector/client/src/components/evals/__tests__/turn-trace-spans.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { EvalTraceSpan } from "@/shared/eval-trace"; +import { hydrateTurnTraceSpans } from "../turn-trace-spans"; + +/** A turn-relative span blob, exactly as a producer writes it: offsets from 0. */ +function turnBlob(durationMs: number): EvalTraceSpan[] { + return [ + { + id: `step-${durationMs}`, + name: "step", + category: "step", + startMs: 0, + endMs: durationMs, + }, + { + id: `llm-${durationMs}`, + name: "llm", + category: "llm", + startMs: 10, + endMs: durationMs - 10, + }, + ]; +} + +const originalFetch = global.fetch; + +function mockBlobs(byUrl: Record) { + global.fetch = vi.fn(async (url: unknown) => { + const body = byUrl[String(url)]; + if (body === undefined) { + return { ok: false, status: 404, json: async () => null } as Response; + } + return { ok: true, status: 200, json: async () => body } as Response; + }) as unknown as typeof fetch; +} + +describe("hydrateTurnTraceSpans", () => { + beforeEach(() => { + global.fetch = originalFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("shifts each turn by its wall-clock distance from the session start", async () => { + // Turn 1 at t=0 runs 5s; turn 2 starts 8s in — a 3s idle gap between them. + mockBlobs({ + "blob://t1": turnBlob(5_000), + "blob://t2": turnBlob(4_000), + }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 1_000_000, spansBlobUrl: "blob://t1" }, + { startedAt: 1_008_000, spansBlobUrl: "blob://t2" }, + ]); + + const steps = spans.filter((span) => span.category === "step"); + expect(steps.map((span) => [span.startMs, span.endMs])).toEqual([ + [0, 5_000], + [8_000, 12_000], + ]); + // The 3s the session spent idle between turns stays visible. + expect(steps[1].startMs - steps[0].endMs).toBe(3_000); + }); + + it("no longer collapses every turn onto 0ms (BB-153)", async () => { + mockBlobs({ + "blob://t1": turnBlob(33_000), + "blob://t2": turnBlob(11_600), + "blob://t3": turnBlob(14_600), + }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 500, spansBlobUrl: "blob://t1" }, + { startedAt: 33_500, spansBlobUrl: "blob://t2" }, + { startedAt: 45_100, spansBlobUrl: "blob://t3" }, + ]); + + const startsAtZero = spans.filter((span) => span.startMs === 0); + expect(startsAtZero).toHaveLength(1); + expect(spans.every((span) => span.endMs >= span.startMs)).toBe(true); + }); + + it("anchors offset 0 at the earliest turn regardless of input order", async () => { + mockBlobs({ + "blob://late": turnBlob(1_000), + "blob://early": turnBlob(1_000), + }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 2_000, spansBlobUrl: "blob://late" }, + { startedAt: 0, spansBlobUrl: "blob://early" }, + ]); + + expect(Math.min(...spans.map((span) => span.startMs))).toBe(0); + expect(Math.max(...spans.map((span) => span.endMs))).toBe(3_000); + }); + + it("preserves every non-timing field on the span", async () => { + mockBlobs({ + "blob://t1": [ + { + id: "tool-1", + name: "tools/call", + category: "tool", + startMs: 100, + endMs: 900, + toolName: "create_workflow", + status: "error", + mcpErrorCode: -32602, + } satisfies EvalTraceSpan, + ], + }); + + const [span] = await hydrateTurnTraceSpans([ + { startedAt: 5_000, spansBlobUrl: "blob://t1" }, + ]); + + expect(span).toMatchObject({ + id: "tool-1", + toolName: "create_workflow", + status: "error", + mcpErrorCode: -32602, + startMs: 100, + endMs: 900, + }); + }); + + it("skips turns with no blob url, and keeps the rest correctly placed", async () => { + mockBlobs({ "blob://t2": turnBlob(2_000) }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 0, spansBlobUrl: null }, + { startedAt: 6_000, spansBlobUrl: "blob://t2" }, + ]); + + // The missing turn still sets the session start, so turn 2 keeps its real + // 6s offset instead of being pulled back to zero. + const step = spans.find((span) => span.category === "step"); + expect(step).toMatchObject({ startMs: 6_000, endMs: 8_000 }); + }); + + it("drops turns whose blob fails to load without shifting the others", async () => { + mockBlobs({ "blob://ok": turnBlob(1_000) }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 0, spansBlobUrl: "blob://ok" }, + { startedAt: 4_000, spansBlobUrl: "blob://gone" }, + ]); + + expect(spans).toHaveLength(2); + expect(Math.max(...spans.map((span) => span.endMs))).toBe(1_000); + }); + + it("tolerates a non-array blob body", async () => { + mockBlobs({ "blob://weird": { spans: [] } }); + + await expect( + hydrateTurnTraceSpans([{ startedAt: 0, spansBlobUrl: "blob://weird" }]), + ).resolves.toEqual([]); + }); + + it("returns an empty array for no turns and never calls fetch", async () => { + mockBlobs({}); + await expect(hydrateTurnTraceSpans([])).resolves.toEqual([]); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("falls back to offset 0 for a turn with an unusable startedAt", async () => { + mockBlobs({ + "blob://t1": turnBlob(1_000), + "blob://bad": turnBlob(1_000), + }); + + const spans = await hydrateTurnTraceSpans([ + { startedAt: 1_000, spansBlobUrl: "blob://t1" }, + { startedAt: Number.NaN, spansBlobUrl: "blob://bad" }, + ]); + + // The NaN row must not poison the base for the valid turn. + expect(spans.every((span) => Number.isFinite(span.startMs))).toBe(true); + expect(Math.min(...spans.map((span) => span.startMs))).toBe(0); + }); +}); diff --git a/mcpjam-inspector/client/src/components/evals/turn-trace-spans.ts b/mcpjam-inspector/client/src/components/evals/turn-trace-spans.ts new file mode 100644 index 0000000000..fe8957fa2e --- /dev/null +++ b/mcpjam-inspector/client/src/components/evals/turn-trace-spans.ts @@ -0,0 +1,71 @@ +import type { EvalTraceSpan } from "@/shared/eval-trace"; +import { rebaseTraceSpans } from "@/shared/live-chat-trace"; + +/** + * The fields of a persisted turn trace this module needs. Structural on + * purpose — `SharedChatTurnTrace` satisfies it, and tests don't have to build + * a full row to exercise the rebasing. + */ +export type TurnTraceSpanSource = { + /** Absolute epoch ms the turn started — the epoch its span offsets are measured from. */ + startedAt: number; + spansBlobUrl?: string | null; +}; + +/** + * Fetch every turn's span blob and flatten them into ONE session timeline. + * + * Each turn is traced with its own `createAiSdkEvalTraceContext(turnStartedAt)` + * (see `runDirectChatTurn`), so the offsets inside a blob are relative to THAT + * turn — every turn's first span sits at `startMs: 0`. Flattening the blobs + * without re-anchoring them stacks all turns on top of each other and the + * timeline renders every span starting at 0.0s (BB-153). + * + * So each turn is shifted by its own distance from the session start + * (`startedAt - sessionStart`), which preserves real wall-clock gaps between + * turns — the model's think time and any tool latency BETWEEN turns stays + * visible as empty space, rather than being packed away. + * + * The base is the earliest `startedAt` of the turns passed in, so offset 0 is + * the session start and matches the `traceStartedAtMs` anchor callers compute + * the same way. Blobs that fail to load contribute nothing and never shift the + * turns that did load. + * + * NOTE: this assumes blobs are turn-relative, which holds for every chat + * producer (only the eval runner passes an explicit `traceStartedAt`, and eval + * traces are read through `use-eval-trace-blob`, not here). A chat producer + * that starts anchoring spans at the session start would double-shift them. + */ +export async function hydrateTurnTraceSpans( + traces: readonly TurnTraceSpanSource[], +): Promise { + if (traces.length === 0) return []; + + // A row with a missing/garbage `startedAt` can't be placed on the timeline; + // it falls back to offset 0 (today's behaviour) instead of poisoning the base + // for every other turn with a NaN. + const startTimes = traces + .map((trace) => trace.startedAt) + .filter((startedAt) => Number.isFinite(startedAt)); + const sessionStartedAt = startTimes.length > 0 ? Math.min(...startTimes) : 0; + + const perTurn = await Promise.all( + traces.map(async (trace) => { + if (!trace.spansBlobUrl) return []; + try { + const response = await fetch(trace.spansBlobUrl); + if (!response.ok) return []; + const parsed = await response.json(); + if (!Array.isArray(parsed)) return []; + const offsetMs = Number.isFinite(trace.startedAt) + ? trace.startedAt - sessionStartedAt + : 0; + return rebaseTraceSpans(parsed as EvalTraceSpan[], offsetMs); + } catch { + return []; + } + }), + ); + + return perTurn.flat(); +} diff --git a/mcpjam-inspector/client/src/components/swarms/use-persisted-session-trace.ts b/mcpjam-inspector/client/src/components/swarms/use-persisted-session-trace.ts index 600b914e97..6c34943a80 100644 --- a/mcpjam-inspector/client/src/components/swarms/use-persisted-session-trace.ts +++ b/mcpjam-inspector/client/src/components/swarms/use-persisted-session-trace.ts @@ -4,12 +4,12 @@ import { useSharedChatThread, useSharedChatTurnTraces, useSharedChatWidgetSnapshots, - type SharedChatTurnTrace, } from "@/hooks/useSharedChatThreads"; import { snapshotsToTraceWidgetSnapshots, type TraceEnvelope, } from "@/components/evals/trace-viewer-adapter"; +import { hydrateTurnTraceSpans } from "@/components/evals/turn-trace-spans"; import type { EvalTraceSpan } from "@/shared/eval-trace"; /** One pinned plugin version recorded on a synthetic session's resume config. */ @@ -20,29 +20,6 @@ export type SessionPluginVersion = { bundleHash: string; }; -/** - * Fetch span blobs from turn trace URLs and flatten into a single span array. - * Same contract as ShareUsageThreadDetail's hydrateSpans. - */ -async function hydrateSpans( - traces: SharedChatTurnTrace[], -): Promise { - const results = await Promise.all( - traces.map(async (trace) => { - if (!trace.spansBlobUrl) return []; - try { - const response = await fetch(trace.spansBlobUrl); - if (!response.ok) return []; - const parsed = await response.json(); - return Array.isArray(parsed) ? (parsed as EvalTraceSpan[]) : []; - } catch { - return []; - } - }), - ); - return results.flat(); -} - function extractMessages(data: unknown): unknown[] | null { if (Array.isArray(data)) return data; if ( @@ -171,7 +148,7 @@ export function usePersistedSessionTrace(threadId: string | null): { let active = true; setLoadingSpans(true); - void hydrateSpans(turnTraces).then((hydrated) => { + void hydrateTurnTraceSpans(turnTraces).then((hydrated) => { if (!active) return; setSpans(hydrated); setLoadingSpans(false);