From 2bade2703e35b0347a517a45ec5deae3030f2542 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sat, 2 May 2026 05:08:05 -0500 Subject: [PATCH] fix(ui): surface slash command dispatch failures ## Summary - surface inline Control UI feedback when local slash-command dispatch is unavailable or throws - cover missing-client and unexpected-error paths for local slash commands - note the user-facing fix in the changelog Fixes #52105. --- CHANGELOG.md | 1 + ui/src/ui/app-chat.test.ts | 64 +++++++++++++++++++++++++++++++++++++- ui/src/ui/app-chat.ts | 24 +++++++++++--- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1036c673f..b34a7e31c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao. - Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser. - Model commands: clarify direct and inline `/model` acknowledgements for non-default selections as session-scoped. Thanks @addu2612. - TUI/chat: skip full provider model normalization during context-window warmup while preserving provider-owned context metadata, avoiding cold-start stalls with large model registries. Thanks @547895019. diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index f0e58c30284..9ba7855c1dc 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -8,9 +8,13 @@ import { registerChatAttachmentPayload, resetChatAttachmentPayloadStoreForTest, } from "./chat/attachment-payload-store.ts"; +import type { executeSlashCommand } from "./chat/slash-command-executor.ts"; import type { GatewaySessionRow, SessionsListResult } from "./types.ts"; -const { setLastActiveSessionKeyMock } = vi.hoisted(() => ({ +type ExecuteSlashCommand = typeof executeSlashCommand; + +const { executeSlashCommandMock, setLastActiveSessionKeyMock } = vi.hoisted(() => ({ + executeSlashCommandMock: vi.fn(), setLastActiveSessionKeyMock: vi.fn(), })); @@ -18,6 +22,21 @@ vi.mock("./app-last-active-session.ts", () => ({ setLastActiveSessionKey: (...args: unknown[]) => setLastActiveSessionKeyMock(...args), })); +vi.mock("./chat/slash-command-executor.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + executeSlashCommand: (...args: Parameters) => { + const implementation = executeSlashCommandMock.getMockImplementation() as + | ExecuteSlashCommand + | undefined; + return implementation + ? executeSlashCommandMock(...args) + : actual.executeSlashCommand(...args); + }, + }; +}); + let handleSendChat: typeof import("./app-chat.ts").handleSendChat; let steerQueuedChatMessage: typeof import("./app-chat.ts").steerQueuedChatMessage; let navigateChatInputHistory: typeof import("./app-chat.ts").navigateChatInputHistory; @@ -420,6 +439,7 @@ describe("handleSendChat", () => { }); beforeEach(() => { + executeSlashCommandMock.mockReset(); setLastActiveSessionKeyMock.mockReset(); }); @@ -619,6 +639,48 @@ describe("handleSendChat", () => { expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective"); }); + it("shows local slash-command feedback when the gateway client is unavailable", async () => { + const host = makeHost({ + client: null, + chatMessage: "/think", + connected: true, + }); + + await handleSendChat(host); + + expect(host.chatMessage).toBe(""); + expect(host.chatMessages).toEqual([ + expect.objectContaining({ + role: "system", + content: "Cannot run `/think`: Control UI is not connected to the Gateway.", + }), + ]); + }); + + it("shows local slash-command feedback when dispatch fails unexpectedly", async () => { + executeSlashCommandMock.mockRejectedValue(new Error("dispatch failed")); + const request = vi.fn(async (method: string) => { + throw new Error(`Unexpected request: ${method}`); + }); + const host = makeHost({ + client: { request } as unknown as ChatHost["client"], + chatMessage: "/think", + connected: true, + }); + + await handleSendChat(host); + + expect(executeSlashCommandMock).toHaveBeenCalledTimes(1); + expect(host.chatMessage).toBe(""); + expect(host.lastError).toBe("Error: dispatch failed"); + expect(host.chatMessages).toEqual([ + expect.objectContaining({ + role: "system", + content: "Command `/think` failed unexpectedly.", + }), + ]); + }); + it("sends /btw immediately while a main run is active without queueing it", async () => { const request = vi.fn(async (method: string) => { if (method === "chat.send") { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index f23563259bf..c376bdaf4aa 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -567,15 +567,29 @@ async function dispatchSlashCommand( return; } - if (!host.client) { + if (!host.client || !host.connected) { + host.lastError = "Gateway not connected"; + injectCommandResult( + host, + `Cannot run \`/${name}\`: Control UI is not connected to the Gateway.`, + ); + scheduleChatScroll(host as unknown as Parameters[0]); return; } const targetSessionKey = host.sessionKey; - const result = await executeSlashCommand(host.client, targetSessionKey, name, args, { - chatModelCatalog: host.chatModelCatalog, - sessionsResult: host.sessionsResult, - }); + let result: Awaited>; + try { + result = await executeSlashCommand(host.client, targetSessionKey, name, args, { + chatModelCatalog: host.chatModelCatalog, + sessionsResult: host.sessionsResult, + }); + } catch (err) { + host.lastError = String(err); + injectCommandResult(host, `Command \`/${name}\` failed unexpectedly.`); + scheduleChatScroll(host as unknown as Parameters[0]); + return; + } if (result.content) { injectCommandResult(host, result.content);