From 02908db62b3021d8c70eefb9ef2eea05be3d24c3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 28 Apr 2026 01:47:00 -0700 Subject: [PATCH] fix(ui): clear webchat pending state only for completed active run (#73368) --- CHANGELOG.md | 1 + ui/src/ui/app-gateway.node.test.ts | 41 +++++++++++++++++++++++++ ui/src/ui/app-gateway.ts | 2 +- ui/src/ui/controllers/chat.test.ts | 49 ++++++++++++++++++++++++++++++ ui/src/ui/controllers/chat.ts | 5 +-- 5 files changed, 95 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a4c83aaa..3e01dfe8f02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc. - Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk. - Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16. +- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu. - Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto. - Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston. - Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev. diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 4ca7124e1c0..c44b4ef0f90 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -977,6 +977,47 @@ describe("connectGateway", () => { expect(loadChatHistoryMock).toHaveBeenCalledWith(host); }); + it("keeps deferred session.message reload pending across unowned terminal events", () => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-unowned"; + host.chatStream = "still streaming"; + loadChatHistoryMock.mockClear(); + + client.emitEvent({ + event: "session.message", + payload: { + sessionKey: "main", + }, + }); + client.emitEvent({ + event: "chat", + payload: { + sessionKey: "main", + state: "final", + }, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(host.chatRunId).toBe("main-run-unowned"); + expect(host.chatStream).toBe("still streaming"); + + client.emitEvent({ + event: "chat", + payload: { + runId: "main-run-unowned", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Done" }], + }, + }, + }); + + expect(host.chatRunId).toBeNull(); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + it("clears tracked BTW terminal runs after reconnect hello", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index bc442a619c5..e6673fc3aea 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -585,7 +585,7 @@ function isEventForDifferentActiveRun( payload: ChatEventPayload | undefined, activeRunId: string | null, ): boolean { - return Boolean(activeRunId && payload?.runId && payload.runId !== activeRunId); + return Boolean(activeRunId && payload && payload.runId !== activeRunId); } function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) { diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index edecf302705..3ee855b307e 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -201,6 +201,55 @@ describe("handleChatEvent", () => { expect(state.chatMessages).toEqual([]); }); + it("keeps active stream for unowned final payloads", () => { + const state = createActiveStreamingState(); + const payload: ChatEventPayload = { + sessionKey: "main", + state: "final", + }; + + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatStreamStartedAt).toBe(123); + expect(state.chatMessages).toEqual([]); + }); + + it("keeps active stream while appending unowned assistant finals", () => { + const state = createActiveStreamingState(); + const payload: ChatEventPayload = { + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Injected note" }], + }, + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatStreamStartedAt).toBe(123); + expect(state.chatMessages).toEqual([payload.message]); + }); + + it.each(["aborted", "error"] as const)( + "keeps active stream for unowned %s payloads", + (terminalState) => { + const state = createActiveStreamingState(); + const payload: ChatEventPayload = { + sessionKey: "main", + state: terminalState, + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBe("run-user"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatStreamStartedAt).toBe(123); + expect(state.chatMessages).toEqual([]); + }, + ); + it("persists streamed text when final event carries no message", () => { const existingMessage = { role: "user", diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 3f3f613f4a3..5deb80a1d6e 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -364,7 +364,7 @@ export type ChatState = { }; export type ChatEventPayload = { - runId: string; + runId?: string; sessionKey: string; state: "delta" | "final" | "aborted" | "error"; message?: unknown; @@ -718,9 +718,10 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { return null; } + // Terminal events for the active client run carry runId; missing-runId events are unowned. // Final from another run (e.g. sub-agent announce): refresh history to show new message. // See https://github.com/openclaw/openclaw/issues/1909 - if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) { + if (state.chatRunId && payload.runId !== state.chatRunId) { if (payload.state === "final") { const finalMessage = normalizeFinalAssistantMessage(payload.message); if (finalMessage && !isAssistantSilentReply(finalMessage)) {