mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
fix: queue chat aborts across reconnect (#70673) (thanks @chinar-amrutkar)
This commit is contained in:
committed by
Peter Steinberger
parent
b0efa8d43d
commit
8513a14406
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user