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 ? (
)}
- >
+
);
};