Python: Fix DevUI streaming memory growth regression (#6038)

* Fix DevUI streaming memory growth regression

Bounds retained streaming/debug state in DevUI and strengthens browser regression coverage for long streamed responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address DevUI memory review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix DevUI bundle trailing whitespace

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-27 09:48:29 +02:00
committed by GitHub
Unverified
parent e1e6e3d35e
commit 3242d8a4c4
8 changed files with 259 additions and 512 deletions
@@ -40,11 +40,22 @@ import type {
ExtendedResponseStreamEvent,
} from "@/types";
import { useDevUIStore } from "@/stores";
import { loadStreamingState } from "@/services/streaming-state";
import { loadStreamingState, type StreamingState } from "@/services/streaming-state";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
const ASSISTANT_TEXT_RENDER_INTERVAL_MS = 50;
const STREAMING_PREVIEW_PREFIX = "[Earlier streaming content omitted after refresh]\n\n";
function getRestoredStreamingText(state: StreamingState): string {
if (!state.accumulatedText) {
return "";
}
return state.accumulatedTextIsPreview
? `${STREAMING_PREVIEW_PREFIX}${state.accumulatedText}`
: state.accumulatedText;
}
interface AgentViewProps {
selectedAgent: AgentInfo;
@@ -683,13 +694,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const state = loadStreamingState(mostRecent.id);
if (state && !state.completed) {
accumulatedTextRef.current = state.accumulatedText || "";
const restoredText = getRestoredStreamingText(state);
accumulatedTextRef.current = restoredText;
// Add assistant message with resumed text
const assistantMsg: import("@/types/openai").ConversationMessage = {
id: state.lastMessageId || `assistant-${Date.now()}`,
type: "message",
role: "assistant",
content: state.accumulatedText ? [{ type: "text", text: state.accumulatedText }] : [],
content: restoredText ? [{ type: "text", text: restoredText }] : [],
status: "in_progress",
};
setChatItems([...allItems as import("@/types/openai").ConversationItem[], assistantMsg]);
@@ -988,13 +1000,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Check for incomplete stream and restore accumulated text
const state = loadStreamingState(conversationId);
if (state?.accumulatedText) {
accumulatedTextRef.current = state.accumulatedText;
const restoredText = getRestoredStreamingText(state);
accumulatedTextRef.current = restoredText;
// Add assistant message with resumed text - streaming will continue automatically
const assistantMsg: import("@/types/openai").ConversationMessage = {
id: `assistant-${Date.now()}`,
type: "message",
role: "assistant",
content: [{ type: "output_text", text: state.accumulatedText }],
content: [{ type: "output_text", text: restoredText }],
status: "in_progress",
};
setChatItems([...items, assistantMsg]);
@@ -49,6 +49,28 @@ interface WorkflowViewProps {
onDebugEvent: DebugEventHandler;
}
function getWorkflowEventTimestamp(event: ExtendedResponseStreamEvent): number | undefined {
if ("created_at" in event && typeof event.created_at === "number" && event.created_at) {
return event.created_at;
}
const response = "response" in event ? event.response : undefined;
if (response && typeof response === "object" && "created_at" in response) {
const createdAt = response.created_at;
if (typeof createdAt === "number") {
return createdAt;
}
}
const data = "data" in event ? event.data : undefined;
if (data && typeof data === "object" && "timestamp" in data && typeof data.timestamp === "string") {
const milliseconds = new Date(data.timestamp).getTime();
return Number.isFinite(milliseconds) ? milliseconds / 1000 : undefined;
}
return undefined;
}
// TODO: CheckpointSelector is not currently used but may be needed for checkpoint resumption feature
// Smart Run Workflow Button Component moved to separate file
@@ -581,20 +603,7 @@ export function WorkflowView({
// 2. response.created_at (response.created / lifecycle events)
// 3. data.timestamp (response.workflow_event.completed ISO string)
// Fall back to a synthesized timestamp only when none is present.
const anyEvent = openAIEvent as Record<string, unknown>;
const eventTimestamp: number | undefined =
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
? (anyEvent["created_at"] as number)
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
: (() => {
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
if (typeof ts !== "string") return undefined;
const ms = new Date(ts).getTime();
// Guard against NaN: Python isoformat() emits microseconds without Z,
// which some JS engines cannot parse. Number.isFinite rejects NaN.
return Number.isFinite(ms) ? ms / 1000 : undefined;
})();
const eventTimestamp = getWorkflowEventTimestamp(openAIEvent);
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp =
prev.length > 0
@@ -1018,20 +1027,7 @@ export function WorkflowView({
// 2. response.created_at (response.created / lifecycle events)
// 3. data.timestamp (response.workflow_event.completed ISO string)
// Fall back to a synthesized timestamp only when none is present.
const anyEvent = openAIEvent as Record<string, unknown>;
const eventTimestamp: number | undefined =
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
? (anyEvent["created_at"] as number)
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
: (() => {
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
if (typeof ts !== "string") return undefined;
const ms = new Date(ts).getTime();
// Guard against NaN: Python isoformat() emits microseconds without Z,
// which some JS engines cannot parse. Number.isFinite rejects NaN.
return Number.isFinite(ms) ? ms / 1000 : undefined;
})();
const eventTimestamp = getWorkflowEventTimestamp(openAIEvent);
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp =
prev.length > 0
@@ -600,7 +600,10 @@ function EventItem({ event }: EventItemProps) {
event.type === "error";
return (
<div className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors">
<div
className="border-l-2 border-muted pl-3 py-2 hover:bg-muted/50 transition-colors"
data-devui-debug-event={eventType}
>
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Icon className={`h-3 w-3 ${colorClass}`} />
<span className="font-mono">{timestamp}</span>
@@ -1088,18 +1091,17 @@ function EventExpandedContent({
function EventsTab({
events,
processedEvents,
isStreaming,
}: {
events: ExtendedResponseStreamEvent[];
processedEvents: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
// Process events to accumulate tool calls and reduce noise
const processedEvents = processEventsForDisplay(events);
// Add separators between message rounds
const eventsWithSeparators = addSeparatorsToEvents(processedEvents);
const eventsWithSeparators = useMemo(() => addSeparatorsToEvents(processedEvents), [processedEvents]);
// Reverse events so latest appears at top
const reversedEvents = [...eventsWithSeparators].reverse();
@@ -1565,10 +1567,13 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
);
}
function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// Process events first to get clean tool calls
const processedEvents = processEventsForDisplay(events);
function ToolsTab({
events,
processedEvents,
}: {
events: ExtendedResponseStreamEvent[];
processedEvents: ExtendedResponseStreamEvent[];
}) {
// Create call->result pairs in chronological order
const toolEvents: ExtendedResponseStreamEvent[] = [];
const functionCalls = processedEvents.filter(
@@ -1755,15 +1760,16 @@ export function DebugPanel({
const activeTab = useDevUIStore((state) => state.debugPanelTab);
const setActiveTab = useDevUIStore((state) => state.setDebugPanelTab);
const processedEvents = useMemo(() => processEventsForDisplay(events), [events]);
// Compute counts once for tab badges (memoized to avoid perf hits)
const counts = useMemo(() => {
const processedEvents = processEventsForDisplay(events);
const eventsCount = processedEvents.length;
const tracesCount = events.filter(e => e.type === "response.trace.completed").length;
const toolsCount = processedEvents.filter(e => e.type === "response.function_call.complete").length
+ events.filter(e => getFunctionResultFromEvent(e) !== null).length;
return { eventsCount, tracesCount, toolsCount };
}, [events]);
}, [events, processedEvents]);
return (
<div className="flex-1 border-l flex flex-col min-h-0">
@@ -1809,7 +1815,7 @@ export function DebugPanel({
</div>
<TabsContent value="events" className="flex-1 mt-0 overflow-hidden">
<EventsTab events={events} isStreaming={isStreaming} />
<EventsTab events={events} processedEvents={processedEvents} isStreaming={isStreaming} />
</TabsContent>
<TabsContent value="traces" className="flex-1 mt-0 overflow-hidden">
@@ -1817,7 +1823,7 @@ export function DebugPanel({
</TabsContent>
<TabsContent value="tools" className="flex-1 mt-0 overflow-hidden">
<ToolsTab events={events} />
<ToolsTab events={events} processedEvents={processedEvents} />
</TabsContent>
</Tabs>
</div>