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
This commit is contained in:
Vincent Koc
2026-04-27 12:52:39 -07:00
committed by GitHub
parent f56897259e
commit cff991c88d
7 changed files with 229 additions and 15 deletions

View File

@@ -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.

View File

@@ -113,6 +113,7 @@ vi.mock("./controllers/control-ui-bootstrap.ts", () => ({
}));
type TestGatewayHost = Parameters<typeof connectGateway>[0] & {
chatMessages: unknown[];
chatSideResult: unknown;
chatSideResultTerminalRuns: Set<string>;
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();

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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<string, unknown>;
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),
);
}

View File

@@ -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);
});

View File

@@ -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.";