fix(webui): clear stale stream state after reconnect

This commit is contained in:
Oxygen56
2026-09-03 17:58:45 +08:00
committed by Xubin Ren
parent b909793784
commit 67f0f26b04
3 changed files with 76 additions and 0 deletions
+22
View File
@@ -643,6 +643,28 @@ export function useNanobotStream(
return () => document.removeEventListener("visibilitychange", flushOnReturn); return () => document.removeEventListener("visibilitychange", flushOnReturn);
}, [flushPendingStreamEvents]); }, [flushPendingStreamEvents]);
useEffect(() => {
if (!chatId) return;
return client.onRunStatus((runChatId, startedAt) => {
if (runChatId !== chatId) return;
if (startedAt !== null) {
setRunStartedAt(startedAt);
setIsStreaming(true);
return;
}
flushPendingStreamEvents();
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
setMessages((prev) => prev.map((message) => (
message.isStreaming ? { ...message, isStreaming: false } : message
)));
setRunStartedAt(null);
setIsStreaming(false);
});
}, [chatId, client, clearActivitySegment, flushPendingStreamEvents]);
// Reset local state when switching chats. Do not reset on every // Reset local state when switching chats. Do not reset on every
// ``initialMessages`` update: a brand-new chat can receive an empty/404 // ``initialMessages`` update: a brand-new chat can receive an empty/404
// history response after the optimistic first message has already rendered. // history response after the optimistic first message has already rendered.
+10
View File
@@ -21,6 +21,7 @@ function makeClient() {
(modelName: string | null, modelPreset?: string | null) => void (modelName: string | null, modelPreset?: string | null) => void
>(); >();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>(); const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const runStartedAtByChatId = new Map<string, number>(); const runStartedAtByChatId = new Map<string, number>();
const runGenerationByChatId = new Map<string, number>(); const runGenerationByChatId = new Map<string, number>();
const latestRunTurnIdByChatId = new Map<string, string>(); const latestRunTurnIdByChatId = new Map<string, string>();
@@ -98,6 +99,13 @@ function makeClient() {
statusHandlers.delete(handler); statusHandlers.delete(handler);
}; };
}, },
onRunStatus: (handler: (chatId: string, startedAt: number | null) => void) => {
runStatusHandlers.add(handler);
for (const [chatId, startedAt] of runStartedAtByChatId) handler(chatId, startedAt);
return () => {
runStatusHandlers.delete(handler);
};
},
onRuntimeModelUpdate: ( onRuntimeModelUpdate: (
handler: (modelName: string | null, modelPreset?: string | null) => void, handler: (modelName: string | null, modelPreset?: string | null) => void,
) => { ) => {
@@ -157,11 +165,13 @@ function makeClient() {
) { ) {
advanceRunGeneration(chatId, ev.turn_id); advanceRunGeneration(chatId, ev.turn_id);
runStartedAtByChatId.set(chatId, ev.started_at); runStartedAtByChatId.set(chatId, ev.started_at);
for (const h of runStatusHandlers) h(chatId, ev.started_at);
} else if ( } else if (
(ev.event === "goal_status" && ev.status === "idle") (ev.event === "goal_status" && ev.status === "idle")
|| ev.event === "turn_end" || ev.event === "turn_end"
) { ) {
runStartedAtByChatId.delete(chatId); runStartedAtByChatId.delete(chatId);
for (const h of runStatusHandlers) h(chatId, null);
} }
if (ev.event === "goal_state") { if (ev.event === "goal_state") {
goalStateByChatId.set(chatId, ev.goal_state); goalStateByChatId.set(chatId, ev.goal_state);
+44
View File
@@ -70,6 +70,7 @@ function normalizeProjection(messages: UIMessage[]): Array<Record<string, unknow
function fakeClient() { function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>(); const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
const statusHandlers = new Set<(status: ConnectionStatus) => void>(); const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const errorHandlers = new Set<(error: StreamError) => void>(); const errorHandlers = new Set<(error: StreamError) => void>();
const runStartedAtByChatId = new Map<string, number>(); const runStartedAtByChatId = new Map<string, number>();
const unsettledRunByChatId = new Map<string, boolean>(); const unsettledRunByChatId = new Map<string, boolean>();
@@ -111,6 +112,11 @@ function fakeClient() {
handler(status); handler(status);
return () => statusHandlers.delete(handler); return () => statusHandlers.delete(handler);
}, },
onRunStatus(handler: (chatId: string, startedAt: number | null) => void) {
runStatusHandlers.add(handler);
for (const [chatId, startedAt] of runStartedAtByChatId) handler(chatId, startedAt);
return () => runStatusHandlers.delete(handler);
},
onError(handler: (error: StreamError) => void) { onError(handler: (error: StreamError) => void) {
errorHandlers.add(handler); errorHandlers.add(handler);
return () => errorHandlers.delete(handler); return () => errorHandlers.delete(handler);
@@ -154,6 +160,11 @@ function fakeClient() {
status = nextStatus; status = nextStatus;
statusHandlers.forEach((handler) => handler(status)); statusHandlers.forEach((handler) => handler(status));
}, },
emitRunStatus(chatId: string, startedAt: number | null) {
if (startedAt === null) runStartedAtByChatId.delete(chatId);
else runStartedAtByChatId.set(chatId, startedAt);
runStatusHandlers.forEach((handler) => handler(chatId, startedAt));
},
emitError(error: StreamError) { emitError(error: StreamError) {
errorHandlers.forEach((handler) => handler(error)); errorHandlers.forEach((handler) => handler(error));
}, },
@@ -327,6 +338,39 @@ describe("useNanobotStream", () => {
}); });
}); });
it("clears stale stream state when the transport resets a run", async () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reconnect-reset", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
act(() => {
fake.emit("chat-reconnect-reset", {
event: "goal_status",
chat_id: "chat-reconnect-reset",
status: "running",
started_at: 1_700,
});
fake.emit("chat-reconnect-reset", {
event: "delta",
chat_id: "chat-reconnect-reset",
text: "partial",
});
});
await flushStreamFrame();
expect(result.current.isStreaming).toBe(true);
act(() => fake.emitRunStatus("chat-reconnect-reset", null));
expect(result.current.runStartedAt).toBeNull();
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages[0]).toMatchObject({
content: "partial",
isStreaming: false,
});
});
it("flushes pending delta text before turn_end finalizes the turn", () => { it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {