From 8513a14406fb64fc99b36f46b170f782284b1f6a Mon Sep 17 00:00:00 2001 From: Chinar Amrutkar Date: Thu, 23 Apr 2026 19:08:43 +0100 Subject: [PATCH] fix: queue chat aborts across reconnect (#70673) (thanks @chinar-amrutkar) --- CHANGELOG.md | 1 + ui/src/ui/app-chat.test.ts | 37 ++++++++++++++++++++- ui/src/ui/app-chat.ts | 7 ++++ ui/src/ui/app-gateway.node.test.ts | 51 +++++++++++++++++++++++++++++ ui/src/ui/app-gateway.ts | 16 +++++++++ ui/src/ui/chat/run-controls.test.ts | 21 ++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 223277559ad..a3266f19203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/chat: queue Stop-button aborts across Gateway reconnects so a disconnected active run is canceled on reconnect instead of only clearing local UI state. (#70673) Thanks @chinar-amrutkar. - QQBot/security: require framework auth for `/bot-approve` so unauthorized QQ senders cannot change exec approval settings through the unauthenticated pre-dispatch slash-command path. (#70706) Thanks @vincentkoc. - MCP/tools: stop the ACPX OpenClaw tools bridge from listing or invoking owner-only tools such as `cron`, closing a privilege-escalation path for non-owner MCP callers. (#70698) Thanks @vincentkoc. - Feishu/onboarding: load Feishu setup surfaces through a setup-only barrel so first-run setup no longer imports Feishu's Lark SDK before bundled runtime deps are staged. (#70339) Thanks @andrejtr. diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 7434a32353e..8940cd54605 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -13,11 +13,12 @@ vi.mock("./app-last-active-session.ts", () => ({ })); let handleSendChat: typeof import("./app-chat.ts").handleSendChat; +let handleAbortChat: typeof import("./app-chat.ts").handleAbortChat; let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; async function loadChatHelpers(): Promise { - ({ handleSendChat, refreshChatAvatar, clearPendingQueueItemsForRun } = + ({ handleSendChat, handleAbortChat, refreshChatAvatar, clearPendingQueueItemsForRun } = await import("./app-chat.ts")); } @@ -546,6 +547,40 @@ describe("handleSendChat", () => { }); }); +describe("handleAbortChat", () => { + beforeAll(async () => { + await loadChatHelpers(); + }); + + it("queues the active run abort while disconnected", async () => { + const host = makeHost({ + connected: false, + chatRunId: "run-main", + chatMessage: "draft", + sessionKey: "agent:main", + }); + + await handleAbortChat(host); + + expect(host.pendingAbort).toEqual({ runId: "run-main", sessionKey: "agent:main" }); + expect(host.chatMessage).toBe(""); + expect(host.chatRunId).toBe("run-main"); + }); + + it("keeps the draft when disconnected without an active run", async () => { + const host = makeHost({ + connected: false, + chatRunId: null, + chatMessage: "draft", + }); + + await handleAbortChat(host); + + expect(host.pendingAbort).toBeUndefined(); + expect(host.chatMessage).toBe("draft"); + }); +}); + afterAll(() => { vi.doUnmock("./app-last-active-session.ts"); vi.resetModules(); diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index c5554a3eb1e..702bc51cdc8 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -49,6 +49,7 @@ export type ChatHost = { sessionsResult?: SessionsListResult | null; updateComplete?: Promise; refreshSessionsAfterChat: Set; + pendingAbort?: { runId: string; sessionKey: string } | null; /** Callback for slash-command side effects that need app-level access. */ onSlashAction?: (action: string) => void; }; @@ -94,6 +95,12 @@ function isBtwCommand(text: string) { } export async function handleAbortChat(host: ChatHost) { + // If disconnected but we have an active runId, queue the abort for when we reconnect + if (!host.connected && host.chatRunId) { + host.chatMessage = ""; + host.pendingAbort = { runId: host.chatRunId, sessionKey: host.sessionKey }; + return; + } if (!host.connected) { return; } diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index ea0f290de6d..ba3500585b2 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -10,6 +10,7 @@ const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => unde type GatewayClientMock = { start: ReturnType; stop: ReturnType; + request: ReturnType; options: { clientVersion?: string }; emitHello: (hello?: GatewayHelloOk) => void; emitClose: (info: { @@ -40,6 +41,12 @@ vi.mock("./gateway.ts", async (importOriginal) => { class GatewayBrowserClient { readonly start = vi.fn(); readonly stop = vi.fn(); + readonly request = vi.fn(async (method: string) => { + if (method === "models.authStatus") { + return { ts: 0, providers: [] }; + } + return {}; + }); constructor( private opts: { @@ -57,6 +64,7 @@ vi.mock("./gateway.ts", async (importOriginal) => { gatewayClientInstances.push({ start: this.start, stop: this.stop, + request: this.request, options: { clientVersion: this.opts.clientVersion }, emitHello: (hello) => { this.opts.onHello?.( @@ -524,6 +532,49 @@ describe("connectGateway", () => { expect(loadControlUiBootstrapConfigMock).toHaveBeenCalledWith(host); }); + it("sends queued chat aborts after reconnect before clearing pending state", async () => { + const host = createHost(); + host.chatRunId = "run-main"; + host.chatStream = "partial"; + host.pendingAbort = { runId: "run-main", sessionKey: "main" }; + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitHello(); + await Promise.resolve(); + + expect(client.request).toHaveBeenCalledWith("chat.abort", { + sessionKey: "main", + runId: "run-main", + }); + expect(host.pendingAbort).toBeNull(); + expect(host.chatRunId).toBeNull(); + expect(host.chatStream).toBeNull(); + }); + + it("logs and drops stale queued chat abort failures after reconnect", async () => { + const host = createHost(); + host.pendingAbort = { runId: "run-stale", sessionKey: "main" }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + const error = new Error("run already finished"); + client.request.mockImplementationOnce(async () => { + throw error; + }); + + client.emitHello(); + await Promise.resolve(); + + expect(host.pendingAbort).toBeNull(); + expect(warn).toHaveBeenCalledWith("[openclaw] pending abort failed:", error); + warn.mockRestore(); + }); + it("keeps shutdown restart reasons on service restart closes", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 6ef2b7d0e3a..494fd61cf5f 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -93,6 +93,7 @@ type GatewayHost = { serverVersion: string | null; sessionKey: string; chatRunId: string | null; + pendingAbort?: { runId: string; sessionKey: string } | null; refreshSessionsAfterChat: Set; execApprovalQueue: ExecApprovalRequest[]; execApprovalError: string | null; @@ -296,6 +297,21 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption void loadControlUiBootstrapConfig( host as unknown as Parameters[0], ); + // Process any pending abort from before the disconnect. + if (host.pendingAbort) { + const abort = host.pendingAbort; + host.pendingAbort = null; + void host.client + .request("chat.abort", { + sessionKey: abort.sessionKey, + runId: abort.runId, + }) + .catch((err) => { + // Log to console for diagnostics; user sees no feedback for a stale abort + // since the run likely completed during the disconnect window anyway. + console.warn("[openclaw] pending abort failed:", err); + }); + } // Reset orphaned chat run state from before disconnect. // Any in-flight run's final event was lost during the disconnect window. host.chatRunId = null; diff --git a/ui/src/ui/chat/run-controls.test.ts b/ui/src/ui/chat/run-controls.test.ts index a28c2a08b81..1cab7989338 100644 --- a/ui/src/ui/chat/run-controls.test.ts +++ b/ui/src/ui/chat/run-controls.test.ts @@ -72,4 +72,25 @@ describe("chat run controls", () => { expect(onSend).toHaveBeenCalledTimes(1); expect(container.textContent).not.toContain("Stop"); }); + + it("keeps Stop clickable while disconnected when a run is abortable", () => { + const container = document.createElement("div"); + const onAbort = vi.fn(); + render( + renderChatRunControls( + createProps({ + canAbort: true, + connected: false, + onAbort, + }), + ), + container, + ); + + const stopButton = container.querySelector('button[title="Stop"]'); + expect(stopButton).not.toBeNull(); + expect(stopButton?.disabled).toBe(false); + stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onAbort).toHaveBeenCalledTimes(1); + }); });