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
+26 -2
View File
@@ -9,6 +9,7 @@ import sys
import time import time
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
from nanobot import __version__ from nanobot import __version__
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
@@ -16,6 +17,14 @@ from nanobot.command.router import CommandContext, CommandRouter
from nanobot.utils.helpers import build_status_content from nanobot.utils.helpers import build_status_content
from nanobot.utils.restart import set_restart_notice_to_env from nanobot.utils.restart import set_restart_notice_to_env
CommandLifecycle = Literal[
"side_channel",
"finalize_active_turn",
"stop_active_turn",
"agent_turn",
"agent_turn_with_args",
]
@dataclass(frozen=True) @dataclass(frozen=True)
class BuiltinCommandSpec: class BuiltinCommandSpec:
@@ -24,14 +33,18 @@ class BuiltinCommandSpec:
description: str description: str
icon: str icon: str
arg_hint: str = "" arg_hint: str = ""
lifecycle: CommandLifecycle = "side_channel"
accepts_args: bool = False
def as_dict(self) -> dict[str, str]: def as_dict(self) -> dict[str, str | bool]:
return { return {
"command": self.command, "command": self.command,
"title": self.title, "title": self.title,
"description": self.description, "description": self.description,
"icon": self.icon, "icon": self.icon,
"arg_hint": self.arg_hint, "arg_hint": self.arg_hint,
"lifecycle": self.lifecycle,
"accepts_args": self.accepts_args,
} }
@@ -41,12 +54,14 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"New chat", "New chat",
"Stop the current task and start a fresh conversation.", "Stop the current task and start a fresh conversation.",
"square-pen", "square-pen",
lifecycle="finalize_active_turn",
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/stop", "/stop",
"Stop current task", "Stop current task",
"Cancel the active agent turn for this chat.", "Cancel the active agent turn for this chat.",
"square", "square",
lifecycle="stop_active_turn",
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/restart", "/restart",
@@ -66,6 +81,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Show or switch the active model preset.", "Show or switch the active model preset.",
"brain", "brain",
"[preset]", "[preset]",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/history", "/history",
@@ -73,6 +89,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Print the last N persisted conversation messages.", "Print the last N persisted conversation messages.",
"history", "history",
"[n]", "[n]",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/goal", "/goal",
@@ -80,6 +97,8 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Tell the agent to treat the request as a long-running goal.", "Tell the agent to treat the request as a long-running goal.",
"activity", "activity",
"<goal>", "<goal>",
lifecycle="agent_turn_with_args",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/trigger", "/trigger",
@@ -87,6 +106,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Create a named CLI trigger bound to this chat session.", "Create a named CLI trigger bound to this chat session.",
"zap", "zap",
"<name>", "<name>",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/dream", "/dream",
@@ -99,12 +119,14 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Show Dream log", "Show Dream log",
"Show what the last Dream consolidation changed.", "Show what the last Dream consolidation changed.",
"book-open", "book-open",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/dream-restore", "/dream-restore",
"Restore memory", "Restore memory",
"Revert memory to a previous Dream snapshot.", "Revert memory to a previous Dream snapshot.",
"undo-2", "undo-2",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/dream-prompt", "/dream-prompt",
@@ -112,6 +134,7 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"Tell Dream how to organize this workspace's memory.", "Tell Dream how to organize this workspace's memory.",
"file-text", "file-text",
"[init]", "[init]",
accepts_args=True,
), ),
BuiltinCommandSpec( BuiltinCommandSpec(
"/skill", "/skill",
@@ -131,11 +154,12 @@ BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = (
"List, approve, deny or revoke pairing requests.", "List, approve, deny or revoke pairing requests.",
"shield", "shield",
"[list|approve <code>|deny <code>|revoke <user_id>]", "[list|approve <code>|deny <code>|revoke <user_id>]",
accepts_args=True,
), ),
) )
def builtin_command_palette() -> list[dict[str, str]]: def builtin_command_palette() -> list[dict[str, str | bool]]:
"""Return structured command metadata for UI command palettes.""" """Return structured command metadata for UI command palettes."""
return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS]
+6
View File
@@ -2072,7 +2072,13 @@ async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> No
body = response.json() body = response.json()
commands = {row["command"]: row for row in body["commands"]} commands = {row["command"]: row for row in body["commands"]}
assert commands["/stop"]["title"] == "Stop current task" assert commands["/stop"]["title"] == "Stop current task"
assert commands["/new"]["lifecycle"] == "finalize_active_turn"
assert commands["/stop"]["lifecycle"] == "stop_active_turn"
assert commands["/history"]["lifecycle"] == "side_channel"
assert commands["/history"]["arg_hint"] == "[n]" assert commands["/history"]["arg_hint"] == "[n]"
assert commands["/history"]["accepts_args"] is True
assert commands["/goal"]["lifecycle"] == "agent_turn_with_args"
assert commands["/goal"]["accepts_args"] is True
assert all("description" in row for row in body["commands"]) assert all("description" in row for row in body["commands"])
finally: finally:
await channel.stop() await channel.stop()
+4 -4
View File
@@ -345,11 +345,11 @@ async def test_dream_prompt_init_recreates_empty_prompt(tmp_path) -> None:
def test_dream_prompt_command_in_help_and_palette() -> None: def test_dream_prompt_command_in_help_and_palette() -> None:
palette = builtin_command_palette() palette = builtin_command_palette()
dream_prompt = next(item for item in palette if item["command"] == "/dream-prompt")
assert any( assert dream_prompt["arg_hint"] == "[init]"
item["command"] == "/dream-prompt" and item["arg_hint"] == "[init]" assert dream_prompt["lifecycle"] == "side_channel"
for item in palette assert dream_prompt["accepts_args"] is True
)
assert "/dream-prompt [init]" in build_help_text() assert "/dream-prompt [init]" in build_help_text()
+8 -2
View File
@@ -149,7 +149,10 @@ async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None:
def test_model_command_in_help_and_palette() -> None: def test_model_command_in_help_and_palette() -> None:
palette = builtin_command_palette() palette = builtin_command_palette()
assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette) model = next(item for item in palette if item["command"] == "/model")
assert model["arg_hint"] == "[preset]"
assert model["lifecycle"] == "side_channel"
assert model["accepts_args"] is True
assert "/model [preset]" in build_help_text() assert "/model [preset]" in build_help_text()
@@ -204,5 +207,8 @@ async def test_goal_command_registered_on_router(tmp_path) -> None:
def test_goal_command_in_help_and_palette() -> None: def test_goal_command_in_help_and_palette() -> None:
palette = builtin_command_palette() palette = builtin_command_palette()
assert any(item["command"] == "/goal" and item["arg_hint"] == "<goal>" for item in palette) goal = next(item for item in palette if item["command"] == "/goal")
assert goal["arg_hint"] == "<goal>"
assert goal["lifecycle"] == "agent_turn_with_args"
assert goal["accepts_args"] is True
assert "/goal <goal>" in build_help_text() assert "/goal <goal>" in build_help_text()
+50 -49
View File
@@ -90,47 +90,49 @@ import { cn } from "@/lib/utils";
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_CODE = "KeyD";
const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D";
const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([
"/new",
"/stop",
"/restart",
"/status",
"/model",
"/history",
"/goal",
"/trigger",
"/dream",
"/dream-log",
"/dream-restore",
"/dream-prompt",
"/skill",
"/help",
"/pairing",
]);
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
type ResolvedSlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn";
function slashCommandName(content: string): string { function slashCommandName(content: string): string {
return content.split(/\s+/, 1)[0]; return content.split(/\s+/, 1)[0];
} }
function isExactSlashCommand(content: string, commandName: string): boolean { function slashCommandArgs(content: string, commandName: string): string {
if (slashCommandName(content) !== commandName) return false; return content.slice(commandName.length).trim();
return content.slice(commandName.length).trim().length === 0;
} }
function shouldFinalizeActiveTurn(content: string): boolean { function matchingSlashCommand(content: string, slashCommands: SlashCommand[]): SlashCommand | null {
return isExactSlashCommand(content, "/new");
}
function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean {
const commandName = slashCommandName(content); const commandName = slashCommandName(content);
if (!commandName.startsWith("/")) return false; if (!commandName.startsWith("/")) return null;
if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) { const command = slashCommands.find((item) => item.command === commandName);
return false; if (!command) return null;
if (slashCommandArgs(content, command.command).length > 0 && !command.acceptsArgs) return null;
return command;
}
function slashCommandLifecycle(
content: string,
slashCommands: SlashCommand[],
): ResolvedSlashCommandLifecycle | null {
const command = matchingSlashCommand(content, slashCommands);
if (!command) return null;
if (command.lifecycle === "agent_turn_with_args") {
return slashCommandArgs(content, command.command).length > 0
? "agent_turn"
: "side_channel";
} }
return command.lifecycle;
}
function isSideChannelLifecycle(lifecycle: ResolvedSlashCommandLifecycle | null): boolean {
return ( return (
FALLBACK_SIDE_CHANNEL_COMMANDS.has(commandName) lifecycle === "side_channel"
|| visibleSlashCommands.some((command) => command.command === commandName) || lifecycle === "finalize_active_turn"
|| lifecycle === "stop_active_turn"
); );
} }
@@ -310,7 +312,12 @@ type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo } | { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo }; | { kind: "mcp"; name: string; preset: McpPresetInfo };
interface SlashPaletteCommand extends SlashCommand { interface SlashPaletteCommand {
command: string;
title: string;
description: string;
icon: string;
argHint?: string;
detail: string; detail: string;
badge?: string; badge?: string;
recent: boolean; recent: boolean;
@@ -968,18 +975,12 @@ export function ThreadComposer({
}, [cursorPosition, disabled, slashMenuDismissed, value]); }, [cursorPosition, disabled, slashMenuDismissed, value]);
const visibleSlashCommands = useMemo(() => { const visibleSlashCommands = useMemo(() => {
const baseCommands = slashCommands.filter((command) => command.command !== "/stop"); const baseCommands = slashCommands.filter(
(command) => command.command !== "/stop" && command.command !== "/restart",
);
if (!(isStreaming && onStop)) return baseCommands; if (!(isStreaming && onStop)) return baseCommands;
const stopCommand = slashCommands.find((command) => command.command === "/stop") ?? { const stopCommand = slashCommands.find((command) => command.command === "/stop");
command: "/stop", return stopCommand ? [stopCommand, ...baseCommands] : baseCommands;
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
};
return [
stopCommand,
...baseCommands,
];
}, [isStreaming, onStop, slashCommands]); }, [isStreaming, onStop, slashCommands]);
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => { const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
@@ -1526,11 +1527,13 @@ export function ThreadComposer({
payload === undefined payload === undefined
&& attachedCliApps.length === 0 && attachedCliApps.length === 0
&& attachedMcpPresets.length === 0; && attachedMcpPresets.length === 0;
const slashLifecycle = hasPlainTextCommandPayload
? slashCommandLifecycle(content, slashCommands)
: null;
if ( if (
hasPlainTextCommandPayload slashLifecycle === "stop_active_turn"
&& isStreaming && isStreaming
&& onStop && onStop
&& isExactSlashCommand(content, "/stop")
) { ) {
handleStop(); handleStop();
setQueuedPrompts([]); setQueuedPrompts([]);
@@ -1538,11 +1541,9 @@ export function ThreadComposer({
clearComposerText(); clearComposerText();
return; return;
} }
const isSlashSideChannel = const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
hasPlainTextCommandPayload
&& isSlashCommandSideChannel(content, visibleSlashCommands);
const finalizeActiveTurn = const finalizeActiveTurn =
isSlashSideChannel && shouldFinalizeActiveTurn(content); slashLifecycle === "finalize_active_turn";
onSend( onSend(
content, content,
payload, payload,
@@ -1572,8 +1573,8 @@ export function ThreadComposer({
onSend, onSend,
onStop, onStop,
readyImages, readyImages,
slashCommands,
value, value,
visibleSlashCommands,
]); ]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => { const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
+29 -8
View File
@@ -20,6 +20,7 @@ import type {
SkillDetail, SkillDetail,
SkillsPayload, SkillsPayload,
SlashCommand, SlashCommand,
SlashCommandLifecycle,
TranscriptionSettingsUpdate, TranscriptionSettingsUpdate,
WebSearchSettingsUpdate, WebSearchSettingsUpdate,
WorkspacesPayload, WorkspacesPayload,
@@ -29,6 +30,20 @@ import type {
import { fetchWithTimeout } from "./http"; import { fetchWithTimeout } from "./http";
const API_READ_TIMEOUT_MS = 20_000; const API_READ_TIMEOUT_MS = 20_000;
const SLASH_COMMAND_LIFECYCLES = new Set<SlashCommandLifecycle>([
"side_channel",
"finalize_active_turn",
"stop_active_turn",
"agent_turn",
"agent_turn_with_args",
]);
function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle {
return (
typeof value === "string"
&& SLASH_COMMAND_LIFECYCLES.has(value as SlashCommandLifecycle)
);
}
export class ApiError extends Error { export class ApiError extends Error {
status: number; status: number;
@@ -498,6 +513,8 @@ export async function listSlashCommands(
description: string; description: string;
icon: string; icon: string;
arg_hint?: string; arg_hint?: string;
lifecycle?: unknown;
accepts_args?: unknown;
}; };
const body = await request<{ commands: Row[] }>( const body = await request<{ commands: Row[] }>(
`${base}/api/commands`, `${base}/api/commands`,
@@ -506,14 +523,18 @@ export async function listSlashCommands(
API_READ_TIMEOUT_MS, API_READ_TIMEOUT_MS,
); );
return body.commands return body.commands
.filter((command) => !["/stop", "/restart"].includes(command.command)) .flatMap((command) => {
.map((command) => ({ if (!isSlashCommandLifecycle(command.lifecycle)) return [];
command: command.command, return [{
title: command.title, command: command.command,
description: command.description, title: command.title,
icon: command.icon, description: command.description,
argHint: command.arg_hint ?? "", icon: command.icon,
})); argHint: command.arg_hint ?? "",
lifecycle: command.lifecycle,
acceptsArgs: command.accepts_args === true,
}];
});
} }
export async function fetchSidebarState( export async function fetchSidebarState(
+9
View File
@@ -781,12 +781,21 @@ export interface TranscriptionSettingsUpdate {
maxUploadMb: number; maxUploadMb: number;
} }
export type SlashCommandLifecycle =
| "side_channel"
| "finalize_active_turn"
| "stop_active_turn"
| "agent_turn"
| "agent_turn_with_args";
export interface SlashCommand { export interface SlashCommand {
command: string; command: string;
title: string; title: string;
description: string; description: string;
icon: string; icon: string;
argHint?: string; argHint?: string;
lifecycle: SlashCommandLifecycle;
acceptsArgs: boolean;
} }
export type ConnectionStatus = export type ConnectionStatus =
+32
View File
@@ -674,12 +674,16 @@ describe("webui API helpers", () => {
title: "Stop current task", title: "Stop current task",
description: "Cancel the active task.", description: "Cancel the active task.",
icon: "square", icon: "square",
lifecycle: "stop_active_turn",
accepts_args: false,
}, },
{ {
command: "/restart", command: "/restart",
title: "Restart nanobot", title: "Restart nanobot",
description: "Restart the bot process.", description: "Restart the bot process.",
icon: "rotate-cw", icon: "rotate-cw",
lifecycle: "side_channel",
accepts_args: false,
}, },
{ {
command: "/history", command: "/history",
@@ -687,18 +691,46 @@ describe("webui API helpers", () => {
description: "Print the last N messages.", description: "Print the last N messages.",
icon: "history", icon: "history",
arg_hint: "[n]", 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); } as Response);
await expect(listSlashCommands("tok")).resolves.toEqual([ 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", command: "/history",
title: "Show conversation history", title: "Show conversation history",
description: "Print the last N messages.", description: "Print the last N messages.",
icon: "history", icon: "history",
argHint: "[n]", argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
}, },
]); ]);
expect(fetch).toHaveBeenCalledWith( expect(fetch).toHaveBeenCalledWith(
+77 -3
View File
@@ -19,6 +19,8 @@ const COMMANDS: SlashCommand[] = [
title: "Stop current task", title: "Stop current task",
description: "Cancel the active agent turn.", description: "Cancel the active agent turn.",
icon: "square", icon: "square",
lifecycle: "stop_active_turn",
acceptsArgs: false,
}, },
{ {
command: "/history", command: "/history",
@@ -26,6 +28,8 @@ const COMMANDS: SlashCommand[] = [
description: "Print the last N persisted messages.", description: "Print the last N persisted messages.",
icon: "history", icon: "history",
argHint: "[n]", argHint: "[n]",
lifecycle: "side_channel",
acceptsArgs: true,
}, },
]; ];
@@ -863,6 +867,8 @@ describe("ThreadComposer", () => {
description: "Show or switch the active model preset.", description: "Show or switch the active model preset.",
icon: "brain", icon: "brain",
argHint: "[preset]", argHint: "[preset]",
lifecycle: "side_channel",
acceptsArgs: true,
}, },
COMMANDS[1], COMMANDS[1],
]} ]}
@@ -886,7 +892,7 @@ describe("ThreadComposer", () => {
onStop={onStop} onStop={onStop}
isStreaming isStreaming
placeholder="Type your message..." placeholder="Type your message..."
slashCommands={[COMMANDS[1]]} slashCommands={COMMANDS}
/>, />,
); );
@@ -940,6 +946,8 @@ describe("ThreadComposer", () => {
title: `Command ${index}`, title: `Command ${index}`,
description: `Description ${index}`, description: `Description ${index}`,
icon: "activity", icon: "activity",
lifecycle: "side_channel",
acceptsArgs: false,
}))} }))}
/>, />,
); );
@@ -1329,7 +1337,7 @@ describe("ThreadComposer", () => {
expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); 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(); const onSend = vi.fn();
render( render(
<ThreadComposer <ThreadComposer
@@ -1342,7 +1350,7 @@ describe("ThreadComposer", () => {
fireEvent.change(input, { target: { value: "/status" } }); fireEvent.change(input, { target: { value: "/status" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" })); 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", () => { it("marks new chat commands as side-channel sends that finalize the active turn", () => {
@@ -1351,6 +1359,16 @@ describe("ThreadComposer", () => {
<ThreadComposer <ThreadComposer
onSend={onSend} onSend={onSend}
placeholder="Type your message..." 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", () => { it("routes a manually submitted stop command through the stop handler", () => {
const onSend = vi.fn(); const onSend = vi.fn();
const onStop = vi.fn(); const onStop = vi.fn();
@@ -1374,6 +1418,7 @@ describe("ThreadComposer", () => {
onStop={onStop} onStop={onStop}
isStreaming isStreaming
placeholder="Type your message..." 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.", description: "Tell the agent to treat the request as a long-running goal.",
icon: "activity", icon: "activity",
argHint: "<goal>", 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", () => { it("shows a stop button while streaming", () => {
const onStop = vi.fn(); const onStop = vi.fn();
render( render(
+2
View File
@@ -1252,6 +1252,8 @@ describe("ThreadShell", () => {
description: "Print the last N persisted messages.", description: "Print the last N persisted messages.",
icon: "history", icon: "history",
arg_hint: "[n]", arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
}, },
], ],
}); });