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.
This commit is contained in:
Val Alexander
2026-05-02 05:08:05 -05:00
committed by GitHub
parent 0e8bd8e75c
commit 2bade2703e
3 changed files with 83 additions and 6 deletions

View File

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

View File

@@ -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<typeof import("./chat/slash-command-executor.ts")>();
return {
...actual,
executeSlashCommand: (...args: Parameters<ExecuteSlashCommand>) => {
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") {

View File

@@ -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<typeof scheduleChatScroll>[0]);
return;
}
const targetSessionKey = host.sessionKey;
const result = await executeSlashCommand(host.client, targetSessionKey, name, args, {
chatModelCatalog: host.chatModelCatalog,
sessionsResult: host.sessionsResult,
});
let result: Awaited<ReturnType<typeof executeSlashCommand>>;
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<typeof scheduleChatScroll>[0]);
return;
}
if (result.content) {
injectCommandResult(host, result.content);