diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f25cdf4743..fd3a2471655 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -200,6 +200,7 @@ Docs: https://docs.openclaw.ai - Docker: pre-create `/home/node/.openclaw` with node ownership and private permissions so first-run Docker Compose named volumes no longer fail startup with EACCES. (#48072, #63959; fixes #61279) Thanks @timoxue and @jeanibarz. - CLI/Gateway: treat local restart probe policy closes for connect, exact `device required`, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool. - Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw. +- Control UI/WebChat: skip redundant final-event history reloads when the assistant payload already rendered, and keep deferred `session.message` reloads attached to the active run so final reconciliation no longer splits, duplicates, or drops assistant bubbles. Fixes #66875 and #66274; follows #66997 and #67037. Thanks @BiznessFish, @scotthuang, and @hansolo949. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. - Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled. - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 3b1cbdc47a1..4ca7124e1c0 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -113,6 +113,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({ })); type TestGatewayHost = Parameters[0] & { + chatMessages: unknown[]; chatSideResult: unknown; chatSideResultTerminalRuns: Set; chatStream: string | null; @@ -873,6 +874,109 @@ describe("connectGateway", () => { }, ); + it("does not reload chat history after final assistant payload reconciles an active run", () => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-4"; + loadChatHistoryMock.mockClear(); + + client.emitEvent({ + event: "session.message", + payload: { + sessionKey: "main", + }, + }); + client.emitEvent({ + event: "chat", + payload: { + runId: "main-run-4", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Final answer" }], + }, + }, + }); + + expect(host.chatRunId).toBeNull(); + expect(host.chatMessages).toEqual([ + { + role: "assistant", + content: [{ type: "text", text: "Final answer" }], + }, + ]); + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + }); + + it("replays deferred session.message reloads after legacy silent final payload", () => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-silent"; + loadChatHistoryMock.mockClear(); + + client.emitEvent({ + event: "session.message", + payload: { + sessionKey: "main", + }, + }); + client.emitEvent({ + event: "chat", + payload: { + runId: "main-run-silent", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + }, + }, + }); + + expect(host.chatRunId).toBeNull(); + expect(host.chatMessages).toEqual([]); + expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); + expect(loadChatHistoryMock).toHaveBeenCalledWith(host); + }); + + it("keeps deferred session.message reload pending across unrelated terminal events", () => { + const { host, client } = connectHostGateway(); + host.chatRunId = "main-run-5"; + host.chatStream = "still streaming"; + loadChatHistoryMock.mockClear(); + + client.emitEvent({ + event: "session.message", + payload: { + sessionKey: "main", + }, + }); + client.emitEvent({ + event: "chat", + payload: { + runId: "other-run-1", + sessionKey: "main", + state: "final", + }, + }); + + expect(loadChatHistoryMock).not.toHaveBeenCalled(); + expect(host.chatRunId).toBe("main-run-5"); + expect(host.chatStream).toBe("still streaming"); + + client.emitEvent({ + event: "chat", + payload: { + runId: "main-run-5", + sessionKey: "main", + state: "aborted", + }, + }); + + expect(host.chatRunId).toBeNull(); + expect(loadChatHistoryMock).toHaveBeenCalledTimes(1); + expect(loadChatHistoryMock).toHaveBeenCalledWith(host); + }); + 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 02782f194fe..bc442a619c5 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -614,23 +614,24 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload; const deferredSessionKey = deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim(); const payloadSessionKey = payload?.sessionKey?.trim(); - const shouldReplayDeferredSessionMessageReload = Boolean( + const finalEventNeedsHistoryReload = + state === "final" && shouldReloadHistoryForFinalEvent(payload); + const shouldResolveDeferredSessionMessageReload = Boolean( deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey && isTerminalChatState(state) && + !terminalEventIsForDifferentActiveRun && payloadSessionKey === host.sessionKey && !host.chatRunId, ); - if (deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey) { + const shouldReplayDeferredSessionMessageReload = + shouldResolveDeferredSessionMessageReload && + (state !== "final" || finalEventNeedsHistoryReload); + if (shouldResolveDeferredSessionMessageReload) { deferredReloadHost.pendingSessionMessageReloadSessionKey = null; } - if ( - state === "final" && - !historyReloaded && - !terminalEventIsForDifferentActiveRun && - shouldReloadHistoryForFinalEvent(payload) - ) { + if (finalEventNeedsHistoryReload && !historyReloaded && !terminalEventIsForDifferentActiveRun) { void loadChatHistory(host as unknown as ChatState); return; } diff --git a/ui/src/ui/chat-event-reload.test.ts b/ui/src/ui/chat-event-reload.test.ts index ec351522c08..5c2ff4b29e5 100644 --- a/ui/src/ui/chat-event-reload.test.ts +++ b/ui/src/ui/chat-event-reload.test.ts @@ -23,7 +23,7 @@ describe("shouldReloadHistoryForFinalEvent", () => { ).toBe(true); }); - it("returns true when final event includes assistant payload", () => { + it("returns false when final event includes renderable assistant payload", () => { expect( shouldReloadHistoryForFinalEvent({ runId: "run-1", @@ -31,9 +31,45 @@ describe("shouldReloadHistoryForFinalEvent", () => { state: "final", message: { role: "assistant", content: [{ type: "text", text: "done" }] }, }), + ).toBe(false); + }); + + it("returns false when final event includes a legacy assistant text payload without role", () => { + expect( + shouldReloadHistoryForFinalEvent({ + runId: "run-1", + sessionKey: "main", + state: "final", + message: { text: "done" }, + }), + ).toBe(false); + }); + + it("returns true when final event includes legacy silent assistant payload", () => { + expect( + shouldReloadHistoryForFinalEvent({ + runId: "run-1", + sessionKey: "main", + state: "final", + message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, + }), ).toBe(true); }); + it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])( + "returns false when assistant payload is plain text %s", + (text) => { + expect( + shouldReloadHistoryForFinalEvent({ + runId: "run-1", + sessionKey: "main", + state: "final", + message: { role: "assistant", content: [{ type: "text", text }] }, + }), + ).toBe(false); + }, + ); + it("returns true when final event message role is non-assistant", () => { expect( shouldReloadHistoryForFinalEvent({ diff --git a/ui/src/ui/chat-event-reload.ts b/ui/src/ui/chat-event-reload.ts index ff01b76f609..af873661ace 100644 --- a/ui/src/ui/chat-event-reload.ts +++ b/ui/src/ui/chat-event-reload.ts @@ -1,5 +1,27 @@ +import { extractText } from "./chat/message-extract.ts"; import type { ChatEventPayload } from "./controllers/chat.ts"; +import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; + +const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; + +function hasRenderableAssistantFinalMessage(message: unknown): boolean { + if (!message || typeof message !== "object") { + return false; + } + const entry = message as Record; + const role = normalizeLowercaseStringOrEmpty(entry.role); + if (role && role !== "assistant") { + return false; + } + if (!("content" in entry) && !("text" in entry)) { + return false; + } + const text = extractText(message); + return typeof text === "string" && text.trim() !== "" && !SILENT_REPLY_PATTERN.test(text); +} export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean { - return Boolean(payload && payload.state === "final"); + return Boolean( + payload && payload.state === "final" && !hasRenderableAssistantFinalMessage(payload.message), + ); } diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts index 2143ab83c56..e9a7c783fef 100644 --- a/ui/src/ui/controllers/chat.test.ts +++ b/ui/src/ui/controllers/chat.test.ts @@ -47,18 +47,22 @@ function createActiveStreamingState() { }); } -function createOtherRunNoReplyFinalPayload(): ChatEventPayload { +function createOtherRunSilentFinalPayload(text: string): ChatEventPayload { return { runId: "run-announce", sessionKey: "main", state: "final", message: { role: "assistant", - content: [{ type: "text", text: "NO_REPLY" }], + content: [{ type: "text", text }], }, }; } +function createOtherRunNoReplyFinalPayload(): ChatEventPayload { + return createOtherRunSilentFinalPayload("NO_REPLY"); +} + describe("handleChatEvent", () => { it("returns null when payload is missing", () => { const state = createState(); @@ -144,6 +148,20 @@ describe("handleChatEvent", () => { expect(state.chatMessages).toEqual([]); }); + it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])( + "keeps plain-text %s final payload from another run without clearing active stream", + (text) => { + const state = createActiveStreamingState(); + const payload = createOtherRunSilentFinalPayload(text); + + 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("replaces the stream when a delta snapshot gets shorter", () => { const state = createState({ sessionKey: "main", @@ -440,6 +458,32 @@ describe("handleChatEvent", () => { expect(state.chatStream).toBe(null); }); + it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])( + "keeps plain-text %s final payload from own run", + (text) => { + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatStream: text, + chatStreamStartedAt: 100, + }); + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text }], + }, + }; + + expect(handleChatEvent(state, payload)).toBe("final"); + expect(state.chatMessages).toEqual([payload.message]); + expect(state.chatRunId).toBe(null); + expect(state.chatStream).toBe(null); + }, + ); + it("does not persist NO_REPLY stream text on final without message", () => { const state = createState({ sessionKey: "main", @@ -522,10 +566,13 @@ describe("handleChatEvent", () => { }); describe("loadChatHistory", () => { - it("filters NO_REPLY assistant messages from history", async () => { + it("filters legacy silent assistant messages from history", async () => { const messages = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] }, + { role: "assistant", content: [{ type: "text", text: "no_reply" }] }, + { role: "assistant", content: [{ type: "text", text: "ANNOUNCE_SKIP" }] }, + { role: "assistant", content: [{ type: "text", text: "REPLY_SKIP" }] }, { role: "assistant", content: [{ type: "text", text: "Real answer" }] }, { role: "assistant", text: " NO_REPLY " }, ]; @@ -539,9 +586,12 @@ describe("loadChatHistory", () => { await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(2); + expect(state.chatMessages).toHaveLength(5); expect(state.chatMessages[0]).toEqual(messages[0]); expect(state.chatMessages[1]).toEqual(messages[2]); + expect(state.chatMessages[2]).toEqual(messages[3]); + expect(state.chatMessages[3]).toEqual(messages[4]); + expect(state.chatMessages[4]).toEqual(messages[5]); expect(state.chatThinkingLevel).toBe("low"); expect(state.chatLoading).toBe(false); }); diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts index 6c03716b531..3e0daf83b3c 100644 --- a/ui/src/ui/controllers/chat.ts +++ b/ui/src/ui/controllers/chat.ts @@ -10,8 +10,8 @@ import { isMissingOperatorReadScopeError, } from "./scope-errors.ts"; -const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; const HEARTBEAT_TOKEN = "HEARTBEAT_OK"; +const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/; const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT = "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";