Skip to content

Commit 02908db

Browse files
authored
fix(ui): clear webchat pending state only for completed active run (#73368)
1 parent 3ed3248 commit 02908db

5 files changed

Lines changed: 95 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
- CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc.
2525
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
2626
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
27+
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
2728
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
2829
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
2930
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,47 @@ describe("connectGateway", () => {
977977
expect(loadChatHistoryMock).toHaveBeenCalledWith(host);
978978
});
979979

980+
it("keeps deferred session.message reload pending across unowned terminal events", () => {
981+
const { host, client } = connectHostGateway();
982+
host.chatRunId = "main-run-unowned";
983+
host.chatStream = "still streaming";
984+
loadChatHistoryMock.mockClear();
985+
986+
client.emitEvent({
987+
event: "session.message",
988+
payload: {
989+
sessionKey: "main",
990+
},
991+
});
992+
client.emitEvent({
993+
event: "chat",
994+
payload: {
995+
sessionKey: "main",
996+
state: "final",
997+
},
998+
});
999+
1000+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
1001+
expect(host.chatRunId).toBe("main-run-unowned");
1002+
expect(host.chatStream).toBe("still streaming");
1003+
1004+
client.emitEvent({
1005+
event: "chat",
1006+
payload: {
1007+
runId: "main-run-unowned",
1008+
sessionKey: "main",
1009+
state: "final",
1010+
message: {
1011+
role: "assistant",
1012+
content: [{ type: "text", text: "Done" }],
1013+
},
1014+
},
1015+
});
1016+
1017+
expect(host.chatRunId).toBeNull();
1018+
expect(loadChatHistoryMock).not.toHaveBeenCalled();
1019+
});
1020+
9801021
it("clears tracked BTW terminal runs after reconnect hello", () => {
9811022
const host = createHost();
9821023

ui/src/ui/app-gateway.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,7 @@ function isEventForDifferentActiveRun(
585585
payload: ChatEventPayload | undefined,
586586
activeRunId: string | null,
587587
): boolean {
588-
return Boolean(activeRunId && payload?.runId && payload.runId !== activeRunId);
588+
return Boolean(activeRunId && payload && payload.runId !== activeRunId);
589589
}
590590

591591
function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) {

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,55 @@ describe("handleChatEvent", () => {
201201
expect(state.chatMessages).toEqual([]);
202202
});
203203

204+
it("keeps active stream for unowned final payloads", () => {
205+
const state = createActiveStreamingState();
206+
const payload: ChatEventPayload = {
207+
sessionKey: "main",
208+
state: "final",
209+
};
210+
211+
expect(handleChatEvent(state, payload)).toBe("final");
212+
expect(state.chatRunId).toBe("run-user");
213+
expect(state.chatStream).toBe("Working...");
214+
expect(state.chatStreamStartedAt).toBe(123);
215+
expect(state.chatMessages).toEqual([]);
216+
});
217+
218+
it("keeps active stream while appending unowned assistant finals", () => {
219+
const state = createActiveStreamingState();
220+
const payload: ChatEventPayload = {
221+
sessionKey: "main",
222+
state: "final",
223+
message: {
224+
role: "assistant",
225+
content: [{ type: "text", text: "Injected note" }],
226+
},
227+
};
228+
229+
expect(handleChatEvent(state, payload)).toBe(null);
230+
expect(state.chatRunId).toBe("run-user");
231+
expect(state.chatStream).toBe("Working...");
232+
expect(state.chatStreamStartedAt).toBe(123);
233+
expect(state.chatMessages).toEqual([payload.message]);
234+
});
235+
236+
it.each(["aborted", "error"] as const)(
237+
"keeps active stream for unowned %s payloads",
238+
(terminalState) => {
239+
const state = createActiveStreamingState();
240+
const payload: ChatEventPayload = {
241+
sessionKey: "main",
242+
state: terminalState,
243+
};
244+
245+
expect(handleChatEvent(state, payload)).toBe(null);
246+
expect(state.chatRunId).toBe("run-user");
247+
expect(state.chatStream).toBe("Working...");
248+
expect(state.chatStreamStartedAt).toBe(123);
249+
expect(state.chatMessages).toEqual([]);
250+
},
251+
);
252+
204253
it("persists streamed text when final event carries no message", () => {
205254
const existingMessage = {
206255
role: "user",

ui/src/ui/controllers/chat.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ export type ChatState = {
364364
};
365365

366366
export type ChatEventPayload = {
367-
runId: string;
367+
runId?: string;
368368
sessionKey: string;
369369
state: "delta" | "final" | "aborted" | "error";
370370
message?: unknown;
@@ -718,9 +718,10 @@ export function handleChatEvent(state: ChatState, payload?: ChatEventPayload) {
718718
return null;
719719
}
720720

721+
// Terminal events for the active client run carry runId; missing-runId events are unowned.
721722
// Final from another run (e.g. sub-agent announce): refresh history to show new message.
722723
// See https://github.com/openclaw/openclaw/issues/1909
723-
if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId) {
724+
if (state.chatRunId && payload.runId !== state.chatRunId) {
724725
if (payload.state === "final") {
725726
const finalMessage = normalizeFinalAssistantMessage(payload.message);
726727
if (finalMessage && !isAssistantSilentReply(finalMessage)) {

0 commit comments

Comments
 (0)