diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ea8bd30caf..e16070085f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. - TTS/Telegram: keep trusted local audio generated by the TTS tool queued for voice-note delivery even when the run-level built-in tool list omits the raw `tts` name. Fixes #74752. Thanks @Loveworld3033 and @andyliu. - TTS: require explicit user or config audio intent for the agent speech tool so dashboard chats stay text unless audio is requested. Fixes #69777. Thanks @alexandre-leng. - Plugins/config: keep bundled source-checkout plugins from being runtime-gated by install-only `minHostVersion` metadata, accept prerelease host floors, trim plugin-service startup failures to one log line, and avoid broad channel-runtime loading during base config parsing. Thanks @vincentkoc. diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 3ee855b307e..f5d185351f0 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -77,7 +77,7 @@ describe("handleChatEvent", () => { expect(handleChatEvent(state, undefined)).toBe(null); }); - it("returns null when sessionKey does not match", () => { + it("returns null when sessionKey does not match and no active run is in flight", () => { const state = createState({ sessionKey: "main" }); const payload: ChatEventPayload = { runId: "run-1", @@ -87,6 +87,73 @@ describe("handleChatEvent", () => { expect(handleChatEvent(state, payload)).toBe(null); }); + it("accepts delta events for the active run when gateway emits a canonical session key", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: null, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "agent:main:main", + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "Live reply" }], + }, + }; + + expect(handleChatEvent(state, payload)).toBe("delta"); + expect(state.chatStream).toBe("Live reply"); + expect(state.chatRunId).toBe("run-1"); + }); + + it("accepts final events for the active run when gateway emits a canonical session key", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Live reply", + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "agent:main:main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Live reply" }], + }, + }; + + 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("still drops events when neither session key nor active run id matches", () => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: "Working...", + }); + const payload: ChatEventPayload = { + runId: "run-2", + sessionKey: "agent:main:main", + state: "delta", + message: { + role: "assistant", + content: [{ type: "text", text: "Wrong run" }], + }, + }; + + expect(handleChatEvent(state, payload)).toBe(null); + expect(state.chatRunId).toBe("run-1"); + expect(state.chatStream).toBe("Working..."); + expect(state.chatMessages).toEqual([]); + }); + it("returns null for delta from another run", () => { const state = createState({ sessionKey: "main", diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 5deb80a1d6e..e63080a3672 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -714,7 +714,12 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { if (!payload) { return null; } - if (payload.sessionKey !== state.sessionKey) { + const sessionMatches = payload.sessionKey === state.sessionKey; + const activeRunMatches = + state.chatRunId !== null && + typeof payload.runId === "string" && + payload.runId === state.chatRunId; + if (!sessionMatches && !activeRunMatches) { return null; }