diff --git a/frontend/package.json b/frontend/package.json index ae01756..3a99587 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,41 +1,41 @@ { - "name": "living-spec-standalone", - "private": true, - "version": "0.5.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview", - "server": "cargo run --manifest-path server/Cargo.toml", - "test": "vitest run", - "test:unit": "vitest run", - "test:e2e": "playwright test", - "test:coverage": "vitest run --coverage" - }, - "dependencies": { - "@types/react-syntax-highlighter": "^15.5.13", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-markdown": "^10.1.0", - "react-syntax-highlighter": "^16.1.0" - }, - "devDependencies": { - "@biomejs/biome": "^2.4.2", - "@playwright/test": "^1.47.2", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.4.3", - "@types/node": "^25.0.0", - "@types/react": "^19.1.8", - "@types/react-dom": "^19.1.6", - "@vitejs/plugin-react": "^4.6.0", - "@vitest/coverage-v8": "^2.1.9", - "jest": "^29.0.0", - "jsdom": "^28.1.0", - "ts-jest": "^29.0.0", - "typescript": "~5.8.3", - "vite": "^5.4.21", - "vitest": "^2.1.4" - } + "name": "living-spec-standalone", + "private": true, + "version": "0.5.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "server": "cargo run --manifest-path server/Cargo.toml", + "test": "vitest run", + "test:unit": "vitest run", + "test:e2e": "playwright test", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@types/react-syntax-highlighter": "^15.5.13", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "react-syntax-highlighter": "^16.1.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.2", + "@playwright/test": "^1.47.2", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.4.3", + "@types/node": "^25.0.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "@vitest/coverage-v8": "^2.1.9", + "jest": "^29.0.0", + "jsdom": "^28.1.0", + "ts-jest": "^29.0.0", + "typescript": "~5.8.3", + "vite": "^5.4.21", + "vitest": "^2.1.4" + } } diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 3204f58..3f9fe76 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,27 +1,27 @@ -import { defineConfig } from "@playwright/test"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { defineConfig } from "@playwright/test"; const configDir = dirname(fileURLToPath(new URL(import.meta.url))); const frontendRoot = resolve(configDir, "."); export default defineConfig({ - testDir: "./tests/e2e", - fullyParallel: true, - timeout: 30_000, - expect: { - timeout: 5_000, - }, - use: { - baseURL: "http://127.0.0.1:41700", - trace: "on-first-retry", - }, - webServer: { - command: - "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort", - url: "http://127.0.0.1:41700/@vite/client", - reuseExistingServer: true, - timeout: 120_000, - cwd: frontendRoot, - }, + testDir: "./tests/e2e", + fullyParallel: true, + timeout: 30_000, + expect: { + timeout: 5_000, + }, + use: { + baseURL: "http://127.0.0.1:41700", + trace: "on-first-retry", + }, + webServer: { + command: + "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort", + url: "http://127.0.0.1:41700/@vite/client", + reuseExistingServer: true, + timeout: 120_000, + cwd: frontendRoot, + }, }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6d682e9..d698124 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -382,6 +382,14 @@ export const api = { deleteStory(storyId: string) { 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( diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index 929f144..4007def 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -40,6 +40,7 @@ vi.mock("../api/client", () => { setAnthropicApiKey: vi.fn(), readFile: vi.fn(), listProjectFiles: vi.fn(), + botCommand: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { @@ -64,6 +65,7 @@ const mockedApi = { setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), readFile: vi.mocked(api.readFile), listProjectFiles: vi.mocked(api.listProjectFiles), + botCommand: vi.mocked(api.botCommand), }; function setupMocks() { @@ -76,6 +78,7 @@ function setupMocks() { mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); + mockedApi.botCommand.mockResolvedValue({ response: "Bot response" }); } describe("Default provider selection (Story 206)", () => { @@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => { 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(); + 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 passes args to botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" }); + render(); + 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 calls botCommand", async () => { + mockedApi.botCommand.mockResolvedValue({ response: "Started agent" }); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 74f4113..e3b4bfe 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { 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 — answered from context without disrupting main chat const btwMatch = messageText.match(/^\/btw\s+(.+)/s); if (btwMatch) { diff --git a/frontend/src/components/HelpOverlay.tsx b/frontend/src/components/HelpOverlay.tsx index 0f6a3e2..e542465 100644 --- a/frontend/src/components/HelpOverlay.tsx +++ b/frontend/src/components/HelpOverlay.tsx @@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [ name: "/help", description: "Show this list of available slash commands.", }, + { + name: "/status", + description: + "Show pipeline status and agent availability. `/status ` shows a story triage dump.", + }, + { + name: "/assign ", + description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).", + }, + { + name: "/start ", + description: + "Start a coder on a story. Optionally specify a model: `/start opus`.", + }, + { + name: "/show ", + description: "Display the full text of a work item.", + }, + { + name: "/move ", + description: + "Move a work item to a pipeline stage (backlog, current, qa, merge, done).", + }, + { + name: "/delete ", + 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 ", + 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 ", description: diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 6d545f5..e8e5246 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,24 +1,24 @@ { - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"] + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 83d210c..2fa69d2 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -3,49 +3,49 @@ import { defineConfig } from "vite"; // https://vite.dev/config/ export default defineConfig(() => { - const backendPort = Number(process.env.STORKIT_PORT || "3001"); - return { - plugins: [react()], - define: { - __STORKIT_PORT__: JSON.stringify(String(backendPort)), - __BUILD_TIME__: JSON.stringify(new Date().toISOString()), - }, - server: { - port: backendPort + 2172, - proxy: { - "/api": { - target: `http://127.0.0.1:${String(backendPort)}`, - timeout: 120000, - configure: (proxy) => { - proxy.on("error", (_err) => { - // Swallow proxy errors (e.g. ECONNREFUSED during backend restart) - // so the vite dev server doesn't crash. - }); - }, - }, - "/agents": { - target: `http://127.0.0.1:${String(backendPort)}`, - timeout: 120000, - configure: (proxy) => { - proxy.on("error", (_err) => {}); - }, - }, - }, - watch: { - ignored: [ - "**/.story_kit/**", - "**/target/**", - "**/.git/**", - "**/server/**", - "**/Cargo.*", - "**/vendor/**", - "**/node_modules/**", - ], - }, - }, - build: { - outDir: "dist", - emptyOutDir: true, - }, - }; + const backendPort = Number(process.env.STORKIT_PORT || "3001"); + return { + plugins: [react()], + define: { + __STORKIT_PORT__: JSON.stringify(String(backendPort)), + __BUILD_TIME__: JSON.stringify(new Date().toISOString()), + }, + server: { + port: backendPort + 2172, + proxy: { + "/api": { + target: `http://127.0.0.1:${String(backendPort)}`, + timeout: 120000, + configure: (proxy) => { + proxy.on("error", (_err) => { + // Swallow proxy errors (e.g. ECONNREFUSED during backend restart) + // so the vite dev server doesn't crash. + }); + }, + }, + "/agents": { + target: `http://127.0.0.1:${String(backendPort)}`, + timeout: 120000, + configure: (proxy) => { + proxy.on("error", (_err) => {}); + }, + }, + }, + watch: { + ignored: [ + "**/.story_kit/**", + "**/target/**", + "**/.git/**", + "**/server/**", + "**/Cargo.*", + "**/vendor/**", + "**/node_modules/**", + ], + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, + }; }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 12c61cb..8c5ad75 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,26 +2,26 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [react()], - define: { - __BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"), - }, - test: { - environment: "jsdom", - environmentOptions: { - jsdom: { - url: "http://localhost:3000", - }, - }, - globals: true, - testTimeout: 10_000, - setupFiles: ["./src/setupTests.ts"], - css: true, - exclude: ["tests/e2e/**", "node_modules/**"], - coverage: { - provider: "v8", - reporter: ["text", "json-summary"], - reportsDirectory: "./coverage", - }, - }, + plugins: [react()], + define: { + __BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"), + }, + test: { + environment: "jsdom", + environmentOptions: { + jsdom: { + url: "http://localhost:3000", + }, + }, + globals: true, + testTimeout: 10_000, + setupFiles: ["./src/setupTests.ts"], + css: true, + exclude: ["tests/e2e/**", "node_modules/**"], + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + reportsDirectory: "./coverage", + }, + }, }); diff --git a/server/src/http/bot_command.rs b/server/src/http/bot_command.rs new file mode 100644 index 0000000..446e841 --- /dev/null +++ b/server/src/http/bot_command.rs @@ -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, +} + +#[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, + ) -> OpenApiResult> { + 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, +) -> 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, +) -> String { + let ambient_rooms: Arc>> = 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, +) -> String { + // args: "" or " " + 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 ` or `/start ` (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, +) -> String { + let number_str = args.trim(); + if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) { + return "Usage: `/delete ` (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, +) -> 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"); + } +} diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 49dba41..63893d4 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -2,6 +2,7 @@ pub mod agents; pub mod agents_sse; pub mod anthropic; pub mod assets; +pub mod bot_command; pub mod chat; pub mod context; pub mod health; @@ -16,6 +17,7 @@ pub mod ws; use agents::AgentsApi; use anthropic::AnthropicApi; +use bot_command::BotCommandApi; use chat::ChatApi; use context::AppContext; use health::HealthApi; @@ -113,6 +115,7 @@ type ApiTuple = ( AgentsApi, SettingsApi, HealthApi, + BotCommandApi, ); type ApiService = OpenApiService; @@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { AgentsApi { ctx: ctx.clone() }, SettingsApi { ctx: ctx.clone() }, HealthApi, + BotCommandApi { ctx: ctx.clone() }, ); let api_service = @@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { IoApi { ctx: ctx.clone() }, ChatApi { ctx: ctx.clone() }, AgentsApi { ctx: ctx.clone() }, - SettingsApi { ctx }, + SettingsApi { ctx: ctx.clone() }, HealthApi, + BotCommandApi { ctx }, ); let docs_service =