Python: Fix DevUI streaming memory growth and add cross-platform regression coverage (#5221)

* fix for memory leak in devui

* update async sleep

* remove old func
This commit is contained in:
Eduard van Valkenburg
2026-04-14 11:27:52 +02:00
committed by GitHub
Unverified
parent 7bb0feca59
commit 98e17764a4
6 changed files with 1211 additions and 309 deletions
+56 -4
View File
@@ -3,7 +3,7 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useState } from "react";
import { useEffect, useCallback, useRef, useState } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
@@ -15,17 +15,22 @@ import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
ResponseTextDeltaEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
export default function App() {
// Local state for auth handling
const [authRequired, setAuthRequired] = useState(false);
const [authToken, setAuthToken] = useState("");
const [isTestingToken, setIsTestingToken] = useState(false);
const [authError, setAuthError] = useState("");
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
const lastBufferedDebugFlushAtRef = useRef(0);
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
@@ -303,16 +308,63 @@ export default function App() {
[selectEntity, updateAgent, updateWorkflow, addToast]
);
const flushBufferedDebugText = useCallback(() => {
const bufferedEvent = bufferedDebugTextRef.current;
if (!bufferedEvent) {
return;
}
bufferedDebugTextRef.current = null;
lastBufferedDebugFlushAtRef.current = performance.now();
addDebugEvent(bufferedEvent);
}, [addDebugEvent]);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
bufferedDebugTextRef.current = null;
clearDebugEvents();
} else {
addDebugEvent(event);
return;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
const bufferedEvent = bufferedDebugTextRef.current;
const isSameOutput =
bufferedEvent !== null &&
bufferedEvent.item_id === event.item_id &&
bufferedEvent.output_index === event.output_index &&
bufferedEvent.content_index === event.content_index;
if (isSameOutput && bufferedEvent) {
bufferedDebugTextRef.current = {
...bufferedEvent,
delta: bufferedEvent.delta + event.delta,
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
};
} else {
flushBufferedDebugText();
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
}
if (
performance.now() - lastBufferedDebugFlushAtRef.current >=
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
) {
flushBufferedDebugText();
}
return;
}
flushBufferedDebugText();
addDebugEvent(event);
},
[addDebugEvent, clearDebugEvents]
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
);
// Show loading state while initializing
@@ -44,6 +44,8 @@ import { loadStreamingState } from "@/services/streaming-state";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
const ASSISTANT_TEXT_RENDER_INTERVAL_MS = 50;
interface AgentViewProps {
selectedAgent: AgentInfo;
onDebugEvent: DebugEventHandler;
@@ -309,6 +311,71 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} | null>(null);
const userJustSentMessage = useRef<boolean>(false);
const accumulatedTextRef = useRef<string>("");
const lastAssistantTextRenderAt = useRef(0);
const renderAssistantStreamingText = useCallback(
(
assistantMessageId: string,
status: "in_progress" | "completed" | "incomplete" = "in_progress",
force: boolean = false
) => {
const now = performance.now();
if (
!force &&
now - lastAssistantTextRenderAt.current < ASSISTANT_TEXT_RENDER_INTERVAL_MS
) {
return;
}
const currentItems = useDevUIStore.getState().chatItems;
let changed = false;
const nextItems = currentItems.map((item) => {
if (item.id !== assistantMessageId || item.type !== "message") {
return item;
}
const nextText = accumulatedTextRef.current;
const existingTextContent = item.content.find(
(content) => content.type === "text" || content.type === "output_text"
);
const currentText =
existingTextContent && "text" in existingTextContent
? existingTextContent.text
: "";
if (currentText === nextText && item.status === status) {
return item;
}
changed = true;
const existingNonTextContent = item.content.filter(
(content) => content.type !== "text" && content.type !== "output_text"
);
return {
...item,
content: nextText
? [
...existingNonTextContent,
{
type: "text",
text: nextText,
} as import("@/types/openai").MessageTextContent,
]
: existingNonTextContent,
status,
};
});
if (changed) {
lastAssistantTextRenderAt.current = now;
setChatItems(nextItems);
} else if (force) {
lastAssistantTextRenderAt.current = now;
}
},
[setChatItems]
);
// Auto-scroll to bottom when new items arrive
useEffect(() => {
@@ -382,6 +449,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
undefined, // No abort signal for resume
storedState.responseId // Pass response ID for resume
);
lastAssistantTextRenderAt.current = 0;
for await (const openAIEvent of streamGenerator) {
// Pass all events to debug panel
@@ -412,6 +480,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: JSON.stringify(error)
: "Request failed";
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return;
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -434,6 +508,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -458,6 +533,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { message?: string };
const errorMessage = errorEvent.message || "An error occurred";
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return;
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -484,27 +565,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
openAIEvent.delta
) {
accumulatedTextRef.current += openAIEvent.delta;
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
renderAssistantStreamingText(assistantMessage.id);
}
}
// Stream ended - mark as complete
const finalUsage = currentMessageUsage.current;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
@@ -721,11 +788,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setIsStreaming(false);
setCurrentConversation(undefined);
accumulatedTextRef.current = "";
lastAssistantTextRenderAt.current = 0;
loadConversations();
// currentConversation is intentionally excluded - this effect should only run when agent changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
}, [selectedAgent, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
// Removed old input handling functions - now handled by ChatMessageInput component
@@ -1118,6 +1186,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedTextRef.current = "";
lastAssistantTextRenderAt.current = 0;
// Create new AbortController for this request
const signal = createAbortSignal();
@@ -1167,6 +1236,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
// Update assistant message with error
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return; // Exit stream processing on failure
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1189,6 +1264,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
// Add to pending approvals (for popup)
setPendingApprovals([
@@ -1267,6 +1343,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorMessage = errorEvent.message || "An error occurred";
// Update assistant message with error and stop streaming
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return; // Exit stream processing early on error
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1290,6 +1372,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
// Handle function calls as separate conversation items
if (item.type === "function_call") {
@@ -1363,28 +1446,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
openAIEvent.delta
) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
renderAssistantStreamingText(assistantMessage.id);
}
// Handle completion/error by detecting when streaming stops
@@ -1394,6 +1456,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Stream ended - mark as complete
// Usage is provided via response.completed event (OpenAI standard)
const finalUsage = currentMessageUsage.current;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
@@ -1419,45 +1482,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
if (isAbortError(error)) {
// User cancelled - mark as cancelled for UI feedback
setWasCancelled(true);
// Mark the message as completed with what we have
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: accumulatedTextRef.current ? "completed" as const : "incomplete" as const,
// Keep whatever text we have accumulated
content: item.content,
}
: item
));
renderAssistantStreamingText(
assistantMessage.id,
accumulatedTextRef.current ? "completed" : "incomplete",
true
);
} else {
// Other errors - show error message
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
} else {
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
}
}
setIsStreaming(false);
resetCancelling();
}
},
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
[selectedAgent, currentConversation, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
);
// Handle non-streaming message sending
@@ -16,9 +16,10 @@ import type {
import type { AgentFrameworkRequest } from "@/types/agent-framework";
import type { ExtendedResponseStreamEvent } from "@/types/openai";
import {
applyStreamingEventToState,
createStreamingState,
loadStreamingState,
updateStreamingState,
markStreamingCompleted,
saveStreamingState,
clearStreamingState,
} from "./streaming-state";
import { isAbortError } from "@/hooks";
@@ -72,6 +73,7 @@ const DEFAULT_API_BASE_URL =
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
const STREAMING_STATE_SAVE_INTERVAL_MS = 250;
// Get backend URL from localStorage or default
function getBackendUrl(): string {
@@ -223,7 +225,7 @@ class ApiClient {
chat_client_type: entity.chat_client_type,
context_provider: entity.context_provider,
middleware: entity.middleware,
};
} as AgentInfo;
} else {
// Workflow - prefer executors field, fall back to tools for backward compatibility
const executorList = entity.executors || entity.tools || [];
@@ -263,7 +265,7 @@ class ApiClient {
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
tools: [],
};
} as WorkflowInfo;
}
});
@@ -484,31 +486,65 @@ class ApiClient {
let hasYieldedAnyEvent = false;
let currentResponseId: string | undefined = resumeResponseId;
let lastMessageId: string | undefined = undefined;
let lastStreamingStateSaveAt = 0;
let storedState = conversationId ? loadStreamingState(conversationId) : null;
let streamingState = storedState ? { ...storedState } : null;
const persistStreamingState = (force: boolean = false): void => {
if (!conversationId || !streamingState) {
return;
}
const now = Date.now();
if (!force && now - lastStreamingStateSaveAt < STREAMING_STATE_SAVE_INTERVAL_MS) {
return;
}
lastStreamingStateSaveAt = now;
saveStreamingState({
...streamingState,
timestamp: now,
});
};
const recordStreamingEvent = (event: ExtendedResponseStreamEvent): void => {
if (!conversationId || !currentResponseId) {
return;
}
streamingState = applyStreamingEventToState(
streamingState ?? createStreamingState({
conversationId,
responseId: currentResponseId,
lastMessageId,
lastSequenceNumber,
accumulatedText: storedState?.accumulatedText,
}),
event,
currentResponseId,
lastMessageId
);
const isTextDelta =
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0;
persistStreamingState(!isTextDelta);
};
// Try to resume from stored state if conversation ID is provided
if (conversationId) {
const storedState = loadStreamingState(conversationId);
if (storedState) {
// Use stored response ID if no explicit one provided
if (!resumeResponseId) {
currentResponseId = storedState.responseId;
}
lastSequenceNumber = storedState.lastSequenceNumber;
lastMessageId = storedState.lastMessageId;
// Replay stored events only if we're not explicitly resuming
// (explicit resume means the caller already has the events)
if (!resumeResponseId) {
for (const event of storedState.events) {
hasYieldedAnyEvent = true;
yield event;
}
} else {
// Mark that we've already seen events up to this sequence number
hasYieldedAnyEvent = storedState.events.length > 0;
}
if (storedState) {
// Use stored response ID if no explicit one provided
if (!resumeResponseId) {
currentResponseId = storedState.responseId;
}
lastSequenceNumber = storedState.lastSequenceNumber;
lastMessageId = storedState.lastMessageId;
hasYieldedAnyEvent =
storedState.lastSequenceNumber >= 0 ||
Boolean(storedState.accumulatedText);
}
while (retryCount <= MAX_RETRY_ATTEMPTS) {
@@ -621,7 +657,8 @@ class ApiClient {
if (done) {
// Stream completed successfully
if (conversationId) {
markStreamingCompleted(conversationId);
clearStreamingState(conversationId);
streamingState = null;
}
return;
}
@@ -640,7 +677,8 @@ class ApiClient {
// Handle [DONE] signal
if (dataStr === "[DONE]") {
if (conversationId) {
markStreamingCompleted(conversationId);
clearStreamingState(conversationId);
streamingState = null;
}
return;
}
@@ -676,6 +714,9 @@ class ApiClient {
if (conversationId) {
clearStreamingState(conversationId);
}
storedState = null;
streamingState = null;
lastStreamingStateSaveAt = 0;
yield {
type: "error",
message: "Connection lost - previous response failed. Starting new response.",
@@ -684,9 +725,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Save new event to storage
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -698,9 +737,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Save event to storage before yielding
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -709,9 +746,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Still save to storage if we have conversation context
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -730,7 +765,8 @@ class ApiClient {
// Don't retry on abort
if (isAbortError(error)) {
if (conversationId) {
markStreamingCompleted(conversationId); // Clean up state
clearStreamingState(conversationId);
streamingState = null;
}
throw error; // Re-throw abort error without retrying
}
@@ -3,7 +3,6 @@
*
* Manages browser storage of streaming response state to enable:
* - Resume interrupted streams after page refresh
* - Replay cached events before fetching new ones
* - Graceful recovery from network disconnections
*/
@@ -14,7 +13,6 @@ export interface StreamingState {
responseId: string;
lastMessageId?: string;
lastSequenceNumber: number;
events: ExtendedResponseStreamEvent[];
timestamp: number; // When this state was last updated
completed: boolean; // Whether the stream completed successfully
accumulatedText?: string; // Accumulated text content for quick restoration
@@ -23,6 +21,14 @@ export interface StreamingState {
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
interface CreateStreamingStateOptions {
conversationId: string;
responseId: string;
lastMessageId?: string;
lastSequenceNumber?: number;
accumulatedText?: string;
}
/**
* Storage key for a specific conversation
*/
@@ -31,16 +37,81 @@ function getStorageKey(conversationId: string): string {
}
/**
* Extract accumulated text from events (for quick restoration)
* Read raw streaming state from storage, including completed entries.
*/
function extractAccumulatedText(events: ExtendedResponseStreamEvent[]): string {
let text = "";
for (const event of events) {
if (event.type === "response.output_text.delta" && "delta" in event) {
text += event.delta;
}
function readStreamingState(conversationId: string): StreamingState | null {
const key = getStorageKey(conversationId);
const data = localStorage.getItem(key);
if (!data) {
return null;
}
return text;
const state: StreamingState = JSON.parse(data);
// Check if state has expired
const age = Date.now() - state.timestamp;
if (age > STATE_EXPIRY_MS) {
clearStreamingState(conversationId);
return null;
}
return state;
}
/**
* Create an initial streaming state snapshot.
*/
export function createStreamingState({
conversationId,
responseId,
lastMessageId,
lastSequenceNumber = -1,
accumulatedText,
}: CreateStreamingStateOptions): StreamingState {
return {
conversationId,
responseId,
lastMessageId,
lastSequenceNumber,
timestamp: Date.now(),
completed: false,
accumulatedText,
};
}
/**
* Apply an incoming stream event to an in-memory streaming state snapshot.
*/
export function applyStreamingEventToState(
state: StreamingState,
event: ExtendedResponseStreamEvent,
responseId: string,
lastMessageId?: string
): StreamingState {
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
const nextState: StreamingState = {
...state,
responseId,
lastMessageId,
timestamp: Date.now(),
completed: event.type === "response.completed" || event.type === "response.failed",
};
if (sequenceNumber !== undefined) {
nextState.lastSequenceNumber = sequenceNumber;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
nextState.accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
}
return nextState;
}
/**
@@ -71,19 +142,8 @@ export function saveStreamingState(state: StreamingState): void {
*/
export function loadStreamingState(conversationId: string): StreamingState | null {
try {
const key = getStorageKey(conversationId);
const data = localStorage.getItem(key);
if (!data) {
return null;
}
const state: StreamingState = JSON.parse(data);
// Check if state has expired
const age = Date.now() - state.timestamp;
if (age > STATE_EXPIRY_MS) {
clearStreamingState(conversationId);
const state = readStreamingState(conversationId);
if (!state) {
return null;
}
@@ -99,54 +159,6 @@ export function loadStreamingState(conversationId: string): StreamingState | nul
}
}
/**
* Update streaming state with a new event
*/
export function updateStreamingState(
conversationId: string,
event: ExtendedResponseStreamEvent,
responseId: string,
lastMessageId?: string
): void {
try {
const existing = loadStreamingState(conversationId);
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
const newEvents = existing ? [...existing.events, event] : [event];
const state: StreamingState = {
conversationId,
responseId,
lastMessageId,
lastSequenceNumber: sequenceNumber ?? (existing?.lastSequenceNumber ?? -1),
events: newEvents,
timestamp: Date.now(),
completed: event.type === "response.completed" || event.type === "response.failed",
accumulatedText: extractAccumulatedText(newEvents),
};
saveStreamingState(state);
} catch (error) {
console.error("Failed to update streaming state:", error);
}
}
/**
* Mark streaming state as completed
*/
export function markStreamingCompleted(conversationId: string): void {
try {
const existing = loadStreamingState(conversationId);
if (existing) {
existing.completed = true;
existing.timestamp = Date.now();
saveStreamingState(existing);
}
} catch (error) {
console.error("Failed to mark streaming as completed:", error);
}
}
/**
* Clear streaming state for a conversation
*/