storkit: merge 374_story_web_ui_implements_all_bot_commands_as_slash_commands
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <question> — answered from context without disrupting main chat
|
||||
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
|
||||
if (btwMatch) {
|
||||
|
||||
@@ -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 <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>",
|
||||
description:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
286
server/src/http/bot_command.rs
Normal file
286
server/src/http/bot_command.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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<ApiTuple, ()>;
|
||||
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (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<AppContext>) -> (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 =
|
||||
|
||||
Reference in New Issue
Block a user