mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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.";
|
||||
|
||||
Reference in New Issue
Block a user