Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EvalTraceSpan[]> {
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,
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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);
});
});
71 changes: 71 additions & 0 deletions mcpjam-inspector/client/src/components/evals/turn-trace-spans.ts
Original file line number Diff line number Diff line change
@@ -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<EvalTraceSpan[]> {
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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<EvalTraceSpan[]> {
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 (
Expand Down Expand Up @@ -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);
Expand Down
Loading