fix(webui): drive slash command routing from metadata

This commit is contained in:
chengyongru
2026-07-07 15:42:04 +08:00
committed by Xubin Ren
parent 8a231b6e4d
commit fa73448f6f
10 changed files with 243 additions and 68 deletions
+32
View File
@@ -674,12 +674,16 @@ describe("webui API helpers", () => {
title: "Stop current task",
description: "Cancel the active task.",
icon: "square",
lifecycle: "stop_active_turn",
accepts_args: false,
},
{
command: "/restart",
title: "Restart nanobot",
description: "Restart the bot process.",
icon: "rotate-cw",
lifecycle: "side_channel",
accepts_args: false,
},
{
command: "/history",
@@ -687,18 +691,46 @@ describe("webui API helpers", () => {
description: "Print the last N messages.",
icon: "history",
arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
},
{
command: "/legacy",
title: "Legacy row",
description: "Old metadata should not be guessed.",
icon: "circle-help",
},
],
}),
} as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active task.",
icon: "square",
argHint: "",
lifecycle: "stop_active_turn",
acceptsArgs: false,
},
{
command: "/restart",
title: "Restart nanobot",
description: "Restart the bot process.",
icon: "rotate-cw",
argHint: "",
lifecycle: "side_channel",
acceptsArgs: false,
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N messages.",
icon: "history",
argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
},
]);
expect(fetch).toHaveBeenCalledWith(
+77 -3
View File
@@ -19,6 +19,8 @@ const COMMANDS: SlashCommand[] = [
title: "Stop current task",
description: "Cancel the active agent turn.",
icon: "square",
lifecycle: "stop_active_turn",
acceptsArgs: false,
},
{
command: "/history",
@@ -26,6 +28,8 @@ const COMMANDS: SlashCommand[] = [
description: "Print the last N persisted messages.",
icon: "history",
argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
},
];
@@ -863,6 +867,8 @@ describe("ThreadComposer", () => {
description: "Show or switch the active model preset.",
icon: "brain",
argHint: "[preset]",
lifecycle: "side_channel",
acceptsArgs: true,
},
COMMANDS[1],
]}
@@ -886,7 +892,7 @@ describe("ThreadComposer", () => {
onStop={onStop}
isStreaming
placeholder="Type your message..."
slashCommands={[COMMANDS[1]]}
slashCommands={COMMANDS}
/>,
);
@@ -940,6 +946,8 @@ describe("ThreadComposer", () => {
title: `Command ${index}`,
description: `Description ${index}`,
icon: "activity",
lifecycle: "side_channel",
acceptsArgs: false,
}))}
/>,
);
@@ -1329,7 +1337,7 @@ describe("ThreadComposer", () => {
expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true });
});
it("marks builtin slash commands as side-channel sends before command metadata loads", () => {
it("does not infer side-channel behavior before command metadata loads", () => {
const onSend = vi.fn();
render(
<ThreadComposer
@@ -1342,7 +1350,7 @@ describe("ThreadComposer", () => {
fireEvent.change(input, { target: { value: "/status" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true });
expect(onSend).toHaveBeenCalledWith("/status", undefined, undefined);
});
it("marks new chat commands as side-channel sends that finalize the active turn", () => {
@@ -1351,6 +1359,16 @@ describe("ThreadComposer", () => {
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={[
{
command: "/new",
title: "New chat",
description: "Stop the current task and start a fresh conversation.",
icon: "square-pen",
lifecycle: "finalize_active_turn",
acceptsArgs: false,
},
]}
/>,
);
@@ -1365,6 +1383,32 @@ describe("ThreadComposer", () => {
);
});
it("does not classify exact-only slash commands with arguments", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={[
{
command: "/new",
title: "New chat",
description: "Stop the current task and start a fresh conversation.",
icon: "square-pen",
lifecycle: "finalize_active_turn",
acceptsArgs: false,
},
]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/new with a title" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("/new with a title", undefined, undefined);
});
it("routes a manually submitted stop command through the stop handler", () => {
const onSend = vi.fn();
const onStop = vi.fn();
@@ -1374,6 +1418,7 @@ describe("ThreadComposer", () => {
onStop={onStop}
isStreaming
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
@@ -1399,6 +1444,8 @@ describe("ThreadComposer", () => {
description: "Tell the agent to treat the request as a long-running goal.",
icon: "activity",
argHint: "<goal>",
lifecycle: "agent_turn_with_args",
acceptsArgs: true,
},
]}
/>,
@@ -1415,6 +1462,33 @@ describe("ThreadComposer", () => {
);
});
it("keeps goal usage commands on the side-channel path", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={[
{
command: "/goal",
title: "Start long-running goal",
description: "Tell the agent to treat the request as a long-running goal.",
icon: "activity",
argHint: "<goal>",
lifecycle: "agent_turn_with_args",
acceptsArgs: true,
},
]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/goal" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("/goal", undefined, { sideChannel: true });
});
it("shows a stop button while streaming", () => {
const onStop = vi.fn();
render(
+2
View File
@@ -1252,6 +1252,8 @@ describe("ThreadShell", () => {
description: "Print the last N persisted messages.",
icon: "history",
arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
},
],
});