diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index f2492ec26..2c7e3ef08 100644 --- a/packages/devtools/src/OpenUIDevtools.test.ts +++ b/packages/devtools/src/OpenUIDevtools.test.ts @@ -220,6 +220,70 @@ describe("OpenUIDevtools", () => { expect(toggle().getAttribute("aria-expanded")).toBe("false"); }); + it("auto-opens the drawer when a stream settles with errors", () => { + render({ enabled: true, autoOpenOnError: true, errorsOnly: false }); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + + act(() => + observability.info({ + kind: "react-lang:stream", + id: "stream-1", + phase: "streaming", + runId: "run-1", + }), + ); + expect(toggle().getAttribute("aria-expanded")).toBe("false"); + + act(() => + observability.error({ + kind: "react-lang:stream", + id: "stream-1", + phase: "settled", + runId: "run-1", + errors: [ + { source: "parser", code: "unknown-component", message: "Unknown component Ghost" }, + ], + }), + ); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); + }); + + it("opens a collapsed run group when an error arrives later", () => { + render({ enabled: true, autoOpenOnError: false, errorsOnly: false }); + + act(() => { + observability.info({ + kind: "LLM:request", + runId: "run-old", + userMessage: { role: "user", content: "older prompt" }, + }); + observability.info({ kind: "LLM:response", runId: "run-old", status: 200 }); + observability.info({ + kind: "LLM:request", + runId: "run-new", + userMessage: { role: "user", content: "newer prompt" }, + }); + observability.info({ kind: "LLM:response", runId: "run-new", status: 200 }); + }); + + const older = container.querySelector('[role="group"][aria-label="older prompt"]'); + expect(older?.querySelector("button")?.getAttribute("aria-expanded")).toBe("false"); + + act(() => + observability.error({ + kind: "react-lang:stream", + id: "stream-old", + phase: "settled", + runId: "run-old", + errors: [{ message: "late parse error" }], + }), + ); + + expect(older?.querySelector("button")?.getAttribute("aria-expanded")).toBe("true"); + expect(older?.querySelector('[aria-label="error"]')).not.toBeNull(); + expect(container.textContent).toContain("late parse error"); + }); + it("restores auto-open on error from a previous session", () => { render({ enabled: true, autoOpenOnError: true }); openSettings(); @@ -385,14 +449,12 @@ describe("OpenUIDevtools", () => { expect(container.textContent).not.toContain("Streaming"); expect(overviewStats()).toContain("1 statement"); expect(overviewStats()).toContain("1 error"); - expect(container.textContent).not.toContain("Unknown component Ghost"); + expect(toggle().getAttribute("aria-expanded")).toBe("true"); const expand = container.querySelector( 'button[aria-label="Toggle OpenUI Lang stream details"]', ); - expect(expand).not.toBeNull(); - click(expand!); - + expect(expand?.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("parser / unknown-component"); expect(container.textContent).toContain("Unknown component Ghost"); expect(container.textContent).toContain("Use a component registered in the library"); @@ -468,7 +530,7 @@ describe("OpenUIDevtools", () => { const expand = container.querySelector( 'button[aria-label="Toggle OpenUI Lang stream details"]', ); - click(expand!); + expect(expand?.getAttribute("aria-expanded")).toBe("true"); expect(container.textContent).toContain("First error"); act(() => diff --git a/packages/devtools/src/OpenUIDevtools.tsx b/packages/devtools/src/OpenUIDevtools.tsx index 6f7e490e0..0a40792c1 100644 --- a/packages/devtools/src/OpenUIDevtools.tsx +++ b/packages/devtools/src/OpenUIDevtools.tsx @@ -4,13 +4,7 @@ import { observability, type ObservabilityEvent } from "@openuidev/observability import { Inbox, RotateCcw, Settings, X } from "lucide-react"; import { useEffect, useRef, useState, type CSSProperties } from "react"; import { DEFAULT_EDITOR_PCT, useDebug } from "./debug"; -import { - EventRow, - getQuotaError, - getReactLangStreamDetail, - QuotaErrorRow, - ReactLangStreamEventRow, -} from "./inspect"; +import { InspectEvent, RunGroup, groupEventsByRunId } from "./inspect"; import { addOrReplaceEvent, isLibraryEvent, @@ -240,26 +234,43 @@ export function OpenUIDevtools({ No events captured yet. ) : ( - visibleEvents.map((event, index) => { + groupEventsByRunId(visibleEvents).map((item, index) => { + if (item.type === "run") { + const defaultOpen = + index === 0 || item.events.some((event) => event.level === "error"); + return ( + + {item.events.map((event, eventIndex) => ( + + ))} + + ); + } + + const event = item.event; const key = typeof event.detail["id"] === "string" ? event.detail["id"] : `${event.timestamp}-${index}`; - const quotaError = getQuotaError(event); - if (quotaError) return ; - const stream = getReactLangStreamDetail(event); - if (stream) { - return ( - - ); - } - return ; + return ( + + ); }) )} diff --git a/packages/devtools/src/inspect/EventRow.tsx b/packages/devtools/src/inspect/EventRow.tsx index 731f56eaf..cfaba0092 100644 --- a/packages/devtools/src/inspect/EventRow.tsx +++ b/packages/devtools/src/inspect/EventRow.tsx @@ -1,14 +1,22 @@ import { type ObservabilityErrorInfo, type ObservabilityEvent } from "@openuidev/observability"; import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; import { useState, type CSSProperties } from "react"; -import { FONT, MONO, useStyles, type ThemeTokens } from "../theme"; +import { FONT, MONO, useStyles, useTheme, type ThemeTokens } from "../theme"; +import { displayEventKind } from "./groupEvents"; import { LevelIcon } from "./LevelIcon"; +import { nestedRowBox } from "./rowBox"; -export function EventRow({ event }: { event: ObservabilityEvent }) { +export function EventRow({ + event, + embedded = false, + last = false, +}: { + event: ObservabilityEvent; + embedded?: boolean; + last?: boolean; +}) { const styles = useStyles(eventRowStyles); - const [expanded, setExpanded] = useState(false); - const [copied, setCopied] = useState(false); - const [hovered, setHovered] = useState(false); + const theme = useTheme(); const error = getErrorInfo(event); const detail = asRecord(event.detail); const kind = asString(detail["kind"]); @@ -17,11 +25,15 @@ export function EventRow({ event }: { event: ObservabilityEvent }) { const summary = message ? null : kind ? null : summarize(event); const stack = error?.stack; const expandable = Boolean(stack); + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); + const [hovered, setHovered] = useState(false); - const copyStack = () => { - if (!stack || typeof navigator === "undefined" || !navigator.clipboard) return; + const copyText = stack ?? ""; + const copyDetail = () => { + if (!copyText || typeof navigator === "undefined" || !navigator.clipboard) return; navigator.clipboard - .writeText(stack) + .writeText(copyText) .then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); @@ -34,7 +46,7 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
- {kind ? {kind} : null} + {kind ? {displayEventKind(kind)} : null} {status ? ( {status} ) : null} @@ -53,7 +65,11 @@ export function EventRow({ event }: { event: ObservabilityEvent }) { return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -75,7 +91,7 @@ export function EventRow({ event }: { event: ObservabilityEvent }) {
{stack}
- @@ -119,7 +135,8 @@ function summarize(event: ObservabilityEvent): string { const parts = [subject, status, message].filter(Boolean); if (parts.length > 0) return parts.join(" "); try { - return JSON.stringify(event.detail) ?? "(no detail)"; + const { runId: _runId, ...rest } = detail; + return JSON.stringify(rest) ?? "(no detail)"; } catch { return "(no detail)"; } @@ -128,7 +145,12 @@ function summarize(event: ObservabilityEvent): string { function eventRowStyles(t: ThemeTokens) { return { row: { - borderWidth: 1, + // Four longhands: `borderWidth` is a shorthand, and mixing it with + // `borderBottomWidth` in the embedded override leaves a leftover box stroke. + borderTopWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderLeftWidth: 1, borderStyle: "solid", borderColor: t.border, borderRadius: 12, @@ -158,6 +180,7 @@ function eventRowStyles(t: ThemeTokens) { justifyContent: "space-between", alignItems: "center", gap: 8, + minHeight: 22, }, rowHeaderRight: { display: "flex", @@ -167,7 +190,11 @@ function eventRowStyles(t: ThemeTokens) { }, chevron: { display: "inline-flex", - width: 14, + alignItems: "center", + justifyContent: "center", + boxSizing: "border-box", + width: 22, + height: 22, flexShrink: 0, color: t.fgMuted, }, diff --git a/packages/devtools/src/inspect/InspectEvent.tsx b/packages/devtools/src/inspect/InspectEvent.tsx new file mode 100644 index 000000000..206924a84 --- /dev/null +++ b/packages/devtools/src/inspect/InspectEvent.tsx @@ -0,0 +1,35 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { EventRow } from "./EventRow"; +import { QuotaErrorRow, getQuotaError } from "./QuotaErrorRow"; +import { ReactLangStreamEventRow, getReactLangStreamDetail } from "./ReactLangStreamEventRow"; + +export function InspectEvent({ + event, + canOpenInDebug, + onOpenInDebug, + embedded = false, + last = false, +}: { + event: ObservabilityEvent; + canOpenInDebug: boolean; + onOpenInDebug: (response: string, libraryId?: string) => void; + embedded?: boolean; + last?: boolean; +}) { + const quotaError = getQuotaError(event); + if (quotaError) return ; + const stream = getReactLangStreamDetail(event); + if (stream) { + return ( + + ); + } + return ; +} diff --git a/packages/devtools/src/inspect/QuotaErrorRow.tsx b/packages/devtools/src/inspect/QuotaErrorRow.tsx index 15735119f..a039ce9f3 100644 --- a/packages/devtools/src/inspect/QuotaErrorRow.tsx +++ b/packages/devtools/src/inspect/QuotaErrorRow.tsx @@ -1,8 +1,9 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { CreditCard, KeyRound } from "lucide-react"; import { useState, type CSSProperties } from "react"; -import { FONT, useStyles, type ThemeTokens } from "../theme"; +import { FONT, useStyles, useTheme, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; +import { nestedRowBox } from "./rowBox"; export interface QuotaErrorInfo { title: string; @@ -36,12 +37,26 @@ export function getQuotaError(event: ObservabilityEvent): QuotaErrorInfo | undef } /** Billing/rate-limit list entry — the highlighted card a known 429 code renders as. */ -export function QuotaErrorRow({ info }: { info: QuotaErrorInfo }) { +export function QuotaErrorRow({ + info, + embedded = false, + last = false, +}: { + info: QuotaErrorInfo; + embedded?: boolean; + last?: boolean; +}) { const styles = useStyles(quotaRowStyles); + const theme = useTheme(); const [hoveredCta, setHoveredCta] = useState(null); return ( -
+
@@ -103,7 +118,12 @@ function asString(value: unknown): string | undefined { function quotaRowStyles(t: ThemeTokens) { return { row: { - border: `1px solid ${t.border}`, + borderTopWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderLeftWidth: 1, + borderStyle: "solid", + borderColor: t.border, borderRadius: 12, padding: 12, display: "flex", diff --git a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx index 34294f176..9d2f63aed 100644 --- a/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx +++ b/packages/devtools/src/inspect/ReactLangStreamEventRow.tsx @@ -1,9 +1,10 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { Bug, Check, ChevronDown, ChevronRight, Copy } from "lucide-react"; -import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { tokenColor, tokenizeLang } from "../lib"; -import { FONT, MONO, useStyles, type ThemeTokens } from "../theme"; +import { FONT, MONO, useStyles, useTheme, type ThemeTokens } from "../theme"; import { LevelIcon } from "./LevelIcon"; +import { nestedRowBox } from "./rowBox"; export interface ReactLangStreamDetail { phase: "streaming" | "settled"; @@ -62,26 +63,42 @@ export function ReactLangStreamEventRow({ stream, onOpenInDebug, canOpenInDebug = false, + embedded = false, + last = false, }: { event: ObservabilityEvent; stream: ReactLangStreamDetail; onOpenInDebug?: (response: string, libraryId?: string) => void; canOpenInDebug?: boolean; + embedded?: boolean; + last?: boolean; }) { - const [expanded, setExpanded] = useState(false); + const [expanded, setExpanded] = useState( + () => stream.phase === "settled" && stream.errors.length > 0, + ); const [hovered, setHovered] = useState(false); const [hoveredAction, setHoveredAction] = useState(null); const [responseCopied, setResponseCopied] = useState(false); const styles = useStyles(streamRowStyles); + const theme = useTheme(); const colors = useStyles(tokenColor); + const isStreaming = stream.phase === "streaming"; + const visibleErrors = isStreaming ? [] : stream.errors; + const hasErrors = visibleErrors.length > 0; + const openedForError = useRef(hasErrors); // Collapsed rows skip tokenizing: a live stream re-renders this on every chunk. const responseTokens = useMemo( () => (expanded && stream.response ? tokenizeLang(stream.response) : []), [expanded, stream.response], ); - const isStreaming = stream.phase === "streaming"; const elapsedMs = useStreamElapsedMs(stream, isStreaming); - const visibleErrors = isStreaming ? [] : stream.errors; + + useEffect(() => { + if (!hasErrors || openedForError.current) return; + openedForError.current = true; + setExpanded(true); + }, [hasErrors]); + const statementCount = stream.parser?.statementCount; const orphaned = stream.parser?.orphaned ?? []; const parserIssues = [ @@ -107,7 +124,11 @@ export function ReactLangStreamEventRow({ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -343,7 +364,12 @@ function streamRowStyles(t: ThemeTokens) { // Longhands, not the `border` shorthand: rowHover overrides borderColor, and // React blanks a shorthand's longhands when a later style touches one of them. row: { - borderWidth: 1, + // Four longhands: `borderWidth` is a shorthand, and mixing it with + // `borderBottomWidth` in the embedded override leaves a leftover box stroke. + borderTopWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderLeftWidth: 1, borderStyle: "solid", borderColor: t.border, borderRadius: 12, diff --git a/packages/devtools/src/inspect/RunGroup.tsx b/packages/devtools/src/inspect/RunGroup.tsx new file mode 100644 index 000000000..d7ccf1e9b --- /dev/null +++ b/packages/devtools/src/inspect/RunGroup.tsx @@ -0,0 +1,116 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { FONT, useStyles, type ThemeTokens } from "../theme"; +import { LevelIcon } from "./LevelIcon"; +import { runGroupLevel, runGroupTitle } from "./groupEvents"; + +export function RunGroup({ + events, + defaultOpen, + children, +}: { + events: ObservabilityEvent[]; + defaultOpen: boolean; + children: ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + const openedForError = useRef(defaultOpen && events.some((event) => event.level === "error")); + const styles = useStyles(runGroupStyles); + const level = runGroupLevel(events); + const title = runGroupTitle(events); + const newest = events[0]; + const hasError = events.some((event) => event.level === "error"); + + useEffect(() => { + if (!hasError || openedForError.current) return; + openedForError.current = true; + setOpen(true); + }, [hasError]); + + return ( +
+ + {open ?
{children}
: null} +
+ ); +} + +function runGroupStyles(t: ThemeTokens) { + return { + group: { + borderWidth: 1, + borderStyle: "solid", + borderColor: t.border, + borderRadius: 12, + background: t.card, + display: "flex", + flexDirection: "column", + overflow: "hidden", + }, + header: { + display: "flex", + alignItems: "center", + gap: 8, + width: "100%", + border: "none", + borderBottom: `1px solid ${t.border}`, + background: t.bgMuted, + color: "inherit", + cursor: "pointer", + fontFamily: FONT, + padding: 12, + textAlign: "left" as const, + }, + title: { + fontSize: 12, + fontWeight: 600, + color: t.fg, + minWidth: 0, + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + headerRight: { + display: "flex", + alignItems: "center", + gap: 6, + marginLeft: "auto", + flexShrink: 0, + }, + time: { + color: t.fgFaint, + fontSize: 11, + }, + chevron: { + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + width: 22, + height: 22, + flexShrink: 0, + color: t.fgMuted, + }, + body: { + display: "flex", + flexDirection: "column", + }, + } satisfies Record; +} diff --git a/packages/devtools/src/inspect/groupEvents.test.ts b/packages/devtools/src/inspect/groupEvents.test.ts new file mode 100644 index 000000000..d32400a3a --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -0,0 +1,96 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; +import { describe, expect, it } from "vitest"; +import { displayEventKind, groupEventsByRunId, runGroupLevel, runGroupTitle } from "./groupEvents"; + +function event( + detail: Record, + extra: Partial = {}, +): ObservabilityEvent { + return { + level: extra.level ?? "info", + timestamp: extra.timestamp ?? 1, + detail: { kind: "x", ...detail }, + }; +} + +describe("groupEventsByRunId", () => { + it("titles a run from the user message on LLM:request", () => { + const items = groupEventsByRunId([ + event( + { + kind: "LLM:request", + runId: "run-1", + userMessage: { role: "user", content: "Who waited?" }, + }, + { timestamp: 1 }, + ), + event({ kind: "LLM:response", runId: "run-1", status: 200 }, { timestamp: 2 }), + ]); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ type: "run", runId: "run-1" }); + expect(runGroupTitle(items[0]!.type === "run" ? items[0].events : [])).toBe("Who waited?"); + }); + + it("keeps one stream row when a run published two stream identities", () => { + const items = groupEventsByRunId([ + event({ kind: "LLM:request", runId: "run-1" }, { timestamp: 1 }), + event( + { kind: "react-lang:stream", runId: "run-1", id: "s-old", phase: "settled" }, + { timestamp: 2 }, + ), + event( + { kind: "react-lang:stream", runId: "run-1", id: "s-new", phase: "settled" }, + { timestamp: 3 }, + ), + ]); + const run = items[0]; + expect(run?.type).toBe("run"); + if (run?.type !== "run") return; + const streams = run.events.filter((item) => item.detail["kind"] === "react-lang:stream"); + expect(streams).toHaveLength(1); + expect(streams[0]?.detail["id"]).toBe("s-new"); + }); + + it("orders request, then response, then stream", () => { + const items = groupEventsByRunId([ + event({ kind: "react-lang:stream", runId: "run-1", id: "s1" }, { timestamp: 30 }), + event({ kind: "LLM:response", runId: "run-1" }, { timestamp: 20 }), + event({ kind: "LLM:request", runId: "run-1" }, { timestamp: 10 }), + ]); + const run = items[0]; + expect(run?.type).toBe("run"); + if (run?.type !== "run") return; + expect(run.events.map((item) => item.detail["kind"])).toEqual([ + "LLM:request", + "LLM:response", + "react-lang:stream", + ]); + }); +}); + +describe("runGroupLevel", () => { + it("surfaces the worst level in the group, including stream parse errors", () => { + expect( + runGroupLevel([ + event({ kind: "LLM:request", runId: "run-1" }), + event({ kind: "LLM:response", runId: "run-1", status: 200 }), + event({ kind: "react-lang:stream", runId: "run-1" }, { level: "error" }), + ]), + ).toBe("error"); + expect( + runGroupLevel([ + event({ kind: "LLM:request", runId: "run-1" }), + event({ kind: "LLM:error", runId: "run-1" }, { level: "error" }), + event({ kind: "react-lang:stream", runId: "run-1" }), + ]), + ).toBe("error"); + }); +}); + +describe("displayEventKind", () => { + it("uses plain-language labels for LLM rows", () => { + expect(displayEventKind("LLM:request")).toBe("Request sent"); + expect(displayEventKind("LLM:response")).toBe("Response received"); + expect(displayEventKind("LLM:error")).toBe("Request failed"); + }); +}); diff --git a/packages/devtools/src/inspect/groupEvents.ts b/packages/devtools/src/inspect/groupEvents.ts new file mode 100644 index 000000000..ec0bd5da5 --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -0,0 +1,143 @@ +import type { ObservabilityEvent } from "@openuidev/observability"; + +const STREAM_KIND = "react-lang:stream"; + +export type InspectListItem = + | { type: "event"; event: ObservabilityEvent } + | { type: "run"; runId: string; events: ObservabilityEvent[] }; + +export function eventRunId(event: ObservabilityEvent): string | null { + const runId = event.detail["runId"]; + return typeof runId === "string" && runId.length > 0 ? runId : null; +} + +export function eventKind(event: ObservabilityEvent): string | undefined { + return typeof event.detail["kind"] === "string" ? event.detail["kind"] : undefined; +} + +/** + * Collapse events that share a `runId` into one list item, parked at the + * newest event of that run. Lone runIds stay as ordinary rows so a single + * in-flight request does not grow a wrapper. + */ +export function groupEventsByRunId(events: ObservabilityEvent[]): InspectListItem[] { + const buckets = new Map(); + for (const event of events) { + const runId = eventRunId(event); + if (!runId) continue; + const bucket = buckets.get(runId); + if (bucket) bucket.push(event); + else buckets.set(runId, [event]); + } + + const emitted = new Set(); + const items: InspectListItem[] = []; + for (const event of events) { + const runId = eventRunId(event); + if (!runId) { + items.push({ type: "event", event }); + continue; + } + const bucket = buckets.get(runId)!; + if (bucket.length === 1) { + items.push({ type: "event", event }); + continue; + } + if (emitted.has(runId)) continue; + emitted.add(runId); + items.push({ type: "run", runId, events: presentRunEvents(bucket) }); + } + return items; +} + +/** One request, one response/error, one stream — chronological inside the card. */ +export function presentRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { + return sortRunEvents(collapseStreams(events)); +} + +export function runGroupTitle(events: ObservabilityEvent[]): string { + for (const event of events) { + if (eventKind(event) !== "LLM:request") continue; + const text = userMessageText(event.detail["userMessage"]); + if (text) return text; + } + return "LLM run"; +} + +/** Worst level in the group, including stream parse errors and 429s. */ +export function runGroupLevel(events: ObservabilityEvent[]): ObservabilityEvent["level"] { + if (events.some((event) => event.level === "error")) return "error"; + if (events.some((event) => event.level === "warning")) return "warning"; + return "info"; +} + +export function displayEventKind(kind: string): string { + switch (kind) { + case "LLM:request": + return "Request sent"; + case "LLM:response": + return "Response received"; + case "LLM:error": + return "Request failed"; + default: + return kind; + } +} + +function collapseStreams(events: ObservabilityEvent[]): ObservabilityEvent[] { + const streams = events.filter((event) => eventKind(event) === STREAM_KIND); + if (streams.length <= 1) return events; + const chosen = streams.reduce((best, event) => (preferStream(event, best) ? event : best)); + return [...events.filter((event) => eventKind(event) !== STREAM_KIND), chosen]; +} + +function preferStream(candidate: ObservabilityEvent, best: ObservabilityEvent): boolean { + const candidateSettled = candidate.detail["phase"] === "settled"; + const bestSettled = best.detail["phase"] === "settled"; + if (candidateSettled !== bestSettled) return candidateSettled; + return candidate.timestamp >= best.timestamp; +} + +function sortRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { + return [...events].sort((a, b) => { + const rank = kindRank(eventKind(a)) - kindRank(eventKind(b)); + if (rank !== 0) return rank; + return a.timestamp - b.timestamp; + }); +} + +function kindRank(kind: string | undefined): number { + if (kind === "LLM:request") return 0; + if (kind === "LLM:response" || kind === "LLM:error") return 1; + if (kind === STREAM_KIND) return 2; + return 3; +} + +function userMessageText(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!value || typeof value !== "object") return undefined; + const content = (value as { content?: unknown }).content; + if (typeof content === "string") { + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (!Array.isArray(content)) return undefined; + const text = content + .map((part) => { + if (typeof part === "string") return part; + if ( + part && + typeof part === "object" && + typeof (part as { text?: unknown }).text === "string" + ) { + return (part as { text: string }).text; + } + return ""; + }) + .join(""); + const trimmed = text.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} diff --git a/packages/devtools/src/inspect/index.ts b/packages/devtools/src/inspect/index.ts index 970c1b2ae..c8ae2fcde 100644 --- a/packages/devtools/src/inspect/index.ts +++ b/packages/devtools/src/inspect/index.ts @@ -1,4 +1,6 @@ export { EventRow } from "./EventRow"; +export { eventRunId, groupEventsByRunId, runGroupLevel, runGroupTitle } from "./groupEvents"; +export { InspectEvent } from "./InspectEvent"; export { LevelIcon } from "./LevelIcon"; export { QuotaErrorRow, getQuotaError, type QuotaErrorInfo } from "./QuotaErrorRow"; export { @@ -6,3 +8,4 @@ export { getReactLangStreamDetail, type ReactLangStreamDetail, } from "./ReactLangStreamEventRow"; +export { RunGroup } from "./RunGroup"; diff --git a/packages/devtools/src/inspect/rowBox.ts b/packages/devtools/src/inspect/rowBox.ts new file mode 100644 index 000000000..ab444c9ae --- /dev/null +++ b/packages/devtools/src/inspect/rowBox.ts @@ -0,0 +1,22 @@ +import type { CSSProperties } from "react"; +import type { ThemeTokens } from "../theme"; + +/** + * Nested inspect rows must overwrite the same border longhands as the + * standalone card. Mixing `borderWidth` / `borderTop` with `borderBottomWidth` + * leaves a leftover box stroke in React (double border inside the group). + */ +export function nestedRowBox(t: ThemeTokens, last: boolean): CSSProperties { + return { + borderTopWidth: 0, + borderRightWidth: 0, + borderLeftWidth: 0, + borderBottomWidth: last ? 0 : 1, + borderStyle: "solid", + borderColor: t.border, + borderRadius: 0, + boxShadow: "none", + background: "transparent", + padding: last ? "12px 12px 14px" : 12, + }; +} diff --git a/packages/observability-cloud/src/events/stream.ts b/packages/observability-cloud/src/events/stream.ts index 1c14df65e..a4f4079f4 100644 --- a/packages/observability-cloud/src/events/stream.ts +++ b/packages/observability-cloud/src/events/stream.ts @@ -53,6 +53,8 @@ export interface SettledStreamEventDetail { message: string; /** `createLibrary()` instance id for the Renderer that produced this stream. */ __libraryId?: string; + /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ + runId?: string; } /** Wire shape for settled stream events sent to cloud ingest. */ diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index eed9fd96c..d027c6d7c 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -216,6 +216,7 @@ export const createChatStore = (configRef: React.RefObject set((s) => ({ messages: [...s.messages, msg] })), updateMessage: (msg) => set((s) => ({ diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index e22f46291..484636498 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -20,6 +20,8 @@ interface Parameters { clearToolExecuting?: (toolCallId: string) => void; /** The adapter to use for parsing the stream */ adapter?: StreamProtocolAdapter; + /** LLM run that produced this stream. Copied onto each assistant message. */ + runId?: string; } /** @@ -32,13 +34,9 @@ export const processStreamedMessage = async ({ markToolExecuting = () => {}, clearToolExecuting = () => {}, adapter = agUIAdapter(), + runId, }: Parameters): Promise => { - let currentMessage: AssistantMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + let currentMessage: AssistantMessage = newAssistantMessage(runId); let isFirst = true; @@ -173,12 +171,7 @@ export const processStreamedMessage = async ({ rafId = null; if (!isFirst) updateMessage(currentMessage); } - currentMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + currentMessage = newAssistantMessage(runId); isFirst = true; } currentTextItemId = startId; @@ -265,3 +258,13 @@ export const processStreamedMessage = async ({ return currentMessage; }; + +function newAssistantMessage(runId?: string): AssistantMessage { + return { + id: crypto.randomUUID(), + role: "assistant", + content: "", + toolCalls: [], + ...(runId ? { runId } : {}), + }; +} diff --git a/packages/react-headless/src/types/message.ts b/packages/react-headless/src/types/message.ts index 2458172cf..070dcf928 100644 --- a/packages/react-headless/src/types/message.ts +++ b/packages/react-headless/src/types/message.ts @@ -1,6 +1,7 @@ +import type { AssistantMessage as AGUIAssistantMessage } from "@ag-ui/core"; + export type { ActivityMessage, - AssistantMessage, BinaryInputContent, DeveloperMessage, FunctionCall, @@ -13,3 +14,8 @@ export type { ToolMessage, UserMessage, } from "@ag-ui/core"; + +export type AssistantMessage = AGUIAssistantMessage & { + /** LLM run that produced this message. Set by `processStreamedMessage`. */ + runId?: string; +}; diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 669ddb5b5..872e0245e 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -51,6 +51,8 @@ export interface RendererProps { */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; + /** LLM run that produced `response`. Echoed on stream observability events. */ + runId?: string; } // ─── Error boundary ─── @@ -209,6 +211,7 @@ export function Renderer({ queryLoader, onError, publishObservability, + runId, }: RendererProps) { useInsertionEffect(() => { ensureLoadingStyle(); @@ -256,6 +259,7 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, + runId, }, renderDeep, ); diff --git a/packages/react-lang/src/hooks/streamEvent.ts b/packages/react-lang/src/hooks/streamEvent.ts index 547bd0c54..7ebe75049 100644 --- a/packages/react-lang/src/hooks/streamEvent.ts +++ b/packages/react-lang/src/hooks/streamEvent.ts @@ -33,4 +33,6 @@ export interface SettledStreamEventDetail { message: string; /** `createLibrary()` instance id for the Renderer that produced this stream. */ __libraryId?: string; + /** LLM run that produced this stream. Matches `LLM:*` events from the chat store. */ + runId?: string; } diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index 13df4cde6..bd7e1ae44 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -49,6 +49,8 @@ export interface UseOpenUIStateOptions { /** Callback for structured, LLM-friendly errors. See OpenUIError type. */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; + /** LLM run that produced `response`. Echoed on stream observability events. */ + runId?: string; } export interface OpenUIState { @@ -79,6 +81,7 @@ export function useOpenUIState( toolProvider, onError, publishObservability, + runId, }: UseOpenUIStateOptions, renderDeep: (value: unknown) => React.ReactNode, ): OpenUIState { @@ -462,6 +465,7 @@ export function useOpenUIState( errorRevision, publish: publishObservability, __libraryId: library.__libraryId, + runId, }); return { result: evaluatedResult, parseResult: result, contextValue, isQueryLoading }; diff --git a/packages/react-lang/src/hooks/useStreamingObservability.test.ts b/packages/react-lang/src/hooks/useStreamingObservability.test.ts index 10a982358..6de577aae 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.test.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.test.ts @@ -85,6 +85,17 @@ describe("streaming observability lifecycle", () => { expect(idFactory).toHaveBeenCalledTimes(2); }); + it("does not start a new id when a settled Renderer is marked streaming with the same response", () => { + const state = createStreamingObservabilityState(); + const idFactory = vi.fn(() => "stream-1"); + + advanceStreamingObservability(state, true, "first", null, idFactory); + advanceStreamingObservability(state, false, "first", "[]", idFactory); + + expect(advanceStreamingObservability(state, true, "first", null, idFactory)).toBeNull(); + expect(idFactory).toHaveBeenCalledOnce(); + }); + it("republishes settled with a new updateIndex when the error snapshot changes", () => { const state = createStreamingObservabilityState(); const idFactory = () => "stream-1"; diff --git a/packages/react-lang/src/hooks/useStreamingObservability.ts b/packages/react-lang/src/hooks/useStreamingObservability.ts index b06c2e109..b40f5f52a 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.ts @@ -20,6 +20,8 @@ export interface UseStreamingObservabilityOptions { publish?: boolean; /** `createLibrary()` instance id, echoed on stream events for Debug matching. */ __libraryId?: string; + /** LLM run that produced this stream. Groups Inspect events with the request/response pair. */ + runId?: string; } export interface StreamingObservabilityState { @@ -86,6 +88,9 @@ export function advanceStreamingObservability( idFactory: () => string = createStreamId, ): StreamingObservabilityUpdate | null { if (isStreaming) { + // Same content flipping back to streaming is not a new run — that happens + // when a historical assistant is marked live because a new user turn started. + if (state.settled && state.lastResponse === response) return null; // A mounted Renderer can be reused for another message. Once the previous // stream has settled, the next streaming transition starts a new identity. if (state.settled) Object.assign(state, createStreamingObservabilityState()); @@ -136,6 +141,7 @@ export function useStreamingObservability({ errorRevision, publish = true, __libraryId, + runId, }: UseStreamingObservabilityOptions): void { const streamRef = useRef(createStreamingObservabilityState()); @@ -150,6 +156,7 @@ export function useStreamingObservability({ settledErrorKey, ); const libraryIdFields = __libraryId !== undefined ? { __libraryId } : {}; + const runIdFields = runId !== undefined ? { runId } : {}; if (isStreaming) { if (update) { @@ -163,6 +170,7 @@ export function useStreamingObservability({ parser: parserMetadata(result), ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runIdFields, message: "OpenUI Lang is streaming", }); } @@ -182,11 +190,12 @@ export function useStreamingObservability({ errorCount: errors.length, ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runIdFields, message: errors.length > 0 ? `OpenUI Lang settled with ${errors.length} error${errors.length === 1 ? "" : "s"}` : "OpenUI Lang settled", } satisfies SettledStreamEventDetail); } - }, [publish, isStreaming, response, result, errorsRef, errorRevision, __libraryId]); + }, [publish, isStreaming, response, result, errorsRef, errorRevision, __libraryId, runId]); } diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index f36907ac6..cc9427784 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -104,6 +104,7 @@ export const GenUIAssistantMessage = ({ onAction={handleAction} onStateUpdate={handleStateUpdate} initialState={initialState} + runId={message.runId} /> )}