Skip to content

Commit cff991c

Browse files
authored
fix(ui): stabilize WebChat final reload reconciliation (#72325)
* fix(ui): stabilize WebChat final reload reconciliation * fix(clownfish): address review for ghcrawl-165991-agentic-merge (1) * fix(ui): keep plain control-token text visible
1 parent f568972 commit cff991c

7 files changed

Lines changed: 229 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ Docs: https://docs.openclaw.ai
200200
- 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.
201201
- 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.
202202
- 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.
203+
- 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.
203204
- 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.
204205
- 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.
205206
- 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.

ui/src/ui/app-gateway.node.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
113113
}));
114114

115115
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
116+
chatMessages: unknown[];
116117
chatSideResult: unknown;
117118
chatSideResultTerminalRuns: Set<string>;
118119
chatStream: string | null;
@@ -873,6 +874,109 @@ describe("connectGateway", () => {
873874
},
874875
);
875876

877+
it("does not reload chat history after final assistant payload reconciles an active run", () => {
878+
const { host, client } = connectHostGateway();
879+
host.chatRunId = "main-run-4";
880+
loadChatHistoryMock.mockClear();
881+
882+
client.emitEvent({
883+
event: "session.message",
884+
payload: {
885+
sessionKey: "main",
886+
},
887+
});
888+
client.emitEvent({
889+
event: "chat",
890+
payload: {
891+
runId: "main-run-4",
892+
sessionKey: "main",
893+
state: "final",
894+
message: {
895+
role: "assistant",
896+
content: [{ type: "text", text: "Final answer" }],
897+
},
898+
},
899+
});
900+
901+
expect(host.chatRunId).toBeNull();
902+
expect(host.chatMessages).toEqual([
903+
{
904+
role: "assistant",
905+
content: [{ type: "text", text: "Final answer" }],
906+
},
907+
]);
908+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
909+
});
910+
911+
it("replays deferred session.message reloads after legacy silent final payload", () => {
912+
const { host, client } = connectHostGateway();
913+
host.chatRunId = "main-run-silent";
914+
loadChatHistoryMock.mockClear();
915+
916+
client.emitEvent({
917+
event: "session.message",
918+
payload: {
919+
sessionKey: "main",
920+
},
921+
});
922+
client.emitEvent({
923+
event: "chat",
924+
payload: {
925+
runId: "main-run-silent",
926+
sessionKey: "main",
927+
state: "final",
928+
message: {
929+
role: "assistant",
930+
content: [{ type: "text", text: "NO_REPLY" }],
931+
},
932+
},
933+
});
934+
935+
expect(host.chatRunId).toBeNull();
936+
expect(host.chatMessages).toEqual([]);
937+
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
938+
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
939+
});
940+
941+
it("keeps deferred session.message reload pending across unrelated terminal events", () => {
942+
const { host, client } = connectHostGateway();
943+
host.chatRunId = "main-run-5";
944+
host.chatStream = "still streaming";
945+
loadChatHistoryMock.mockClear();
946+
947+
client.emitEvent({
948+
event: "session.message",
949+
payload: {
950+
sessionKey: "main",
951+
},
952+
});
953+
client.emitEvent({
954+
event: "chat",
955+
payload: {
956+
runId: "other-run-1",
957+
sessionKey: "main",
958+
state: "final",
959+
},
960+
});
961+
962+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
963+
expect(host.chatRunId).toBe("main-run-5");
964+
expect(host.chatStream).toBe("still streaming");
965+
966+
client.emitEvent({
967+
event: "chat",
968+
payload: {
969+
runId: "main-run-5",
970+
sessionKey: "main",
971+
state: "aborted",
972+
},
973+
});
974+
975+
expect(host.chatRunId).toBeNull();
976+
expect(loadChatHistoryMock).toHaveBeenCalledTimes(1);
977+
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
978+
});
979+
876980
it("clears tracked BTW terminal runs after reconnect hello", () => {
877981
const host = createHost();
878982

ui/src/ui/app-gateway.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -614,23 +614,24 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
614614
const deferredReloadHost = host as GatewayHostWithDeferredSessionMessageReload;
615615
const deferredSessionKey = deferredReloadHost.pendingSessionMessageReloadSessionKey?.trim();
616616
const payloadSessionKey = payload?.sessionKey?.trim();
617-
const shouldReplayDeferredSessionMessageReload = Boolean(
617+
const finalEventNeedsHistoryReload =
618+
state === "final" && shouldReloadHistoryForFinalEvent(payload);
619+
const shouldResolveDeferredSessionMessageReload = Boolean(
618620
deferredSessionKey &&
619621
payloadSessionKey &&
620622
deferredSessionKey === payloadSessionKey &&
621623
isTerminalChatState(state) &&
624+
!terminalEventIsForDifferentActiveRun &&
622625
payloadSessionKey === host.sessionKey &&
623626
!host.chatRunId,
624627
);
625-
if (deferredSessionKey && payloadSessionKey && deferredSessionKey === payloadSessionKey) {
628+
const shouldReplayDeferredSessionMessageReload =
629+
shouldResolveDeferredSessionMessageReload &&
630+
(state !== "final" || finalEventNeedsHistoryReload);
631+
if (shouldResolveDeferredSessionMessageReload) {
626632
deferredReloadHost.pendingSessionMessageReloadSessionKey = null;
627633
}
628-
if (
629-
state === "final" &&
630-
!historyReloaded &&
631-
!terminalEventIsForDifferentActiveRun &&
632-
shouldReloadHistoryForFinalEvent(payload)
633-
) {
634+
if (finalEventNeedsHistoryReload && !historyReloaded && !terminalEventIsForDifferentActiveRun) {
634635
void loadChatHistory(host as unknown as ChatState);
635636
return;
636637
}

ui/src/ui/chat-event-reload.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,53 @@ describe("shouldReloadHistoryForFinalEvent", () => {
2323
).toBe(true);
2424
});
2525

26-
it("returns true when final event includes assistant payload", () => {
26+
it("returns false when final event includes renderable assistant payload", () => {
2727
expect(
2828
shouldReloadHistoryForFinalEvent({
2929
runId: "run-1",
3030
sessionKey: "main",
3131
state: "final",
3232
message: { role: "assistant", content: [{ type: "text", text: "done" }] },
3333
}),
34+
).toBe(false);
35+
});
36+
37+
it("returns false when final event includes a legacy assistant text payload without role", () => {
38+
expect(
39+
shouldReloadHistoryForFinalEvent({
40+
runId: "run-1",
41+
sessionKey: "main",
42+
state: "final",
43+
message: { text: "done" },
44+
}),
45+
).toBe(false);
46+
});
47+
48+
it("returns true when final event includes legacy silent assistant payload", () => {
49+
expect(
50+
shouldReloadHistoryForFinalEvent({
51+
runId: "run-1",
52+
sessionKey: "main",
53+
state: "final",
54+
message: { role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
55+
}),
3456
).toBe(true);
3557
});
3658

59+
it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
60+
"returns false when assistant payload is plain text %s",
61+
(text) => {
62+
expect(
63+
shouldReloadHistoryForFinalEvent({
64+
runId: "run-1",
65+
sessionKey: "main",
66+
state: "final",
67+
message: { role: "assistant", content: [{ type: "text", text }] },
68+
}),
69+
).toBe(false);
70+
},
71+
);
72+
3773
it("returns true when final event message role is non-assistant", () => {
3874
expect(
3975
shouldReloadHistoryForFinalEvent({

ui/src/ui/chat-event-reload.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
1+
import { extractText } from "./chat/message-extract.ts";
12
import type { ChatEventPayload } from "./controllers/chat.ts";
3+
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
4+
5+
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
6+
7+
function hasRenderableAssistantFinalMessage(message: unknown): boolean {
8+
if (!message || typeof message !== "object") {
9+
return false;
10+
}
11+
const entry = message as Record<string, unknown>;
12+
const role = normalizeLowercaseStringOrEmpty(entry.role);
13+
if (role && role !== "assistant") {
14+
return false;
15+
}
16+
if (!("content" in entry) && !("text" in entry)) {
17+
return false;
18+
}
19+
const text = extractText(message);
20+
return typeof text === "string" && text.trim() !== "" && !SILENT_REPLY_PATTERN.test(text);
21+
}
222

323
export function shouldReloadHistoryForFinalEvent(payload?: ChatEventPayload): boolean {
4-
return Boolean(payload && payload.state === "final");
24+
return Boolean(
25+
payload && payload.state === "final" && !hasRenderableAssistantFinalMessage(payload.message),
26+
);
527
}

ui/src/ui/controllers/chat.test.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,22 @@ function createActiveStreamingState() {
4747
});
4848
}
4949

50-
function createOtherRunNoReplyFinalPayload(): ChatEventPayload {
50+
function createOtherRunSilentFinalPayload(text: string): ChatEventPayload {
5151
return {
5252
runId: "run-announce",
5353
sessionKey: "main",
5454
state: "final",
5555
message: {
5656
role: "assistant",
57-
content: [{ type: "text", text: "NO_REPLY" }],
57+
content: [{ type: "text", text }],
5858
},
5959
};
6060
}
6161

62+
function createOtherRunNoReplyFinalPayload(): ChatEventPayload {
63+
return createOtherRunSilentFinalPayload("NO_REPLY");
64+
}
65+
6266
describe("handleChatEvent", () => {
6367
it("returns null when payload is missing", () => {
6468
const state = createState();
@@ -144,6 +148,20 @@ describe("handleChatEvent", () => {
144148
expect(state.chatMessages).toEqual([]);
145149
});
146150

151+
it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
152+
"keeps plain-text %s final payload from another run without clearing active stream",
153+
(text) => {
154+
const state = createActiveStreamingState();
155+
const payload = createOtherRunSilentFinalPayload(text);
156+
157+
expect(handleChatEvent(state, payload)).toBe(null);
158+
expect(state.chatRunId).toBe("run-user");
159+
expect(state.chatStream).toBe("Working...");
160+
expect(state.chatStreamStartedAt).toBe(123);
161+
expect(state.chatMessages).toEqual([payload.message]);
162+
},
163+
);
164+
147165
it("replaces the stream when a delta snapshot gets shorter", () => {
148166
const state = createState({
149167
sessionKey: "main",
@@ -440,6 +458,32 @@ describe("handleChatEvent", () => {
440458
expect(state.chatStream).toBe(null);
441459
});
442460

461+
it.each(["no_reply", "ANNOUNCE_SKIP", "REPLY_SKIP"])(
462+
"keeps plain-text %s final payload from own run",
463+
(text) => {
464+
const state = createState({
465+
sessionKey: "main",
466+
chatRunId: "run-1",
467+
chatStream: text,
468+
chatStreamStartedAt: 100,
469+
});
470+
const payload: ChatEventPayload = {
471+
runId: "run-1",
472+
sessionKey: "main",
473+
state: "final",
474+
message: {
475+
role: "assistant",
476+
content: [{ type: "text", text }],
477+
},
478+
};
479+
480+
expect(handleChatEvent(state, payload)).toBe("final");
481+
expect(state.chatMessages).toEqual([payload.message]);
482+
expect(state.chatRunId).toBe(null);
483+
expect(state.chatStream).toBe(null);
484+
},
485+
);
486+
443487
it("does not persist NO_REPLY stream text on final without message", () => {
444488
const state = createState({
445489
sessionKey: "main",
@@ -522,10 +566,13 @@ describe("handleChatEvent", () => {
522566
});
523567

524568
describe("loadChatHistory", () => {
525-
it("filters NO_REPLY assistant messages from history", async () => {
569+
it("filters legacy silent assistant messages from history", async () => {
526570
const messages = [
527571
{ role: "user", content: [{ type: "text", text: "Hello" }] },
528572
{ role: "assistant", content: [{ type: "text", text: "NO_REPLY" }] },
573+
{ role: "assistant", content: [{ type: "text", text: "no_reply" }] },
574+
{ role: "assistant", content: [{ type: "text", text: "ANNOUNCE_SKIP" }] },
575+
{ role: "assistant", content: [{ type: "text", text: "REPLY_SKIP" }] },
529576
{ role: "assistant", content: [{ type: "text", text: "Real answer" }] },
530577
{ role: "assistant", text: " NO_REPLY " },
531578
];
@@ -539,9 +586,12 @@ describe("loadChatHistory", () => {
539586

540587
await loadChatHistory(state);
541588

542-
expect(state.chatMessages).toHaveLength(2);
589+
expect(state.chatMessages).toHaveLength(5);
543590
expect(state.chatMessages[0]).toEqual(messages[0]);
544591
expect(state.chatMessages[1]).toEqual(messages[2]);
592+
expect(state.chatMessages[2]).toEqual(messages[3]);
593+
expect(state.chatMessages[3]).toEqual(messages[4]);
594+
expect(state.chatMessages[4]).toEqual(messages[5]);
545595
expect(state.chatThinkingLevel).toBe("low");
546596
expect(state.chatLoading).toBe(false);
547597
});

ui/src/ui/controllers/chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
isMissingOperatorReadScopeError,
1111
} from "./scope-errors.ts";
1212

13-
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
1413
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
14+
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
1515
const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
1616
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
1717
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";

0 commit comments

Comments
 (0)