fix: queue chat aborts across reconnect (#70673) (thanks @chinar-amrutkar)

This commit is contained in:
Chinar Amrutkar
2026-04-23 19:08:43 +01:00
committed by Peter Steinberger
parent b0efa8d43d
commit 8513a14406
6 changed files with 132 additions and 1 deletions

View File

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

View File

@@ -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<void> {
({ 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();

View File

@@ -49,6 +49,7 @@ export type ChatHost = {
sessionsResult?: SessionsListResult | null;
updateComplete?: Promise<unknown>;
refreshSessionsAfterChat: Set<string>;
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;
}

View File

@@ -10,6 +10,7 @@ const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => unde
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn>;
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();

View File

@@ -93,6 +93,7 @@ type GatewayHost = {
serverVersion: string | null;
sessionKey: string;
chatRunId: string | null;
pendingAbort?: { runId: string; sessionKey: string } | null;
refreshSessionsAfterChat: Set<string>;
execApprovalQueue: ExecApprovalRequest[];
execApprovalError: string | null;
@@ -296,6 +297,21 @@ export function connectGateway(host: GatewayHost, options?: ConnectGatewayOption
void loadControlUiBootstrapConfig(
host as unknown as Parameters<typeof loadControlUiBootstrapConfig>[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;

View File

@@ -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<HTMLButtonElement>('button[title="Stop"]');
expect(stopButton).not.toBeNull();
expect(stopButton?.disabled).toBe(false);
stopButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onAbort).toHaveBeenCalledTimes(1);
});
});