diff --git a/apps/api/src/routes/internal/ai-sessions.http.test.ts b/apps/api/src/routes/internal/ai-sessions.http.test.ts new file mode 100644 index 000000000..1cfe41913 --- /dev/null +++ b/apps/api/src/routes/internal/ai-sessions.http.test.ts @@ -0,0 +1,148 @@ +// SAFETY-FILE: JSON in this test is emitted by the route under test before its fields are asserted. +import { describe, expect, it } from "@effect/vitest" +import { + AiSessionsInternalApiGroup, + CurrentTenant, + V1SchemaErrors, + V1UnexpectedErrors, +} from "@maple/domain/http" +import { AI_SESSION_SPANS_MAX_SPANS } from "@maple/query-engine-integrations" +import { WarehouseResponseLimitError } from "@maple/query-engine/execution" +import { Context, Effect, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" +import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi" +import type { WarehouseQueryServiceApi } from "@/services/warehouse/WarehouseQueryService" +import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +import { makeWarehouseServiceStub } from "../v2/v2-test-support" +import { V1ErrorBoundaryLive } from "../v1/error-boundary" +import { HttpAiSessionsInternalLive } from "./ai-sessions.http" + +/** + * The truncation contract of `POST /internal/ai-sessions/spans`: what the row + * cap does, and what the byte cap does instead. Both are one-off shapes the + * other warehouse reads have no equivalent of. + */ + +class AiSessionsOnlyApi extends HttpApi.make("MapleInternalApi") + .add(AiSessionsInternalApiGroup) + .middleware(V1SchemaErrors) + .middleware(V1UnexpectedErrors) {} + +const SESSION_ID = "wrun_01KZTEST" + +const TENANT = new CurrentTenant.TenantSchema({ + orgId: "org_ai_sessions" as CurrentTenant.TenantSchema["orgId"], + userId: "user_ai_sessions" as CurrentTenant.TenantSchema["userId"], + roles: [], + authMode: "self_hosted", +}) + +const AuthorizationStubLayer = Layer.succeed( + CurrentTenant.SessionAuthorization, + CurrentTenant.SessionAuthorization.of({ + bearer: (httpEffect) => Effect.provideService(httpEffect, CurrentTenant.Context, TENANT), + }), +) + +/** One warehouse row, in the wire shape `aiSessionSpansRowSchema` decodes. */ +const spanRow = (index: number) => ({ + traceId: "trace-1", + spanId: `span-${index}`, + parentSpanId: "", + spanName: "chat", + spanKind: "SPAN_KIND_CLIENT", + serviceName: "agent-runner", + durationMs: 12, + statusCode: "Unset", + statusMessage: "", + timestamp: "2026-08-19 10:00:00.000000000", + spanAttributes: { "gen_ai.operation.name": "chat", "maple_ai.session.id": SESSION_ID }, + resourceAttributes: {}, +}) + +const makeHarness = (overrides: Partial) => { + const routes = HttpApiBuilder.layer(AiSessionsOnlyApi).pipe( + Layer.provide(HttpAiSessionsInternalLive), + Layer.provide(V1ErrorBoundaryLive), + Layer.provideMerge(AuthorizationStubLayer), + Layer.provideMerge(Layer.succeed(WarehouseQueryService, makeWarehouseServiceStub(overrides))), + ) + const { handler, dispose } = HttpRouter.toWebHandler(routes as never, { disableLogger: true }) + + const spans = async () => { + // SAFETY: the handler's second argument is the Worker environment context, + // and this route reads nothing out of it. + const response = await handler( + new Request("http://maple.test/internal/ai-sessions/spans", { + method: "POST", + headers: { authorization: "Bearer test-token", "content-type": "application/json" }, + body: JSON.stringify({ + sessionId: SESSION_ID, + startTime: "2026-08-19 09:00:00", + endTime: "2026-08-19 11:00:00", + }), + }), + Context.empty() as never, + ) + const text = await response.text() + return { + status: response.status, + body: text.length === 0 ? null : (JSON.parse(text) as Record), + } + } + + return { spans, dispose } +} + +describe("POST /internal/ai-sessions/spans", () => { + it("answers a response-limit failure with the 413 the client can act on", async () => { + const harness = makeHarness({ + compiledQueryBounded: () => + Effect.fail( + new WarehouseResponseLimitError({ kind: "bytes", message: "response too large" }), + ), + }) + + try { + const response = await harness.spans() + expect(response.status).toBe(413) + expect(response.body?._tag).toBe("@maple/http/ai-sessions/AiSessionTooLargeError") + } finally { + await harness.dispose() + } + }) + + it("cuts the session at the row cap and says so", async () => { + // The query asks for one row past the cap precisely so this case is + // distinguishable from a session that exactly fills it. + const rows = Array.from({ length: AI_SESSION_SPANS_MAX_SPANS + 1 }, (_, index) => spanRow(index)) + const harness = makeHarness({ + compiledQueryBounded: (_tenant, compiled) => compiled.decodeRows(rows).pipe(Effect.orDie), + }) + + try { + const response = await harness.spans() + expect(response.status).toBe(200) + expect(response.body?.truncated).toBe(true) + expect(response.body?.data).toHaveLength(AI_SESSION_SPANS_MAX_SPANS) + } finally { + await harness.dispose() + } + }) + + it("reports a session that fits as complete", async () => { + const harness = makeHarness({ + compiledQueryBounded: (_tenant, compiled) => + compiled.decodeRows([spanRow(0), spanRow(1)]).pipe(Effect.orDie), + }) + + try { + const response = await harness.spans() + expect(response.status).toBe(200) + expect(response.body?.truncated).toBe(false) + expect(response.body?.data).toHaveLength(2) + } finally { + await harness.dispose() + } + }) +}) diff --git a/apps/api/src/routes/internal/ai-sessions.http.ts b/apps/api/src/routes/internal/ai-sessions.http.ts index 039f5642b..0c8b52704 100644 --- a/apps/api/src/routes/internal/ai-sessions.http.ts +++ b/apps/api/src/routes/internal/ai-sessions.http.ts @@ -1,15 +1,45 @@ import { HttpApiBuilder } from "effect/unstable/httpapi" import { + AiSessionTooLargeError, CurrentTenant, + GetAiSessionSpansResponse, ListAiSessionsFacetsResponse, ListAiSessionsResponse, MapleInternalApi, + MAX_AI_SESSION_SPANS_RESPONSE_BYTES, } from "@maple/domain/http" +import type { AiSessionGenAiValues, AiSessionSpan } from "@maple/domain/http" import { Effect } from "effect" import { CH } from "@maple/query-engine" import * as Integrations from "@maple/query-engine-integrations" import { WarehouseQueryService } from "@/services/warehouse/WarehouseQueryService" +// The wire span shape is declared in `@maple/domain` and the mapped one in +// `@maple/query-engine-integrations`, because the integrations package depends +// on the domain and cannot be imported back from it. This is the only place +// both are visible, so it is where the claim that they are the same shape gets +// enforced. +type Assert = T +type NoExtraKeys = [Exclude] extends [never] ? true : false + +// Assignability alone would pass a wire struct missing a `gen_ai` field — +// every one of them is optional, so a dropped key satisfies both directions. +// The key sets are compared as well, which is the drift that actually happens: +// a field added to the catalog and not to the wire would silently stop being +// sent. Together they cover both the names and the value types. +type _MappedSpanMatchesWireSpan = Assert< + Integrations.AiAgentSpan extends AiSessionSpan + ? AiSessionSpan extends Integrations.AiAgentSpan + ? true + : false + : false +> +type _WireCarriesEveryCatalogField = Assert> +type _WireInventsNoField = Assert> +// Same hole one level up: the span's own optional top-level fields (`sessionId`, +// `vendorId`, …) are invisible to assignability for exactly the same reason. +type _SpanKeysMatch = Assert> + /** * Dashboard-only AI agent session reads. * @@ -72,5 +102,63 @@ export const HttpAiSessionsInternalLive = HttpApiBuilder.group( }) }), ) + .handle("spans", ({ payload }) => + Effect.gen(function* () { + const tenant = yield* CurrentTenant.Context + yield* Effect.annotateCurrentSpan({ orgId: tenant.orgId }) + // One row past the cap: the extra row is what distinguishes a + // session that exactly fills the cap from one whose tail was cut. + const compiled = CH.compile( + Integrations.aiSessionSpansQuery({ + limit: Integrations.AI_SESSION_SPANS_MAX_SPANS + 1, + }), + { + orgId: tenant.orgId, + startTime: payload.startTime, + endTime: payload.endTime, + sessionId: payload.sessionId, + }, + { rowSchema: Integrations.aiSessionSpansRowSchema }, + ) + const rows = yield* warehouse + .compiledQueryBounded(tenant, compiled, { + profile: "list", + context: "aiSessionSpans", + responseLimits: { + maxRows: Integrations.AI_SESSION_SPANS_MAX_SPANS + 1, + maxBytes: MAX_AI_SESSION_SPANS_RESPONSE_BYTES, + }, + }) + .pipe( + Effect.catchTag( + "@maple/query-engine/execution/WarehouseResponseLimitError", + (error) => + Effect.fail( + new AiSessionTooLargeError({ + sessionId: payload.sessionId, + message: `AI session spans exceeded the ${error.kind} response limit.`, + }), + ), + ), + ) + const truncated = rows.length > Integrations.AI_SESSION_SPANS_MAX_SPANS + yield* Effect.annotateCurrentSpan({ + "maple.ai.session_id": payload.sessionId, + "maple.ai.span_count": Math.min( + rows.length, + Integrations.AI_SESSION_SPANS_MAX_SPANS, + ), + "maple.ai.truncated": truncated, + }) + // Mapped server-side: the raw attribute maps are the dominant + // weight of this read and nothing downstream needs them. + return new GetAiSessionSpansResponse({ + data: Integrations.mapAiSpans( + rows.slice(0, Integrations.AI_SESSION_SPANS_MAX_SPANS), + ), + truncated, + }) + }), + ) }), ) diff --git a/apps/web/src/api/warehouse/ai-sessions.ts b/apps/web/src/api/warehouse/ai-sessions.ts index dc4dc9a9c..9da0e6f06 100644 --- a/apps/web/src/api/warehouse/ai-sessions.ts +++ b/apps/web/src/api/warehouse/ai-sessions.ts @@ -1,5 +1,9 @@ import { Clock, Effect, Schema } from "effect" -import { ListAiSessionsFacetsRequest, ListAiSessionsRequest } from "@maple/domain/http" +import { + GetAiSessionSpansRequest, + ListAiSessionsFacetsRequest, + ListAiSessionsRequest, +} from "@maple/domain/http" import { MapleInternalAtomClient } from "@/lib/services/common/internal-atom-client" import { WarehouseDateTimeString, decodeInput, runWarehouseQuery } from "@/api/warehouse/effect-utils" @@ -73,3 +77,36 @@ export const getAiSessionsFacets = Effect.fn("AiSessions.aiSessionsFacets")(func ) return { vendors: result.vendors, services: result.services } }) + +// Session spans (detail page) + +const AiSessionSpansInput = Schema.Struct({ + sessionId: Schema.String.check(Schema.isMinLength(1)), + // No `defaultTimeRange` fallback: the window bounds which spans of the + // session are found at all, so the caller supplies one derived from the + // session it is opening rather than inheriting the list page's 24h default. + startTime: WarehouseDateTimeString, + endTime: WarehouseDateTimeString, +}) +export type AiSessionSpansInput = Schema.Schema.Type + +export const getAiSessionSpans = Effect.fn("AiSessions.aiSessionSpans")(function* ({ + data, +}: { + data: AiSessionSpansInput +}) { + const input = yield* decodeInput(AiSessionSpansInput, data, "aiSessionSpans") + const result = yield* runWarehouseQuery("aiSessionSpans", () => + Effect.gen(function* () { + const client = yield* MapleInternalAtomClient + return yield* client.aiSessionsInternal.spans({ + payload: new GetAiSessionSpansRequest({ + sessionId: input.sessionId, + startTime: input.startTime, + endTime: input.endTime, + }), + }) + }), + ) + return { data: result.data, truncated: result.truncated } +}) diff --git a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx index 78166aaf5..3def9965e 100644 --- a/apps/web/src/components/agent-sessions/agent-sessions-list.tsx +++ b/apps/web/src/components/agent-sessions/agent-sessions-list.tsx @@ -1,3 +1,5 @@ +import { Link } from "@tanstack/react-router" + import { formatRelativeTimeOrDate, toEpochMs } from "@maple/ui/lib/time-format" import { formatSessionDuration } from "@maple/ui/lib/replay-format" import { ChatBubbleSparkleIcon } from "@/components/icons" @@ -92,9 +94,15 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) { ? `${vendor} · v${session.vendorVersion}` : vendor return ( -
{/* Errored sessions get a left accent so they can be picked out while scanning — same signal as the replays list. */} @@ -164,7 +172,7 @@ export function AgentSessionsList({ sessions, limit }: AgentSessionsListProps) { {formatRelativeTimeOrDate(session.startTime)}
- + ) })} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx new file mode 100644 index 000000000..77a5dd5b7 --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/session-detail.test.tsx @@ -0,0 +1,537 @@ +// @vitest-environment jsdom +// TEST-SEAM: the virtualizer sizes its viewport from offsetWidth/offsetHeight, +// which jsdom reports as 0 — leaving it convinced no row is on screen, so the +// two layout globals below are stubbed for that reason alone. Router navigation +// is stubbed to a plain anchor — these are rendering tests, and mounting a +// router would only add a second thing that can fail. + +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react" +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest" + +import type { AiSessionSpan } from "@maple/domain/http" +import { agentSpan, llmSpan, makeSpan, toolSpan, userMessages } from "@/lib/agent-sessions/span-fixtures" +import { buildSessionSummary, type SessionSummary } from "@/lib/agent-sessions/session-summary" +import { buildSessionTurns, type SessionTurn } from "@/lib/agent-sessions/session-turns" +import { SessionFlow } from "./session-flow" +import { SessionHeader } from "./session-header" +import { SessionViews } from "./session-views" +import { SessionWaterfall } from "./session-waterfall" + +vi.mock("@tanstack/react-router", () => ({ + Link: ({ + children, + className, + style, + title, + }: { + children: React.ReactNode + className?: string + style?: React.CSSProperties + title?: string + }) => ( + + {children} + + ), +})) + +beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { configurable: true, value: 1200 }) + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, value: 900 }) +}) + +afterEach(cleanup) + +const SECOND = 1000 +const MINUTE = 60 * SECOND + +// Two turns split by four minutes of a human thinking, a parallel pair of tool +// calls, and a tool that failed — the shape the page exists to show. +const spans = [ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 40 * SECOND }), + llmSpan({ + spanId: "llm-1", + parentSpanId: "agent-1", + startMs: SECOND, + durationMs: 8 * SECOND, + model: "claude-sonnet-4-5", + tokens: [40_000, 0, 0, 600, 0], + ttftSeconds: 1.4, + genAi: { inputMessages: userMessages("fix the webhook retry backoff") }, + }), + toolSpan({ + spanId: "tool-1", + parentSpanId: "agent-1", + startMs: 10 * SECOND, + durationMs: SECOND, + toolName: "read_file", + }), + toolSpan({ + spanId: "tool-2", + parentSpanId: "agent-1", + startMs: 10 * SECOND, + durationMs: 2 * SECOND, + toolName: "grep_repo", + }), + toolSpan({ + spanId: "tool-3", + parentSpanId: "agent-1", + startMs: 14 * SECOND, + durationMs: 20 * SECOND, + toolName: "run_tests", + statusCode: "Error", + statusMessage: "exit 1", + }), + makeSpan({ + spanId: "http-1", + parentSpanId: "agent-1", + startMs: 2 * SECOND, + durationMs: 200, + spanName: "GET /repo/file", + isAiSpan: false, + }), + agentSpan({ spanId: "agent-2", startMs: 5 * MINUTE, durationMs: 12 * SECOND }), + llmSpan({ + spanId: "llm-2", + parentSpanId: "agent-2", + startMs: 5 * MINUTE + SECOND, + durationMs: 10 * SECOND, + model: "claude-sonnet-4-5", + tokens: [90_000, 0, 0, 1200, 300], + }), +] + +const NOW = Date.UTC(2026, 7, 19, 18, 0, 0) +const turns = buildSessionTurns(spans) +const summary = buildSessionSummary(spans, turns, NOW) + +// The other common shape: one agent span, no captured message, no usage +// reported, and no human to wait on. +const quietSpans = [agentSpan({ spanId: "a", startMs: 0, durationMs: SECOND })] +const quiet = buildSessionSummary(quietSpans, buildSessionTurns(quietSpans), NOW) + +const gatewaySpans = [ + agentSpan({ spanId: "g-agent", startMs: 0, durationMs: 4 * SECOND }), + llmSpan({ + spanId: "g-llm", + parentSpanId: "g-agent", + startMs: SECOND, + durationMs: 2 * SECOND, + model: "openrouter/openai/gpt-4o-mini", + tokens: [1000, 0, 0, 100, 0], + }), +] + +const EMPTY = new Set() +const noop = () => {} + +/** The waterfall's expansion state lives in SessionViews, so the tests supply it. */ +function Waterfall(props: { + turns?: readonly SessionTurn[] + summary?: SessionSummary + query?: string + agentSpansOnly?: boolean + collapseIdle?: boolean + collapsedTurns?: ReadonlySet + onToggleTurn?: (turnId: string) => void +}) { + return ( + + ) +} + +function Flow(props: { + turns?: readonly SessionTurn[] + mergeRepeats?: boolean + query?: string + agentSpansOnly?: boolean +}) { + return ( + + ) +} + +describe("SessionHeader", () => { + it("states the session's duration, status and work", () => { + render() + + // Title comes from the captured user message, not the fallback. + expect(screen.getByRole("heading").textContent).toBe("fix the webhook retry backoff") + expect(screen.getByText("COMPLETED")).toBeTruthy() + expect(screen.getByText("conv_8f14e45f2a1c")).toBeTruthy() + // 5m 12s wall clock, 4m 20s of it idle. + expect(screen.getByText("5m 12s")).toBeTruthy() + expect(screen.getByText("Idle · awaiting user")).toBeTruthy() + expect(screen.getByText("claude-sonnet-4-5")).toBeTruthy() + }) + + it("prices the session against the list-price table", () => { + render() + + // 130K input at $3/MTok + 2.1K output/reasoning at $15/MTok. + expect(screen.getByText("$0.42")).toBeTruthy() + expect(screen.queryByText(/unpriced/)).toBeNull() + }) + + it("falls back to the agent's name when no message was captured", () => { + render() + + expect(screen.getByRole("heading").textContent).toBe("billing-agent · Aug 19") + }) + + it("drops the wall-clock twin and its toggle when nothing waited on a human", () => { + render() + + expect(screen.getByText("wall clock")).toBeTruthy() + expect(screen.queryByText(/active ·/)).toBeNull() + expect(screen.queryByText("Active only")).toBeNull() + }) + + it("says no usage was reported rather than pricing a session at zero", () => { + render() + + // Both the token breakdown and the spend it derives from. + expect(screen.getAllByText("no token usage reported")).toHaveLength(2) + expect(screen.queryByText("$0.00")).toBeNull() + }) + + it("shows the last path segment of a gateway model id, full id in the title", () => { + const gateway = buildSessionSummary(gatewaySpans, buildSessionTurns(gatewaySpans), NOW) + render() + + const name = screen.getByText("gpt-4o-mini") + expect(name.getAttribute("title")).toBe("openrouter/openai/gpt-4o-mini") + }) +}) + +describe("SessionWaterfall", () => { + it("groups spans under their turn and marks the idle between them", () => { + render() + + expect(screen.getByText("Turn 1")).toBeTruthy() + expect(screen.getByText("Turn 2")).toBeTruthy() + expect(screen.getByText(/“fix the webhook retry backoff”/)).toBeTruthy() + expect(screen.getByText(/idle 4m 20s · awaiting user/)).toBeTruthy() + expect(screen.getByText(/of idle removed across 1 gap/)).toBeTruthy() + }) + + it("steps the ruler in clock values rather than fifths of the total", () => { + // 52s of active time: 15s steps, not the 10.4s an even division would give. + render() + + expect(screen.getByText("15s")).toBeTruthy() + expect(screen.getByText("45s")).toBeTruthy() + }) + + it("hides the app's own spans unless asked for them", () => { + const view = render() + expect(screen.queryByText("GET /repo/file")).toBeNull() + + view.rerender() + expect(screen.getByText("GET /repo/file")).toBeTruthy() + }) + + it("narrows to the spans that match the filter", () => { + render() + + expect(screen.getAllByText(/run_tests/).length).toBeGreaterThan(0) + expect(screen.queryByText("Turn 2")).toBeNull() + }) + + it("renders the empty state, and no orphan idle rows, when nothing matches", () => { + render() + + expect(screen.getByText("No spans match this filter.")).toBeTruthy() + expect(screen.queryByText(/awaiting user/)).toBeNull() + }) + + it("draws a trace rule only where a trace bands more than one turn", () => { + // Both turns share trace-1, so one rule opens the band. + render() + + expect(screen.getByText("turns 1–2")).toBeTruthy() + expect(screen.getByText("Trace trace-1")).toBeTruthy() + }) + + it("puts a one-turn trace in that turn's header instead of a rule row", () => { + render() + + expect(screen.queryByText(/^turns? \d/)).toBeNull() + expect(screen.getByText("Trace trace-1")).toBeTruthy() + expect(screen.getByText("Trace trace-2")).toBeTruthy() + }) + + it("moves a trace link off a fully filtered-out turn instead of leaving it dangling", () => { + render() + + expect(screen.queryByText(/Trace trace-1/)).toBeNull() + expect(screen.getByText(/Trace trace-2/)).toBeTruthy() + }) + + it("places an idle gap inside the turn it interrupts", () => { + const view = render() + + const text = view.container.textContent ?? "" + expect(text.indexOf("idle 1m 0s")).toBeGreaterThan(text.indexOf("read_file")) + expect(text.indexOf("idle 1m 0s")).toBeLessThan(text.indexOf("run_tests")) + }) + + it("counts only the spans the filter shows on a collapsed turn", () => { + const onToggleTurn = vi.fn() + render() + + // Six spans in the turn, one an app HTTP span the agent-spans-only filter hides. + expect(screen.getByText("5 spans")).toBeTruthy() + + fireEvent.click(screen.getByText("Turn 1")) + expect(onToggleTurn).toHaveBeenCalledWith(turns[0]!.id) + }) + + it("never repeats in MODEL / TARGET what the span name already says", () => { + render() + + // An agent span's target is the agent, which the row already names. + const agentRow = screen.getByText("invoke_agent").closest("a")! + expect(within(agentRow).getByText("billing-agent")).toBeTruthy() + expect(within(agentRow).getAllByText("—")).toHaveLength(2) + + // A model already in the span name is not printed a second time. + expect(within(screen.getByText("chat gpt-5").closest("a")!).queryByText("gpt-5")).toBeNull() + + // A gateway-prefixed model shows its last segment, full id in the title. + const modelCell = within(screen.getByText("chat").closest("a")!).getByText("gpt-4o-mini") + expect(modelCell.getAttribute("title")).toBe("openrouter/openai/gpt-4o-mini") + + // A tool row's target is what the tool acted on, never the tool's own name. + const toolRow = screen.getByText("execute_tool").closest("a")! + expect(within(toolRow).getByText("src/webhooks/retry.ts")).toBeTruthy() + expect(within(toolRow).getAllByText("read_file")).toHaveLength(1) + }) + + it("marks a delegation as a subagent, and a framework step not at all", () => { + render() + + expect(screen.getAllByText("Subagent")).toHaveLength(1) + const delegated = screen.getByText("agent.delegate").closest("a")! + expect(within(delegated).getByText("Subagent")).toBeTruthy() + }) + + it("marks the errored call that was sent again as a retry", () => { + render() + + expect(screen.getAllByText("Retry")).toHaveLength(1) + const failed = screen.getByText("429").closest("a")! + expect(within(failed).getByText("Retry")).toBeTruthy() + }) +}) + +describe("SessionFlow", () => { + it("lays out one lane per turn", () => { + render() + + expect(screen.getByText("Turn 1")).toBeTruthy() + expect(screen.getByText("Turn 2")).toBeTruthy() + expect(screen.getByText("read_file")).toBeTruthy() + expect(screen.getByText("grep_repo")).toBeTruthy() + }) + + it("merges a run of identical calls into one counted node", () => { + const repeated = [ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 20 * SECOND }), + toolSpan({ spanId: "t1", parentSpanId: "agent", startMs: SECOND, durationMs: SECOND }), + toolSpan({ spanId: "t2", parentSpanId: "agent", startMs: 3 * SECOND, durationMs: SECOND }), + toolSpan({ spanId: "t3", parentSpanId: "agent", startMs: 5 * SECOND, durationMs: SECOND }), + ] + const view = render() + expect(screen.getAllByText("read_file")).toHaveLength(3) + + view.rerender() + const merged = screen.getByText("read_file").closest("a") + expect(merged).not.toBeNull() + expect(within(merged!).getByText("×3")).toBeTruthy() + }) + + it("skips the container that only wraps the call below it", () => { + const nested = [ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), + agentSpan({ + spanId: "a2", + parentSpanId: "a1", + startMs: SECOND, + durationMs: 5 * SECOND, + spanName: "call_llm", + }), + llmSpan({ + spanId: "l1", + parentSpanId: "a2", + startMs: 2 * SECOND, + durationMs: 2 * SECOND, + spanName: "generate_content", + model: "gpt-5", + }), + ] + render() + + expect(screen.getByText("invoke_agent")).toBeTruthy() + expect(screen.getByText("generate_content")).toBeTruthy() + expect(screen.queryByText("call_llm")).toBeNull() + }) + + it("wraps a long turn into a block instead of one endless ribbon", () => { + const long = [ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 40 * SECOND }), + ...Array.from({ length: 9 }, (_, index) => + toolSpan({ + spanId: `t${index}`, + parentSpanId: "agent", + startMs: (index + 1) * 2 * SECOND, + durationMs: SECOND, + toolName: `tool_${index}`, + }), + ), + ] + const view = render() + + const cards = [...view.container.querySelectorAll("a")] + expect(cards).toHaveLength(10) + expect(cards[8]!.style.left).toBe(cards[0]!.style.left) + expect(Number.parseFloat(cards[8]!.style.top)).toBeGreaterThan(Number.parseFloat(cards[0]!.style.top)) + }) +}) + +describe("SessionViews", () => { + it("counts what is on screen, in the singular when there is one of it", () => { + render() + + expect(screen.getByText(/^4 spans · 2 turns · 2 traces$/)).toBeTruthy() + + fireEvent.change(screen.getByPlaceholderText("Filter spans"), { + target: { value: "run_tests" }, + }) + expect(screen.getByText(/^1 of 4 spans · 2 turns · 2 traces$/)).toBeTruthy() + }) +}) + +/* -------------------------------------------------------------------------- */ +/* Fixtures used by a single test */ +/* -------------------------------------------------------------------------- */ + +function sessionOf(input: readonly AiSessionSpan[]) { + const sessionTurns = buildSessionTurns(input) + return { turns: sessionTurns, summary: buildSessionSummary(input, sessionTurns, NOW) } +} + +/** Two turns, one trace each. */ +const crossTrace = [ + agentSpan({ spanId: "t1-agent", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ + spanId: "t1-tool", + parentSpanId: "t1-agent", + startMs: SECOND, + durationMs: SECOND, + toolName: "read_file", + }), + agentSpan({ spanId: "t2-agent", traceId: "trace-2", startMs: 20 * SECOND, durationMs: 30 * SECOND }), + toolSpan({ + spanId: "t2-tool", + traceId: "trace-2", + parentSpanId: "t2-agent", + startMs: 21 * SECOND, + durationMs: 20 * SECOND, + toolName: "run_tests", + }), +] +const { turns: crossTurns, summary: crossSummary } = sessionOf(crossTrace) + +/** One turn with a minute of nothing between its first and last span. */ +const { turns: midTurnGapTurns, summary: midTurnGapSummary } = sessionOf([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 2 * SECOND }), + toolSpan({ spanId: "t1", parentSpanId: "a1", startMs: 500, durationMs: 500, toolName: "read_file" }), + toolSpan({ spanId: "t2", startMs: 62 * SECOND, durationMs: SECOND, toolName: "run_tests" }), +]) + +const { turns: targetTurns, summary: targetSummary } = sessionOf([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ + spanId: "l1", + parentSpanId: "a1", + startMs: SECOND, + durationMs: SECOND, + spanName: "chat gpt-5", + model: "gpt-5", + }), + llmSpan({ + spanId: "l2", + parentSpanId: "a1", + startMs: 3 * SECOND, + durationMs: SECOND, + model: "openrouter/openai/gpt-4o-mini", + }), + toolSpan({ + spanId: "t1", + parentSpanId: "a1", + startMs: 5 * SECOND, + durationMs: SECOND, + toolName: "read_file", + genAi: { toolCallArguments: { path: "src/webhooks/retry.ts" } }, + }), +]) + +const { turns: delegationTurns, summary: delegationSummary } = sessionOf([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 10 * SECOND, agentName: "billing-agent" }), + agentSpan({ + spanId: "a2", + parentSpanId: "a1", + startMs: SECOND, + durationMs: 5 * SECOND, + spanName: "agent.step", + agentName: "billing-agent", + }), + agentSpan({ + spanId: "a3", + parentSpanId: "a1", + startMs: 7 * SECOND, + durationMs: 2 * SECOND, + spanName: "agent.delegate", + agentName: "test-runner", + }), +]) + +const { turns: retryTurns, summary: retrySummary } = sessionOf([ + agentSpan({ spanId: "a1", startMs: 0, durationMs: 20 * SECOND }), + llmSpan({ + spanId: "llm-429", + parentSpanId: "a1", + startMs: SECOND, + durationMs: SECOND, + model: "gpt-5", + statusCode: "Error", + statusMessage: "429 rate limit", + genAi: { errorType: "429" }, + }), + llmSpan({ + spanId: "llm-ok", + parentSpanId: "a1", + startMs: 5 * SECOND, + durationMs: 2 * SECOND, + model: "gpt-5", + tokens: [100, 0, 0, 50, 0], + }), +]) diff --git a/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx new file mode 100644 index 000000000..fa1dbde7f --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/session-flow.tsx @@ -0,0 +1,471 @@ +import { useMemo } from "react" +import { Link } from "@tanstack/react-router" + +import type { AiSessionSpan } from "@maple/domain/http" +import { MinusIcon, PlusIcon, SquareIcon } from "@/components/icons" +import { Button } from "@maple/ui/components/ui/button" +import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { cn } from "@maple/ui/lib/utils" + +import { + classifySpan, + isLlmCall, + spanEndMs, + spanModel, + spanStartMs, + type SessionTurn, + type SpanCategory, +} from "@/lib/agent-sessions/session-turns" +import { CATEGORY_FILL, CATEGORY_LABEL, filterSpans, isDelegation, shortTarget } from "./span-visuals" + +// One lane per turn, laid out by hand. 612 spans in a single graph is a +// hairball, and a graph library to draw fixed-size boxes in columns would be a +// dependency doing arithmetic — so the positions are computed here and the +// edges are four SVG curves. +const NODE_WIDTH = 148 +const NODE_HEIGHT = 52 +const COLUMN_GAP = 48 +const STACK_GAP = 10 +const LANE_GAP = 40 +const LANE_LABEL_WIDTH = 120 +const CANVAS_PADDING = 20 +/** A long turn wraps into a block instead of an 8,000px ribbon nobody scrolls. */ +const MAX_COLUMNS = 8 +const WRAP_GAP = 24 + +const MIN_ZOOM = 0.5 +const MAX_ZOOM = 1.5 +const ZOOM_STEP = 0.25 + +interface FlowNode { + readonly key: string + readonly span: AiSessionSpan + readonly category: SpanCategory + /** Full value; the card renders its last path segment. */ + readonly title: string + readonly subtitle: string + readonly errored: boolean + /** Identical calls merged into one node, when "Merge repeat tools" is on. */ + readonly count: number + readonly x: number + readonly y: number +} + +interface FlowLane { + readonly turn: SessionTurn + readonly nodes: readonly FlowNode[] + /** Node index pairs to draw a connector between. */ + readonly edges: readonly (readonly [FlowNode, FlowNode])[] + readonly height: number +} + +interface SessionFlowProps { + turns: readonly SessionTurn[] + /** Collapse runs of identical calls into one `×N` node. Off by default — + * merging hides the retry loop that is often the bug. */ + mergeRepeats: boolean + /** The toolbar's filter, which applies to both views. */ + query: string + agentSpansOnly: boolean + zoom: number + onZoomChange: (zoom: number) => void +} + +export function SessionFlow({ + turns, + mergeRepeats, + query, + agentSpansOnly, + zoom, + onZoomChange, +}: SessionFlowProps) { + const lanes = useMemo( + () => layoutLanes(turns, { mergeRepeats, query, agentSpansOnly }), + [turns, mergeRepeats, query, agentSpansOnly], + ) + + const contentWidth = + CANVAS_PADDING + + Math.max( + LANE_LABEL_WIDTH + NODE_WIDTH, + ...lanes.flatMap((lane) => lane.nodes.map((node) => node.x + NODE_WIDTH)), + ) + const contentHeight = + CANVAS_PADDING * 2 + lanes.reduce((total, lane) => total + lane.height + LANE_GAP, 0) + + return ( +
+
+ {lanes.length === 0 ? ( +

+ No spans match this filter. +

+ ) : ( +
+
+ {lanes.map((lane) => ( + + ))} +
+
+ )} +
+ +
+
+ {(["agent", "inference", "tool"] as const).map((category) => ( + + + {CATEGORY_LABEL[category]} + + ))} + + + error + +
+
+ + + +
+
+
+ ) +} + +function Lane({ lane }: { lane: FlowLane }) { + return ( + <> +
+

+ Turn {lane.turn.index} +

+

+ {formatDuration(lane.turn.durationMs)} +

+ {lane.turn.failed &&

failed

} +
+ + + {lane.edges.map(([from, to]) => ( + ${to.key}`} + d={edgePath(from, to)} + className={to.errored ? "stroke-destructive/70" : "stroke-border"} + strokeWidth={1} + /> + ))} + + + {lane.nodes.map((node) => ( + + ))} + + ) +} + +function FlowNodeCard({ node }: { node: FlowNode }) { + return ( + + + + + {shortTarget(node.title)} + + {node.count > 1 && ( + + ×{node.count} + + )} + + + {node.subtitle} + + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Layout */ +/* -------------------------------------------------------------------------- */ + +function layoutLanes( + turns: readonly SessionTurn[], + options: { mergeRepeats: boolean; query: string; agentSpansOnly: boolean }, +): readonly FlowLane[] { + const lanes: FlowLane[] = [] + let laneTop = CANVAS_PADDING + + for (const turn of turns) { + const spans = flowSpans(turn, options.query, options.agentSpansOnly) + if (spans.length === 0) continue + + const groups = options.mergeRepeats ? mergeConsecutive(spans) : spans.map((span) => [span]) + const columns = assignColumns(groups, new Set(spans.map((span) => span.spanId))) + + // Columns wrap into rows within the lane, and a row is as tall as its + // deepest stack. + const rowHeights: number[] = [] + columns.forEach((column, index) => { + const row = Math.floor(index / MAX_COLUMNS) + const height = column.length * (NODE_HEIGHT + STACK_GAP) - STACK_GAP + rowHeights[row] = Math.max(rowHeights[row] ?? 0, height) + }) + const rowTops = rowHeights.map( + (_, row) => + laneTop + rowHeights.slice(0, row).reduce((top, height) => top + height + WRAP_GAP, 0), + ) + + const nodes: FlowNode[] = [] + columns.forEach((column, columnIndex) => { + column.forEach((group, stackIndex) => { + const span = group[0]! + nodes.push({ + key: span.spanId, + span, + category: classifySpan(span), + title: nodeTitle(span), + subtitle: nodeSubtitle(group), + errored: group.some((member) => member.statusCode === "Error"), + count: group.length, + x: + LANE_LABEL_WIDTH + + CANVAS_PADDING + + (columnIndex % MAX_COLUMNS) * (NODE_WIDTH + COLUMN_GAP), + y: + rowTops[Math.floor(columnIndex / MAX_COLUMNS)]! + + stackIndex * (NODE_HEIGHT + STACK_GAP), + }) + }) + }) + + const edges: (readonly [FlowNode, FlowNode])[] = [] + for (let index = 1; index < columns.length; index++) { + // A connector across a wrap would run backwards up the block; the rows + // read in order the way lines of text do. + if (Math.floor(index / MAX_COLUMNS) !== Math.floor((index - 1) / MAX_COLUMNS)) continue + for (const from of nodesInColumn(nodes, columns, index - 1)) { + for (const to of nodesInColumn(nodes, columns, index)) edges.push([from, to]) + } + } + + const height = + rowHeights.reduce((total, rowHeight) => total + rowHeight, 0) + (rowHeights.length - 1) * WRAP_GAP + lanes.push({ turn, nodes, edges, height }) + laneTop += height + LANE_GAP + } + + return lanes +} + +/** + * The spans worth a node: the turn's anchor, the leaf work (model calls and + * tools), and real delegations. + * + * Frameworks wrap one model call in two or three spans — `invoke_agent` → + * `call_llm` → `generate_content` — and drawing each of them turns a single call + * into a chain of handoffs that never happened. The deepest span is the work; + * everything above it is scaffolding. + */ +function flowSpans(turn: SessionTurn, query: string, agentSpansOnly: boolean): readonly AiSessionSpan[] { + const spans = filterSpans(turn.spans, query, agentSpansOnly) + const byId = new Map(spans.map((span) => [span.spanId, span])) + + const candidates = spans.filter((span) => { + const category = classifySpan(span) + if (category === "other") return false + if (span.spanId === turn.anchor.spanId) return true + return category === "agent" ? isDelegation(span, byId) : true + }) + + const wrappers = new Set() + for (const span of candidates) { + const category = classifySpan(span) + let parent = byId.get(span.parentSpanId) + while (parent !== undefined) { + if (classifySpan(parent) === category) wrappers.add(parent.spanId) + parent = byId.get(parent.parentSpanId) + } + } + + // The anchor and the delegations are structural, so only leaf work can be a + // wrapper of its own kind. + return candidates.filter((span) => classifySpan(span) === "agent" || !wrappers.has(span.spanId)) +} + +function nodesInColumn( + nodes: readonly FlowNode[], + columns: readonly (readonly (readonly AiSessionSpan[])[])[], + columnIndex: number, +): readonly FlowNode[] { + const ids = new Set(columns[columnIndex]!.map((group) => group[0]!.spanId)) + return nodes.filter((node) => ids.has(node.key)) +} + +/** Runs of identical calls become one node. Only consecutive ones merge: two + * `read_file` calls either side of a model call are two steps, not one. */ +function mergeConsecutive(spans: readonly AiSessionSpan[]): readonly (readonly AiSessionSpan[])[] { + const groups: AiSessionSpan[][] = [] + for (const span of spans) { + const current = groups[groups.length - 1] + if (current !== undefined && mergeKey(current[0]!) === mergeKey(span)) current.push(span) + else groups.push([span]) + } + return groups +} + +function mergeKey(span: AiSessionSpan): string { + return `${classifySpan(span)}:${nodeTitle(span)}` +} + +/** + * Sequence the turn's work into columns. + * + * A group opens a new column unless it overlaps the one already open, in which + * case it stacks beside it — that is what a parallel fan-out of tool calls looks + * like in the data. Spans that contain other nodes (the agent invocation, a + * delegated subagent) always take a column of their own: they overlap + * everything by construction, and letting them absorb their own children would + * collapse the whole turn into one column. + */ +function assignColumns( + groups: readonly (readonly AiSessionSpan[])[], + spanIds: ReadonlySet, +): readonly (readonly (readonly AiSessionSpan[])[])[] { + const parents = new Set( + groups.flatMap((group) => + group[0]!.parentSpanId !== "" && spanIds.has(group[0]!.parentSpanId) + ? [group[0]!.parentSpanId] + : [], + ), + ) + + const columns: (readonly AiSessionSpan[])[][] = [] + let openEndMs = Number.NEGATIVE_INFINITY + let openIsContainer = true + + for (const group of groups) { + const span = group[0]! + const isContainer = parents.has(span.spanId) + if (columns.length === 0 || isContainer || openIsContainer || spanStartMs(span) >= openEndMs) { + columns.push([group]) + openEndMs = spanEndMs(group[group.length - 1]!) + openIsContainer = isContainer + } else { + columns[columns.length - 1]!.push(group) + openEndMs = Math.max(openEndMs, spanEndMs(group[group.length - 1]!)) + } + } + + return columns +} + +function edgePath(from: FlowNode, to: FlowNode): string { + const x1 = from.x + NODE_WIDTH + const y1 = from.y + NODE_HEIGHT / 2 + const x2 = to.x + const y2 = to.y + NODE_HEIGHT / 2 + const bend = Math.max(16, (x2 - x1) / 2) + return `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}` +} + +function nodeTitle(span: AiSessionSpan): string { + return span.genAi.toolName ?? span.spanName +} + +function nodeSubtitle(group: readonly AiSessionSpan[]): string { + const span = group[0]! + const durationMs = group.reduce((total, member) => total + member.durationMs, 0) + const parts: string[] = [] + + if (span.statusCode === "Error") { + parts.push(span.genAi.errorType ?? (span.statusMessage === "" ? "error" : span.statusMessage)) + } else if (isLlmCall(span)) { + const prompt = + (span.genAi.usageInputTokens ?? 0) + + (span.genAi.usageCacheReadInputTokens ?? 0) + + (span.genAi.usageCacheCreationInputTokens ?? 0) + const completion = (span.genAi.usageOutputTokens ?? 0) + (span.genAi.usageReasoningOutputTokens ?? 0) + if (prompt > 0 || completion > 0) { + parts.push(`${formatNumber(prompt)} → ${formatNumber(completion)}`) + } else { + const model = spanModel(span) + if (model !== undefined) parts.push(shortTarget(model)) + } + } else if (span.genAi.agentName !== undefined) { + parts.push(span.genAi.agentName) + } + + parts.push(formatDuration(durationMs)) + return parts.filter((part) => part !== "").join(" · ") +} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-header.tsx b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx new file mode 100644 index 000000000..35fe83b10 --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/session-header.tsx @@ -0,0 +1,377 @@ +import { useMemo, useState, type ReactNode } from "react" + +import { Badge } from "@maple/ui/components/ui/badge" +import { ToggleGroup, ToggleGroupItem } from "@maple/ui/components/ui/toggle-group" +import { formatDuration, formatNumber, formatPercent } from "@maple/ui/lib/format" +import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { cn } from "@maple/ui/lib/utils" + +import { computeModelSpend, PRICE_TABLE_DATE } from "@/lib/agent-sessions/model-pricing" +import type { SessionStatus, SessionSummary } from "@/lib/agent-sessions/session-summary" +import { vendorLabel } from "@/components/agent-sessions/agent-sessions-list" +import { OCCUPANCY_DOT_FILL, OCCUPANCY_FILL, OCCUPANCY_LABEL, shortTarget } from "./span-visuals" + +/** + * Which clock the header measures against. + * + * A session that waited eleven minutes on a human is two different sessions + * depending on which clock you read it by, and neither is the right default for + * everyone — so both are one click apart. This governs the header alone: the + * waterfall's axis has its own control ("Collapse idle") sitting next to it, + * because dropping idle from a ruler and dropping it from a percentage are + * different decisions and a reader may well want one without the other. + */ +export type AxisMode = "wall" | "active" + +const STATUS_LABEL = { + active: "ACTIVE", + completed: "COMPLETED", + failed: "FAILED", + abandoned: "ABANDONED", +} satisfies Record + +const STATUS_VARIANT = { + active: "info", + completed: "success", + failed: "error", + abandoned: "outline", +} as const + +const TOKEN_BUCKETS = [ + { key: "input", label: "input", fill: "bg-chart-2" }, + { key: "cacheRead", label: "cache read", fill: "bg-chart-4" }, + { key: "cacheWrite", label: "cache write", fill: "bg-chart-5" }, + { key: "output", label: "output", fill: "bg-chart-1" }, + { key: "reasoning", label: "reasoning", fill: "bg-chart-3" }, +] as const + +interface SessionHeaderProps { + summary: SessionSummary + /** The framework's own session id, verbatim. */ + sessionId: string + /** Rendered when the session captured no opening user message. */ + fallbackTitle: string +} + +export function SessionHeader({ summary, sessionId, fallbackTitle }: SessionHeaderProps) { + const [axisMode, setAxisMode] = useState("wall") + const spend = useMemo(() => computeModelSpend(summary.models), [summary.models]) + + // In "active only" the idle segment leaves the bar entirely and the rest are + // re-based on active time — otherwise every share on a session with a long + // pause reads as single digits and the bar says nothing. + const denominatorMs = axisMode === "wall" ? summary.wallClockMs : summary.activeMs + const segments = summary.occupancy.filter( + (segment) => + (axisMode === "wall" || segment.kind !== "idle") && + // Under half a percent the segment is a sub-pixel sliver against a legend + // row that reads "0%" — the muted track behind the bar covers the loss. + sharePercent(segment.ms, denominatorMs) >= 0.5, + ) + // A session nobody waited on has no contrast to draw: "active · 100%" beside + // the wall clock is the same number twice, and a toggle between two identical + // readings is a control that does nothing. + const hasIdle = summary.idleMs >= 1000 + const hasTokens = summary.tokens.total > 0 + const tokenBuckets = TOKEN_BUCKETS.filter((bucket) => summary.tokens[bucket.key] > 0) + + return ( +
+
+

+ {summary.title ?? fallbackTitle} +

+ + {STATUS_LABEL[summary.status]} + +
+ +
+ + {summary.serviceNames.length > 0 && |} + + {/* The breadcrumb above carries a truncated id; the full one is here, + small, because it is what a support thread asks for. */} + | + {sessionId} +
+ +
+ + {formatSessionDuration(axisMode === "wall" ? summary.wallClockMs : summary.activeMs)} + + + {axisMode === "wall" ? "wall clock" : "active only"} + + {hasIdle && ( + <> + + + {formatSessionDuration(axisMode === "wall" ? summary.activeMs : summary.idleMs)} + + + {axisMode === "wall" ? "active" : "idle"} ·{" "} + {formatPercent( + summary.wallClockMs === 0 + ? 0 + : (axisMode === "wall" ? summary.activeMs : summary.idleMs) / + summary.wallClockMs, + )} + + { + const next = values[0] + if (next === "wall" || next === "active") setAxisMode(next) + }} + aria-label="Time axis" + > + Wall clock + Active only + + + )} +
+ +
+
+ {segments.map((segment) => ( +
+ ))} +
+
+ {segments.map((segment) => ( + + + {OCCUPANCY_LABEL[segment.kind]} + {/* Not the session formatter: a segment is a sum of spans, and + "0s" for 300ms of tool time is the page contradicting the rows + below it. */} + {formatDuration(segment.ms)} + + {formatPercent(denominatorMs === 0 ? 0 : segment.ms / denominatorMs)} + + + ))} +
+
+ + {/* Five columns is the design's band, and left to stack it is 645px of + header at a 960px container — enough to push the tabs and the waterfall + off the bottom of a 1280×800 window. Measured at that width: two columns + 441px, three 296px, five 164px. Four is missing on purpose — five stats + in four columns still wrap to two rows, so it buys nothing three + doesn't. */} +
+ + {summary.models.length === 0 ? ( + no model calls + ) : ( +
+ {summary.models.slice(0, 4).map((model) => ( +
+ {/* Gateways prefix the provider path, and two models from one + gateway truncate to the same string in this column. */} + + {shortTarget(model.model)} + + {/* The bar is the cell's least load-bearing element — it re-states + the count beside it — so it is the first thing to go when the + five columns pack into a ~180px cell and the model names + themselves start truncating. */} + + + + {model.llmCalls} +
+ ))} +
+ )} + {summary.agentNames.length > 0 && ( +

+ + {summary.agentNames.length} agent{summary.agentNames.length === 1 ? "" : "s"} + {" "} + {summary.agentNames.join(" → ")} +

+ )} +
+ + {/* Usage capture is opt-in per framework, so "no tokens" is a real and + common state — and 0 / $0.00 reads as a measurement rather than the + absence of one. */} + + {!hasTokens ? ( + no token usage reported + ) : ( + <> +
+ {tokenBuckets.map((bucket) => ( +
+ ))} +
+
+ {tokenBuckets.map((bucket) => ( + + + {bucket.label} + + {formatNumber(summary.tokens[bucket.key])} + + + ))} +
+ + )} + + + + {!hasTokens ? ( + no token usage reported + ) : ( + <> +
+ + ${spend.totalUsd.toFixed(2)} + + est. +
+

+ list price · {PRICE_TABLE_DATE} +

+ {spend.unpricedModels.length > 0 && ( +

+ {spend.unpricedModels.length} model + {spend.unpricedModels.length === 1 ? "" : "s"} unpriced +

+ )} + + )} +
+ + + + + + + + + + + + + + +
+
+ ) +} + +function sharePercent(value: number, total: number): number { + if (total <= 0) return 0 + return Math.max(0, Math.min(100, (value / total) * 100)) +} + +/** First value in full, the rest as a count — a session routinely touches four + * services, and naming one as if it were the whole session would be a lie. */ +function ChipGroup({ values, emptyLabel }: { values: readonly string[]; emptyLabel: string }) { + if (values.length === 0) return emptyLabel === "" ? null : {emptyLabel} + return ( + + {values[0]} + {values.length > 1 && ( + + +{values.length - 1} + + )} + + ) +} + +function StatColumn({ + title, + value, + danger, + children, +}: { + title: string + value?: string + danger?: boolean + children: ReactNode +}) { + // `first:pl-0 last:pr-0` flushes the band with the heading above it, which + // only holds once the five columns are one row — in the two- and three-column + // grids "first" is one cell of several in the left column, so every cell there + // keeps its padding. + return ( +
+

+ + {title} + + {value !== undefined && {value}} +

+ {children} +
+ ) +} + +function StatRow({ label, value, tone }: { label: string; value: number; tone?: "warn" | "error" }) { + return ( + // Capped: in the stacked and two-column layouts the column is most of the + // page, and a label at one edge with its number at the other is two facts, + // not one. +
+ {label} + 0 && tone === "warn" && "text-primary", + value > 0 && tone === "error" && "text-destructive", + )} + > + {value} + +
+ ) +} + +function EmptyStat({ children }: { children: ReactNode }) { + return

{children}

+} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-views.tsx b/apps/web/src/components/agent-sessions/session-detail/session-views.tsx new file mode 100644 index 000000000..7bc94ede8 --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/session-views.tsx @@ -0,0 +1,152 @@ +import { useMemo, useState } from "react" + +import { MenuIcon, NetworkNodesIcon } from "@/components/icons" +import { SearchInput } from "@maple/ui/components/ui/search-input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@maple/ui/components/ui/tabs" +import { Toggle } from "@maple/ui/components/ui/toggle" +import { cn } from "@maple/ui/lib/utils" + +import type { SessionSummary } from "@/lib/agent-sessions/session-summary" +import type { SessionTurn } from "@/lib/agent-sessions/session-turns" +import { SessionFlow } from "./session-flow" +import { filterSpans } from "./span-visuals" +import { SessionWaterfall } from "./session-waterfall" + +/** + * The two readings of the same spans, and the controls that shape them. + * + * Three things turn a plain waterfall into an agent view — turns, collapsed + * idle, and hiding the app's own spans — and all three are toggles here rather + * than assumptions, because each one is occasionally the thing you need to see. + */ +export function SessionViews({ turns, summary }: { turns: readonly SessionTurn[]; summary: SessionSummary }) { + const [query, setQuery] = useState("") + const [agentSpansOnly, setAgentSpansOnly] = useState(true) + const [collapseIdle, setCollapseIdle] = useState(true) + const [mergeRepeats, setMergeRepeats] = useState(false) + const [view, setView] = useState("trace") + // The views unmount when the tab changes, so what the reader opened, collapsed + // or zoomed lives here — otherwise a look at Flow and back costs them the + // place they had found in a 600-span session. + const [collapsedTurns, setCollapsedTurns] = useState>(() => new Set()) + const [expandedGaps, setExpandedGaps] = useState>(() => new Set()) + const [zoom, setZoom] = useState(1) + + const visibleSpans = useMemo( + () => turns.reduce((total, turn) => total + filterSpans(turn.spans, query, agentSpansOnly).length, 0), + [turns, query, agentSpansOnly], + ) + + const spanCount = + query.trim() === "" + ? `${summary.spanCount.toLocaleString()} spans` + : `${visibleSpans.toLocaleString()} of ${summary.spanCount.toLocaleString()} spans` + const counts = `${spanCount} · ${plural(turns.length, "turn")} · ${plural(summary.traceCount, "trace")}` + + return ( + setView(String(value))} + className="flex h-full min-h-0 flex-col gap-0" + > +
+ + + + Trace + + + + Flow + + + +
+ {view === "trace" ? ( + <> + + + Agent spans only + + + Collapse idle + + + ) : ( + + Merge repeat tools + + )} + + {counts} + +
+
+ + + setCollapsedTurns((previous) => toggled(previous, turnId))} + expandedGaps={expandedGaps} + onToggleGap={(gapId) => setExpandedGaps((previous) => toggled(previous, gapId))} + /> + + + + +
+ ) +} + +function toggled(set: ReadonlySet, id: string): ReadonlySet { + const next = new Set(set) + if (!next.delete(id)) next.add(id) + return next +} + +function plural(count: number, noun: string): string { + return `${count.toLocaleString()} ${noun}${count === 1 ? "" : "s"}` +} + +function ViewChip({ + pressed, + onPressedChange, + children, +}: { + pressed: boolean + onPressedChange: (pressed: boolean) => void + children: string +}) { + return ( + + + {children} + + ) +} diff --git a/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx new file mode 100644 index 000000000..04351ff5d --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/session-waterfall.tsx @@ -0,0 +1,664 @@ +import { useMemo, useRef, type ReactNode } from "react" +import { Link } from "@tanstack/react-router" +import { useVirtualizer } from "@tanstack/react-virtual" + +import type { AiSessionSpan } from "@maple/domain/http" +import { ChevronDownIcon, ChevronRightIcon } from "@/components/icons" +import { formatDuration, formatNumber } from "@maple/ui/lib/format" +import { formatSessionDuration } from "@maple/ui/lib/replay-format" +import { cn } from "@maple/ui/lib/utils" + +import { buildSessionAxis, type SessionAxis } from "@/lib/agent-sessions/active-axis" +import { + countSessionTokens, + retriedSpanIds, + type IdleGap, + type SessionSummary, +} from "@/lib/agent-sessions/session-summary" +import { + classifySpan, + isLlmCall, + spanEndMs, + spanModel, + spanStartMs, + spanTtftMs, + type SessionTurn, + type SpanCategory, +} from "@/lib/agent-sessions/session-turns" +import { CATEGORY_FILL, filterSpans, isDelegation, shortTarget } from "./span-visuals" + +// Row heights are fixed and known, so the virtualizer never has to measure. +const TURN_ROW_HEIGHT = 30 +const ROW_HEIGHT = 26 +/** Past this the indent eats the span name; deep agent trees are common. */ +const MAX_INDENT_DEPTH = 6 +const INDENT_PX = 14 + +const COL_SPAN = "w-[398px] max-w-[46%] min-w-0 shrink-0 flex items-center gap-1.5" +const COL_MODEL = "hidden w-[150px] shrink-0 truncate px-2 text-muted-foreground @3xl:block" +// Wider than the design's 84px, and `truncate` on top of that: the prompt figure +// counts cache reads, which run to six digits on a real agent session, and the +// product's type is monospace. Rows are absolutely positioned at a fixed height, +// so a cell that wrapped to two lines would spill into the row below. +const COL_TOKENS = + "hidden w-[104px] shrink-0 truncate px-2 text-right tabular-nums text-muted-foreground @3xl:block" +// A margin, not padding: the bars and ticks inside are positioned in percent, +// which resolves against the padding box and would ignore padding entirely. +const COL_AXIS = "relative ml-3 min-w-0 flex-1 self-stretch" +const COL_DUR = "w-[60px] shrink-0 pl-2 text-right tabular-nums" + +interface TraceLinkTarget { + readonly traceId: string + readonly timestamp: string +} + +type WaterfallRow = + | { readonly kind: "trace"; readonly key: string; readonly link: TraceLinkTarget; readonly turns: string } + | { + readonly kind: "turn" + readonly key: string + readonly turn: SessionTurn + readonly hiddenCount: number + /** Set only when the turn is the whole of its trace band (B3). */ + readonly link: TraceLinkTarget | undefined + } + | { readonly kind: "span"; readonly key: string; readonly span: AiSessionSpan; readonly depth: number } + | { readonly kind: "gap"; readonly key: string; readonly gap: IdleGap; readonly collapsed: boolean } + +interface SessionWaterfallProps { + turns: readonly SessionTurn[] + summary: SessionSummary + /** Free-text filter over span name, model and tool/agent target. */ + query: string + /** Hide the app's own HTTP/DB spans that share the agent's traces. */ + agentSpansOnly: boolean + collapseIdle: boolean + /** Expansion state lives in SessionViews so a Trace → Flow → Trace round-trip keeps it. */ + collapsedTurns: ReadonlySet + onToggleTurn: (turnId: string) => void + expandedGaps: ReadonlySet + onToggleGap: (gapId: string) => void +} + +export function SessionWaterfall({ + turns, + summary, + query, + agentSpansOnly, + collapseIdle, + collapsedTurns, + onToggleTurn, + expandedGaps, + onToggleGap, +}: SessionWaterfallProps) { + const scrollRef = useRef(null) + + const spansById = useMemo( + () => new Map(turns.flatMap((turn) => turn.spans).map((span) => [span.spanId, span])), + [turns], + ) + const retried = useMemo(() => retriedSpanIds(turns), [turns]) + + const axis = useMemo( + () => + buildSessionAxis({ + startMs: summary.startMs, + endMs: summary.endMs, + collapsedGaps: collapseIdle + ? summary.idleGaps.filter((gap) => !expandedGaps.has(gap.id)) + : [], + }), + [summary.startMs, summary.endMs, summary.idleGaps, collapseIdle, expandedGaps], + ) + const ticks = axis.ticks + + const rows = useMemo( + () => + buildRows({ + turns, + gaps: collapseIdle ? summary.idleGaps : [], + expandedGaps, + collapsedTurns, + query, + agentSpansOnly, + }), + [turns, summary.idleGaps, collapseIdle, expandedGaps, collapsedTurns, query, agentSpansOnly], + ) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => (rows[index]!.kind === "turn" ? TURN_ROW_HEIGHT : ROW_HEIGHT), + getItemKey: (index) => rows[index]!.key, + overscan: 16, + }) + + return ( +
+
+ Span + Model / target + Tokens + + {ticks.map((tick, index) => ( + + {tick.label} + + ))} + + Dur +
+ + {axis.removedGapCount > 0 && ( +

+ Axis shows active time. {formatSessionDuration(axis.removedMs)} of idle removed across{" "} + {axis.removedGapCount} gap{axis.removedGapCount === 1 ? "" : "s"}. +

+ )} + +
+ {rows.length === 0 ? ( +

+ No spans match this filter. +

+ ) : ( +
+ {virtualizer.getVirtualItems().map((item) => { + const row = rows[item.index]! + return ( +
+ {row.kind === "trace" && } + {row.kind === "turn" && ( + onToggleTurn(row.turn.id)} + /> + )} + {row.kind === "span" && ( + + )} + {row.kind === "gap" && ( + onToggleGap(row.gap.id)} /> + )} +
+ ) + })} +
+ )} +
+
+ ) +} + +/* -------------------------------------------------------------------------- */ +/* Rows */ +/* -------------------------------------------------------------------------- */ + +function buildRows(input: { + turns: readonly SessionTurn[] + gaps: readonly IdleGap[] + expandedGaps: ReadonlySet + collapsedTurns: ReadonlySet + query: string + agentSpansOnly: boolean +}): readonly WaterfallRow[] { + const surviving = input.turns.flatMap((turn) => { + const spans = filterSpans(turn.spans, input.query, input.agentSpansOnly) + return spans.length === 0 ? [] : [{ turn, spans }] + }) + // A turn whose every span was filtered out drops off the page entirely, and a + // filter that empties every turn renders the empty state rather than a column + // of orphaned idle rows. + if (surviving.length === 0) return [] + + // Traces band the turns: a trace commonly holds several turns and a turn can + // cross traces, so neither nests inside the other and the rule is drawn where + // the trace changes. Banding the *surviving* turns keeps a rule from + // advertising turns the filter removed. + const bands = traceBands(surviving.map((entry) => entry.turn)) + + const rows: WaterfallRow[] = [] + let gapIndex = 0 + const flushGaps = (limitMs: number) => { + while (gapIndex < input.gaps.length && input.gaps[gapIndex]!.startMs < limitMs) { + const gap = input.gaps[gapIndex]! + rows.push({ kind: "gap", key: gap.id, gap, collapsed: !input.expandedGaps.has(gap.id) }) + gapIndex++ + } + } + + surviving.forEach(({ turn, spans }, index) => { + flushGaps(turn.startMs) + + const band = bands.ranges[bands.byTurn[index]!]! + const link = { traceId: band.traceId, timestamp: turn.anchor.timestamp } + // A band covering one turn would spend a whole row saying what fits in the + // spare width of that turn's own header. + if (bands.byTurn[index] !== bands.byTurn[index - 1] && band.turnCount > 1) { + rows.push({ + kind: "trace", + key: `trace:${band.traceId}:${band.from}`, + link, + turns: `turns ${band.from}–${band.to}`, + }) + } + + rows.push({ + kind: "turn", + key: turn.id, + turn, + hiddenCount: turn.spans.length - spans.length, + link: band.turnCount === 1 ? link : undefined, + }) + + if (input.collapsedTurns.has(turn.id)) { + flushGaps(turn.endMs) + return + } + for (const { span, depth } of orderByTree(spans)) { + // Nothing at all runs during an idle gap, so no span straddles one: the + // turn's own rows split cleanly at the first span that starts after it. + flushGaps(spanStartMs(span)) + rows.push({ kind: "span", key: `${turn.id}:${span.spanId}`, span, depth }) + } + flushGaps(turn.endMs) + }) + + flushGaps(Number.POSITIVE_INFINITY) + return rows +} + +/** Contiguous runs of turns sharing a primary trace; one band opens each rule. */ +function traceBands(turns: readonly SessionTurn[]): { + byTurn: readonly number[] + ranges: readonly { traceId: string; from: number; to: number; turnCount: number }[] +} { + const byTurn: number[] = [] + const ranges: { traceId: string; from: number; to: number; turnCount: number }[] = [] + for (const turn of turns) { + const traceId = turn.traceIds[0] ?? "" + const open = ranges.at(-1) + if (open === undefined || traceId !== open.traceId) { + ranges.push({ traceId, from: turn.index, to: turn.index, turnCount: 1 }) + } else { + open.to = turn.index + open.turnCount++ + } + byTurn.push(ranges.length - 1) + } + return { byTurn, ranges } +} + +/** Depth-first over the parent chain, with anything whose parent was filtered + * out (or lives in another turn) promoted to the top level. */ +function orderByTree(spans: readonly AiSessionSpan[]): readonly { span: AiSessionSpan; depth: number }[] { + const present = new Set(spans.map((span) => span.spanId)) + const children = new Map() + const roots: AiSessionSpan[] = [] + for (const span of spans) { + if (span.parentSpanId !== "" && present.has(span.parentSpanId)) { + const siblings = children.get(span.parentSpanId) + if (siblings === undefined) children.set(span.parentSpanId, [span]) + else siblings.push(span) + } else { + roots.push(span) + } + } + + const out: { span: AiSessionSpan; depth: number }[] = [] + const walk = (span: AiSessionSpan, depth: number) => { + out.push({ span, depth }) + for (const child of children.get(span.spanId) ?? []) walk(child, depth + 1) + } + for (const root of roots) walk(root, 0) + return out +} + +/* -------------------------------------------------------------------------- */ +/* Row components */ +/* -------------------------------------------------------------------------- */ + +function TraceLink({ link, className }: { link: TraceLinkTarget; className?: string }) { + return ( + + Trace {link.traceId.slice(0, 8)} + + ) +} + +function TraceRule({ row }: { row: Extract }) { + return ( +
+ + + {row.turns} +
+ ) +} + +function TurnHeader({ + row, + axis, + collapsed, + onToggle, +}: { + row: Extract + axis: SessionAxis + collapsed: boolean + onToggle: () => void +}) { + const { turn } = row + // Tokens and duration are facts about the turn, not about the rows on screen: + // they stay whole while a filter narrows the spans under them. + const tokens = countSessionTokens(turn.spans) + const left = axis.fraction(turn.startMs) * 100 + const width = Math.max(0.4, (axis.fraction(turn.endMs) - axis.fraction(turn.startMs)) * 100) + + return ( +
+ + + {row.link !== undefined && ( + + )} + + {turn.agentName ?? "—"} + {tokens.total > 0 ? formatNumber(tokens.total) : "—"} + + + + {/* The same formatter as the span rows below, or a 52.4s turn reads as + "52s" above a 52.40s child and looks shorter than its own content. */} + {formatDuration(turn.durationMs)} +
+ ) +} + +function SpanRow({ + row, + axis, + spansById, + retried, + singleService, +}: { + row: Extract + axis: SessionAxis + spansById: ReadonlyMap + retried: ReadonlySet + singleService: boolean +}) { + const { span } = row + const category = classifySpan(span) + const errored = span.statusCode === "Error" + const target = spanTarget(span, category, singleService) + // Only a model id is a provider path — a tool's target is usually a file path, + // whose last segment is not the part worth keeping. + const targetLabel = target === undefined ? "—" : category === "tool" ? target : shortTarget(target) + + return ( + + + + {span.spanName} + {spanMeta(span, category)} + {errored && {span.genAi.errorType ?? "Error"}} + {retried.has(span.spanId) && Retry} + {isDelegation(span, spansById) && Subagent} + + + {targetLabel} + + {spanTokenSummary(span)} + + + + {formatDuration(span.durationMs)} + + ) +} + +function SpanBar({ + span, + axis, + category, + errored, +}: { + span: AiSessionSpan + axis: SessionAxis + category: SpanCategory + errored: boolean +}) { + const startMs = spanStartMs(span) + const left = axis.fraction(startMs) * 100 + // A hairline floor: a 20ms tool call on a twelve-minute axis still has to be + // findable, and the row's DUR column carries the real number. + const width = Math.max(0.35, (axis.fraction(spanEndMs(span)) - axis.fraction(startMs)) * 100) + const ttftMs = spanTtftMs(span) + const ttftShare = ttftMs === undefined ? 0 : (ttftMs / span.durationMs) * 100 + // An agent span contains the leaf work rather than being work, and at full + // strength its bar is the longest and loudest thing in the column. + const container = category === "agent" && !errored + + return ( + + {ttftMs !== undefined && !errored ? ( + <> + + + + ) : ( + + )} + + ) +} + +function GapRow({ row, onToggle }: { row: Extract; onToggle: () => void }) { + return ( +
+ idle {formatSessionDuration(row.gap.durationMs)} · awaiting user + + +
+ ) +} + +const PILL_TONE = { + error: "bg-destructive/12 text-destructive", + warn: "bg-severity-warn/12 text-severity-warn", + outline: "border border-border text-muted-foreground", +} satisfies Record + +function Pill({ tone, children }: { tone: keyof typeof PILL_TONE; children: ReactNode }) { + return ( + + {children} + + ) +} + +/* -------------------------------------------------------------------------- */ +/* Cell content */ +/* -------------------------------------------------------------------------- */ + +/** Inline meta, and never a second copy of what the span name already says. */ +function spanMeta(span: AiSessionSpan, category: SpanCategory): string { + const name = span.spanName.toLowerCase() + const parts: string[] = [] + const agentName = span.genAi.agentName + if (category === "agent" && agentName !== undefined && !name.includes(agentName.toLowerCase())) { + parts.push(agentName) + } + const toolName = span.genAi.toolName + if (category === "tool" && toolName !== undefined && !name.includes(toolName.toLowerCase())) { + parts.push(toolName) + } + const ttftMs = spanTtftMs(span) + if (ttftMs !== undefined) parts.push(`ttft ${formatDuration(ttftMs)}`) + const reasoning = span.genAi.usageReasoningOutputTokens + if (reasoning !== undefined && reasoning > 0) parts.push(`${formatNumber(reasoning)} reasoning`) + if (span.statusMessage !== "") parts.push(span.statusMessage) + return parts.join(" · ") +} + +/** + * The MODEL / TARGET cell: what the row adds to the span name. + * + * An agent span's target is the agent itself, which the name and the meta + * already carry, so the column stays empty rather than printing the same word a + * third time. + */ +function spanTarget(span: AiSessionSpan, category: SpanCategory, singleService: boolean): string | undefined { + if (category === "agent") return undefined + if (category === "tool") return toolTarget(span) + const model = spanModel(span) + if (model !== undefined) { + return span.spanName.toLowerCase().includes(model.toLowerCase()) ? undefined : model + } + // The app's own spans borrow the column for the service that ran them, which + // only says anything when the session crosses services. + return span.isAiSpan || singleService ? undefined : span.serviceName +} + +/** Argument keys that name what a tool acted on, most specific first. */ +const TOOL_TARGET_KEYS = ["path", "file_path", "filePath", "file", "query", "pattern", "command", "url"] +/** The target shares a 150px cell; the full value stays in the row's `title`. */ +const MAX_TARGET_LENGTH = 120 + +/** + * What the tool was pointed at. `gen_ai.tool.call.arguments` is vendor JSON, so + * this reads the keys that name a target and otherwise gives up — a printed blob + * of arguments would push the useful columns off the row. + */ +function toolTarget(span: AiSessionSpan): string | undefined { + const args = span.genAi.toolCallArguments + if (typeof args === "string") return clipTarget(args) + if (typeof args !== "object" || args === null || Array.isArray(args)) return undefined + + const record = args as Record + for (const key of TOOL_TARGET_KEYS) { + const value = record[key] + if (typeof value === "string") return clipTarget(value) + } + const strings = Object.values(record).filter((value): value is string => typeof value === "string") + return strings.length === 1 ? clipTarget(strings[0]!) : undefined +} + +function clipTarget(value: string): string | undefined { + const text = value.trim().replace(/\s+/g, " ") + if (text.length === 0) return undefined + return text.length > MAX_TARGET_LENGTH ? `${text.slice(0, MAX_TARGET_LENGTH - 1)}…` : text +} + +function spanTokenSummary(span: AiSessionSpan): string { + if (!isLlmCall(span)) return "—" + const { genAi } = span + const prompt = + (genAi.usageInputTokens ?? 0) + + (genAi.usageCacheReadInputTokens ?? 0) + + (genAi.usageCacheCreationInputTokens ?? 0) + const completion = (genAi.usageOutputTokens ?? 0) + (genAi.usageReasoningOutputTokens ?? 0) + if (prompt === 0 && completion === 0) return "—" + return `${formatNumber(prompt)} → ${formatNumber(completion)}` +} diff --git a/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts b/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts new file mode 100644 index 000000000..f40a78f82 --- /dev/null +++ b/apps/web/src/components/agent-sessions/session-detail/span-visuals.ts @@ -0,0 +1,103 @@ +// The session page's shared vocabulary: one color per kind of work, and the few +// display rules the views have to read the same way. Colors are used by the +// header's breakdown bar, the waterfall's dots and bars, and the flow view's +// nodes; the rules below are shared because the waterfall, the flow view and the +// toolbar count must agree on what a filter hides and what a delegation is. +// +// The colors map onto the app's existing chart tokens rather than new ones — +// agent work is the product's own amber, inference the blue that already means +// "outbound call", tools the teal, and time-to-first-token the purple the charts +// use for a leading segment. + +import type { AiSessionSpan } from "@maple/domain/http" +import { classifySpan, spanModel, type SpanCategory } from "@/lib/agent-sessions/session-turns" +import type { OccupancyKind } from "@/lib/agent-sessions/session-summary" + +/** Background for a bar or a dot. */ +export const CATEGORY_FILL = { + agent: "bg-chart-1", + inference: "bg-chart-2", + tool: "bg-chart-4", + other: "bg-muted-foreground/40", +} satisfies Record + +export const CATEGORY_LABEL = { + agent: "agent", + inference: "inference", + tool: "tool", + other: "other", +} satisfies Record + +export const OCCUPANCY_FILL = { + // Idle is the absence of work, so it gets a neutral surface rather than a + // hue: a colored idle segment reads as a category of work at a glance. + idle: "bg-muted-foreground/35", + ttft: "bg-chart-5", + inference: "bg-chart-2", + tool: "bg-chart-4", + // Framework overhead is usually a percent or two of the bar, so it needs more + // density than idle to survive at that width — the opposite of what its + // importance suggests, and the reason the two neutrals aren't one token. + unaccounted: "bg-muted-foreground/50", +} satisfies Record + +/** The same vocabulary at 6px, where the two neutrals wash out entirely. */ +export const OCCUPANCY_DOT_FILL = { + ...OCCUPANCY_FILL, + idle: "bg-muted-foreground/50", + unaccounted: "bg-muted-foreground/70", +} satisfies Record + +export const OCCUPANCY_LABEL = { + idle: "Idle · awaiting user", + ttft: "Time to first token", + inference: "Inference · streaming", + tool: "Tool execution", + unaccounted: "Unaccounted", +} satisfies Record + +/** + * The toolbar's filter, applied identically by every view. + * + * The query is normalized here rather than at each call site: the count in the + * toolbar, the waterfall's rows and the flow view's nodes have to agree on what + * is hidden, and a filter typed in one view still applies in the other. + */ +export function filterSpans( + spans: readonly AiSessionSpan[], + query: string, + agentSpansOnly: boolean, +): readonly AiSessionSpan[] { + const needle = query.trim().toLowerCase() + return spans.filter((span) => { + if (agentSpansOnly && !span.isAiSpan) return false + if (needle === "") return true + return [span.spanName, spanModel(span), span.genAi.toolName, span.genAi.agentName] + .filter((value): value is string => value !== undefined) + .some((value) => value.toLowerCase().includes(needle)) + }) +} + +/** + * A real handoff: the span names a different agent than the one that invoked it. + * An agent span under another agent span is otherwise just a framework step — + * `invoke_agent` wrapping `agent_step` is one agent, not two. + */ +export function isDelegation(span: AiSessionSpan, spansById: ReadonlyMap): boolean { + if (classifySpan(span) !== "agent") return false + const agentName = span.genAi.agentName + const parentAgentName = spansById.get(span.parentSpanId)?.genAi.agentName + return agentName !== undefined && parentAgentName !== undefined && agentName !== parentAgentName +} + +/** + * Last path segment of a model id or tool target. + * + * Gateways prefix the provider path ("openrouter/openai/gpt-4o-mini"), which in + * a 150px column truncates two different models to the same string. Callers keep + * the full value in a `title`. + */ +export function shortTarget(value: string): string { + const segment = value.split("/").at(-1) + return segment === undefined || segment === "" ? value : segment +} diff --git a/apps/web/src/lib/agent-sessions/active-axis.test.ts b/apps/web/src/lib/agent-sessions/active-axis.test.ts new file mode 100644 index 000000000..3329846ad --- /dev/null +++ b/apps/web/src/lib/agent-sessions/active-axis.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest" + +import { buildSessionAxis, formatAxisTick } from "./active-axis" +import type { IdleGap } from "./session-summary" + +const SECOND = 1000 +const MINUTE = 60 * SECOND +const START = 1_787_140_000_000 + +const gap = (startMs: number, endMs: number): IdleGap => ({ + id: `gap:${startMs}`, + startMs: START + startMs, + endMs: START + endMs, + durationMs: endMs - startMs, +}) + +describe("buildSessionAxis", () => { + it("is plain elapsed time when nothing is collapsed", () => { + const axis = buildSessionAxis({ startMs: START, endMs: START + 2 * MINUTE, collapsedGaps: [] }) + + expect(axis.totalMs).toBe(2 * MINUTE) + expect(axis.toAxisMs(START + 30 * SECOND)).toBe(30 * SECOND) + expect(axis.fraction(START + MINUTE)).toBe(0.5) + }) + + it("shortens the axis by every collapsed gap", () => { + const axis = buildSessionAxis({ + startMs: START, + endMs: START + 10 * MINUTE, + collapsedGaps: [gap(MINUTE, 4 * MINUTE), gap(6 * MINUTE, 8 * MINUTE)], + }) + + expect(axis.totalMs).toBe(5 * MINUTE) + expect(axis.removedMs).toBe(5 * MINUTE) + expect(axis.removedGapCount).toBe(2) + }) + + it("maps instants around a collapsed gap onto the shortened axis", () => { + const axis = buildSessionAxis({ + startMs: START, + endMs: START + 10 * MINUTE, + collapsedGaps: [gap(MINUTE, 4 * MINUTE)], + }) + + // Before the gap: untouched. + expect(axis.toAxisMs(START + 30 * SECOND)).toBe(30 * SECOND) + // After it: the whole gap is gone. + expect(axis.toAxisMs(START + 5 * MINUTE)).toBe(2 * MINUTE) + // Inside it: the seam the gap collapsed to. + expect(axis.toAxisMs(START + 2 * MINUTE)).toBe(MINUTE) + expect(axis.toAxisMs(START + 4 * MINUTE)).toBe(MINUTE) + }) + + it("clamps instants outside the session to the ends of the axis", () => { + const axis = buildSessionAxis({ startMs: START, endMs: START + MINUTE, collapsedGaps: [] }) + + expect(axis.fraction(START - MINUTE)).toBe(0) + expect(axis.fraction(START + 10 * MINUTE)).toBe(1) + }) + + it("takes gaps in any order", () => { + const axis = buildSessionAxis({ + startMs: START, + endMs: START + 10 * MINUTE, + collapsedGaps: [gap(6 * MINUTE, 8 * MINUTE), gap(MINUTE, 4 * MINUTE)], + }) + + expect(axis.toAxisMs(START + 9 * MINUTE)).toBe(4 * MINUTE) + }) + + it("labels a ruler that starts at zero and ends at the axis length", () => { + const axis = buildSessionAxis({ startMs: START, endMs: START + 4 * MINUTE, collapsedGaps: [] }) + + expect(axis.ticks[0]).toEqual({ axisMs: 0, label: "0s" }) + expect(axis.ticks.at(-1)!.axisMs).toBe(axis.totalMs) + }) + + it("steps the ruler in clock values rather than fifths of the total", () => { + // 52s: 15s steps, not the 13s an even division would give. + const short = buildSessionAxis({ startMs: START, endMs: START + 52 * SECOND, collapsedGaps: [] }) + expect(short.ticks.map((tick) => tick.label)).toEqual(["0s", "15s", "30s", "45s", "52s"]) + + // 4m: a minute a tick, written out with the seconds the rows below carry. + const long = buildSessionAxis({ startMs: START, endMs: START + 4 * MINUTE, collapsedGaps: [] }) + expect(long.ticks.map((tick) => tick.label)).toEqual(["0s", "1m 00s", "2m 00s", "3m 00s", "4m 00s"]) + }) + + it("survives a session with no measurable duration", () => { + const axis = buildSessionAxis({ startMs: START, endMs: START, collapsedGaps: [] }) + + expect(axis.fraction(START)).toBe(0) + expect(Number.isFinite(axis.fraction(START + SECOND))).toBe(true) + }) +}) + +describe("formatAxisTick", () => { + it("writes minutes out with their seconds, which the shared formatter drops", () => { + expect(formatAxisTick(90 * SECOND, 45 * SECOND)).toBe("1m 30s") + expect(formatAxisTick(3 * MINUTE, 45 * SECOND)).toBe("3m 00s") + }) + + it("defers to the shared duration formatter below a minute", () => { + expect(formatAxisTick(0, 45 * SECOND)).toBe("0s") + expect(formatAxisTick(45 * SECOND, 45 * SECOND)).toBe("45s") + }) +}) diff --git a/apps/web/src/lib/agent-sessions/active-axis.ts b/apps/web/src/lib/agent-sessions/active-axis.ts new file mode 100644 index 000000000..8ca8bc508 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/active-axis.ts @@ -0,0 +1,106 @@ +// The waterfall's time axis, with the idle removed. +// +// A session that a human replied to twice is mostly nothing happening: without +// collapsing the gaps, ~70% of the waterfall is empty and every bar is a +// hairline. Collapsing maps absolute time onto a shorter axis by subtracting the +// gaps the user chose to hide, which keeps the bars proportional to each other +// while the axis reads in cumulative active time. + +import { formatDurationAtStep } from "@maple/ui/lib/format" + +import type { IdleGap } from "./session-summary" + +export interface AxisTick { + /** Offset along the axis, in axis milliseconds. */ + readonly axisMs: number + readonly label: string +} + +export interface SessionAxis { + readonly startMs: number + /** Axis length: wall clock minus every collapsed gap. */ + readonly totalMs: number + readonly removedMs: number + readonly removedGapCount: number + readonly ticks: readonly AxisTick[] + /** Absolute instant → offset along the axis. */ + readonly toAxisMs: (ms: number) => number + /** Absolute instant → 0…1 position along the axis. */ + readonly fraction: (ms: number) => number +} + +/** + * Ruler steps a reader can hold: the 1/2/5 × 10ⁿ ladder plus the clock values a + * duration axis wants. Splitting the total into equal fifths instead prints + * ticks like "1m 07s", which nothing in the rows below lines up with. + */ +const AXIS_STEPS_MS = [ + 1, 2, 5, 10, 20, 50, 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 15_000, 30_000, 60_000, 120_000, 300_000, + 600_000, 900_000, 1_800_000, 3_600_000, +] +const AXIS_TICK_TARGET = 6 +const HOUR_MS = 3_600_000 + +export function buildSessionAxis(options: { + readonly startMs: number + readonly endMs: number + /** The gaps to remove. The caller decides which — an expanded gap is simply absent. */ + readonly collapsedGaps: readonly IdleGap[] +}): SessionAxis { + const { startMs, endMs } = options + const collapsedGaps = [...options.collapsedGaps].sort((a, b) => a.startMs - b.startMs) + const removedMs = collapsedGaps.reduce((total, gap) => total + gap.durationMs, 0) + // A one-millisecond floor rather than a guard at every call site: the axis is + // only ever used as a denominator. + const totalMs = Math.max(1, endMs - startMs - removedMs) + + const toAxisMs = (ms: number): number => { + let axisMs = ms - startMs + for (const gap of collapsedGaps) { + if (ms <= gap.startMs) break + // An instant inside a collapsed gap lands on the seam where the gap was. + axisMs -= Math.min(ms, gap.endMs) - gap.startMs + } + return Math.min(Math.max(axisMs, 0), totalMs) + } + + return { + startMs, + totalMs, + removedMs, + removedGapCount: collapsedGaps.length, + ticks: axisTicks(totalMs), + toAxisMs, + fraction: (ms) => toAxisMs(ms) / totalMs, + } +} + +function axisTicks(totalMs: number): readonly AxisTick[] { + const rough = totalMs / (AXIS_TICK_TARGET - 1) + const step = AXIS_STEPS_MS.find((candidate) => candidate >= rough) ?? Math.ceil(rough / HOUR_MS) * HOUR_MS + const ticks: AxisTick[] = [] + // The closing tick is the axis length itself, drawn right-aligned against the + // edge — a stepped tick landing next to it would overprint the label. + for (let axisMs = 0; axisMs < totalMs * 0.94; axisMs += step) { + ticks.push({ axisMs, label: formatAxisTick(axisMs, step) }) + } + ticks.push({ axisMs: totalMs, label: formatAxisTick(totalMs, step) }) + return ticks +} + +/** + * Ruler label for an offset into the session. + * + * `formatDurationAtStep` is the app's axis formatter and handles everything + * under a minute, but its minute rendering ("1.5min") drops the seconds a + * session ruler needs to line up with the durations in the rows beside it — so + * minutes are written out as `m` + `s` here. + */ +export function formatAxisTick(ms: number, stepMs: number): string { + if (ms <= 0) return "0s" + if (ms < 60_000) return formatDurationAtStep(ms, stepMs) + const totalSeconds = Math.round(ms / 1000) + const minutes = Math.floor(totalSeconds / 60) + const seconds = totalSeconds % 60 + return `${minutes}m ${String(seconds).padStart(2, "0")}s` +} diff --git a/apps/web/src/lib/agent-sessions/model-pricing.test.ts b/apps/web/src/lib/agent-sessions/model-pricing.test.ts new file mode 100644 index 000000000..a3a62183e --- /dev/null +++ b/apps/web/src/lib/agent-sessions/model-pricing.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest" + +import { computeModelSpend, lookupModelPrice, PRICE_TABLE_DATE } from "./model-pricing" + +const noTokens = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 } + +// The arithmetic is what these assert; the rates come from the table so a price +// revision does not read as a broken sum. +const SONNET = lookupModelPrice("claude-sonnet-4-5")! +const HAIKU = lookupModelPrice("claude-haiku-4-5")! + +describe("lookupModelPrice", () => { + it("prices a dated release id off its family", () => { + expect(lookupModelPrice("claude-sonnet-4-5-20250929")).toEqual(lookupModelPrice("claude-sonnet-4-5")) + }) + + it("prefers the longest matching prefix", () => { + // Two rows either of which the id starts with, at very different prices. + expect(lookupModelPrice("gpt-5-mini-2025-08-07")?.input).toBe(0.25) + expect(lookupModelPrice("gpt-5")?.input).toBe(1.25) + }) + + it("ignores a gateway's routing prefix and the model's casing", () => { + expect(lookupModelPrice("anthropic/Claude-Opus-4-1")).toEqual(lookupModelPrice("claude-opus-4-1")) + expect(lookupModelPrice("models/gemini-2.5-pro")).toEqual(lookupModelPrice("gemini-2.5-pro")) + }) + + it("has no answer for a model it does not list", () => { + expect(lookupModelPrice("some-internal-model")).toBeUndefined() + }) + + it("will not price a model off a neighbouring row", () => { + // Each of these starts with a row that is not its family: a bare prefix + // match would read gpt-4o's price for the mini (16.7x too high), o3's for + // o3-pro (10x too low), and gpt-5's for whatever ships next. + expect(lookupModelPrice("gpt-4o-mini")).not.toEqual(lookupModelPrice("gpt-4o")) + expect(lookupModelPrice("o3-pro")).not.toEqual(lookupModelPrice("o3")) + expect(lookupModelPrice("gpt-5.6-luna")).toBeUndefined() + expect(lookupModelPrice("o3-deep-research")).toBeUndefined() + }) + + it("carries a date to show beside the estimate", () => { + expect(PRICE_TABLE_DATE).toMatch(/^\d{4}-\d{2}-\d{2}$/) + }) +}) + +describe("computeModelSpend", () => { + it("prices each bucket at its own rate", () => { + const { totalUsd } = computeModelSpend([ + { + model: "claude-sonnet-4-5", + // 1M input, 1M cache read, 1M cache write, 1M output. + tokens: { input: 1e6, cacheRead: 1e6, cacheWrite: 1e6, output: 1e6, reasoning: 0 }, + }, + ]) + + expect(totalUsd).toBeCloseTo(SONNET.input + SONNET.cacheRead + SONNET.cacheWrite + SONNET.output, 6) + }) + + it("bills reasoning tokens as output", () => { + const reasoningOnly = computeModelSpend([ + { model: "claude-sonnet-4-5", tokens: { ...noTokens, reasoning: 1e6 } }, + ]) + const outputOnly = computeModelSpend([ + { model: "claude-sonnet-4-5", tokens: { ...noTokens, output: 1e6 } }, + ]) + + expect(reasoningOnly.totalUsd).toBe(outputOnly.totalUsd) + }) + + it("adds up across models", () => { + const { totalUsd, unpricedModels } = computeModelSpend([ + { model: "claude-sonnet-4-5", tokens: { ...noTokens, output: 1e6 } }, + { model: "claude-haiku-4-5", tokens: { ...noTokens, output: 1e6 } }, + ]) + + expect(totalUsd).toBeCloseTo(SONNET.output + HAIKU.output, 6) + expect(unpricedModels).toEqual([]) + }) + + it("names the models it could not price instead of guessing at them", () => { + const { totalUsd, unpricedModels } = computeModelSpend([ + { model: "claude-sonnet-4-5", tokens: { ...noTokens, output: 1e6 } }, + { model: "an-unreleased-model", tokens: { ...noTokens, input: 5000, output: 500 } }, + ]) + + expect(totalUsd).toBeCloseTo(SONNET.output, 6) + expect(unpricedModels).toEqual(["an-unreleased-model"]) + }) + + it("does not flag an unpriced model that spent nothing", () => { + const { unpricedModels } = computeModelSpend([{ model: "an-unreleased-model", tokens: noTokens }]) + + expect(unpricedModels).toEqual([]) + }) + + it("is zero for a session with no usage at all", () => { + expect(computeModelSpend([])).toEqual({ totalUsd: 0, unpricedModels: [] }) + }) +}) diff --git a/apps/web/src/lib/agent-sessions/model-pricing.ts b/apps/web/src/lib/agent-sessions/model-pricing.ts new file mode 100644 index 000000000..054412b01 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/model-pricing.ts @@ -0,0 +1,155 @@ +// Model spend, estimated from a dated table of published list prices. +// +// This is what the session's tokens would have cost at each provider's public +// per-token list price — not what anyone was billed. Committed spend, volume +// discounts, batch and priority tiers, provider-side caching rules and every +// enterprise agreement move the real number, usually downwards. The page +// therefore labels the figure "model spend", prints the table's date beside it, +// and counts the models it could not price instead of quietly dropping them, so +// an estimate is never mistaken for an invoice. +// +// Prices are per million tokens, in USD. A model absent from the table is +// reported as unpriced rather than approximated from a neighbouring one: an +// invented price is worse than a stated gap. + +/** Shown next to the total. Update it whenever a row below changes. */ +export const PRICE_TABLE_DATE = "2026-08-20" + +export interface ModelPrice { + readonly input: number + readonly output: number + /** Reading a cached prompt prefix. */ + readonly cacheRead: number + /** + * Writing one. Anthropic bills a premium over input for this; providers that + * cache automatically bill it as ordinary input, which is what an entry equal + * to `input` means. + */ + readonly cacheWrite: number +} + +/** + * Keyed by model-id prefix, longest match wins — so a dated release id + * (`claude-sonnet-4-5-20250929`) prices off its family without a row per build, + * and `claude-sonnet-4-5` still beats `claude-sonnet-4`. + * + * A prefix only matches when what follows it is a build or date qualifier, so a + * row is never stretched over a neighbouring model: `gpt-4o-mini` is not + * `gpt-4o` (16.7x too high) and `o3-pro` is not `o3` (10x too low). Both now + * have rows; anything else lands in `unpricedModels`, where a gap is visible. + */ +const PRICE_TABLE: ReadonlyArray = [ + // Anthropic — cache writes at 1.25x input (5-minute TTL), reads at 0.1x. + ["claude-opus-4-5", { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }], + ["claude-opus-4-1", { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }], + ["claude-opus-4", { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }], + ["claude-sonnet-4-5", { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }], + ["claude-sonnet-4", { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }], + ["claude-haiku-4-5", { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }], + ["claude-3-7-sonnet", { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }], + ["claude-3-5-haiku", { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }], + + // OpenAI — prompt caching is automatic and writes are billed as input. + ["gpt-5-mini", { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 }], + ["gpt-5-nano", { input: 0.05, output: 0.4, cacheRead: 0.005, cacheWrite: 0.05 }], + ["gpt-5", { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 }], + ["gpt-4.1-mini", { input: 0.4, output: 1.6, cacheRead: 0.1, cacheWrite: 0.4 }], + ["gpt-4.1", { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 }], + ["gpt-4o-mini", { input: 0.15, output: 0.6, cacheRead: 0.075, cacheWrite: 0.15 }], + ["gpt-4o", { input: 2.5, output: 10, cacheRead: 1.25, cacheWrite: 2.5 }], + ["o3-pro", { input: 20, output: 80, cacheRead: 5, cacheWrite: 20 }], + ["o3-mini", { input: 1.1, output: 4.4, cacheRead: 0.55, cacheWrite: 1.1 }], + ["o3", { input: 2, output: 8, cacheRead: 0.5, cacheWrite: 2 }], + + // Google — the Gemini 2.5 Pro row is its short-prompt tier; long prompts are + // billed higher, so this estimate reads low on very large contexts. + ["gemini-2.5-pro", { input: 1.25, output: 10, cacheRead: 0.31, cacheWrite: 1.25 }], + ["gemini-2.5-flash-lite", { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0.1 }], + ["gemini-2.5-flash", { input: 0.3, output: 2.5, cacheRead: 0.075, cacheWrite: 0.3 }], + ["gemini-2.0-flash", { input: 0.1, output: 0.4, cacheRead: 0.025, cacheWrite: 0.1 }], +] + +/** + * Normalize a wire model id to something the table can match: lowercase, and + * without the routing prefix gateways prepend (`anthropic/claude-sonnet-4`, + * `models/gemini-2.5-pro`). Ids that carry a cloud-provider namespace instead + * (Bedrock's `us.anthropic.…`) stay unmatched and are reported as unpriced. + */ +function normalizeModelId(model: string): string { + const lower = model.trim().toLowerCase() + const lastSlash = lower.lastIndexOf("/") + return lastSlash === -1 ? lower : lower.slice(lastSlash + 1) +} + +/** + * What may follow a matched prefix: a build date (`-20250929`, `-2025-08-07`), + * a version, or `latest`. Anything else — `-mini`, `-pro`, `.6-luna` — names a + * different model, whose price is not this row's to guess. + */ +const BUILD_QUALIFIER = /^[-@.](\d{4}-\d{2}-\d{2}|\d{6,8}|v\d+|latest)/ + +export function lookupModelPrice(model: string): ModelPrice | undefined { + const id = normalizeModelId(model) + let best: { readonly prefix: string; readonly price: ModelPrice } | undefined + for (const [prefix, price] of PRICE_TABLE) { + if (!id.startsWith(prefix)) continue + const rest = id.slice(prefix.length) + if (rest !== "" && !BUILD_QUALIFIER.test(rest)) continue + if (best === undefined || prefix.length > best.prefix.length) best = { prefix, price } + } + return best?.price +} + +export interface ModelTokenUsage { + readonly model: string + readonly tokens: { + readonly input: number + readonly cacheRead: number + readonly cacheWrite: number + readonly output: number + readonly reasoning: number + } +} + +export interface ModelSpendEstimate { + readonly totalUsd: number + /** Models the table has no row for, in the order they were given. */ + readonly unpricedModels: readonly string[] +} + +/** + * Estimated spend across a session's models. + * + * Reasoning tokens bill as output: providers charge for them at the output rate + * even though they are absent from the response body. + * + * Token counts are taken exactly as the span reported them, so the buckets are + * only as disjoint as the provider makes them: where cached tokens are reported + * as a subset of the input count rather than beside it, those tokens are priced + * twice and the estimate reads high. Erring high is deliberate — a spend figure + * that flatters the bill is the one that gets believed. + */ +export function computeModelSpend(usage: readonly ModelTokenUsage[]): ModelSpendEstimate { + let totalUsd = 0 + const unpricedModels: string[] = [] + + for (const { model, tokens } of usage) { + const price = lookupModelPrice(model) + if (price === undefined) { + // Only worth flagging if it actually used tokens — a model id on a span + // that reported no usage cannot move the total. + const used = + tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning + if (used > 0 && !unpricedModels.includes(model)) unpricedModels.push(model) + continue + } + totalUsd += + (tokens.input * price.input + + tokens.cacheRead * price.cacheRead + + tokens.cacheWrite * price.cacheWrite + + (tokens.output + tokens.reasoning) * price.output) / + 1_000_000 + } + + return { totalUsd, unpricedModels } +} diff --git a/apps/web/src/lib/agent-sessions/session-summary.test.ts b/apps/web/src/lib/agent-sessions/session-summary.test.ts new file mode 100644 index 000000000..ed567bc09 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/session-summary.test.ts @@ -0,0 +1,462 @@ +import { describe, expect, it } from "vitest" + +import { agentSpan, llmSpan, makeSpan, T0, toolSpan } from "./span-fixtures" +import { buildSessionTurns } from "./session-turns" +import { + buildSessionSummary, + findIdleGaps, + retriedSpanIds, + SESSION_ACTIVE_WINDOW_MS, + type OccupancyKind, +} from "./session-summary" + +const SECOND = 1000 +const MINUTE = 60 * SECOND + +/** Long after the session, so `status` is not "active" unless a test asks for it. */ +const LATER = T0 + 4 * 60 * MINUTE + +const summarize = (spans: Parameters[0], nowMs = LATER) => + buildSessionSummary(spans, buildSessionTurns(spans), nowMs) + +const segment = ( + occupancy: readonly { readonly kind: OccupancyKind; readonly ms: number }[], + kind: OccupancyKind, +) => occupancy.find((entry) => entry.kind === kind)?.ms + +describe("findIdleGaps", () => { + it("finds the stretches where nothing was running", () => { + const gaps = findIdleGaps([ + llmSpan({ spanId: "a", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "b", startMs: 70 * SECOND, durationMs: 5 * SECOND }), + ]) + + expect(gaps).toHaveLength(1) + expect(gaps[0]!.durationMs).toBe(60 * SECOND) + }) + + it("ignores a hole too short to be a human", () => { + const gaps = findIdleGaps([ + llmSpan({ spanId: "a", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "b", startMs: 12 * SECOND, durationMs: SECOND }), + ]) + + expect(gaps).toEqual([]) + }) + + it("sees no gap under a span that covers it", () => { + const gaps = findIdleGaps([ + // The agent span stays open across the whole turn. + agentSpan({ spanId: "agent", startMs: 0, durationMs: 90 * SECOND }), + llmSpan({ spanId: "a", parentSpanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "b", parentSpanId: "agent", startMs: 70 * SECOND, durationMs: 5 * SECOND }), + ]) + + expect(gaps).toEqual([]) + }) +}) + +describe("buildSessionSummary — time", () => { + it("reports the wall clock, and active time as the wall clock less idle", () => { + const summary = summarize([ + llmSpan({ spanId: "a", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "b", startMs: 70 * SECOND, durationMs: 10 * SECOND }), + ]) + + expect(summary.wallClockMs).toBe(80 * SECOND) + expect(summary.idleMs).toBe(60 * SECOND) + expect(summary.activeMs).toBe(20 * SECOND) + }) + + it("measures occupancy, so parallel tools cannot exceed the wall clock", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + // Four tools, ten seconds each, all at once: 40s of duration inside a + // 10s session. + toolSpan({ spanId: "t1", parentSpanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ spanId: "t2", parentSpanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ spanId: "t3", parentSpanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + toolSpan({ spanId: "t4", parentSpanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + ]) + + expect(segment(summary.occupancy, "tool")).toBe(10 * SECOND) + expect(summary.occupancy.reduce((total, entry) => total + entry.ms, 0)).toBe(summary.wallClockMs) + }) + + it("charges overlapping inference and tool time once, to inference", () => { + const summary = summarize([ + llmSpan({ spanId: "llm", startMs: 0, durationMs: 10 * SECOND }), + // A tool that ran while the model was still streaming. + toolSpan({ spanId: "tool", startMs: 5 * SECOND, durationMs: 10 * SECOND }), + ]) + + expect(segment(summary.occupancy, "inference")).toBe(10 * SECOND) + expect(segment(summary.occupancy, "tool")).toBe(5 * SECOND) + }) + + it("splits a streaming call into time to first token and the rest", () => { + const summary = summarize([ + llmSpan({ spanId: "llm", startMs: 0, durationMs: 10 * SECOND, ttftSeconds: 4 }), + ]) + + expect(segment(summary.occupancy, "ttft")).toBe(4 * SECOND) + expect(segment(summary.occupancy, "inference")).toBe(6 * SECOND) + }) + + it("omits the time-to-first-token segment when no vendor reported one", () => { + const summary = summarize([llmSpan({ spanId: "llm", startMs: 0, durationMs: 10 * SECOND })]) + + expect(segment(summary.occupancy, "ttft")).toBeUndefined() + }) + + it("leaves the time no gen_ai span accounts for as the framework's own", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "llm", parentSpanId: "agent", startMs: 2 * SECOND, durationMs: 3 * SECOND }), + ]) + + expect(segment(summary.occupancy, "inference")).toBe(3 * SECOND) + expect(segment(summary.occupancy, "unaccounted")).toBe(7 * SECOND) + }) +}) + +describe("buildSessionSummary — status", () => { + const spans = [ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "llm", parentSpanId: "agent", startMs: SECOND, durationMs: SECOND }), + ] + + it("is active while a span landed inside the last half hour", () => { + const summary = summarize(spans, T0 + 10 * SECOND + SESSION_ACTIVE_WINDOW_MS - MINUTE) + + expect(summary.status).toBe("active") + }) + + it("is completed once the last turn closed cleanly and nothing followed", () => { + expect(summarize(spans).status).toBe("completed") + }) + + it("is failed when the last turn's root span errored", () => { + const summary = summarize([ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 10 * SECOND }), + agentSpan({ spanId: "agent-2", startMs: 5 * MINUTE, durationMs: SECOND, statusCode: "Error" }), + ]) + + expect(summary.status).toBe("failed") + }) + + it("is failed rather than active when the error landed moments ago", () => { + const failedSpans = [ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 10 * SECOND }), + agentSpan({ spanId: "agent-2", startMs: 5 * MINUTE, durationMs: SECOND, statusCode: "Error" }), + ] + + expect(summarize(failedSpans, T0 + 5 * MINUTE + 2 * MINUTE).status).toBe("failed") + }) + + it("is abandoned when nothing in the data says the agent ever finished", () => { + // No conversation id and no agent invocation: turns came from trace + // boundaries, which are not evidence of completion. + const summary = summarize([llmSpan({ spanId: "llm", startMs: 0, durationMs: SECOND })]) + + expect(summary.status).toBe("abandoned") + }) +}) + +describe("buildSessionSummary — tokens and models", () => { + it("sums the five usage buckets", () => { + const summary = summarize([ + llmSpan({ spanId: "a", startMs: 0, durationMs: SECOND, tokens: [100, 2000, 300, 40, 5] }), + llmSpan({ spanId: "b", startMs: 2 * SECOND, durationMs: SECOND, tokens: [10, 20, 30, 4, 5] }), + ]) + + expect(summary.tokens).toEqual({ + input: 110, + cacheRead: 2020, + cacheWrite: 330, + output: 44, + reasoning: 10, + total: 2514, + }) + }) + + it("counts usage at the deepest span that reports it", () => { + const summary = summarize([ + // The framework reports a turn total on the agent span AND on each model + // span underneath it. Its own figure is lower than theirs here, so a + // total that read 250 would be the roll-up rather than the model calls. + agentSpan({ + spanId: "agent", + startMs: 0, + durationMs: 10 * SECOND, + genAi: { usageInputTokens: 250, usageOutputTokens: 25 }, + }), + llmSpan({ + spanId: "a", + parentSpanId: "agent", + startMs: 0, + durationMs: SECOND, + tokens: [100, 0, 0, 10, 0], + }), + llmSpan({ + spanId: "b", + parentSpanId: "agent", + startMs: 2 * SECOND, + durationMs: SECOND, + tokens: [200, 0, 0, 20, 0], + }), + ]) + + expect(summary.tokens.input).toBe(300) + expect(summary.tokens.output).toBe(30) + }) + + it("keeps what a roll-up reported above the children that reported", () => { + const summary = summarize([ + // Three model calls under one agent span, and the middle one carries no + // usage at all — its tokens survive as the agent span's excess. + agentSpan({ + spanId: "agent", + startMs: 0, + durationMs: 10 * SECOND, + genAi: { usageInputTokens: 300, usageOutputTokens: 30 }, + }), + llmSpan({ + spanId: "a", + parentSpanId: "agent", + startMs: 0, + durationMs: SECOND, + tokens: [100, 0, 0, 10, 0], + }), + llmSpan({ spanId: "b", parentSpanId: "agent", startMs: 2 * SECOND, durationMs: SECOND }), + llmSpan({ + spanId: "c", + parentSpanId: "agent", + startMs: 4 * SECOND, + durationMs: SECOND, + tokens: [100, 0, 0, 10, 0], + }), + ]) + + expect(summary.tokens.input).toBe(300) + expect(summary.tokens.output).toBe(30) + }) + + it("does not add cached tokens to input when they are a subset of it", () => { + const summary = summarize([ + // The dominant convention: cache reads are part of the input count, not + // beside it. + llmSpan({ spanId: "a", startMs: 0, durationMs: SECOND, tokens: [1000, 900, 0, 100, 0] }), + ]) + + expect(summary.tokens.cacheRead).toBe(900) + expect(summary.tokens.total).toBe(1100) + }) + + it("keeps usage reported only at the top of the tree", () => { + const summary = summarize([ + agentSpan({ + spanId: "agent", + startMs: 0, + durationMs: 10 * SECOND, + genAi: { usageInputTokens: 300, usageOutputTokens: 30 }, + }), + llmSpan({ spanId: "a", parentSpanId: "agent", startMs: 0, durationMs: SECOND }), + ]) + + expect(summary.tokens.input).toBe(300) + }) + + it("groups models by the one that answered, busiest first", () => { + const summary = summarize([ + llmSpan({ spanId: "a", startMs: 0, durationMs: SECOND, model: "claude-haiku-4-5" }), + llmSpan({ spanId: "b", startMs: 2 * SECOND, durationMs: SECOND, model: "claude-opus-4-1" }), + llmSpan({ spanId: "c", startMs: 4 * SECOND, durationMs: SECOND, model: "claude-opus-4-1" }), + ]) + + expect(summary.models.map((model) => [model.model, model.llmCalls])).toEqual([ + ["claude-opus-4-1", 2], + ["claude-haiku-4-5", 1], + ]) + }) +}) + +describe("buildSessionSummary — work and failures", () => { + it("counts turns, model calls and tool calls separately", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 20 * SECOND }), + llmSpan({ spanId: "llm-1", parentSpanId: "agent", startMs: SECOND, durationMs: SECOND }), + toolSpan({ spanId: "tool-1", parentSpanId: "agent", startMs: 3 * SECOND, durationMs: SECOND }), + toolSpan({ spanId: "tool-2", parentSpanId: "agent", startMs: 5 * SECOND, durationMs: SECOND }), + llmSpan({ spanId: "llm-2", parentSpanId: "agent", startMs: 7 * SECOND, durationMs: SECOND }), + ]) + + expect(summary.work).toMatchObject({ turns: 1, llmCalls: 2, toolCalls: 2 }) + }) + + it("counts a rate-limited model call that was tried again as a retry", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 20 * SECOND }), + llmSpan({ + spanId: "llm-1", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + statusCode: "Error", + genAi: { errorType: "429" }, + }), + llmSpan({ spanId: "llm-2", parentSpanId: "agent", startMs: 10 * SECOND, durationMs: SECOND }), + ]) + + expect(summary.work.retries).toBe(1) + expect(summary.failures.rateLimited).toBe(1) + }) + + it("counts a failure once when a wrapper span restates it", () => { + const spans = [ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 20 * SECOND }), + // The framework's own container span around the model call, carrying the + // same error verbatim. + llmSpan({ + spanId: "container", + parentSpanId: "agent", + startMs: SECOND, + durationMs: 2 * SECOND, + statusCode: "Error", + genAi: { errorType: "429" }, + }), + llmSpan({ + spanId: "inner", + parentSpanId: "container", + startMs: SECOND, + durationMs: SECOND, + statusCode: "Error", + genAi: { errorType: "429" }, + }), + llmSpan({ spanId: "retry", parentSpanId: "agent", startMs: 10 * SECOND, durationMs: SECOND }), + ] + const summary = summarize(spans) + + expect(summary.work.retries).toBe(1) + expect(summary.failures.rateLimited).toBe(1) + expect([...retriedSpanIds(buildSessionTurns(spans))]).toEqual(["inner"]) + }) + + it("counts a refusal once when the agent span repeats the finish reason", () => { + const summary = summarize([ + agentSpan({ + spanId: "agent", + startMs: 0, + durationMs: 20 * SECOND, + genAi: { operationName: "invoke_agent", responseFinishReasons: ["refusal"] }, + }), + llmSpan({ + spanId: "llm", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + genAi: { responseFinishReasons: ["refusal"] }, + }), + ]) + + expect(summary.failures.refusals).toBe(1) + }) + + it("does not call the turn's last model call a retry, however it ended", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 20 * SECOND }), + llmSpan({ + spanId: "llm", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + statusCode: "Error", + genAi: { errorType: "429" }, + }), + ]) + + expect(summary.work.retries).toBe(0) + }) + + it("groups failures by cause, and counts each errored span once", () => { + const summary = summarize([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 40 * SECOND }), + toolSpan({ + spanId: "tool", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + statusCode: "Error", + statusMessage: "exit 1", + }), + llmSpan({ + spanId: "context", + parentSpanId: "agent", + startMs: 3 * SECOND, + durationMs: SECOND, + statusCode: "Error", + statusMessage: "context_length_exceeded", + }), + llmSpan({ + spanId: "refused", + parentSpanId: "agent", + startMs: 5 * SECOND, + durationMs: SECOND, + genAi: { responseFinishReasons: ["refusal"] }, + }), + ]) + + expect(summary.failures).toEqual({ + toolErrors: 1, + rateLimited: 0, + contextExceeded: 1, + refusals: 1, + }) + }) + + it("does not read a max_tokens finish as a failure", () => { + const summary = summarize([ + llmSpan({ + spanId: "llm", + startMs: 0, + durationMs: SECOND, + genAi: { responseFinishReasons: ["length"] }, + }), + ]) + + expect(summary.failures).toEqual({ + toolErrors: 0, + rateLimited: 0, + contextExceeded: 0, + refusals: 0, + }) + }) +}) + +describe("buildSessionSummary — identity", () => { + it("names services busiest first and vendors in first-seen order", () => { + const summary = summarize([ + agentSpan({ + spanId: "a", + startMs: 0, + durationMs: 30 * SECOND, + serviceName: "gateway", + vendorId: "eve", + }), + llmSpan({ spanId: "b", startMs: SECOND, durationMs: SECOND, serviceName: "agent-runner" }), + llmSpan({ spanId: "c", startMs: 3 * SECOND, durationMs: SECOND, serviceName: "agent-runner" }), + makeSpan({ + spanId: "d", + startMs: 5 * SECOND, + durationMs: SECOND, + serviceName: "tool-worker", + vendorId: "mastra", + }), + ]) + + expect(summary.serviceNames).toEqual(["agent-runner", "gateway", "tool-worker"]) + expect(summary.vendorIds).toEqual(["eve", "mastra"]) + expect(summary.traceCount).toBe(1) + expect(summary.spanCount).toBe(4) + }) +}) diff --git a/apps/web/src/lib/agent-sessions/session-summary.ts b/apps/web/src/lib/agent-sessions/session-summary.ts new file mode 100644 index 000000000..c0875be3c --- /dev/null +++ b/apps/web/src/lib/agent-sessions/session-summary.ts @@ -0,0 +1,583 @@ +// Everything the session header states, derived from the spans. +// +// Two rules shape this module. Time is measured as *occupancy* of the wall +// clock, never as a sum of span durations — a session running four tools in +// parallel would otherwise report 180% of itself. And tokens are counted at the +// deepest span that reports them, because frameworks that also roll usage up to +// the agent span would otherwise double the bill. + +import type { AiSessionSpan } from "@maple/domain/http" +import { + classifySpan, + isLlmCall, + spanEndMs, + spanModel, + spanStartMs, + spanTtftMs, + type SessionTurn, +} from "./session-turns" + +/** + * Shortest hole in the session that counts as the user thinking rather than the + * framework working. Below it, a gap is overhead and stays in active time. + */ +export const IDLE_GAP_MIN_MS = 5_000 + +/** No span for this long and the session is no longer running. */ +export const SESSION_ACTIVE_WINDOW_MS = 30 * 60_000 + +export interface IdleGap { + readonly id: string + readonly startMs: number + readonly endMs: number + readonly durationMs: number +} + +/** Wall-clock occupancy classes, in the order the breakdown bar draws them. */ +export type OccupancyKind = "idle" | "ttft" | "inference" | "tool" | "unaccounted" + +export interface OccupancySegment { + readonly kind: OccupancyKind + readonly ms: number +} + +export interface SessionTokenTotals { + readonly input: number + readonly cacheRead: number + readonly cacheWrite: number + readonly output: number + readonly reasoning: number + readonly total: number +} + +export interface SessionModelUsage { + readonly model: string + readonly llmCalls: number + readonly tokens: SessionTokenTotals +} + +export interface SessionWorkCounts { + readonly turns: number + readonly llmCalls: number + readonly toolCalls: number + readonly retries: number +} + +export interface SessionFailureCounts { + readonly toolErrors: number + readonly rateLimited: number + readonly contextExceeded: number + readonly refusals: number +} + +export type SessionStatus = "active" | "completed" | "failed" | "abandoned" + +export interface SessionSummary { + readonly startMs: number + readonly endMs: number + readonly wallClockMs: number + readonly activeMs: number + readonly idleMs: number + readonly idleGaps: readonly IdleGap[] + /** Non-zero segments only: an unavailable TTFT is absent, never a zero bar. */ + readonly occupancy: readonly OccupancySegment[] + readonly status: SessionStatus + /** The opening user message, when content was captured. */ + readonly title: string | undefined + readonly agentNames: readonly string[] + readonly vendorIds: readonly string[] + readonly serviceNames: readonly string[] + readonly models: readonly SessionModelUsage[] + readonly tokens: SessionTokenTotals + readonly work: SessionWorkCounts + readonly failures: SessionFailureCounts + readonly spanCount: number + readonly traceCount: number +} + +// Error signals, read off `error.type` (often just the status code), +// `gen_ai.response.status` and the span's own status message. `length` is +// deliberately absent from the context pattern: as a finish reason it means +// max_tokens was reached, which is a normal completion, not a failure. +const RATE_LIMIT_PATTERN = /\b429\b|rate.?limit|too.many.requests|resource.exhausted|overloaded/i +const SERVER_ERROR_PATTERN = /\b5\d{2}\b|unavailable|internal.server|bad.gateway|timeout/i +const CONTEXT_EXCEEDED_PATTERN = + /context.{0,16}(length|window|limit)|maximum.context|prompt is too long|too many tokens/i +const REFUSAL_FINISH_REASONS = new Set(["refusal", "content_filter"]) + +export function buildSessionSummary( + spans: readonly AiSessionSpan[], + turns: readonly SessionTurn[], + nowMs: number, +): SessionSummary { + const startMs = Math.min(...spans.map(spanStartMs)) + const endMs = Math.max(...spans.map(spanEndMs)) + const wallClockMs = endMs - startMs + + const idleGaps = findIdleGaps(spans) + const idleMs = idleGaps.reduce((total, gap) => total + gap.durationMs, 0) + + const tokensBySpan = countableUsageSpans(spans) + const tokens = sumTokens([...tokensBySpan.values()]) + + return { + startMs, + endMs, + wallClockMs, + activeMs: wallClockMs - idleMs, + idleMs, + idleGaps, + occupancy: computeOccupancy(spans, wallClockMs, idleMs), + status: sessionStatus(turns, endMs, nowMs), + title: turns[0]?.label, + agentNames: distinctInOrder(spans.map((span) => span.genAi.agentName)), + vendorIds: distinctInOrder(spans.map((span) => span.vendorId)), + serviceNames: byFrequency(spans.map((span) => span.serviceName)), + models: modelUsage(spans, tokensBySpan), + tokens, + work: { + turns: turns.length, + llmCalls: spans.filter(isLlmCall).length, + toolCalls: spans.filter((span) => classifySpan(span) === "tool").length, + retries: countRetries(turns), + }, + failures: countFailures(spans), + spanCount: spans.length, + traceCount: new Set(spans.map((span) => span.traceId)).size, + } +} + +/* -------------------------------------------------------------------------- */ +/* Time */ +/* -------------------------------------------------------------------------- */ + +interface Interval { + readonly startMs: number + readonly endMs: number +} + +/** Merge overlapping intervals into a disjoint, ordered cover. */ +function union(intervals: readonly Interval[]): Interval[] { + const sorted = [...intervals] + .filter((interval) => interval.endMs > interval.startMs) + .sort((a, b) => a.startMs - b.startMs) + const merged: Interval[] = [] + for (const interval of sorted) { + const last = merged[merged.length - 1] + if (last !== undefined && interval.startMs <= last.endMs) { + if (interval.endMs > last.endMs) merged[merged.length - 1] = { ...last, endMs: interval.endMs } + } else { + merged.push(interval) + } + } + return merged +} + +/** `a` minus `b`; both are expected to be disjoint covers. */ +function subtract(a: readonly Interval[], b: readonly Interval[]): Interval[] { + const out: Interval[] = [] + for (const interval of a) { + let cursor = interval.startMs + for (const hole of b) { + if (hole.endMs <= cursor) continue + if (hole.startMs >= interval.endMs) break + if (hole.startMs > cursor) out.push({ startMs: cursor, endMs: hole.startMs }) + cursor = Math.max(cursor, hole.endMs) + } + if (cursor < interval.endMs) out.push({ startMs: cursor, endMs: interval.endMs }) + } + return out +} + +function totalMs(intervals: readonly Interval[]): number { + return intervals.reduce((total, interval) => total + (interval.endMs - interval.startMs), 0) +} + +/** + * The stretches where nothing at all was running, long enough to read as the + * session waiting on a human. Short holes stay in active time — they are the + * framework's own overhead between spans, and calling a 200ms pause "idle" + * would scatter the waterfall with meaningless gap rows. + */ +export function findIdleGaps(spans: readonly AiSessionSpan[]): readonly IdleGap[] { + const busy = union(spans.map((span) => ({ startMs: spanStartMs(span), endMs: spanEndMs(span) }))) + const gaps: IdleGap[] = [] + for (let i = 1; i < busy.length; i++) { + const startMs = busy[i - 1]!.endMs + const endMs = busy[i]!.startMs + const durationMs = endMs - startMs + if (durationMs > IDLE_GAP_MIN_MS) gaps.push({ id: `gap:${startMs}`, startMs, endMs, durationMs }) + } + return gaps +} + +/** + * Split the wall clock into disjoint occupancy classes. + * + * Overlaps are resolved by a fixed priority — time to first token, then + * inference, then tool — so the segments always sum to the wall clock. What + * neither idle nor a gen_ai span accounts for lands in `unaccounted`: agent + * scaffolding, framework overhead, the app's own spans. That residual is the + * point of the bar, so it is never folded into a neighbour. + */ +function computeOccupancy( + spans: readonly AiSessionSpan[], + wallClockMs: number, + idleMs: number, +): readonly OccupancySegment[] { + const ttftIntervals: Interval[] = [] + const inferenceIntervals: Interval[] = [] + const toolIntervals: Interval[] = [] + + for (const span of spans) { + const startMs = spanStartMs(span) + const endMs = spanEndMs(span) + const category = classifySpan(span) + if (category === "tool") { + toolIntervals.push({ startMs, endMs }) + continue + } + if (category !== "inference") continue + const ttftMs = spanTtftMs(span) + if (ttftMs === undefined) { + inferenceIntervals.push({ startMs, endMs }) + } else { + ttftIntervals.push({ startMs, endMs: startMs + ttftMs }) + inferenceIntervals.push({ startMs: startMs + ttftMs, endMs }) + } + } + + const ttft = union(ttftIntervals) + const inference = subtract(union(inferenceIntervals), ttft) + const tool = subtract( + union(toolIntervals), + [...ttft, ...inference].sort((a, b) => a.startMs - b.startMs), + ) + + const ttftMs = totalMs(ttft) + const inferenceMs = totalMs(inference) + const toolMs = totalMs(tool) + const unaccountedMs = Math.max(0, wallClockMs - idleMs - ttftMs - inferenceMs - toolMs) + + return ( + [ + { kind: "idle", ms: idleMs }, + { kind: "ttft", ms: ttftMs }, + { kind: "inference", ms: inferenceMs }, + { kind: "tool", ms: toolMs }, + { kind: "unaccounted", ms: unaccountedMs }, + ] as const + ).filter((segment) => segment.ms > 0) +} + +function sessionStatus(turns: readonly SessionTurn[], endMs: number, nowMs: number): SessionStatus { + const lastTurn = turns[turns.length - 1] + // Failure is checked before the active window: the window measures silence, + // and a session that errored two minutes ago is silent for a known reason. + if (lastTurn?.failed === true) return "failed" + if (nowMs - endMs < SESSION_ACTIVE_WINDOW_MS) return "active" + if (lastTurn === undefined) return "abandoned" + // Completion needs positive evidence. Turns recovered from trace boundaries + // carry none — nothing in the data says the agent finished — so a session + // that simply stopped reads as abandoned rather than quietly successful. + return lastTurn.anchorKind === "trace" ? "abandoned" : "completed" +} + +/* -------------------------------------------------------------------------- */ +/* Tokens, models, spend inputs */ +/* -------------------------------------------------------------------------- */ + +const EMPTY_TOKENS: SessionTokenTotals = { + input: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + reasoning: 0, + total: 0, +} + +function spanTokens(span: AiSessionSpan): SessionTokenTotals | undefined { + const { usageInputTokens, usageCacheReadInputTokens, usageCacheCreationInputTokens } = span.genAi + const { usageOutputTokens, usageReasoningOutputTokens } = span.genAi + if ( + usageInputTokens === undefined && + usageCacheReadInputTokens === undefined && + usageCacheCreationInputTokens === undefined && + usageOutputTokens === undefined && + usageReasoningOutputTokens === undefined + ) { + return undefined + } + return tokenTotals({ + input: usageInputTokens ?? 0, + cacheRead: usageCacheReadInputTokens ?? 0, + cacheWrite: usageCacheCreationInputTokens ?? 0, + output: usageOutputTokens ?? 0, + reasoning: usageReasoningOutputTokens ?? 0, + }) +} + +/** + * The five buckets plus the headline total. + * + * Cached input is a SUBSET of `gen_ai.usage.input_tokens` under the dominant + * vendor convention — one production call reports 4935 input against 4932 cache + * writes — so adding the cache buckets to input nearly doubles the figure the + * header prints. Where they exceed input they are evidently reported beside it, + * and the additive total is the honest one. The bucket legend is unaffected + * either way: it shows what was reported. + */ +function tokenTotals(buckets: Omit): SessionTokenTotals { + const cached = buckets.cacheRead + buckets.cacheWrite + const total = + cached <= buckets.input + ? buckets.input + buckets.output + buckets.reasoning + : buckets.input + cached + buckets.output + buckets.reasoning + return { ...buckets, total } +} + +/** + * Usage per span, with what a deeper span already reported taken off it. + * + * Several frameworks stamp `gen_ai.usage.*` on the model span AND sum it onto + * the agent span that wraps it. Counting the deepest reporter keeps the session + * total equal to what was actually billed. The wrapper is not dropped outright, + * though: it keeps whatever it reported ABOVE the sum of the reporters beneath + * it — zero for a clean roll-up, and the missing call's usage when one of its + * children reported none. + */ +function countableUsageSpans(spans: readonly AiSessionSpan[]): Map { + const byId = new Map(spans.map((span) => [span.spanId, span])) + const reported = new Map() + for (const span of spans) { + const tokens = spanTokens(span) + if (tokens !== undefined) reported.set(span.spanId, tokens) + } + + // Each reporter is charged to the NEAREST ancestor that also reports, so a + // two-level roll-up subtracts each figure once rather than at every level. + const claimed = new Map() + for (const [spanId, tokens] of reported) { + const seen = new Set([spanId]) + let parent = byId.get(byId.get(spanId)!.parentSpanId) + while (parent !== undefined && !seen.has(parent.spanId)) { + seen.add(parent.spanId) + if (reported.has(parent.spanId)) { + claimed.set(parent.spanId, [...(claimed.get(parent.spanId) ?? []), tokens]) + break + } + parent = byId.get(parent.parentSpanId) + } + } + + const countable = new Map() + for (const [spanId, tokens] of reported) { + const beneath = claimed.get(spanId) + const countableTokens = beneath === undefined ? tokens : excessTokens(tokens, sumTokens(beneath)) + if (countableTokens.total > 0) countable.set(spanId, countableTokens) + } + return countable +} + +/** Per bucket, what `reported` claims over `counted`. Never negative: a wrapper + * that under-reports its own children adds nothing rather than subtracting. */ +function excessTokens(reported: SessionTokenTotals, counted: SessionTokenTotals): SessionTokenTotals { + return tokenTotals({ + input: Math.max(0, reported.input - counted.input), + cacheRead: Math.max(0, reported.cacheRead - counted.cacheRead), + cacheWrite: Math.max(0, reported.cacheWrite - counted.cacheWrite), + output: Math.max(0, reported.output - counted.output), + reasoning: Math.max(0, reported.reasoning - counted.reasoning), + }) +} + +/** + * The five usage buckets over any set of spans. Exported so the waterfall counts + * a turn's tokens by the same rule the header counts the session's, and the + * turns therefore add up to the total printed above them. + */ +export function countSessionTokens(spans: readonly AiSessionSpan[]): SessionTokenTotals { + return sumTokens([...countableUsageSpans(spans).values()]) +} + +function sumTokens(totals: readonly SessionTokenTotals[]): SessionTokenTotals { + return totals.reduce( + (sum, tokens) => ({ + input: sum.input + tokens.input, + cacheRead: sum.cacheRead + tokens.cacheRead, + cacheWrite: sum.cacheWrite + tokens.cacheWrite, + output: sum.output + tokens.output, + reasoning: sum.reasoning + tokens.reasoning, + total: sum.total + tokens.total, + }), + EMPTY_TOKENS, + ) +} + +const UNKNOWN_MODEL = "unknown model" + +function modelUsage( + spans: readonly AiSessionSpan[], + tokensBySpan: ReadonlyMap, +): readonly SessionModelUsage[] { + const byModel = new Map() + const entryFor = (model: string) => { + const existing = byModel.get(model) + if (existing !== undefined) return existing + const created = { llmCalls: 0, tokens: [] as SessionTokenTotals[] } + byModel.set(model, created) + return created + } + + for (const span of spans) { + const model = spanModel(span) ?? UNKNOWN_MODEL + if (isLlmCall(span)) entryFor(model).llmCalls++ + const tokens = tokensBySpan.get(span.spanId) + if (tokens !== undefined) entryFor(model).tokens.push(tokens) + } + + return [...byModel] + .map(([model, entry]) => ({ model, llmCalls: entry.llmCalls, tokens: sumTokens(entry.tokens) })) + .sort((a, b) => b.llmCalls - a.llmCalls || b.tokens.total - a.tokens.total) +} + +/* -------------------------------------------------------------------------- */ +/* Work and failures */ +/* -------------------------------------------------------------------------- */ + +function errorSignal(span: AiSessionSpan): string { + return [span.genAi.errorType, span.genAi.responseStatus, span.statusMessage] + .filter((value): value is string => value !== undefined && value !== "") + .join(" ") +} + +/** The error a span reports, or nothing — for a span that did not fail, or one + * that failed without saying anything an ancestor could be matched against. */ +function failureSignal(span: AiSessionSpan): string | undefined { + if (span.statusCode !== "Error") return undefined + const signal = errorSignal(span) + return signal === "" ? undefined : signal +} + +function refusalSignal(span: AiSessionSpan): string | undefined { + const reasons = (span.genAi.responseFinishReasons ?? []) + .map((reason) => reason.toLowerCase()) + .filter((reason) => REFUSAL_FINISH_REASONS.has(reason)) + return reasons.length === 0 ? undefined : reasons.join(",") +} + +/** + * Ancestors carrying a signal a span below them already carries. + * + * Frameworks stamp the model call's error and its finish reasons on the agent + * span wrapping it as well. Counted at both levels, one refusal is two, and the + * outer copy of a failed call becomes a retry that never happened — so only the + * deepest span carrying a given signal counts. + */ +function shadowedAncestorIds( + spans: readonly AiSessionSpan[], + signalOf: (span: AiSessionSpan) => string | undefined, +): ReadonlySet { + const byId = new Map(spans.map((span) => [span.spanId, span])) + const shadowed = new Set() + for (const span of spans) { + const signal = signalOf(span) + if (signal === undefined) continue + const seen = new Set([span.spanId]) + let parent = byId.get(span.parentSpanId) + while (parent !== undefined && !seen.has(parent.spanId)) { + if (signalOf(parent) === signal) shadowed.add(parent.spanId) + seen.add(parent.spanId) + parent = byId.get(parent.parentSpanId) + } + } + return shadowed +} + +/** + * The spans counted as retries: errored-then-resent inference. + * + * No convention field records "this was attempt 2", so the heuristic is: a model + * span that failed with a rate limit or a server error, and was followed by + * another model span in the same turn. It names the failures the agent had to + * pay for again — the successful attempt is the call, not the retry — and it + * misses a retry the client swallowed without emitting a span for the failure. + * + * Exported as a set of span ids so the waterfall can mark the same spans the + * header counted, rather than re-deriving the rule beside it. + */ +export function retriedSpanIds(turns: readonly SessionTurn[]): ReadonlySet { + const shadowed = shadowedAncestorIds( + turns.flatMap((turn) => [...turn.spans]), + failureSignal, + ) + const retried = new Set() + for (const turn of turns) { + const llmSpans = turn.spans.filter(isLlmCall) + for (let i = 0; i < llmSpans.length - 1; i++) { + const span = llmSpans[i]! + if (span.statusCode !== "Error" || shadowed.has(span.spanId)) continue + const signal = errorSignal(span) + if (RATE_LIMIT_PATTERN.test(signal) || SERVER_ERROR_PATTERN.test(signal)) { + retried.add(span.spanId) + } + } + } + return retried +} + +function countRetries(turns: readonly SessionTurn[]): number { + return retriedSpanIds(turns).size +} + +/** + * Errored spans, grouped by why. First match wins — a tool call that failed with + * a 429 counts once, as rate limiting, because that is the cause worth acting + * on. An errored span matching none of the three is left out of all of them + * rather than swelling `tool errors`. + * + * Refusals are the exception: they are a finish reason on a span that succeeded, + * so they are counted independently of span status. + * + * Both counts take the deepest reporter, because a framework that copies the + * model's error or finish reason onto the agent span would otherwise report one + * failure as two. + */ +function countFailures(spans: readonly AiSessionSpan[]): SessionFailureCounts { + const shadowedFailures = shadowedAncestorIds(spans, failureSignal) + const shadowedRefusals = shadowedAncestorIds(spans, refusalSignal) + let toolErrors = 0 + let rateLimited = 0 + let contextExceeded = 0 + let refusals = 0 + + for (const span of spans) { + if (refusalSignal(span) !== undefined && !shadowedRefusals.has(span.spanId)) refusals++ + if (span.statusCode !== "Error" || shadowedFailures.has(span.spanId)) continue + const signal = errorSignal(span) + if (RATE_LIMIT_PATTERN.test(signal)) rateLimited++ + else if (CONTEXT_EXCEEDED_PATTERN.test(signal)) contextExceeded++ + else if (classifySpan(span) === "tool") toolErrors++ + } + + return { toolErrors, rateLimited, contextExceeded, refusals } +} + +/* -------------------------------------------------------------------------- */ +/* Small collection helpers */ +/* -------------------------------------------------------------------------- */ + +function distinctInOrder(values: readonly (string | undefined)[]): readonly string[] { + const seen: string[] = [] + for (const value of values) { + if (value !== undefined && value !== "" && !seen.includes(value)) seen.push(value) + } + return seen +} + +/** Distinct values, busiest first — the header names the dominant service. */ +function byFrequency(values: readonly string[]): readonly string[] { + const counts = new Map() + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1) + return [...counts].sort((a, b) => b[1] - a[1]).map(([value]) => value) +} diff --git a/apps/web/src/lib/agent-sessions/session-turns.test.ts b/apps/web/src/lib/agent-sessions/session-turns.test.ts new file mode 100644 index 000000000..40d635eb5 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/session-turns.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, it } from "vitest" + +import { agentSpan, llmSpan, makeSpan, toolSpan, userMessages } from "./span-fixtures" +import { buildSessionTurns, classifySpan, firstUserMessageText, isLlmCall, spanTtftMs } from "./session-turns" + +const SECOND = 1000 + +describe("buildSessionTurns", () => { + it("groups by gen_ai.conversation.id, in first-start order", () => { + const turns = buildSessionTurns([ + llmSpan({ + spanId: "b", + startMs: 30 * SECOND, + durationMs: SECOND, + genAi: { conversationId: "t2" }, + }), + llmSpan({ spanId: "a", startMs: 0, durationMs: SECOND, genAi: { conversationId: "t1" } }), + llmSpan({ + spanId: "c", + startMs: 40 * SECOND, + durationMs: SECOND, + genAi: { conversationId: "t2" }, + }), + ]) + + expect(turns.map((turn) => turn.index)).toEqual([1, 2]) + expect(turns[0]!.anchorKind).toBe("conversation") + expect(turns[0]!.spans.map((span) => span.spanId)).toEqual(["a"]) + expect(turns[1]!.spans.map((span) => span.spanId)).toEqual(["b", "c"]) + }) + + it("puts a span with no conversation id in the turn that was open when it started", () => { + const turns = buildSessionTurns([ + llmSpan({ spanId: "a", startMs: 0, durationMs: SECOND, genAi: { conversationId: "t1" } }), + // No conversation id of its own — a tool the framework did not tag. + toolSpan({ spanId: "untagged", startMs: 35 * SECOND, durationMs: SECOND }), + llmSpan({ + spanId: "b", + startMs: 30 * SECOND, + durationMs: SECOND, + genAi: { conversationId: "t2" }, + }), + ]) + + expect(turns[1]!.spans.map((span) => span.spanId)).toEqual(["b", "untagged"]) + }) + + it("keeps spans that start before the first anchor in turn 1", () => { + const turns = buildSessionTurns([ + // The gateway span opens the trace before the agent is invoked. + makeSpan({ spanId: "http", startMs: 0, durationMs: 5 * SECOND, isAiSpan: false }), + agentSpan({ spanId: "agent", startMs: 2 * SECOND, durationMs: 20 * SECOND }), + ]) + + expect(turns).toHaveLength(1) + expect(turns[0]!.spans.map((span) => span.spanId)).toEqual(["http", "agent"]) + }) + + it("ignores a conversation id that only names the session", () => { + // Six vendors derive the session id FROM the conversation id, so every span + // carries the same value and it partitions nothing. + const turns = buildSessionTurns([ + agentSpan({ + spanId: "agent-1", + startMs: 0, + durationMs: 10 * SECOND, + sessionId: "sess-1", + genAi: { operationName: "invoke_agent", conversationId: "sess-1" }, + }), + agentSpan({ + spanId: "agent-2", + startMs: 60 * SECOND, + durationMs: 10 * SECOND, + sessionId: "sess-1", + genAi: { operationName: "invoke_agent", conversationId: "sess-1" }, + }), + ]) + + expect(turns.map((turn) => turn.anchorKind)).toEqual(["agent-root", "agent-root"]) + }) + + it("does not partition on a conversation id the whole session shares", () => { + const turns = buildSessionTurns([ + agentSpan({ + spanId: "agent-1", + startMs: 0, + durationMs: 10 * SECOND, + genAi: { operationName: "invoke_agent", conversationId: "conv-1" }, + }), + agentSpan({ + spanId: "agent-2", + startMs: 60 * SECOND, + durationMs: 10 * SECOND, + genAi: { operationName: "invoke_agent", conversationId: "conv-1" }, + }), + ]) + + expect(turns.map((turn) => turn.anchorKind)).toEqual(["agent-root", "agent-root"]) + }) + + it("opens a turn at agent work under the app's own spans", () => { + // What production actually looks like: the query returns the app's spans, + // so the trace root is an HTTP handler and no agent span is ever parentless. + const turns = buildSessionTurns([ + makeSpan({ spanId: "route-1", startMs: 0, durationMs: 30 * SECOND, isAiSpan: false }), + agentSpan({ + spanId: "agent-1", + parentSpanId: "route-1", + startMs: SECOND, + durationMs: 20 * SECOND, + }), + makeSpan({ spanId: "route-2", startMs: 60 * SECOND, durationMs: 30 * SECOND, isAiSpan: false }), + agentSpan({ + spanId: "agent-2", + parentSpanId: "route-2", + startMs: 61 * SECOND, + durationMs: 20 * SECOND, + }), + ]) + + expect(turns.map((turn) => turn.anchorKind)).toEqual(["agent-root", "agent-root"]) + // Assignment is by time, so the second route span — which opened before the + // agent it invokes — closes turn 1 rather than opening turn 2. + expect(turns[0]!.spans.map((span) => span.spanId)).toEqual(["route-1", "agent-1", "route-2"]) + expect(turns[1]!.spans.map((span) => span.spanId)).toEqual(["agent-2"]) + }) + + it("never emits a turn with no spans in it", () => { + // Two anchors in the same millisecond: the earlier one's bucket is empty, + // and a turn measured over no spans starts at Infinity. + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent-1", traceId: "trace-1", startMs: 0, durationMs: 10 * SECOND }), + agentSpan({ spanId: "agent-2", traceId: "trace-2", startMs: 0, durationMs: 10 * SECOND }), + ]) + + expect(turns.every((turn) => turn.spans.length > 0)).toBe(true) + expect(turns.map((turn) => turn.index)).toEqual([1]) + expect(Number.isFinite(turns[0]!.startMs)).toBe(true) + }) + + it("falls back to root agent invocations when no conversation id exists", () => { + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ spanId: "llm-1", parentSpanId: "agent-1", startMs: SECOND, durationMs: 2 * SECOND }), + agentSpan({ spanId: "agent-2", startMs: 60 * SECOND, durationMs: 10 * SECOND }), + llmSpan({ spanId: "llm-2", parentSpanId: "agent-2", startMs: 61 * SECOND, durationMs: SECOND }), + ]) + + expect(turns.map((turn) => turn.anchorKind)).toEqual(["agent-root", "agent-root"]) + expect(turns[0]!.spans.map((span) => span.spanId)).toEqual(["agent-1", "llm-1"]) + expect(turns[1]!.spans.map((span) => span.spanId)).toEqual(["agent-2", "llm-2"]) + }) + + it("does not treat a nested agent span as a turn boundary", () => { + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent-1", startMs: 0, durationMs: 30 * SECOND }), + // A delegated subagent: its parent is inside the session, so it is work + // within the turn, not a new one. + agentSpan({ + spanId: "subagent", + parentSpanId: "agent-1", + startMs: 5 * SECOND, + durationMs: 10 * SECOND, + agentName: "test-runner", + }), + ]) + + expect(turns).toHaveLength(1) + expect(turns[0]!.spans).toHaveLength(2) + }) + + it("falls back to one turn per trace when nothing marks a boundary", () => { + const turns = buildSessionTurns([ + llmSpan({ spanId: "a", traceId: "trace-1", startMs: 0, durationMs: SECOND }), + llmSpan({ spanId: "b", traceId: "trace-2", startMs: 60 * SECOND, durationMs: SECOND }), + ]) + + expect(turns.map((turn) => turn.anchorKind)).toEqual(["trace", "trace"]) + expect(turns.map((turn) => turn.traceIds)).toEqual([["trace-1"], ["trace-2"]]) + }) + + it("lets one turn span several traces", () => { + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent", traceId: "trace-1", startMs: 0, durationMs: 30 * SECOND }), + // The tool worker is a separate service and starts its own trace. + toolSpan({ spanId: "tool", traceId: "trace-2", startMs: 5 * SECOND, durationMs: SECOND }), + ]) + + expect(turns).toHaveLength(1) + expect(turns[0]!.traceIds).toEqual(["trace-1", "trace-2"]) + }) + + it("measures a turn from its first span's start to its last span's end", () => { + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + // Outlives its parent — a background tool the agent did not await. + toolSpan({ spanId: "tool", parentSpanId: "agent", startMs: 5 * SECOND, durationMs: 20 * SECOND }), + ]) + + expect(turns[0]!.durationMs).toBe(25 * SECOND) + }) + + it("takes its label from the first captured user message", () => { + const turns = buildSessionTurns([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + llmSpan({ + spanId: "llm", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + genAi: { inputMessages: userMessages("fix the webhook retry backoff") }, + }), + ]) + + expect(turns[0]!.label).toBe("fix the webhook retry backoff") + expect(turns[0]!.agentName).toBe("billing-agent") + }) + + it("has no label when message content was not captured", () => { + const turns = buildSessionTurns([agentSpan({ spanId: "agent", startMs: 0, durationMs: SECOND })]) + + expect(turns[0]!.label).toBeUndefined() + }) + + it("fails a turn whose root span errored, but not one that only errored inside", () => { + const [failedTurn] = buildSessionTurns([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND, statusCode: "Error" }), + ]) + const [retriedTurn] = buildSessionTurns([ + agentSpan({ spanId: "agent", startMs: 0, durationMs: 10 * SECOND }), + // A rate-limited attempt that the agent retried successfully. + llmSpan({ + spanId: "llm", + parentSpanId: "agent", + startMs: SECOND, + durationMs: SECOND, + statusCode: "Error", + }), + ]) + + expect(failedTurn!.failed).toBe(true) + expect(retriedTurn!.failed).toBe(false) + }) + + it("returns nothing for a session with no spans", () => { + expect(buildSessionTurns([])).toEqual([]) + }) +}) + +describe("classifySpan", () => { + it("reads gen_ai.operation.name when it is there", () => { + expect(classifySpan(llmSpan({ spanId: "a", startMs: 0, durationMs: 1 }))).toBe("inference") + expect(classifySpan(toolSpan({ spanId: "b", startMs: 0, durationMs: 1 }))).toBe("tool") + expect(classifySpan(agentSpan({ spanId: "c", startMs: 0, durationMs: 1 }))).toBe("agent") + }) + + it("accepts operation names outside the documented set", () => { + const vercelStep = makeSpan({ + spanId: "a", + startMs: 0, + durationMs: 1, + genAi: { operationName: "agent_step" }, + }) + + expect(classifySpan(vercelStep)).toBe("agent") + }) + + it("falls back to the span name when the operation is not recorded", () => { + const named = (spanName: string) => + classifySpan(makeSpan({ spanId: "a", startMs: 0, durationMs: 1, spanName })) + + expect(named("ai.toolCall")).toBe("tool") + expect(named("workflow.run")).toBe("agent") + expect(named("chat gpt-5")).toBe("inference") + }) + + it("classifies a span with no AI signal as other, whatever it is called", () => { + const httpSpan = makeSpan({ + spanId: "a", + startMs: 0, + durationMs: 1, + spanName: "POST /v1/agent/chat", + isAiSpan: false, + }) + + expect(classifySpan(httpSpan)).toBe("other") + }) +}) + +describe("isLlmCall", () => { + it("counts chat-shaped operations", () => { + expect(isLlmCall(llmSpan({ spanId: "a", startMs: 0, durationMs: 1 }))).toBe(true) + }) + + it("agrees with classifySpan on an operation name outside the documented set", () => { + const openSet = makeSpan({ + spanId: "a", + startMs: 0, + durationMs: 1, + genAi: { operationName: "generate_text", responseModel: "gpt-5" }, + }) + + expect(classifySpan(openSet)).toBe("inference") + expect(isLlmCall(openSet)).toBe(true) + }) + + it("does not count embeddings, which are inference but not a model turn", () => { + const embedding = makeSpan({ + spanId: "a", + startMs: 0, + durationMs: 1, + genAi: { operationName: "embeddings" }, + }) + + expect(classifySpan(embedding)).toBe("inference") + expect(isLlmCall(embedding)).toBe(false) + }) +}) + +describe("spanTtftMs", () => { + it("converts the seconds the convention records into milliseconds", () => { + const span = llmSpan({ spanId: "a", startMs: 0, durationMs: 8000, ttftSeconds: 1.4 }) + + expect(spanTtftMs(span)).toBe(1400) + }) + + it("ignores a value longer than the span it belongs to", () => { + const span = llmSpan({ spanId: "a", startMs: 0, durationMs: 800, ttftSeconds: 1.4 }) + + expect(spanTtftMs(span)).toBeUndefined() + }) + + it("is absent when the vendor did not report it", () => { + expect(spanTtftMs(llmSpan({ spanId: "a", startMs: 0, durationMs: 800 }))).toBeUndefined() + }) +}) + +describe("firstUserMessageText", () => { + it("reads the user's text out of an OTel messages array", () => { + expect(firstUserMessageText(userMessages("hello"))).toBe("hello") + }) + + it("accepts content as a bare string", () => { + expect(firstUserMessageText([{ role: "user", content: "just run the whole suite" }])).toBe( + "just run the whole suite", + ) + }) + + it("collapses whitespace and elides a very long message", () => { + expect(firstUserMessageText([{ role: "user", content: " two words " }])).toBe("two words") + + const long = firstUserMessageText([{ role: "user", content: "x".repeat(500) }]) + expect(long).toHaveLength(80) + expect(long?.endsWith("…")).toBe(true) + }) + + it("drops the pseudo-XML context frameworks inject, keeping the prose", () => { + const withContext = [ + { + role: "user", + content: + "2026-08-19T10:33:25Z\n" + + "\nchannel: #eng\nuser: U123\n\n" + + "fix the webhook retry backoff", + }, + ] + + expect(firstUserMessageText(withContext)).toBe("fix the webhook retry backoff") + }) + + it("has no label when the message is only injected context", () => { + expect( + firstUserMessageText([ + { role: "user", content: "2026-08-19T10:33:25Z" }, + ]), + ).toBeUndefined() + // The block left open — its contents are metadata either way. + expect( + firstUserMessageText([{ role: "user", content: "\nchannel: #eng" }]), + ).toBeUndefined() + }) + + it("gives up rather than guessing on a shape it does not recognize", () => { + expect(firstUserMessageText(undefined)).toBeUndefined() + expect(firstUserMessageText("a plain string")).toBeUndefined() + expect(firstUserMessageText([{ role: "assistant", content: "hi" }])).toBeUndefined() + expect(firstUserMessageText([{ role: "user", content: [{ type: "image" }] }])).toBeUndefined() + }) +}) diff --git a/apps/web/src/lib/agent-sessions/session-turns.ts b/apps/web/src/lib/agent-sessions/session-turns.ts new file mode 100644 index 000000000..9edd711e8 --- /dev/null +++ b/apps/web/src/lib/agent-sessions/session-turns.ts @@ -0,0 +1,323 @@ +// Session structure: what a span is, and where one turn ends and the next begins. +// +// A session arrives as a flat list of spans drawn from several traces and +// services, and the detail page's whole value is recovering the shape of the +// conversation that produced them. No framework records "this is turn 4", so +// every rule below is a heuristic over the OTel gen_ai attributes — which is +// exactly why they live here, named and commented, instead of inside the +// components that render them. + +import type { AiSessionSpan } from "@maple/domain/http" +import { toEpochMs } from "@maple/ui/lib/time-format" + +/** + * How a span reads on the page. Deliberately coarse — these four are the + * distinctions the waterfall colors, the occupancy bar splits on, and the work + * counters tally, and nothing else needs a finer taxonomy. + */ +export type SpanCategory = "agent" | "inference" | "tool" | "other" + +// `gen_ai.operation.name` is an open set (see AI_KNOWN_OPERATION_NAMES in +// @maple/query-engine-integrations), so these group the documented values and +// an unknown one falls through to the span-name rules below rather than being +// rejected. +const INFERENCE_OPS = new Set(["chat", "generate_content", "text_completion", "fetch_response"]) +/** Inference-shaped work that is not a chat completion: counted as inference + * occupancy, never as an "llm call" — an embedding is not a model turn. */ +const RETRIEVAL_OPS = new Set(["embeddings", "retrieval"]) +const TOOL_OPS = new Set(["execute_tool"]) +// `agent_step` is Vercel AI SDK's, not the convention's, and already appears in +// production data. +const AGENT_OPS = new Set(["invoke_agent", "create_agent", "invoke_workflow", "plan", "agent_step"]) + +export function spanStartMs(span: AiSessionSpan): number { + return toEpochMs(span.timestamp) +} + +export function spanEndMs(span: AiSessionSpan): number { + return spanStartMs(span) + span.durationMs +} + +/** The model the span actually ran on, falling back to the one it asked for. */ +export function spanModel(span: AiSessionSpan): string | undefined { + return span.genAi.responseModel ?? span.genAi.requestModel +} + +export function classifySpan(span: AiSessionSpan): SpanCategory { + const operation = span.genAi.operationName + if (operation !== undefined) { + if (INFERENCE_OPS.has(operation) || RETRIEVAL_OPS.has(operation)) return "inference" + if (TOOL_OPS.has(operation)) return "tool" + if (AGENT_OPS.has(operation)) return "agent" + } + // Spans with no AI signal at all are the app's own HTTP/DB work, sharing the + // agent's traces. They are rendered, muted, and never colored as agent work. + if (!span.isAiSpan) return "other" + + // `gen_ai.operation.name` is optional and plenty of instrumentations skip it. + // The span name is the next best evidence: by convention it leads with the + // operation ("execute_tool read_file", "chat gpt-5"). + const name = span.spanName.toLowerCase() + if (span.genAi.toolName !== undefined || name.includes("tool")) return "tool" + if (name.includes("agent") || name.includes("workflow")) return "agent" + if (spanModel(span) !== undefined || name.includes("chat") || name.includes("completion")) { + return "inference" + } + // An AI span we can't place is still the agent's own work, not the app's. + return "agent" +} + +/** + * A model turn — one request/response with a model, and what the header counts + * as an "llm call". Embeddings and retrieval are excluded: they are inference + * time, but counting them here would make the calls-per-turn ratio (the agent's + * loop depth) meaningless. + * + * Everything else `classifySpan` reads as inference counts, including the + * open-set operation names vendors invent (`generate_text`): matching only the + * documented four would color a span as inference and then leave it out of the + * call count, the model rows and the token column. + */ +export function isLlmCall(span: AiSessionSpan): boolean { + const operation = span.genAi.operationName + if (operation !== undefined && RETRIEVAL_OPS.has(operation)) return false + return classifySpan(span) === "inference" +} + +/** `gen_ai.response.time_to_first_chunk` is in SECONDS (see ai-vendors.ts). */ +export function spanTtftMs(span: AiSessionSpan): number | undefined { + const seconds = span.genAi.responseTimeToFirstChunk + if (seconds === undefined || seconds <= 0) return undefined + const ms = seconds * 1000 + // A TTFT longer than the span it belongs to is a unit mix-up somewhere + // upstream; drawing it would push the streaming segment negative. + return ms > span.durationMs ? undefined : ms +} + +/** Which rule produced a turn boundary — the header reads this to tell a + * session that ended from one that was simply abandoned mid-flight. */ +export type TurnAnchorKind = "conversation" | "agent-root" | "trace" + +export interface SessionTurn { + readonly id: string + /** 1-based, in start order — the `TURN n` the page prints. */ + readonly index: number + readonly anchorKind: TurnAnchorKind + /** The span that opened the turn. */ + readonly anchor: AiSessionSpan + /** The turn's opening user message, when the vendor captured message content. */ + readonly label: string | undefined + readonly agentName: string | undefined + readonly startMs: number + readonly endMs: number + readonly durationMs: number + /** Every span of the turn, in start order. */ + readonly spans: readonly AiSessionSpan[] + /** A span that roots the turn ended `Error` — the turn did not close cleanly. */ + readonly failed: boolean + /** Traces the turn's spans came from, first-seen first. A turn may cross traces. */ + readonly traceIds: readonly string[] +} + +interface TurnAnchor { + readonly span: AiSessionSpan + readonly kind: TurnAnchorKind + readonly id: string +} + +/** + * Split a session's spans into turns. + * + * Three rules, tried in order, because there is no single attribute that marks + * "the user said something new": + * + * 1. `gen_ai.conversation.id` — the only turn key the convention has, and the + * one eve stamps with its turn id. One id, one turn, but only where the ids + * actually partition the session (see `findAnchors`). + * 2. Agent invocations with no agent above them — a session with no usable + * conversation id still opens each turn by invoking the agent. + * 3. One turn per trace — the floor. Wrong for a trace holding several turns, + * but it never merges two traces into one turn, and it always renders. + * + * Assignment is by time, not by parentage: a turn owns every span that started + * before the next turn did, whatever trace or service it came from. Spans that + * start before the first anchor (a gateway span that opens the trace, say) join + * turn 1 rather than becoming a turn of their own. + */ +export function buildSessionTurns(spans: readonly AiSessionSpan[]): readonly SessionTurn[] { + if (spans.length === 0) return [] + + const ordered = [...spans].sort((a, b) => spanStartMs(a) - spanStartMs(b)) + const anchors = findAnchors(ordered) + const buckets: AiSessionSpan[][] = anchors.map(() => []) + + // Both lists are in start order, so one forward cursor assigns every span. + let cursor = 0 + for (const span of ordered) { + const start = spanStartMs(span) + while (cursor + 1 < anchors.length && spanStartMs(anchors[cursor + 1]!.span) <= start) cursor++ + buckets[cursor]!.push(span) + } + + // Two anchors starting in the same millisecond leave the earlier one's bucket + // empty, and a turn with no spans has no start, no end and nothing to draw. + return anchors + .map((anchor, index) => ({ anchor, turnSpans: buckets[index]! })) + .filter((entry) => entry.turnSpans.length > 0) + .map(({ anchor, turnSpans }, index) => { + const spanIds = new Set(turnSpans.map((span) => span.spanId)) + const startMs = Math.min(...turnSpans.map(spanStartMs)) + const endMs = Math.max(...turnSpans.map(spanEndMs)) + const traceIds: string[] = [] + for (const span of turnSpans) if (!traceIds.includes(span.traceId)) traceIds.push(span.traceId) + + return { + id: anchor.id, + index: index + 1, + anchorKind: anchor.kind, + anchor: anchor.span, + label: turnLabel(turnSpans), + agentName: + anchor.span.genAi.agentName ?? turnSpans.find((s) => s.genAi.agentName)?.genAi.agentName, + startMs, + endMs, + durationMs: endMs - startMs, + spans: turnSpans, + // Only spans that root the turn count: a retried inference that errored + // and then succeeded is a retry, not a failed turn. + failed: turnSpans.some( + (span) => + span.statusCode === "Error" && + (span.parentSpanId === "" || !spanIds.has(span.parentSpanId)), + ), + traceIds, + } + }) +} + +function findAnchors(ordered: readonly AiSessionSpan[]): readonly TurnAnchor[] { + const byConversation = new Map() + for (const span of ordered) { + const conversationId = span.genAi.conversationId + // Six vendors (flue, google_adk, mastra, microsoft_agent_framework, + // openai_agents_sdk, pydantic_ai) derive `maple_ai.session.id` FROM + // `gen_ai.conversation.id`, so for them the id names the session and + // repeats on every span — a partition of one, not a turn key. + if (conversationId === undefined || conversationId === span.sessionId) continue + if (!byConversation.has(conversationId)) byConversation.set(conversationId, span) + } + if (byConversation.size > 1) { + return [...byConversation].map(([conversationId, span]) => ({ + span, + kind: "conversation" as const, + id: `conversation:${conversationId}`, + })) + } + + const byId = new Map(ordered.map((span) => [span.spanId, span])) + // Not "no parent in the session": the query returns the app's own spans too, + // so the trace root is an HTTP or workflow span and every agent span has an + // in-session parent. A turn opens where agent work starts, which is an agent + // span with no agent work above it anywhere in the chain. + const underAgent = (span: AiSessionSpan): boolean => { + const seen = new Set([span.spanId]) + let parent = byId.get(span.parentSpanId) + while (parent !== undefined && !seen.has(parent.spanId)) { + if (parent.isAiSpan || classifySpan(parent) === "agent") return true + seen.add(parent.spanId) + parent = byId.get(parent.parentSpanId) + } + return false + } + const agentRoots = ordered.filter( + (span) => span.isAiSpan && classifySpan(span) === "agent" && !underAgent(span), + ) + if (agentRoots.length > 0) { + return agentRoots.map((span) => ({ span, kind: "agent-root" as const, id: `span:${span.spanId}` })) + } + + const byTrace = new Map() + for (const span of ordered) if (!byTrace.has(span.traceId)) byTrace.set(span.traceId, span) + return [...byTrace].map(([traceId, span]) => ({ span, kind: "trace" as const, id: `trace:${traceId}` })) +} + +function turnLabel(turnSpans: readonly AiSessionSpan[]): string | undefined { + for (const span of turnSpans) { + const text = firstUserMessageText(span.genAi.inputMessages) + if (text !== undefined) return text + } + return undefined +} + +/** Longest turn label the page will render before eliding — a captured prompt + * can be tens of kilobytes, and the title is one line. */ +const MAX_LABEL_LENGTH = 80 + +// Frameworks prepend pseudo-XML context blocks to the user's message +// (``, `…`). They are +// identical on every turn, so a label taken from one names nothing — it would +// print the same title above the session and the same row on all eight turns. +const TAG_BLOCK = /<([a-z_][\w.-]*)\b[^>]*>[\s\S]*?<\/\1>/gi +const ORPHAN_TAG = /<\/?[a-z_][\w.-]*\b[^>]*>/gi +// What survives the blocks is often injected metadata rather than prose: a +// single `key: value` with no sentence around it. +const KEY_VALUE_LINE = /^[\w .-]{1,32}:\s*\S*$/ + +/** + * The first user text inside a `gen_ai.input.messages` value. + * + * The value is captured JSON whose exact shape belongs to the vendor, so this + * walks it and gives up rather than guessing: the documented shape is + * `[{ role, parts: [{ type: "text", content }] }]`, and vendors also emit the + * content as a bare string. Message capture is opt-in and off by default, so + * returning `undefined` is the ordinary case, not a failure. + */ +export function firstUserMessageText(value: unknown): string | undefined { + if (!Array.isArray(value)) return undefined + for (const entry of value) { + if (!isRecord(entry) || entry.role !== "user") continue + const text = messageText(entry.parts) ?? messageText(entry.content) + if (text !== undefined) return text + } + return undefined +} + +function messageText(value: unknown): string | undefined { + if (typeof value === "string") return proseLine(value) + if (!Array.isArray(value)) return undefined + for (const part of value) { + if (typeof part === "string") { + const text = proseLine(part) + if (text !== undefined) return text + } + if (!isRecord(part)) continue + const content = typeof part.content === "string" ? part.content : part.text + if (typeof content === "string") { + const text = proseLine(content) + if (text !== undefined) return text + } + } + return undefined +} + +/** + * The first line of a captured message that reads as something a person wrote. + * + * Injected context is dropped rather than truncated, and a message that is only + * injected context has no label at all — the callers' fallbacks (the agent name + * and start time for the title, "no captured message" for a turn row) say more + * than `2026-08-…` would. + */ +function proseLine(value: string): string | undefined { + const stripped = value.replace(TAG_BLOCK, "\n").replace(ORPHAN_TAG, "\n") + for (const rawLine of stripped.split("\n")) { + const line = rawLine.trim().replace(/\s+/g, " ") + if (line.length === 0 || !/\p{L}/u.test(line) || KEY_VALUE_LINE.test(line)) continue + return line.length > MAX_LABEL_LENGTH ? `${line.slice(0, MAX_LABEL_LENGTH - 1)}…` : line + } + return undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/apps/web/src/lib/agent-sessions/span-fixtures.ts b/apps/web/src/lib/agent-sessions/span-fixtures.ts new file mode 100644 index 000000000..8787edc9d --- /dev/null +++ b/apps/web/src/lib/agent-sessions/span-fixtures.ts @@ -0,0 +1,127 @@ +// Span builders for the colocated tests in this directory. +// +// The real shape has fifteen required fields and a sixty-key `genAi` bag, so a +// test that spelled one out per span would be unreadable and would say nothing +// about the rule under test. Everything here defaults to "an ordinary AI span"; +// each test overrides only the attribute its rule reads. + +import type { AiSessionGenAiValues, AiSessionSpan } from "@maple/domain/http" +import { formatWarehouseDateTimeMs } from "@maple/query-engine" + +/** Session start, fixed so offsets in the tests read as seconds into the session. */ +export const T0 = Date.UTC(2026, 7, 19, 10, 0, 0) + +export const at = (offsetMs: number): string => formatWarehouseDateTimeMs(T0 + offsetMs) + +export interface SpanInput { + readonly spanId: string + readonly parentSpanId?: string + readonly traceId?: string + /** Milliseconds after `T0`. */ + readonly startMs: number + readonly durationMs: number + readonly spanName?: string + readonly serviceName?: string + readonly statusCode?: string + readonly statusMessage?: string + readonly isAiSpan?: boolean + readonly vendorId?: string + readonly sessionId?: string + readonly genAi?: AiSessionGenAiValues +} + +export function makeSpan(input: SpanInput): AiSessionSpan { + const span: AiSessionSpan = { + traceId: input.traceId ?? "trace-1", + spanId: input.spanId, + parentSpanId: input.parentSpanId ?? "", + spanName: input.spanName ?? "gen_ai.chat", + spanKind: "SPAN_KIND_CLIENT", + serviceName: input.serviceName ?? "agent-runner", + timestamp: at(input.startMs), + durationMs: input.durationMs, + statusCode: input.statusCode ?? "Unset", + statusMessage: input.statusMessage ?? "", + integrationId: "gen_ai", + isAiSpan: input.isAiSpan ?? true, + genAi: input.genAi ?? {}, + } + // `vendorId` and `sessionId` are optional keys on the wire shape: present or + // absent, never present-and-undefined. + const withVendor = input.vendorId === undefined ? span : { ...span, vendorId: input.vendorId } + return input.sessionId === undefined ? withVendor : { ...withVendor, sessionId: input.sessionId } +} + +/** A model call. Tokens are the five `gen_ai.usage.*` buckets, in order. */ +export function llmSpan({ + model, + tokens, + ttftSeconds, + ...input +}: SpanInput & { + readonly model?: string + readonly tokens?: readonly [number, number, number, number, number] + readonly ttftSeconds?: number +}): AiSessionSpan { + const base: AiSessionGenAiValues = { operationName: "chat" } + const withModel = model === undefined ? base : { ...base, responseModel: model } + const withTtft = + ttftSeconds === undefined ? withModel : { ...withModel, responseTimeToFirstChunk: ttftSeconds } + const withUsage = + tokens === undefined + ? withTtft + : { + ...withTtft, + usageInputTokens: tokens[0], + usageCacheReadInputTokens: tokens[1], + usageCacheCreationInputTokens: tokens[2], + usageOutputTokens: tokens[3], + usageReasoningOutputTokens: tokens[4], + } + return makeSpan({ + ...input, + spanName: input.spanName ?? "chat", + genAi: { ...withUsage, ...input.genAi }, + }) +} + +export function toolSpan({ toolName, ...input }: SpanInput & { readonly toolName?: string }): AiSessionSpan { + return makeSpan({ + ...input, + spanName: input.spanName ?? "execute_tool", + genAi: { operationName: "execute_tool", toolName: toolName ?? "read_file", ...input.genAi }, + }) +} + +export function agentSpan({ + agentName, + ...input +}: SpanInput & { readonly agentName?: string }): AiSessionSpan { + return makeSpan({ + ...input, + spanName: input.spanName ?? "invoke_agent", + genAi: { + operationName: "invoke_agent", + agentName: agentName ?? "billing-agent", + ...input.genAi, + }, + }) +} + +interface OtelTextPart { + readonly type: "text" + readonly content: string +} + +interface OtelMessage { + readonly role: string + readonly parts: readonly OtelTextPart[] +} + +/** An OTel `gen_ai.input.messages` value carrying one user message. */ +export function userMessages(text: string): readonly OtelMessage[] { + return [ + { role: "system", parts: [{ type: "text", content: "you are a helpful agent" }] }, + { role: "user", parts: [{ type: "text", content: text }] }, + ] +} diff --git a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts index d7cfaaf03..db1391934 100644 --- a/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts +++ b/apps/web/src/lib/services/atoms/warehouse-query-atoms.ts @@ -104,7 +104,7 @@ import { getSessionTraceSummaries, listReplays, } from "@/api/warehouse/replays" -import { getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" +import { getAiSessionSpans, getAiSessionsFacets, listAiSessions } from "@/api/warehouse/ai-sessions" import { getWebAnalyticsBreakdowns, getWebAnalyticsPages, @@ -249,6 +249,10 @@ export const aiSessionsFacetsResultAtom = makeQueryAtomFamily(getAiSessionsFacet staleTime: 30_000, }) +export const aiSessionSpansResultAtom = makeQueryAtomFamily(getAiSessionSpans, { + staleTime: 30_000, +}) + export const replaysFacetsResultAtom = makeQueryAtomFamily(getReplaysFacets, { staleTime: 30_000, }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 260525e8c..eb7f0f42d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as SignInRouteImport } from './routes/sign-in' import { Route as SignUpRouteImport } from './routes/sign-up' import { Route as AgentSessionsIndexRouteImport } from './routes/agent-sessions/index' +import { Route as AgentSessionsSessionIdRouteImport } from './routes/agent-sessions/$sessionId' import { Route as AlertsIndexRouteImport } from './routes/alerts/index' import { Route as AlertsRuleIdRouteImport } from './routes/alerts/$ruleId' import { Route as AlertsCreateRouteImport } from './routes/alerts/create' @@ -171,6 +172,11 @@ const AgentSessionsIndexRoute = AgentSessionsIndexRouteImport.update({ path: '/agent-sessions/', getParentRoute: () => rootRouteImport, } as any) +const AgentSessionsSessionIdRoute = AgentSessionsSessionIdRouteImport.update({ + id: '/agent-sessions/$sessionId', + path: '/agent-sessions/$sessionId', + getParentRoute: () => rootRouteImport, +} as any) const AlertsIndexRoute = AlertsIndexRouteImport.update({ id: '/alerts/', path: '/alerts/', @@ -464,6 +470,7 @@ export interface FileRoutesByFullPath { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute '/anomalies/$incidentId': typeof AnomaliesIncidentIdRoute @@ -536,6 +543,7 @@ export interface FileRoutesByTo { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute '/anomalies/$incidentId': typeof AnomaliesIncidentIdRoute @@ -610,6 +618,7 @@ export interface FileRoutesById { '/settings': typeof SettingsRoute '/sign-in': typeof SignInRoute '/sign-up': typeof SignUpRoute + '/agent-sessions/$sessionId': typeof AgentSessionsSessionIdRoute '/alerts/$ruleId': typeof AlertsRuleIdRoute '/alerts/create': typeof AlertsCreateRoute '/anomalies/$incidentId': typeof AnomaliesIncidentIdRoute @@ -685,6 +694,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' | '/anomalies/$incidentId' @@ -757,6 +767,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' | '/anomalies/$incidentId' @@ -830,6 +841,7 @@ export interface FileRouteTypes { | '/settings' | '/sign-in' | '/sign-up' + | '/agent-sessions/$sessionId' | '/alerts/$ruleId' | '/alerts/create' | '/anomalies/$incidentId' @@ -904,6 +916,7 @@ export interface RootRouteChildren { SettingsRoute: typeof SettingsRoute SignInRoute: typeof SignInRoute SignUpRoute: typeof SignUpRoute + AgentSessionsSessionIdRoute: typeof AgentSessionsSessionIdRoute AlertsRuleIdRoute: typeof AlertsRuleIdRoute AlertsCreateRoute: typeof AlertsCreateRoute AnomaliesIncidentIdRoute: typeof AnomaliesIncidentIdRoute @@ -1075,6 +1088,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AgentSessionsIndexRouteImport parentRoute: typeof rootRouteImport } + '/agent-sessions/$sessionId': { + id: '/agent-sessions/$sessionId' + path: '/agent-sessions/$sessionId' + fullPath: '/agent-sessions/$sessionId' + preLoaderRoute: typeof AgentSessionsSessionIdRouteImport + parentRoute: typeof rootRouteImport + } '/alerts/': { id: '/alerts/' path: '/alerts' @@ -1503,6 +1523,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsRoute: SettingsRoute, SignInRoute: SignInRoute, SignUpRoute: SignUpRoute, + AgentSessionsSessionIdRoute: AgentSessionsSessionIdRoute, AlertsRuleIdRoute: AlertsRuleIdRoute, AlertsCreateRoute: AlertsCreateRoute, AnomaliesIncidentIdRoute: AnomaliesIncidentIdRoute, diff --git a/apps/web/src/routes/agent-sessions/$sessionId.tsx b/apps/web/src/routes/agent-sessions/$sessionId.tsx new file mode 100644 index 000000000..2cbf0ee86 --- /dev/null +++ b/apps/web/src/routes/agent-sessions/$sessionId.tsx @@ -0,0 +1,249 @@ +import { useMemo, type ReactNode } from "react" +import { createFileRoute, useRouterState } from "@tanstack/react-router" +import { Schema } from "effect" + +import type { AiSessionSpan } from "@maple/domain/http" +import { formatWarehouseDateTime } from "@maple/query-engine" +import { Skeleton } from "@maple/ui/components/ui/skeleton" +import { toEpochMs } from "@maple/ui/lib/time-format" + +import { ChatBubbleSparkleIcon } from "@/components/icons" +import { DashboardLayout } from "@/components/layout/dashboard-layout" +import { NotFoundError } from "@/components/route-error" +import { QueryErrorState } from "@/components/common/query-error-state" +import { vendorLabel } from "@/components/agent-sessions/agent-sessions-list" +import { SessionHeader } from "@/components/agent-sessions/session-detail/session-header" +import { SessionViews } from "@/components/agent-sessions/session-detail/session-views" +import { useOrganizationFeatureFlags } from "@/hooks/use-organization-feature-flags" +import { useTimezonePreference } from "@/hooks/use-timezone-preference" +import { buildSessionSummary } from "@/lib/agent-sessions/session-summary" +import { buildSessionTurns } from "@/lib/agent-sessions/session-turns" +import { Result, useAtomValue } from "@/lib/effect-atom" +import { displayError } from "@/lib/error-messages" +import { formatTimestampInTimezone } from "@/lib/timezone-format" +import { aiSessionSpansResultAtom } from "@/lib/services/atoms/warehouse-query-atoms" + +const agentSessionSearchSchema = Schema.Struct({ + // Warehouse timestamps carried in from the list row: the session's first and + // last span. They bound the warehouse read, which is what makes it cheap — + // without them the query scans every retained partition. + t: Schema.optional(Schema.String), + end: Schema.optional(Schema.String), +}) + +// Padding around the session's own window, so a session that straddles the list +// page's range edge still arrives whole. It is asymmetric because the hint is: +// the list clamps a row's start to the list's own window, so a session that began +// before the visible range reports its start as the range edge and reading from +// there alone silently drops its opening turns. The end needs no such allowance — +// a session running past the range edge is still running now. +const WINDOW_START_PADDING_MS = 24 * 60 * 60 * 1000 +const WINDOW_END_PADDING_MS = 60 * 60 * 1000 + +/** Deep link with no hints: look back far enough to find most sessions, and + * accept the slower read that comes with it. */ +const FALLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000 + +const SESSION_TOO_LARGE_TAG = "@maple/http/ai-sessions/AiSessionTooLargeError" + +export const Route = createFileRoute("/agent-sessions/$sessionId")({ + component: AgentSessionDetailPage, + validateSearch: Schema.toStandardSchemaV1(agentSessionSearchSchema), +}) + +/** + * Behind the `agent_tracing` org rollout flag, gated the same way the list page + * is: in the component rather than `beforeLoad` (router context carries no + * flags), `isLoaded` first so an entitled org gets no not-found flash, and no + * route `loader` — a loader would fire the warehouse read for orgs that are not + * entitled to see the page at all. + */ +function AgentSessionDetailPage() { + const { flags, isLoaded } = useOrganizationFeatureFlags() + if (!isLoaded) return null + if (!flags.agentTracing) return + return +} + +function AgentSessionDetailContent() { + const { sessionId } = Route.useParams() + const search = Route.useSearch() + const queryWindow = useMemo(() => resolveWindow(search.t, search.end), [search.t, search.end]) + const result = useAtomValue(aiSessionSpansResultAtom({ data: { sessionId, ...queryWindow } })) + + return Result.builder(result) + .onInitial(() => ( + + + + + +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+ +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+
+
+ )) + .onError((error) => ( + + + + + + )) + .onSuccess((value) => ( + + {value.data.length === 0 ? ( + + + + ) : ( + + )} + + )) + .render() +} + +function SessionDetailBody({ + sessionId, + spans, + truncated, +}: { + sessionId: string + spans: readonly AiSessionSpan[] + truncated: boolean +}) { + const { effectiveTimezone } = useTimezonePreference() + const turns = useMemo(() => buildSessionTurns(spans), [spans]) + const summary = useMemo(() => buildSessionSummary(spans, turns, Date.now()), [spans, turns]) + + // Message content is opt-in and off by default, so most sessions have no + // opening user message to title the page with. Naming the agent and when it + // ran is the next most identifying thing about it. + const fallbackTitle = `${summary.agentNames[0] ?? primaryVendorLabel(summary.vendorIds)} · ${formatTimestampInTimezone(summary.startMs, { timeZone: effectiveTimezone })}` + + return ( + <> + + + {truncated && ( +

+ This session has more spans than one response carries — everything after the{" "} + {summary.spanCount.toLocaleString()} spans below is missing, so the totals and the + waterfall both stop early. +

+ )} +
+ + {/* A floor rather than `min-h-0`: the header above is a sticky sibling + that cannot shrink, so on a short window this pane is what gives way, + and at zero the tabs and the waterfall are gone rather than scrolled. + 16rem still lets the pane shrink far below its content — the waterfall + owns its own scrolling. */} +
+ +
+
+ + ) +} + +function SessionShell({ sessionId, children }: { sessionId: string; children: ReactNode }) { + const searchStr = useRouterState({ select: (state) => state.location.searchStr }) + + return ( + + + + {children} + + + ) +} + +function EmptySession({ sessionId }: { sessionId: string }) { + return ( +
+
+ +
+

No spans for this session

+

+ Nothing was found for {sessionId} in this time range. Open + it from the Agent Sessions list, or widen the range there first. +

+
+ ) +} + +/** Everything but this page's own params, so Back lands on the list the reader + * left — same time range, same filters. Mirrors `buildBackToTracesHref` in + * traces/$traceId, including reading the raw `searchStr`: the list owns its + * search schema, and re-encoding it through this route's would drop it. */ +function buildBackToSessionsHref(searchStr: string): string { + const params = new URLSearchParams(searchStr) + params.delete("t") + params.delete("end") + const nextSearch = params.toString() + return nextSearch ? `/agent-sessions?${nextSearch}` : "/agent-sessions" +} + +/** Session ids belong to the framework that wrote them, and the long ones carry + * their entropy at both ends — `slice(0, 8)` of a `wrun_01KZ…` id renders the + * word "wrun_01K", which identifies nothing. */ +const BREADCRUMB_ID_MAX_CHARS = 24 + +function breadcrumbSessionId(sessionId: string): string { + if (sessionId.length <= BREADCRUMB_ID_MAX_CHARS) return sessionId + return `${sessionId.slice(0, 9)}…${sessionId.slice(-4)}` +} + +function primaryVendorLabel(vendorIds: readonly string[]): string { + const vendorId = vendorIds[0] + return vendorId === undefined ? "Agent session" : vendorLabel(vendorId) +} + +function resolveWindow(t: string | undefined, end: string | undefined) { + const startHint = t === undefined ? Number.NaN : toEpochMs(t) + // A link that carries only `t` (copied from a trace, say) still narrows the + // read: the session started there, so pad around that instant alone. + const endHint = end === undefined ? startHint : toEpochMs(end) + + if (Number.isNaN(startHint) || Number.isNaN(endHint)) { + const now = Date.now() + return { + startTime: formatWarehouseDateTime(now - FALLBACK_WINDOW_MS), + endTime: formatWarehouseDateTime(now), + } + } + + return { + startTime: formatWarehouseDateTime(startHint - WINDOW_START_PADDING_MS), + endTime: formatWarehouseDateTime(endHint + WINDOW_END_PADDING_MS), + } +} diff --git a/packages/domain/src/http/ai-sessions.ts b/packages/domain/src/http/ai-sessions.ts index acbc0d598..44a4704ad 100644 --- a/packages/domain/src/http/ai-sessions.ts +++ b/packages/domain/src/http/ai-sessions.ts @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import { Schema } from "effect" import { TinybirdDateTime } from "../query-engine" import { SessionAuthorization } from "./current-tenant" +import { HttpTaggedError } from "./error-policy" import { warehouseReadHttpErrors } from "./warehouse" // AI agent session endpoint schemas @@ -71,6 +72,220 @@ export class ListAiSessionsFacetsResponse extends Schema.Class( + "GetAiSessionSpansRequest", +)({ + /** The framework's own session id, verbatim — `maple_ai.session.id`. */ + sessionId: Schema.String.check(Schema.isMinLength(1)), + // Required, unlike the list endpoints' optional window: `aiSessionSpansQuery` + // bounds both the session detection and the span fan-out with it, so a + // session straddling the window edge returns only the spans inside it. The + // caller states the window it wants, and there is no server-side default + // that would quietly cut a session in half. + startTime: TinybirdDateTime, + endTime: TinybirdDateTime, +}) {} + +/** + * Every `gen_ai.*` value the integration layer decoded off the span, one + * optional key per semantic-convention attribute. + * + * Mirrored here rather than imported from `@maple/query-engine-integrations`, + * where the catalog that generates it lives: that package depends on + * `@maple/query-engine`, which depends on this one, so importing it back would + * close a workspace cycle. The two shapes are held together by a compile-time + * assertion in the handler (`apps/api/src/routes/internal/ai-sessions.http.ts`) + * — add a field to the catalog without adding it here and the build fails, + * rather than the value disappearing on the wire. + */ +export const AiSessionGenAiValues = Schema.Struct({ + // operation + operationName: Schema.optionalKey(Schema.String), + providerName: Schema.optionalKey(Schema.String), + + // request + requestModel: Schema.optionalKey(Schema.String), + requestMaxTokens: Schema.optionalKey(Schema.Finite), + requestChoiceCount: Schema.optionalKey(Schema.Finite), + requestTemperature: Schema.optionalKey(Schema.Finite), + requestTopP: Schema.optionalKey(Schema.Finite), + requestTopK: Schema.optionalKey(Schema.Finite), + requestStopSequences: Schema.optionalKey(Schema.Array(Schema.String)), + requestFrequencyPenalty: Schema.optionalKey(Schema.Finite), + requestPresencePenalty: Schema.optionalKey(Schema.Finite), + requestEncodingFormats: Schema.optionalKey(Schema.Array(Schema.String)), + requestSeed: Schema.optionalKey(Schema.Finite), + requestStream: Schema.optionalKey(Schema.Boolean), + requestReasoningLevel: Schema.optionalKey(Schema.String), + requestPreviousResponseId: Schema.optionalKey(Schema.String), + requestStreamCursor: Schema.optionalKey(Schema.String), + + // response + responseId: Schema.optionalKey(Schema.String), + responseModel: Schema.optionalKey(Schema.String), + responseFinishReasons: Schema.optionalKey(Schema.Array(Schema.String)), + responseStatus: Schema.optionalKey(Schema.String), + responseTimeToFirstChunk: Schema.optionalKey(Schema.Finite), + outputType: Schema.optionalKey(Schema.String), + + // usage + usageInputTokens: Schema.optionalKey(Schema.Finite), + usageCacheReadInputTokens: Schema.optionalKey(Schema.Finite), + usageCacheCreationInputTokens: Schema.optionalKey(Schema.Finite), + usageOutputTokens: Schema.optionalKey(Schema.Finite), + usageReasoningOutputTokens: Schema.optionalKey(Schema.Finite), + + // conversation + conversationId: Schema.optionalKey(Schema.String), + conversationCompacted: Schema.optionalKey(Schema.Boolean), + + // agent + agentId: Schema.optionalKey(Schema.String), + agentName: Schema.optionalKey(Schema.String), + agentDescription: Schema.optionalKey(Schema.String), + agentVersion: Schema.optionalKey(Schema.String), + + // tool + toolName: Schema.optionalKey(Schema.String), + toolCallId: Schema.optionalKey(Schema.String), + toolDescription: Schema.optionalKey(Schema.String), + toolType: Schema.optionalKey(Schema.String), + toolCallArguments: Schema.optionalKey(Schema.Unknown), + toolCallResult: Schema.optionalKey(Schema.Unknown), + toolDefinitions: Schema.optionalKey(Schema.Unknown), + + // content + systemInstructions: Schema.optionalKey(Schema.Unknown), + inputMessages: Schema.optionalKey(Schema.Unknown), + outputMessages: Schema.optionalKey(Schema.Unknown), + + // data source / retrieval + dataSourceId: Schema.optionalKey(Schema.String), + retrievalQueryText: Schema.optionalKey(Schema.String), + retrievalTopK: Schema.optionalKey(Schema.Finite), + retrievalDocuments: Schema.optionalKey(Schema.Unknown), + + // memory + memoryStoreId: Schema.optionalKey(Schema.String), + memoryRecordId: Schema.optionalKey(Schema.String), + memoryRecordCount: Schema.optionalKey(Schema.Finite), + memoryQueryText: Schema.optionalKey(Schema.String), + memoryRecords: Schema.optionalKey(Schema.Unknown), + + // embeddings + embeddingsDimensionCount: Schema.optionalKey(Schema.Finite), + + // evaluation + evaluationName: Schema.optionalKey(Schema.String), + evaluationScoreValue: Schema.optionalKey(Schema.Finite), + evaluationScoreLabel: Schema.optionalKey(Schema.String), + evaluationExplanation: Schema.optionalKey(Schema.String), + + // prompt + promptName: Schema.optionalKey(Schema.String), + promptVersion: Schema.optionalKey(Schema.String), + + // workflow + workflowName: Schema.optionalKey(Schema.String), + + // core semconv attributes AI spans carry + errorType: Schema.optionalKey(Schema.String), + serverAddress: Schema.optionalKey(Schema.String), + serverPort: Schema.optionalKey(Schema.Finite), +}) +export type AiSessionGenAiValues = Schema.Schema.Type + +/** + * One span of a session, already normalized onto Maple's standard AI span + * shape. The raw attribute maps the query reads are the bulk of that read and + * are dropped server-side, so what lands here is the decoded view alone. + */ +export const AiSessionSpan = Schema.Struct({ + traceId: Schema.String, + spanId: Schema.String, + parentSpanId: Schema.String, + spanName: Schema.String, + spanKind: Schema.String, + serviceName: Schema.String, + /** Warehouse datetime literal, e.g. `2026-08-19 10:33:25.825000000`. */ + timestamp: Schema.String, + durationMs: Schema.Finite, + statusCode: Schema.String, + statusMessage: Schema.String, + /** Maple AI envelope, stamped by the ingest gateway. */ + sessionId: Schema.optionalKey(Schema.String), + vendorId: Schema.optionalKey(Schema.String), + vendorVersion: Schema.optionalKey(Schema.String), + /** Which integration decoded `genAi` — the default gen_ai one, or a vendor dialect. */ + integrationId: Schema.String, + /** + * False for the ordinary infrastructure spans that share an agent trace. + * They are returned rather than dropped: the session view shows the whole + * agent context, not only the spans carrying AI signal. + */ + isAiSpan: Schema.Boolean, + genAi: AiSessionGenAiValues, + promptVariables: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), +}) +export type AiSessionSpan = Schema.Schema.Type + +export class GetAiSessionSpansResponse extends Schema.Class( + "GetAiSessionSpansResponse", +)({ + data: Schema.Array(AiSessionSpan), + /** + * The session has more spans than one response carries. Truncation drops the + * END of the session, so a client must say so rather than present the result + * as a complete transcript. + */ + truncated: Schema.Boolean, +}) {} + +/** + * Response ceiling for one session's spans, measured over the warehouse rows — + * which still carry the raw attribute maps, in production ~17KB on a single + * agent span. + * + * The byte counter accumulates over rows that are already parsed, so the + * ceiling only trips once that much of the JS object graph is resident: it has + * to sit far below the 128MB isolate heap, not near it. Replay events get 8MB + * for opaque strings; 10MB here because these rows are attribute-map-heavy, + * and the 2,000-row cap bounds the ordinary session well before this does. + * + * For a pathologically attribute-heavy session the byte cap fires first and the + * request 413s instead of truncating. That is the designed outcome — the + * alternative is an OOM that takes the isolate with it. + */ +export const MAX_AI_SESSION_SPANS_RESPONSE_BYTES = 10_000_000 + +/** + * The session's spans exceed `MAX_AI_SESSION_SPANS_RESPONSE_BYTES`. + * + * Distinct from the row cap, which truncates and reports `truncated: true`: the + * byte ceiling aborts the read before a response can be materialized, so there + * is nothing to return. The endpoint takes no size parameter, but it does take + * a window, and both the session detection and the span fan-out are bounded by + * it — so a narrower range genuinely returns fewer bytes, which is what + * `recovery: "fix_request"` points the caller at. + */ +export class AiSessionTooLargeError extends HttpTaggedError()( + "@maple/http/ai-sessions/AiSessionTooLargeError", + { + sessionId: Schema.String, + message: Schema.String, + }, + { + status: 413, + code: "ai_session_too_large", + title: "Session is too large to load", + message: + "This session's spans are too large to return in one response. Open it from the Agent Sessions list, or narrow the time range.", + retry: "never", + recovery: "fix_request", + exposure: "redacted", + }, +) {} + // Exactly what a compiled warehouse read can fail with — not the wider // `sessionReplayEndpointErrors` union, whose extra members (the legacy // QueryEngine wrappers, token-mint errors) this endpoint can never produce. @@ -91,5 +306,12 @@ export class AiSessionsInternalApiGroup extends HttpApiGroup.make("aiSessionsInt error: aiSessionEndpointErrors, }), ) + .add( + HttpApiEndpoint.post("spans", "/spans", { + payload: GetAiSessionSpansRequest, + success: GetAiSessionSpansResponse, + error: [...aiSessionEndpointErrors, AiSessionTooLargeError], + }), + ) .prefix("/internal/ai-sessions") .middleware(SessionAuthorization) {}