storkit: merge 374_story_web_ui_implements_all_bot_commands_as_slash_commands

This commit is contained in:
dave
2026-03-23 18:33:13 +00:00
parent 6d3eab92fd
commit 1a3b69301a
11 changed files with 774 additions and 146 deletions

View File

@@ -1,41 +1,41 @@
{ {
"name": "living-spec-standalone", "name": "living-spec-standalone",
"private": true, "private": true,
"version": "0.5.0", "version": "0.5.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"server": "cargo run --manifest-path server/Cargo.toml", "server": "cargo run --manifest-path server/Cargo.toml",
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@types/react-syntax-highlighter": "^15.5.13", "@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0" "react-syntax-highlighter": "^16.1.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.4.2", "@biomejs/biome": "^2.4.2",
"@playwright/test": "^1.47.2", "@playwright/test": "^1.47.2",
"@testing-library/jest-dom": "^6.0.0", "@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0", "@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/node": "^25.0.0", "@types/node": "^25.0.0",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9", "@vitest/coverage-v8": "^2.1.9",
"jest": "^29.0.0", "jest": "^29.0.0",
"jsdom": "^28.1.0", "jsdom": "^28.1.0",
"ts-jest": "^29.0.0", "ts-jest": "^29.0.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^5.4.21", "vite": "^5.4.21",
"vitest": "^2.1.4" "vitest": "^2.1.4"
} }
} }

View File

@@ -1,27 +1,27 @@
import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "@playwright/test";
const configDir = dirname(fileURLToPath(new URL(import.meta.url))); const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
const frontendRoot = resolve(configDir, "."); const frontendRoot = resolve(configDir, ".");
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
fullyParallel: true, fullyParallel: true,
timeout: 30_000, timeout: 30_000,
expect: { expect: {
timeout: 5_000, timeout: 5_000,
}, },
use: { use: {
baseURL: "http://127.0.0.1:41700", baseURL: "http://127.0.0.1:41700",
trace: "on-first-retry", trace: "on-first-retry",
}, },
webServer: { webServer: {
command: command:
"pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort", "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
url: "http://127.0.0.1:41700/@vite/client", url: "http://127.0.0.1:41700/@vite/client",
reuseExistingServer: true, reuseExistingServer: true,
timeout: 120_000, timeout: 120_000,
cwd: frontendRoot, cwd: frontendRoot,
}, },
}); });

View File

@@ -382,6 +382,14 @@ export const api = {
deleteStory(storyId: string) { deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId }); return callMcpTool("delete_story", { story_id: storyId });
}, },
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>(
"/bot/command",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
}; };
async function callMcpTool( async function callMcpTool(

View File

@@ -40,6 +40,7 @@ vi.mock("../api/client", () => {
setAnthropicApiKey: vi.fn(), setAnthropicApiKey: vi.fn(),
readFile: vi.fn(), readFile: vi.fn(),
listProjectFiles: vi.fn(), listProjectFiles: vi.fn(),
botCommand: vi.fn(),
}; };
class ChatWebSocket { class ChatWebSocket {
connect(handlers: WsHandlers) { connect(handlers: WsHandlers) {
@@ -64,6 +65,7 @@ const mockedApi = {
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile), readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles), listProjectFiles: vi.mocked(api.listProjectFiles),
botCommand: vi.mocked(api.botCommand),
}; };
function setupMocks() { function setupMocks() {
@@ -76,6 +78,7 @@ function setupMocks() {
mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true); mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true);
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
} }
describe("Default provider selection (Story 206)", () => { describe("Default provider selection (Story 206)", () => {
@@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => {
expect(mockedApi.readFile).not.toHaveBeenCalled(); expect(mockedApi.readFile).not.toHaveBeenCalled();
}); });
}); });
describe("Slash command handling (Story 374)", () => {
beforeEach(() => {
capturedWsHandlers = null;
lastSendChatArgs = null;
setupMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it("AC: /status calls botCommand and displays response", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"",
undefined,
);
});
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
});
it("AC: /status <number> passes args to botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/status 42" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"42",
undefined,
);
});
});
it("AC: /start <number> calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/start 42 opus" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"start",
"42 opus",
undefined,
);
});
expect(await screen.findByText("Started agent")).toBeInTheDocument();
});
it("AC: /git calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
});
});
it("AC: /cost calls botCommand", async () => {
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/cost" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
});
});
it("AC: /reset clears messages and session without LLM", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// First add a message so there is history to clear
act(() => {
capturedWsHandlers?.onUpdate([
{ role: "user", content: "hello" },
{ role: "assistant", content: "world" },
]);
});
expect(await screen.findByText("world")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/reset" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// LLM must NOT be invoked
expect(lastSendChatArgs).toBeNull();
// botCommand must NOT be invoked (reset is frontend-only)
expect(mockedApi.botCommand).not.toHaveBeenCalled();
// Confirmation message should appear
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
});
it("AC: unrecognised slash command shows error message", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/foobar" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
// Should NOT go to LLM
expect(lastSendChatArgs).toBeNull();
// Should NOT call botCommand
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: /help shows help overlay", async () => {
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/help" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
expect(lastSendChatArgs).toBeNull();
expect(mockedApi.botCommand).not.toHaveBeenCalled();
});
it("AC: botCommand API error shows error message in chat", async () => {
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "/git" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
expect(
await screen.findByText(/Error running command/),
).toBeInTheDocument();
});
});

View File

@@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
return; return;
} }
// /reset — clear session and message history without LLM
if (/^\/reset\s*$/i.test(messageText)) {
setMessages([]);
setClaudeSessionId(null);
setStreamingContent("");
setStreamingThinking("");
setActivityStatus(null);
setMessages([
{
role: "assistant",
content: "Session reset. Starting a fresh conversation.",
},
]);
return;
}
// Slash commands forwarded to the backend bot command endpoint
const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
if (slashMatch) {
const cmd = slashMatch[1].toLowerCase();
const args = (slashMatch[2] ?? "").trim();
// Ignore commands handled elsewhere
if (cmd !== "btw") {
const knownCommands = new Set([
"status",
"assign",
"start",
"show",
"move",
"delete",
"cost",
"git",
"overview",
"rebuild",
]);
if (knownCommands.has(cmd)) {
// Show the slash command in chat as a user message (display only)
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
]);
try {
const result = await api.botCommand(cmd, args, undefined);
setMessages((prev: Message[]) => [
...prev,
{ role: "assistant", content: result.response },
]);
} catch (e) {
setMessages((prev: Message[]) => [
...prev,
{
role: "assistant",
content: `**Error running command:** ${e}`,
},
]);
}
return;
}
// Unknown slash command
setMessages((prev: Message[]) => [
...prev,
{ role: "user", content: messageText },
{
role: "assistant",
content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
},
]);
return;
}
}
// /btw <question> — answered from context without disrupting main chat // /btw <question> — answered from context without disrupting main chat
const btwMatch = messageText.match(/^\/btw\s+(.+)/s); const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
if (btwMatch) { if (btwMatch) {

View File

@@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [
name: "/help", name: "/help",
description: "Show this list of available slash commands.", description: "Show this list of available slash commands.",
}, },
{
name: "/status",
description:
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
},
{
name: "/assign <number> <model>",
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
},
{
name: "/start <number>",
description:
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
},
{
name: "/show <number>",
description: "Display the full text of a work item.",
},
{
name: "/move <number> <stage>",
description:
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
},
{
name: "/delete <number>",
description:
"Remove a work item from the pipeline and stop any running agent.",
},
{
name: "/cost",
description:
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
},
{
name: "/git",
description:
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
},
{
name: "/overview <number>",
description: "Show the implementation summary for a merged story.",
},
{
name: "/rebuild",
description: "Rebuild the server binary and restart.",
},
{
name: "/reset",
description:
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
},
{ {
name: "/btw <question>", name: "/btw <question>",
description: description:

View File

@@ -1,24 +1,24 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -3,49 +3,49 @@ import { defineConfig } from "vite";
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig(() => { export default defineConfig(() => {
const backendPort = Number(process.env.STORKIT_PORT || "3001"); const backendPort = Number(process.env.STORKIT_PORT || "3001");
return { return {
plugins: [react()], plugins: [react()],
define: { define: {
__STORKIT_PORT__: JSON.stringify(String(backendPort)), __STORKIT_PORT__: JSON.stringify(String(backendPort)),
__BUILD_TIME__: JSON.stringify(new Date().toISOString()), __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
}, },
server: { server: {
port: backendPort + 2172, port: backendPort + 2172,
proxy: { proxy: {
"/api": { "/api": {
target: `http://127.0.0.1:${String(backendPort)}`, target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000, timeout: 120000,
configure: (proxy) => { configure: (proxy) => {
proxy.on("error", (_err) => { proxy.on("error", (_err) => {
// Swallow proxy errors (e.g. ECONNREFUSED during backend restart) // Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
// so the vite dev server doesn't crash. // so the vite dev server doesn't crash.
}); });
}, },
}, },
"/agents": { "/agents": {
target: `http://127.0.0.1:${String(backendPort)}`, target: `http://127.0.0.1:${String(backendPort)}`,
timeout: 120000, timeout: 120000,
configure: (proxy) => { configure: (proxy) => {
proxy.on("error", (_err) => {}); proxy.on("error", (_err) => {});
}, },
}, },
}, },
watch: { watch: {
ignored: [ ignored: [
"**/.story_kit/**", "**/.story_kit/**",
"**/target/**", "**/target/**",
"**/.git/**", "**/.git/**",
"**/server/**", "**/server/**",
"**/Cargo.*", "**/Cargo.*",
"**/vendor/**", "**/vendor/**",
"**/node_modules/**", "**/node_modules/**",
], ],
}, },
}, },
build: { build: {
outDir: "dist", outDir: "dist",
emptyOutDir: true, emptyOutDir: true,
}, },
}; };
}); });

View File

@@ -2,26 +2,26 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
define: { define: {
__BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"), __BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"),
}, },
test: { test: {
environment: "jsdom", environment: "jsdom",
environmentOptions: { environmentOptions: {
jsdom: { jsdom: {
url: "http://localhost:3000", url: "http://localhost:3000",
}, },
}, },
globals: true, globals: true,
testTimeout: 10_000, testTimeout: 10_000,
setupFiles: ["./src/setupTests.ts"], setupFiles: ["./src/setupTests.ts"],
css: true, css: true,
exclude: ["tests/e2e/**", "node_modules/**"], exclude: ["tests/e2e/**", "node_modules/**"],
coverage: { coverage: {
provider: "v8", provider: "v8",
reporter: ["text", "json-summary"], reporter: ["text", "json-summary"],
reportsDirectory: "./coverage", reportsDirectory: "./coverage",
}, },
}, },
}); });

View File

@@ -0,0 +1,286 @@
//! Bot command HTTP endpoint.
//!
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
//! commands available in Matrix without going through the LLM.
//!
//! Synchronous commands (status, assign, git, cost, move, show, overview,
//! help) are dispatched directly through the matrix command registry.
//! Asynchronous commands (start, delete, rebuild) are dispatched to their
//! dedicated async handlers. The `reset` command is handled by the frontend
//! (it clears local session state and message history) and is not routed here.
use crate::http::context::{AppContext, OpenApiResult};
use crate::matrix::commands::CommandDispatch;
use poem::http::StatusCode;
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
#[derive(Tags)]
enum BotCommandTags {
BotCommand,
}
/// Body for `POST /api/bot/command`.
#[derive(Object, Deserialize)]
struct BotCommandRequest {
/// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
command: String,
/// Any text after the command keyword, trimmed (may be empty).
#[oai(default)]
args: String,
}
/// Response body for `POST /api/bot/command`.
#[derive(Object, Serialize)]
struct BotCommandResponse {
/// Markdown-formatted response text.
response: String,
}
pub struct BotCommandApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "BotCommandTags::BotCommand")]
impl BotCommandApi {
/// Execute a slash command without LLM invocation.
///
/// Dispatches to the same handlers used by the Matrix and Slack bots.
/// Returns a markdown-formatted response that the frontend can display
/// directly in the chat panel.
#[oai(path = "/bot/command", method = "post")]
async fn run_command(
&self,
body: Json<BotCommandRequest>,
) -> OpenApiResult<Json<BotCommandResponse>> {
let project_root = self.ctx.state.get_project_root().map_err(|e| {
poem::Error::from_string(e, StatusCode::BAD_REQUEST)
})?;
let cmd = body.command.trim().to_ascii_lowercase();
let args = body.args.trim();
let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await;
Ok(Json(BotCommandResponse { response }))
}
}
/// Dispatch a command keyword + args to the appropriate handler.
async fn dispatch_command(
cmd: &str,
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
match cmd {
"start" => dispatch_start(args, project_root, agents).await,
"delete" => dispatch_delete(args, project_root, agents).await,
"rebuild" => dispatch_rebuild(project_root, agents).await,
// All other commands go through the synchronous command registry.
_ => dispatch_sync(cmd, args, project_root, agents),
}
}
fn dispatch_sync(
cmd: &str,
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
let ambient_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
// Use a synthetic bot name/id so strip_bot_mention passes through.
let bot_name = "__web_ui__";
let bot_user_id = "@__web_ui__:localhost";
let room_id = "__web_ui__";
let dispatch = CommandDispatch {
bot_name,
bot_user_id,
project_root,
agents,
ambient_rooms: &ambient_rooms,
room_id,
};
// Build a synthetic message that the registry can parse.
let synthetic = if args.is_empty() {
format!("{bot_name} {cmd}")
} else {
format!("{bot_name} {cmd} {args}")
};
match crate::matrix::commands::try_handle_command(&dispatch, &synthetic) {
Some(response) => response,
None => {
// Command exists in the registry but its fallback handler returns None
// (start, delete, rebuild, reset, htop — handled elsewhere or in
// the frontend). Should not be reached for those since we intercept
// them above. For genuinely unknown commands, tell the user.
format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.")
}
}
}
async fn dispatch_start(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
// args: "<number>" or "<number> <model_hint>"
let mut parts = args.splitn(2, char::is_whitespace);
let number_str = parts.next().unwrap_or("").trim();
let hint_str = parts.next().unwrap_or("").trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/start <number>` or `/start <number> <model>` (e.g. `/start 42 opus`)"
.to_string();
}
let agent_hint = if hint_str.is_empty() {
None
} else {
Some(hint_str)
};
crate::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
.await
}
async fn dispatch_delete(
args: &str,
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
let number_str = args.trim();
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
}
crate::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
}
async fn dispatch_rebuild(
project_root: &std::path::Path,
agents: &Arc<crate::agents::AgentPool>,
) -> String {
crate::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_api(dir: &TempDir) -> BotCommandApi {
BotCommandApi {
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
}
}
#[tokio::test]
async fn help_command_returns_response() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "help".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(!resp.response.is_empty());
}
#[tokio::test]
async fn unknown_command_returns_error_message() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "nonexistent_xyz".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Unknown command"),
"expected 'Unknown command' in: {}",
resp.response
);
}
#[tokio::test]
async fn start_without_number_returns_usage() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "start".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint in: {}",
resp.response
);
}
#[tokio::test]
async fn delete_without_number_returns_usage() {
let dir = TempDir::new().unwrap();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "delete".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
let resp = result.unwrap().0;
assert!(
resp.response.contains("Usage"),
"expected usage hint in: {}",
resp.response
);
}
#[tokio::test]
async fn git_command_returns_response() {
let dir = TempDir::new().unwrap();
// Initialise a bare git repo so the git command has something to query.
std::process::Command::new("git")
.args(["init"])
.current_dir(dir.path())
.output()
.ok();
let api = test_api(&dir);
let body = BotCommandRequest {
command: "git".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn run_command_requires_project_root() {
// Create a context with no project root set.
let dir = TempDir::new().unwrap();
let ctx = AppContext::new_test(dir.path().to_path_buf());
// Clear the project root.
*ctx.state.project_root.lock().unwrap() = None;
let api = BotCommandApi { ctx: Arc::new(ctx) };
let body = BotCommandRequest {
command: "status".to_string(),
args: String::new(),
};
let result = api.run_command(Json(body)).await;
assert!(result.is_err(), "should fail when no project root is set");
}
}

View File

@@ -2,6 +2,7 @@ pub mod agents;
pub mod agents_sse; pub mod agents_sse;
pub mod anthropic; pub mod anthropic;
pub mod assets; pub mod assets;
pub mod bot_command;
pub mod chat; pub mod chat;
pub mod context; pub mod context;
pub mod health; pub mod health;
@@ -16,6 +17,7 @@ pub mod ws;
use agents::AgentsApi; use agents::AgentsApi;
use anthropic::AnthropicApi; use anthropic::AnthropicApi;
use bot_command::BotCommandApi;
use chat::ChatApi; use chat::ChatApi;
use context::AppContext; use context::AppContext;
use health::HealthApi; use health::HealthApi;
@@ -113,6 +115,7 @@ type ApiTuple = (
AgentsApi, AgentsApi,
SettingsApi, SettingsApi,
HealthApi, HealthApi,
BotCommandApi,
); );
type ApiService = OpenApiService<ApiTuple, ()>; type ApiService = OpenApiService<ApiTuple, ()>;
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() },
HealthApi, HealthApi,
BotCommandApi { ctx: ctx.clone() },
); );
let api_service = let api_service =
@@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
IoApi { ctx: ctx.clone() }, IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx }, SettingsApi { ctx: ctx.clone() },
HealthApi, HealthApi,
BotCommandApi { ctx },
); );
let docs_service = let docs_service =