From f2e9986813dab628f25db5832c731dc17acbf173 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 21:14:25 +0100 Subject: [PATCH] fix(webchat): append out-of-band final payloads in active chat (#11139) Co-authored-by: AkshayNavle <110360+AkshayNavle@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/ui/controllers/chat.test.ts | 22 ++++++++++++++++++++-- ui/src/ui/controllers/chat.ts | 5 +++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3054bcae451..4c114d97fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai - Webchat/Sessions: preserve existing session `label` across `/new` and `/reset` rollovers so reset sessions remain discoverable in session history lists. (#23755) Thanks @ThunderStormer. - Control UI/WebSocket: stop and clear the browser gateway client on UI teardown so remounts cannot leave orphan websocket clients that create duplicate active connections. (#23422) Thanks @floatinggball-design. - Webchat/Chat: apply assistant `final` payload messages directly to chat state so sent turns render without waiting for a full history refresh cycle. (#14928) Thanks @BradGroux. +- Webchat/Chat: for out-of-band final events (for example tool-call side runs), append provided final assistant payloads directly instead of forcing a transient history reset. (#11139) Thanks @AkshayNavle. - Config/Memory: allow `"mistral"` in `agents.defaults.memorySearch.provider` and `agents.defaults.memorySearch.fallback` schema validation. (#14934) Thanks @ThomsenDrake. - Security/Feishu: enforce ID-only allowlist matching for DM/group sender authorization, normalize Feishu ID prefixes during checks, and ignore mutable display names so display-name collisions cannot satisfy allowlist entries. This ships in the next npm release. Thanks @jiseoung for reporting. - Security/Group policy: harden `channels.*.groups.*.toolsBySender` matching by requiring explicit sender-key types (`id:`, `e164:`, `username:`, `name:`), preventing cross-identifier collisions across mutable/display-name fields while keeping legacy untyped keys on a deprecated ID-only path. This ships in the next npm release. Thanks @jiseoung for reporting. diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index ada46e15d7d..456d9a537c0 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -53,7 +53,7 @@ describe("handleChatEvent", () => { expect(state.chatStream).toBe("Hello"); }); - it("returns final for final from another run without clearing active stream", () => { + it("appends final payload from another run without clearing active stream", () => { const state = createState({ sessionKey: "main", chatRunId: "run-user", @@ -69,10 +69,28 @@ describe("handleChatEvent", () => { content: [{ type: "text", text: "Sub-agent findings" }], }, }; - expect(handleChatEvent(state, payload)).toBe("final"); + 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).toHaveLength(1); + expect(state.chatMessages[0]).toEqual(payload.message); + }); + + it("returns final for another run when payload has no message", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-user", + chatStream: "Working...", + chatStreamStartedAt: 123, + }); + const payload: ChatEventPayload = { + runId: "run-announce", + sessionKey: "main", + state: "final", + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatRunId).toBe("run-user"); expect(state.chatMessages).toEqual([]); }); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index c1af193e907..c3207950079 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -209,6 +209,11 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { // See https://github.com/openclaw/openclaw/issues/1909 if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) { if (payload.state === "final") { + const finalMessage = normalizeFinalAssistantMessage(payload.message); + if (finalMessage) { + state.chatMessages = [...state.chatMessages, finalMessage]; + return null; + } return "final"; } return null;