diff --git a/.changeset/inspect-run-groups.md b/.changeset/inspect-run-groups.md new file mode 100644 index 0000000000..3348a6927a --- /dev/null +++ b/.changeset/inspect-run-groups.md @@ -0,0 +1,11 @@ +--- +"@openuidev/devtools": patch +"@openuidev/observability-cloud": patch +"@openuidev/react-headless": patch +"@openuidev/react-lang": patch +"@openuidev/react-ui": patch +--- + +Group OpenUI Inspect events by LLM run, with the user's prompt as the group title and request, response, and stream details shown together. Expand groups and settled stream details when errors arrive. + +Carry the run ID and title through assistant messages. Standalone users can wrap their renderers in `RendererDevtoolsProvider` and supply `run={{ id, title }}` once to group rendered responses in Inspect without additional instrumentation. AgentInterface provides the run around each turn, including custom message and tool renderers. A Renderer can override the inherited run with `devtools={{ run: { id, title } }}`. Keep distinct renderer streams in the same group, preserve the run metadata across assistant message segments, and avoid duplicate stream identities when unchanged historical content is marked streaming again. diff --git a/docs/content/docs/api-reference/react-lang.mdx b/docs/content/docs/api-reference/react-lang.mdx index 1a329b7d57..98445f24e9 100644 --- a/docs/content/docs/api-reference/react-lang.mdx +++ b/docs/content/docs/api-reference/react-lang.mdx @@ -108,9 +108,36 @@ interface RendererProps { | null; queryLoader?: React.ReactNode; onError?: (errors: OpenUIError[]) => void; + devtools?: { + run?: { + id: string; + title?: string; + }; + }; } ``` +### Inspect grouping + +Wrap the renderers for one generation in `RendererDevtoolsProvider` to group their responses in OpenUI Inspect. Supply the run once: its `id` is required and the optional `title` labels the group, for example with the user's prompt. Every nested Renderer inherits the run. + +```tsx +import { Renderer, RendererDevtoolsProvider } from "@openuidev/react-lang"; + + + {messages.map((message) => ( + + ))} + +``` + +Use a separate provider and a new run ID for each generation. A Renderer can override the inherited run with `devtools={{ run: { id, title } }}`. Renderers without a run ID appear as individual stream rows. Inspect captures renderers while they stream; mounting historical content with `isStreaming={false}` does not create a new stream entry. `AgentInterface` supplies the run ID and title through a provider around each turn, including custom message and tool renderers. + ## Tool Provider Handles `Query()` and `Mutation()` tool calls at runtime. The `toolProvider` prop accepts two forms: diff --git a/packages/devtools/src/OpenUIDevtools.test.ts b/packages/devtools/src/OpenUIDevtools.test.ts index b6afcc42c5..9ba0d264e7 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/OpenUIDevtoolsWidget.tsx b/packages/devtools/src/OpenUIDevtoolsWidget.tsx index e9d01a8a49..0ec55d5240 100644 --- a/packages/devtools/src/OpenUIDevtoolsWidget.tsx +++ b/packages/devtools/src/OpenUIDevtoolsWidget.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 { groupEventsByRunId, InspectEvent, RunGroup } from "./inspect"; import { addOrReplaceEvent, isLibraryEvent, @@ -216,26 +210,43 @@ export function OpenUIDevtoolsWidget({ 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 731f56eaf6..cfaba00922 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 0000000000..206924a84b --- /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 72601ae623..336867bb5b 100644 --- a/packages/devtools/src/inspect/QuotaErrorRow.tsx +++ b/packages/devtools/src/inspect/QuotaErrorRow.tsx @@ -2,8 +2,9 @@ import { type ObservabilityEvent } from "@openuidev/observability"; import { CreditCard, KeyRound } from "lucide-react"; import { useState, type CSSProperties } from "react"; import { withDevtoolsAttribution } from "../lib/links"; -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; @@ -37,12 +38,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 ( -
+
@@ -115,7 +130,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 fa0ad9aef1..582fe3b332 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 = [ @@ -109,7 +126,11 @@ export function ReactLangStreamEventRow({ return (
setHovered(true)} onMouseLeave={() => setHovered(false)} > @@ -347,7 +368,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 0000000000..d7ccf1e9bc --- /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 0000000000..8cd4bae588 --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.test.ts @@ -0,0 +1,95 @@ +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 distinct Renderer streams within the same run", () => { + 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.map((stream) => stream.detail["id"])).toEqual(["s-old", "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 0000000000..511228f04a --- /dev/null +++ b/packages/devtools/src/inspect/groupEvents.ts @@ -0,0 +1,155 @@ +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. A standalone Renderer stream can form a group + * on its own; a lone in-flight request stays an ordinary row. + */ +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 && eventKind(event) !== STREAM_KIND) { + items.push({ type: "event", event }); + continue; + } + if (emitted.has(runId)) continue; + emitted.add(runId); + items.push({ type: "run", runId, events: presentRunEvents(bucket) }); + } + return items; +} + +/** Request, response/error, then each Renderer stream inside the card. */ +export function presentRunEvents(events: ObservabilityEvent[]): ObservabilityEvent[] { + return sortRunEvents(collapseStreams(events)); +} + +export function runGroupTitle(events: ObservabilityEvent[]): string { + for (const event of events) { + const title = event.detail["runTitle"]; + if (typeof title === "string" && title.trim()) return title.trim(); + } + 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 = new Map(); + const remaining: ObservabilityEvent[] = []; + for (const event of events) { + const id = event.detail["id"]; + if (eventKind(event) !== STREAM_KIND || typeof id !== "string") { + remaining.push(event); + continue; + } + const previous = streams.get(id); + if (!previous || preferStream(event, previous)) streams.set(id, event); + } + return [...remaining, ...streams.values()]; +} + +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 970c1b2ae8..c8ae2fcde8 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 0000000000..ab444c9ae3 --- /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 1c14df65ee..596cba77e0 100644 --- a/packages/observability-cloud/src/events/stream.ts +++ b/packages/observability-cloud/src/events/stream.ts @@ -53,6 +53,10 @@ 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; + /** Display title for the Inspect run group. */ + runTitle?: 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 eed9fd96c3..85555d2eb3 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -175,11 +175,13 @@ export const createChatStore = (configRef: React.RefObject set((s) => ({ messages: [...s.messages, msg] })), updateMessage: (msg) => set((s) => ({ @@ -281,3 +287,12 @@ export const createChatStore = (configRef: React.RefObject (part.type === "text" ? part.text : "")).join(""); + return text.trim() || undefined; +} diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index ed1235bd95..661d89702a 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -20,6 +20,10 @@ 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; + /** Inspect run title. Copied onto each assistant message. */ + runTitle?: string; } /** @@ -32,13 +36,10 @@ export const processStreamedMessage = async ({ markToolExecuting = () => {}, clearToolExecuting = () => {}, adapter = agUIAdapter(), + runId, + runTitle, }: Parameters): Promise => { - let currentMessage: AssistantMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + let currentMessage: AssistantMessage = newAssistantMessage(runId, runTitle); let isFirst = true; @@ -69,12 +70,7 @@ export const processStreamedMessage = async ({ rafId = null; if (!isFirst) updateMessage(currentMessage); } - currentMessage = { - id: crypto.randomUUID(), - role: "assistant", - content: "", - toolCalls: [], - }; + currentMessage = newAssistantMessage(runId, runTitle); isFirst = true; currentTextItemId = null; }; @@ -292,3 +288,14 @@ export const processStreamedMessage = async ({ return currentMessage; }; + +function newAssistantMessage(runId?: string, runTitle?: string): AssistantMessage { + return { + id: crypto.randomUUID(), + role: "assistant", + content: "", + toolCalls: [], + ...(runId ? { runId } : {}), + ...(runTitle ? { runTitle } : {}), + }; +} diff --git a/packages/react-headless/src/types/message.ts b/packages/react-headless/src/types/message.ts index 2458172cf4..654f957e01 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,10 @@ export type { ToolMessage, UserMessage, } from "@ag-ui/core"; + +export type AssistantMessage = AGUIAssistantMessage & { + /** LLM run that produced this message. Set by `processStreamedMessage`. */ + runId?: string; + /** Inspect run title derived from the user prompt. Set by `processStreamedMessage`. */ + runTitle?: string; +}; diff --git a/packages/react-lang/README.md b/packages/react-lang/README.md index 6a1b94f9b6..83db9a98fb 100644 --- a/packages/react-lang/README.md +++ b/packages/react-lang/README.md @@ -113,6 +113,26 @@ function AssistantMessage({ response, isStreaming }) { | `onStateUpdate` | `(state: Record) => void` | Callback when form field values change | | `initialState` | `Record` | Initial form state for hydration | | `onParseResult` | `(result: ParseResult \| null) => void` | Callback when the parse result changes | +| `devtools` | `{ run?: { id: string; title?: string } }` | Optional metadata for grouping rendered responses in OpenUI Inspect | + +To group responses from the same generation in Inspect, wrap them in `RendererDevtoolsProvider`. Supply the run once with a stable `id` and an optional `title`, such as the user's prompt. Every nested Renderer inherits it: + +```tsx +import { Renderer, RendererDevtoolsProvider } from "@openuidev/react-lang"; + + + {messages.map((message) => ( + + ))} + +``` + +Use a separate provider and a new run ID for each generation. A Renderer can override the inherited run with `devtools={{ run: { id, title } }}`. Renderers without a run ID appear as individual stream rows. Inspect captures renderers while they stream; mounting historical content with `isStreaming={false}` does not create a new stream entry. `AgentInterface` supplies the run ID and title through a provider around each turn, including custom message and tool renderers. ### Parser (Server-Side) diff --git a/packages/react-lang/src/Renderer.tsx b/packages/react-lang/src/Renderer.tsx index 669ddb5b58..fc30324ec1 100644 --- a/packages/react-lang/src/Renderer.tsx +++ b/packages/react-lang/src/Renderer.tsx @@ -11,6 +11,7 @@ import React, { Component, Fragment, useEffect, useInsertionEffect, useRef } fro import { OpenUIContext, useOpenUI, useRenderNode } from "./context"; import { useOpenUIState } from "./hooks/useOpenUIState"; import type { ComponentRenderer, Library } from "./library"; +import { useRendererDevtoolsRun, type RendererDevtoolsRun } from "./RendererDevtoolsProvider"; export interface RendererProps { /** Raw response text (openui-lang code). */ @@ -51,6 +52,11 @@ export interface RendererProps { */ onError?: (errors: OpenUIError[]) => void; publishObservability?: boolean; + /** Optional metadata for grouping rendered responses in OpenUI Inspect. */ + devtools?: { + /** Overrides the run supplied by RendererDevtoolsProvider for this Renderer. */ + run?: RendererDevtoolsRun; + }; } // ─── Error boundary ─── @@ -209,7 +215,10 @@ export function Renderer({ queryLoader, onError, publishObservability, + devtools, }: RendererProps) { + const inheritedRun = useRendererDevtoolsRun(); + const run = devtools?.run ?? inheritedRun; useInsertionEffect(() => { ensureLoadingStyle(); }, []); @@ -256,6 +265,8 @@ export function Renderer({ toolProvider: resolvedToolProvider, onError, publishObservability, + runId: run?.id, + runTitle: run?.title, }, renderDeep, ); diff --git a/packages/react-lang/src/RendererDevtoolsProvider.tsx b/packages/react-lang/src/RendererDevtoolsProvider.tsx new file mode 100644 index 0000000000..fc48a91de2 --- /dev/null +++ b/packages/react-lang/src/RendererDevtoolsProvider.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext, type ReactNode } from "react"; + +export interface RendererDevtoolsRun { + /** Renderers with the same ID appear in one Inspect group. Keep it stable during a generation. */ + id: string; + /** Display title for the run group, such as the user's prompt. */ + title?: string; +} + +const RendererDevtoolsContext = createContext(undefined); + +/** Supplies Inspect run metadata to all nested Renderers. */ +export function RendererDevtoolsProvider({ + run, + children, +}: { + run?: RendererDevtoolsRun; + children: ReactNode; +}) { + return ( + {children} + ); +} + +export function useRendererDevtoolsRun(): RendererDevtoolsRun | undefined { + return useContext(RendererDevtoolsContext); +} diff --git a/packages/react-lang/src/exports.ts b/packages/react-lang/src/exports.ts index 586c5ef995..e8abdcbecf 100644 --- a/packages/react-lang/src/exports.ts +++ b/packages/react-lang/src/exports.ts @@ -16,6 +16,8 @@ export type { // openui-lang renderer export { Renderer } from "./Renderer"; export type { RendererProps } from "./Renderer"; +export { RendererDevtoolsProvider } from "./RendererDevtoolsProvider"; +export type { RendererDevtoolsRun } from "./RendererDevtoolsProvider"; // openui-lang action types export { ACTION_STEPS, BuiltinActionType } from "@openuidev/lang-core"; diff --git a/packages/react-lang/src/hooks/streamEvent.ts b/packages/react-lang/src/hooks/streamEvent.ts index 547bd0c540..8f1b94caa1 100644 --- a/packages/react-lang/src/hooks/streamEvent.ts +++ b/packages/react-lang/src/hooks/streamEvent.ts @@ -33,4 +33,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; + /** Display title for the Inspect run group. */ + runTitle?: string; } diff --git a/packages/react-lang/src/hooks/useOpenUIState.ts b/packages/react-lang/src/hooks/useOpenUIState.ts index 13df4cde60..f0261e992d 100644 --- a/packages/react-lang/src/hooks/useOpenUIState.ts +++ b/packages/react-lang/src/hooks/useOpenUIState.ts @@ -49,6 +49,10 @@ 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; + /** Display title for the Inspect run group. */ + runTitle?: string; } export interface OpenUIState { @@ -79,6 +83,8 @@ export function useOpenUIState( toolProvider, onError, publishObservability, + runId, + runTitle, }: UseOpenUIStateOptions, renderDeep: (value: unknown) => React.ReactNode, ): OpenUIState { @@ -462,6 +468,8 @@ export function useOpenUIState( errorRevision, publish: publishObservability, __libraryId: library.__libraryId, + runId, + runTitle, }); 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 10a9823588..6de577aae7 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 b06c2e109c..e6132979ac 100644 --- a/packages/react-lang/src/hooks/useStreamingObservability.ts +++ b/packages/react-lang/src/hooks/useStreamingObservability.ts @@ -20,6 +20,10 @@ 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; + /** Display title for the Inspect run group. */ + runTitle?: string; } export interface StreamingObservabilityState { @@ -86,6 +90,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,11 +143,18 @@ export function useStreamingObservability({ errorRevision, publish = true, __libraryId, + runId, + runTitle, }: UseStreamingObservabilityOptions): void { const streamRef = useRef(createStreamingObservabilityState()); + const runIdRef = useRef(runId); useEffect(() => { if (!publish) return; + if (runIdRef.current !== runId) { + Object.assign(streamRef.current, createStreamingObservabilityState()); + runIdRef.current = runId; + } const errors = errorsRef.current; const settledErrorKey = isStreaming ? null : JSON.stringify(errors); const update = advanceStreamingObservability( @@ -150,6 +164,10 @@ export function useStreamingObservability({ settledErrorKey, ); const libraryIdFields = __libraryId !== undefined ? { __libraryId } : {}; + const runFields = { + ...(runId !== undefined ? { runId } : {}), + ...(runTitle !== undefined ? { runTitle } : {}), + }; if (isStreaming) { if (update) { @@ -163,6 +181,7 @@ export function useStreamingObservability({ parser: parserMetadata(result), ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runFields, message: "OpenUI Lang is streaming", }); } @@ -182,11 +201,22 @@ export function useStreamingObservability({ errorCount: errors.length, ...captureStreamTiming(streamRef.current), ...libraryIdFields, + ...runFields, 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, + runTitle, + ]); } diff --git a/packages/react-ui/src/components/AgentInterface/Thread.tsx b/packages/react-ui/src/components/AgentInterface/Thread.tsx index 943e5f5be5..d5a6e3430e 100644 --- a/packages/react-ui/src/components/AgentInterface/Thread.tsx +++ b/packages/react-ui/src/components/AgentInterface/Thread.tsx @@ -7,6 +7,7 @@ import { useThread, useToolActivities, } from "@openuidev/react-headless"; +import { RendererDevtoolsProvider } from "@openuidev/react-lang"; import clsx from "clsx"; import React, { memo, useId, useMemo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; @@ -385,6 +386,10 @@ const InterleavedTurn = ({ const last = activeSegments[activeSegments.length - 1]!; const turnLive = isRunning && lastAssistantId === last.id; + const devtoolsRun = useMemo( + () => (last.runId ? { id: last.runId, title: last.runTitle } : undefined), + [last.runId, last.runTitle], + ); // One id-keyed pairing across every segment's tool calls (synthetic message). const turnMessage = useMemo( @@ -438,7 +443,7 @@ const InterleavedTurn = ({ const answerStarted = !!answer && lastContent.length > 0; return ( - <> + {turnActivities.length > 0 && (CustomToolCallTimeline ? ( )} - + ); };