diff --git a/CHANGELOG.md b/CHANGELOG.md index 5419af7dc24..3054bcae451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Webchat/Sessions: preserve external session routing metadata when internal `chat.send` turns run under `webchat`, so explicit channel-keyed sessions (for example Telegram) no longer get rewritten to `webchat` and misroute follow-up delivery. (#23258) Thanks @binary64. - 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. - 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 2989092ae3b..ada46e15d7d 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 (e.g. sub-agent announce) without clearing state", () => { + it("returns final for final from another run without clearing active stream", () => { const state = createState({ sessionKey: "main", chatRunId: "run-user", @@ -73,6 +73,7 @@ describe("handleChatEvent", () => { expect(state.chatRunId).toBe("run-user"); expect(state.chatStream).toBe("Working..."); expect(state.chatStreamStartedAt).toBe(123); + expect(state.chatMessages).toEqual([]); }); it("processes final from own run and clears state", () => { @@ -93,6 +94,30 @@ describe("handleChatEvent", () => { expect(state.chatStreamStartedAt).toBe(null); }); + it("appends final payload message from own run before clearing stream state", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Reply", + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Reply" }], + timestamp: 101, + }, + }; + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatMessages).toEqual([payload.message]); + expect(state.chatRunId).toBe(null); + expect(state.chatStream).toBe(null); + expect(state.chatStreamStartedAt).toBe(null); + }); + it("processes aborted from own run and keeps partial assistant message", () => { const existingMessage = { role: "user", diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index d819ceee022..c1af193e907 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -72,6 +72,21 @@ function normalizeAbortedAssistantMessage(message: unknown): Record | null { + if (!message || typeof message !== "object") { + return null; + } + const candidate = message as Record; + const role = typeof candidate.role === "string" ? candidate.role.toLowerCase() : ""; + if (role && role !== "assistant") { + return null; + } + if (!("content" in candidate) && !("text" in candidate)) { + return null; + } + return candidate; +} + export async function sendChatMessage( state: ChatState, message: string, @@ -208,6 +223,10 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { } } } else if (payload.state === "final") { + const finalMessage = normalizeFinalAssistantMessage(payload.message); + if (finalMessage) { + state.chatMessages = [...state.chatMessages, finalMessage]; + } state.chatStream = null; state.chatRunId = null; state.chatStreamStartedAt = null;