From bdffc4994b2a8b1608cd47d612f5b842f0227148 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 25 Feb 2026 21:36:49 -0600 Subject: [PATCH] feat(web): add orchestrator command system in chat interface (CT-ORCH-001) Add a /command system to the chat UI that routes slash commands to the orchestrator proxy instead of the LLM. Implements /status, /agents, /jobs (/queue), /pause, /resume, and /help commands with formatted markdown responses (tables, bold, code blocks). Adds a command autocomplete dropdown above the chat input that filters suggestions as the user types and supports keyboard navigation (ArrowUp/Down, Tab/Enter to accept, Escape to dismiss). - apps/web/src/hooks/useOrchestratorCommands.ts: New hook that parses command prefixes, dispatches to /api/orchestrator/* proxy routes, and formats JSON responses into readable markdown. - apps/web/src/components/chat/Chat.tsx: Intercepts slash commands before LLM routing; adds user message + command result as messages. - apps/web/src/components/chat/ChatInput.tsx: Command autocomplete dropdown with keyboard navigation and click-to-fill support. - 59 unit/integration tests covering hook, autocomplete, and routing. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/chat/Chat.test.tsx | 128 ++++++- apps/web/src/components/chat/Chat.tsx | 24 +- .../src/components/chat/ChatInput.test.tsx | 197 +++++++++- apps/web/src/components/chat/ChatInput.tsx | 129 ++++++- .../src/hooks/useOrchestratorCommands.test.ts | 293 ++++++++++++++ apps/web/src/hooks/useOrchestratorCommands.ts | 356 ++++++++++++++++++ 6 files changed, 1119 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/hooks/useOrchestratorCommands.test.ts create mode 100644 apps/web/src/hooks/useOrchestratorCommands.ts diff --git a/apps/web/src/components/chat/Chat.test.tsx b/apps/web/src/components/chat/Chat.test.tsx index 50c3eda..7c21dfb 100644 --- a/apps/web/src/components/chat/Chat.test.tsx +++ b/apps/web/src/components/chat/Chat.test.tsx @@ -4,12 +4,13 @@ */ import { createRef } from "react"; -import { render } from "@testing-library/react"; +import { render, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest"; import { Chat, type ChatRef } from "./Chat"; import * as useChatModule from "@/hooks/useChat"; import * as useWebSocketModule from "@/hooks/useWebSocket"; import * as authModule from "@/lib/auth/auth-context"; +import * as orchestratorModule from "@/hooks/useOrchestratorCommands"; // Mock scrollIntoView (not available in JSDOM) Element.prototype.scrollIntoView = vi.fn(); @@ -39,9 +40,14 @@ vi.mock("./ChatInput", () => ({ disabled: boolean; inputRef: React.RefObject; }): React.ReactElement => ( - + <> + + + ), DEFAULT_TEMPERATURE: 0.7, DEFAULT_MAX_TOKENS: 4096, @@ -54,11 +60,18 @@ vi.mock("./ChatInput", () => ({ ], })); +vi.mock("@/hooks/useOrchestratorCommands", () => ({ + useOrchestratorCommands: vi.fn(), +})); + const mockUseAuth = authModule.useAuth as MockedFunction; const mockUseChat = useChatModule.useChat as MockedFunction; const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction< typeof useWebSocketModule.useWebSocket >; +const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction< + typeof orchestratorModule.useOrchestratorCommands +>; function createMockUseChatReturn( overrides: Partial = {} @@ -107,6 +120,12 @@ describe("Chat", () => { socket: null, connectionError: null, }); + + // Default: no commands intercepted + mockUseOrchestratorCommands.mockReturnValue({ + isCommand: vi.fn().mockReturnValue(false), + executeCommand: vi.fn().mockResolvedValue(null), + }); }); afterEach(() => { @@ -160,4 +179,105 @@ describe("Chat", () => { }); }); }); + + describe("orchestrator command routing", () => { + it("routes command messages through orchestrator instead of LLM", async () => { + const mockSendMessage = vi.fn().mockResolvedValue(undefined); + const mockSetMessages = vi.fn(); + const mockExecuteCommand = vi.fn().mockResolvedValue({ + id: "orch-123", + role: "assistant" as const, + content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |", + createdAt: new Date().toISOString(), + }); + + mockUseChat.mockReturnValue( + createMockUseChatReturn({ + sendMessage: mockSendMessage, + setMessages: mockSetMessages, + }) + ); + + mockUseOrchestratorCommands.mockReturnValue({ + isCommand: (content: string) => content.trim().startsWith("/"), + executeCommand: mockExecuteCommand, + }); + + const { getByTestId } = render(); + + const commandButton = getByTestId("chat-input-command"); + fireEvent.click(commandButton); + + await waitFor(() => { + // executeCommand should have been called with the slash command + expect(mockExecuteCommand).toHaveBeenCalledWith("/status"); + }); + + // sendMessage should NOT have been called + expect(mockSendMessage).not.toHaveBeenCalled(); + + // setMessages should have been called to add user and assistant messages + await waitFor(() => { + expect(mockSetMessages).toHaveBeenCalledTimes(2); + }); + }); + + it("does not call orchestrator for regular messages", async () => { + const mockSendMessage = vi.fn().mockResolvedValue(undefined); + const mockExecuteCommand = vi.fn().mockResolvedValue(null); + + mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage })); + + mockUseOrchestratorCommands.mockReturnValue({ + isCommand: vi.fn().mockReturnValue(false), + executeCommand: mockExecuteCommand, + }); + + const { getByTestId } = render(); + + fireEvent.click(getByTestId("chat-input")); + + await waitFor(() => { + expect(mockSendMessage).toHaveBeenCalledWith("test message"); + }); + + expect(mockExecuteCommand).not.toHaveBeenCalled(); + }); + + it("still adds user message to chat for commands", async () => { + const mockSetMessages = vi.fn(); + const mockExecuteCommand = vi.fn().mockResolvedValue({ + id: "orch-456", + role: "assistant" as const, + content: "Help content", + createdAt: new Date().toISOString(), + }); + + mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages })); + + mockUseOrchestratorCommands.mockReturnValue({ + isCommand: (content: string) => content.trim().startsWith("/"), + executeCommand: mockExecuteCommand, + }); + + const { getByTestId } = render(); + + fireEvent.click(getByTestId("chat-input-command")); + + await waitFor(() => { + expect(mockSetMessages).toHaveBeenCalled(); + }); + + // First setMessages call should add the user message + const firstCall = mockSetMessages.mock.calls[0]; + if (!firstCall) throw new Error("Expected setMessages to have been called"); + const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[]; + const result = updater([]); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + role: "user", + content: "/status", + }); + }); + }); }); diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index f969be1..c7c711d 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react"; import { useAuth } from "@/lib/auth/auth-context"; import { useChat } from "@/hooks/useChat"; +import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands"; import { useWebSocket } from "@/hooks/useWebSocket"; import { MessageList } from "./MessageList"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; @@ -79,6 +80,7 @@ export const Chat = forwardRef(function Chat( abortStream, loadConversation, startNewConversation, + setMessages, clearError, } = useChat({ model: selectedModel, @@ -89,6 +91,8 @@ export const Chat = forwardRef(function Chat( const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {}); + const { isCommand, executeCommand } = useOrchestratorCommands(); + const messagesEndRef = useRef(null); const inputRef = useRef(null); const [loadingQuip, setLoadingQuip] = useState(null); @@ -192,9 +196,27 @@ export const Chat = forwardRef(function Chat( const handleSendMessage = useCallback( async (content: string) => { + if (isCommand(content)) { + // Add user message immediately + const userMessage: Message = { + id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`, + role: "user", + content: content.trim(), + createdAt: new Date().toISOString(), + }; + setMessages((prev) => [...prev, userMessage]); + + // Execute orchestrator command + const result = await executeCommand(content); + if (result) { + setMessages((prev) => [...prev, result]); + } + return; + } + await sendMessage(content); }, - [sendMessage] + [isCommand, executeCommand, setMessages, sendMessage] ); const handleSuggestionClick = useCallback((prompt: string): void => { diff --git a/apps/web/src/components/chat/ChatInput.test.tsx b/apps/web/src/components/chat/ChatInput.test.tsx index a7c3ce6..4fc668b 100644 --- a/apps/web/src/components/chat/ChatInput.test.tsx +++ b/apps/web/src/components/chat/ChatInput.test.tsx @@ -1,9 +1,10 @@ /** * @file ChatInput.test.tsx - * @description Tests for ChatInput: model selector, temperature/params, localStorage persistence + * @description Tests for ChatInput: model selector, temperature/params, localStorage persistence, + * and command autocomplete. */ -import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { ChatInput, @@ -291,3 +292,195 @@ describe("ChatInput — send behavior", () => { expect(onStop).toHaveBeenCalledOnce(); }); }); + +describe("ChatInput — command autocomplete", () => { + it("shows no autocomplete for regular text", () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "hello world" } }); + }); + + expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); + }); + + it("shows autocomplete dropdown when user types /", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + }); + + it("shows all commands when only / is typed", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + const dropdown = screen.getByTestId("command-autocomplete"); + expect(dropdown).toHaveTextContent("/status"); + expect(dropdown).toHaveTextContent("/agents"); + expect(dropdown).toHaveTextContent("/jobs"); + expect(dropdown).toHaveTextContent("/pause"); + expect(dropdown).toHaveTextContent("/resume"); + expect(dropdown).toHaveTextContent("/help"); + }); + }); + + it("filters commands by typed prefix", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/ag" } }); + }); + + await waitFor(() => { + const dropdown = screen.getByTestId("command-autocomplete"); + expect(dropdown).toHaveTextContent("/agents"); + expect(dropdown).not.toHaveTextContent("/status"); + expect(dropdown).not.toHaveTextContent("/pause"); + }); + }); + + it("dismisses autocomplete on Escape key", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.keyDown(textarea, { key: "Escape" }); + }); + + await waitFor(() => { + expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); + }); + }); + + it("accepts first command on Tab key", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/stat" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.keyDown(textarea, { key: "Tab" }); + }); + + await waitFor(() => { + expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); + }); + + expect((textarea as HTMLTextAreaElement).value).toBe("/status "); + }); + + it("navigates with ArrowDown key", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.keyDown(textarea, { key: "ArrowDown" }); + }); + + await waitFor(() => { + const options = screen.getAllByRole("option"); + // Second item should be selected after ArrowDown + expect(options[1]).toHaveAttribute("aria-selected", "true"); + }); + }); + + it("fills command when clicking a suggestion", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + + // Click on /agents option + const options = screen.getAllByRole("option"); + const agentsOption = options.find((o) => o.textContent.includes("/agents")); + if (!agentsOption) throw new Error("Could not find /agents option"); + + act(() => { + fireEvent.click(agentsOption); + }); + + await waitFor(() => { + expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); + }); + + expect((textarea as HTMLTextAreaElement).value).toBe("/agents "); + }); + + it("shows command descriptions", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + const dropdown = screen.getByTestId("command-autocomplete"); + expect(dropdown).toHaveTextContent("Show orchestrator health"); + expect(dropdown).toHaveTextContent("Pause the job queue"); + }); + }); + + it("hides autocomplete when input no longer starts with /", async () => { + render(); + const textarea = screen.getByLabelText(/message input/i); + + act(() => { + fireEvent.change(textarea, { target: { value: "/" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(textarea, { target: { value: "" } }); + }); + + await waitFor(() => { + expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/components/chat/ChatInput.tsx b/apps/web/src/components/chat/ChatInput.tsx index 0c484f2..5509a78 100644 --- a/apps/web/src/components/chat/ChatInput.tsx +++ b/apps/web/src/components/chat/ChatInput.tsx @@ -2,6 +2,7 @@ import type { KeyboardEvent, RefObject } from "react"; import { useCallback, useState, useEffect, useRef } from "react"; +import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands"; export const AVAILABLE_MODELS = [ { id: "llama3.2", label: "Llama 3.2" }, @@ -94,6 +95,11 @@ export function ChatInput({ const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false); const [isParamsOpen, setIsParamsOpen] = useState(false); + // Command autocomplete state + const [commandSuggestions, setCommandSuggestions] = useState([]); + const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0); + const commandDropdownRef = useRef(null); + const modelDropdownRef = useRef(null); const paramsDropdownRef = useRef(null); @@ -147,6 +153,35 @@ export function ChatInput({ }); }, []); + // Update command autocomplete suggestions when message changes + useEffect(() => { + const trimmed = message.trimStart(); + if (!trimmed.startsWith("/")) { + setCommandSuggestions([]); + setHighlightedCommandIndex(0); + return; + } + + // If the input contains a space, a command has been completed — no suggestions + if (trimmed.includes(" ")) { + setCommandSuggestions([]); + setHighlightedCommandIndex(0); + return; + } + + const typedCommand = trimmed.toLowerCase(); + + // Build flat list including aliases + const matches = ORCHESTRATOR_COMMANDS.filter((cmd) => { + if (cmd.name.startsWith(typedCommand)) return true; + if (cmd.aliases?.some((a) => a.startsWith(typedCommand))) return true; + return false; + }); + + setCommandSuggestions(matches); + setHighlightedCommandIndex(0); + }, [message]); + // Close dropdowns on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent): void => { @@ -156,6 +191,9 @@ export function ChatInput({ if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) { setIsParamsOpen(false); } + if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) { + setCommandSuggestions([]); + } }; document.addEventListener("mousedown", handleClickOutside); return (): void => { @@ -174,8 +212,48 @@ export function ChatInput({ onStopStreaming?.(); }, [onStopStreaming]); + const acceptCommand = useCallback((cmdName: string): void => { + setMessage(cmdName + " "); + setCommandSuggestions([]); + setHighlightedCommandIndex(0); + }, []); + const handleKeyDown = useCallback( (e: KeyboardEvent) => { + // Command autocomplete navigation + if (commandSuggestions.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightedCommandIndex((prev) => + prev < commandSuggestions.length - 1 ? prev + 1 : 0 + ); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightedCommandIndex((prev) => + prev > 0 ? prev - 1 : commandSuggestions.length - 1 + ); + return; + } + if ( + e.key === "Tab" || + (e.key === "Enter" && !e.shiftKey && commandSuggestions.length > 0) + ) { + e.preventDefault(); + const selected = commandSuggestions[highlightedCommandIndex]; + if (selected) { + acceptCommand(selected.name); + } + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setCommandSuggestions([]); + return; + } + } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -185,7 +263,7 @@ export function ChatInput({ handleSubmit(); } }, - [handleSubmit] + [handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand] ); const handleModelSelect = useCallback( @@ -462,6 +540,55 @@ export function ChatInput({ + {/* Command Autocomplete Dropdown */} + {commandSuggestions.length > 0 && ( +
+ {commandSuggestions.map((cmd, idx) => ( + + ))} +
+ )} + {/* Input Container */}
Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + } as unknown as Response; +} + +/** Run executeCommand and return the result synchronously after act() */ +async function runCommand( + executeCommand: (content: string) => Promise, + content: string +): Promise { + let msg: Message | null = null; + await act(async () => { + msg = await executeCommand(content); + }); + return msg; +} + +describe("useOrchestratorCommands", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + + describe("isCommand", () => { + it("returns true for messages starting with /", () => { + const { result } = renderHook(() => useOrchestratorCommands()); + expect(result.current.isCommand("/status")).toBe(true); + expect(result.current.isCommand("/agents")).toBe(true); + expect(result.current.isCommand("/help")).toBe(true); + expect(result.current.isCommand(" /status")).toBe(true); + }); + + it("returns false for regular messages", () => { + const { result } = renderHook(() => useOrchestratorCommands()); + expect(result.current.isCommand("hello")).toBe(false); + expect(result.current.isCommand("tell me about /status")).toBe(false); + expect(result.current.isCommand("")).toBe(false); + }); + }); + + describe("executeCommand", () => { + describe("/help", () => { + it("returns help message without network calls", async () => { + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/help"); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(msg).not.toBeNull(); + expect(msg?.role).toBe("assistant"); + expect(msg?.content).toContain("/status"); + expect(msg?.content).toContain("/agents"); + expect(msg?.content).toContain("/jobs"); + expect(msg?.content).toContain("/pause"); + expect(msg?.content).toContain("/resume"); + }); + + it("returns message with id and createdAt", async () => { + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/help"); + + expect(msg?.id).toBeDefined(); + expect(msg?.createdAt).toBeDefined(); + }); + }); + + describe("/status", () => { + it("calls /api/orchestrator/health and returns formatted status", async () => { + mockFetch.mockResolvedValueOnce( + makeOkResponse({ status: "ready", version: "1.2.3", uptime: 3661 }) + ); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/status"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/health", { method: "GET" }); + expect(msg?.role).toBe("assistant"); + expect(msg?.content).toContain("Ready"); + expect(msg?.content).toContain("1.2.3"); + expect(msg?.content).toContain("1h"); + }); + + it("shows Not Ready when status is not ready", async () => { + mockFetch.mockResolvedValueOnce(makeOkResponse({ status: "not-ready" })); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/status"); + + expect(msg?.content).toContain("Not Ready"); + }); + + it("handles network error gracefully", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/status"); + + expect(msg?.role).toBe("assistant"); + expect(msg?.content).toContain("Error"); + expect(msg?.content).toContain("Connection refused"); + }); + + it("shows error from API response", async () => { + mockFetch.mockResolvedValueOnce( + makeOkResponse({ error: "ORCHESTRATOR_API_KEY is not configured" }) + ); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/status"); + + expect(msg?.content).toContain("Not reachable"); + }); + }); + + describe("/agents", () => { + it("calls /api/orchestrator/agents and returns agent table", async () => { + const agents = [ + { id: "agent-1", status: "active", type: "codex", startedAt: "2026-02-25T10:00:00Z" }, + { + id: "agent-2", + agentStatus: "TERMINATED", + channel: "claude", + startedAt: "2026-02-25T09:00:00Z", + }, + ]; + mockFetch.mockResolvedValueOnce(makeOkResponse(agents)); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/agents"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/agents", { method: "GET" }); + expect(msg?.content).toContain("agent-1"); + expect(msg?.content).toContain("agent-2"); + expect(msg?.content).toContain("TERMINATED"); + }); + + it("handles empty agent list", async () => { + mockFetch.mockResolvedValueOnce(makeOkResponse([])); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/agents"); + + expect(msg?.content).toContain("No agents currently running"); + }); + + it("handles agents in nested object", async () => { + const data = { + agents: [{ id: "agent-nested", status: "active" }], + }; + mockFetch.mockResolvedValueOnce(makeOkResponse(data)); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/agents"); + + expect(msg?.content).toContain("agent-nested"); + }); + + it("handles network error gracefully", async () => { + mockFetch.mockRejectedValueOnce(new Error("Timeout")); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/agents"); + + expect(msg?.content).toContain("Error"); + expect(msg?.content).toContain("Timeout"); + }); + }); + + describe("/jobs", () => { + it("calls /api/orchestrator/queue/stats", async () => { + mockFetch.mockResolvedValueOnce( + makeOkResponse({ pending: 3, active: 1, completed: 42, failed: 0 }) + ); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/jobs"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" }); + expect(msg?.content).toContain("3"); + expect(msg?.content).toContain("42"); + expect(msg?.content).toContain("Pending"); + expect(msg?.content).toContain("Completed"); + }); + + it("/queue is an alias for /jobs", async () => { + mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0 })); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/queue"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/stats", { method: "GET" }); + expect(msg?.role).toBe("assistant"); + }); + + it("shows paused indicator when queue is paused", async () => { + mockFetch.mockResolvedValueOnce(makeOkResponse({ pending: 0, active: 0, paused: true })); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/jobs"); + + expect(msg?.content).toContain("paused"); + }); + }); + + describe("/pause", () => { + it("calls POST /api/orchestrator/queue/pause", async () => { + mockFetch.mockResolvedValueOnce( + makeOkResponse({ success: true, message: "Queue paused." }) + ); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/pause"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/pause", { + method: "POST", + }); + expect(msg?.content).toContain("paused"); + }); + + it("handles API error response", async () => { + mockFetch.mockResolvedValueOnce(makeOkResponse({ error: "Already paused." })); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/pause"); + + expect(msg?.content).toContain("failed"); + expect(msg?.content).toContain("Already paused"); + }); + + it("handles network error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network failure")); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/pause"); + + expect(msg?.content).toContain("Error"); + }); + }); + + describe("/resume", () => { + it("calls POST /api/orchestrator/queue/resume", async () => { + mockFetch.mockResolvedValueOnce( + makeOkResponse({ success: true, message: "Queue resumed." }) + ); + + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/resume"); + + expect(mockFetch).toHaveBeenCalledWith("/api/orchestrator/queue/resume", { + method: "POST", + }); + expect(msg?.content).toContain("resumed"); + }); + }); + + describe("unknown command", () => { + it("returns help hint for unknown commands", async () => { + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "/unknown-command"); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(msg?.content).toContain("Unknown command"); + expect(msg?.content).toContain("/unknown-command"); + expect(msg?.content).toContain("/help"); + }); + }); + + describe("non-command input", () => { + it("returns null for regular messages", async () => { + const { result } = renderHook(() => useOrchestratorCommands()); + const msg = await runCommand(result.current.executeCommand, "hello world"); + + expect(msg).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/apps/web/src/hooks/useOrchestratorCommands.ts b/apps/web/src/hooks/useOrchestratorCommands.ts new file mode 100644 index 0000000..bb436c6 --- /dev/null +++ b/apps/web/src/hooks/useOrchestratorCommands.ts @@ -0,0 +1,356 @@ +/** + * useOrchestratorCommands hook + * + * Parses chat messages for `/command` prefixes and routes them to the + * orchestrator proxy API routes instead of the LLM. + * + * Supported commands: + * /status — GET /api/orchestrator/health + * /agents — GET /api/orchestrator/agents + * /jobs — GET /api/orchestrator/queue/stats + * /queue — alias for /jobs + * /pause — POST /api/orchestrator/queue/pause + * /resume — POST /api/orchestrator/queue/resume + * /help — Display available commands locally (no API call) + */ + +import { useCallback } from "react"; +import type { Message } from "@/hooks/useChat"; + +// --------------------------------------------------------------------------- +// Command definitions +// --------------------------------------------------------------------------- + +export interface OrchestratorCommand { + name: string; + description: string; + aliases?: string[]; +} + +export const ORCHESTRATOR_COMMANDS: OrchestratorCommand[] = [ + { name: "/status", description: "Show orchestrator health and status" }, + { name: "/agents", description: "List all running agents" }, + { name: "/jobs", description: "Show queue statistics", aliases: ["/queue"] }, + { name: "/pause", description: "Pause the job queue" }, + { name: "/resume", description: "Resume the job queue" }, + { name: "/help", description: "Show available commands" }, +]; + +// --------------------------------------------------------------------------- +// API response shapes (loosely typed — orchestrator may vary) +// --------------------------------------------------------------------------- + +interface HealthResponse { + status?: string; + version?: string; + uptime?: number; + ready?: boolean; + error?: string; +} + +interface Agent { + id?: string; + sessionKey?: string; + status?: string; + type?: string; + agentStatus?: string; + startedAt?: string; + label?: string; + channel?: string; +} + +interface AgentsResponse { + agents?: Agent[]; + error?: string; +} + +interface QueueStats { + pending?: number; + active?: number; + completed?: number; + failed?: number; + waiting?: number; + delayed?: number; + paused?: boolean; + error?: string; +} + +interface ActionResponse { + success?: boolean; + message?: string; + status?: string; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeId(): string { + return `orch-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function makeMessage(content: string): Message { + return { + id: makeId(), + role: "assistant", + content, + createdAt: new Date().toISOString(), + }; +} + +function errorMessage(command: string, detail: string): Message { + return makeMessage( + `**Error running \`${command}\`**\n\n${detail}\n\n_Check that the orchestrator is running and the API key is configured._` + ); +} + +// --------------------------------------------------------------------------- +// Formatters +// --------------------------------------------------------------------------- + +function formatStatus(data: HealthResponse): string { + if (data.error) { + return `**Orchestrator Status**\n\nStatus: Not reachable\n\nError: ${data.error}`; + } + + const statusLabel = data.status ?? (data.ready === true ? "ready" : "unknown"); + const isReady = + statusLabel === "ready" || + statusLabel === "ok" || + statusLabel === "healthy" || + data.ready === true; + const badge = isReady ? "Ready" : "Not Ready"; + + const lines: string[] = [ + `**Orchestrator Status**\n`, + `| Field | Value |`, + `|---|---|`, + `| Status | **${badge}** |`, + ]; + + if (data.version != null) { + lines.push(`| Version | \`${data.version}\` |`); + } + + if (data.uptime != null) { + const uptimeSec = Math.floor(data.uptime); + const hours = Math.floor(uptimeSec / 3600); + const mins = Math.floor((uptimeSec % 3600) / 60); + const secs = uptimeSec % 60; + const uptimeStr = + hours > 0 + ? `${String(hours)}h ${String(mins)}m ${String(secs)}s` + : `${String(mins)}m ${String(secs)}s`; + lines.push(`| Uptime | ${uptimeStr} |`); + } + + return lines.join("\n"); +} + +function formatAgents(raw: unknown): string { + let agents: Agent[] = []; + + if (Array.isArray(raw)) { + agents = raw as Agent[]; + } else if (raw !== null && typeof raw === "object") { + const obj = raw as AgentsResponse; + if (obj.error) { + return `**Agents**\n\nError: ${obj.error}`; + } + if (Array.isArray(obj.agents)) { + agents = obj.agents; + } + } + + if (agents.length === 0) { + return "**Agents**\n\nNo agents currently running."; + } + + const lines: string[] = [ + `**Agents** (${String(agents.length)} total)\n`, + `| ID / Key | Status | Type / Channel | Started |`, + `|---|---|---|---|`, + ]; + + for (const agent of agents) { + const id = agent.id ?? agent.sessionKey ?? "—"; + const status = agent.agentStatus ?? agent.status ?? "—"; + const type = agent.type ?? agent.channel ?? "—"; + const started = agent.startedAt ? new Date(agent.startedAt).toLocaleString() : "—"; + lines.push(`| \`${id}\` | ${status} | ${type} | ${started} |`); + } + + return lines.join("\n"); +} + +function formatQueueStats(data: QueueStats): string { + if (data.error) { + return `**Queue Stats**\n\nError: ${data.error}`; + } + + const lines: string[] = [`**Queue Statistics**\n`, `| Metric | Count |`, `|---|---|`]; + + const metrics: [string, number | undefined][] = [ + ["Pending", data.pending ?? data.waiting], + ["Active", data.active], + ["Delayed", data.delayed], + ["Completed", data.completed], + ["Failed", data.failed], + ]; + + for (const [label, value] of metrics) { + if (value !== undefined) { + lines.push(`| ${label} | ${String(value)} |`); + } + } + + if (data.paused === true) { + lines.push("\n_Queue is currently **paused**._"); + } + + return lines.join("\n"); +} + +function formatAction(command: string, data: ActionResponse): string { + if (data.error) { + return `**${command}** failed.\n\nError: ${data.error}`; + } + + const verb = command === "/pause" ? "paused" : "resumed"; + const msg = data.message ?? data.status ?? `Queue ${verb} successfully.`; + return `**Queue ${verb}**\n\n${msg}`; +} + +function formatHelp(): string { + const lines: string[] = [ + "**Available Orchestrator Commands**\n", + "| Command | Description |", + "|---|---|", + ]; + + for (const cmd of ORCHESTRATOR_COMMANDS) { + const name = cmd.aliases ? `${cmd.name} (${cmd.aliases.join(", ")})` : cmd.name; + lines.push(`| \`${name}\` | ${cmd.description} |`); + } + + lines.push("\n_Commands starting with `/` are routed to the orchestrator instead of the LLM._"); + + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Command parser +// --------------------------------------------------------------------------- + +function parseCommandName(content: string): string | null { + const trimmed = content.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + const parts = trimmed.split(/\s+/); + return parts[0]?.toLowerCase() ?? null; +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export interface UseOrchestratorCommandsReturn { + /** + * Returns true if the content looks like an orchestrator command. + */ + isCommand: (content: string) => boolean; + + /** + * Execute an orchestrator command. + * Returns a Message with formatted markdown output, or null if not a command. + */ + executeCommand: (content: string) => Promise; +} + +export function useOrchestratorCommands(): UseOrchestratorCommandsReturn { + const isCommand = useCallback((content: string): boolean => { + return content.trim().startsWith("/"); + }, []); + + const executeCommand = useCallback(async (content: string): Promise => { + const command = parseCommandName(content); + + if (!command) { + return null; + } + + // /help — local, no network + if (command === "/help") { + return makeMessage(formatHelp()); + } + + // /status + if (command === "/status") { + try { + const res = await fetch("/api/orchestrator/health", { method: "GET" }); + const data = (await res.json()) as HealthResponse; + return makeMessage(formatStatus(data)); + } catch (err) { + const detail = err instanceof Error ? err.message : "Network error"; + return errorMessage("/status", detail); + } + } + + // /agents + if (command === "/agents") { + try { + const res = await fetch("/api/orchestrator/agents", { method: "GET" }); + const data: unknown = await res.json(); + return makeMessage(formatAgents(data)); + } catch (err) { + const detail = err instanceof Error ? err.message : "Network error"; + return errorMessage("/agents", detail); + } + } + + // /jobs or /queue + if (command === "/jobs" || command === "/queue") { + try { + const res = await fetch("/api/orchestrator/queue/stats", { method: "GET" }); + const data = (await res.json()) as QueueStats; + return makeMessage(formatQueueStats(data)); + } catch (err) { + const detail = err instanceof Error ? err.message : "Network error"; + return errorMessage(command, detail); + } + } + + // /pause + if (command === "/pause") { + try { + const res = await fetch("/api/orchestrator/queue/pause", { method: "POST" }); + const data = (await res.json()) as ActionResponse; + return makeMessage(formatAction("/pause", data)); + } catch (err) { + const detail = err instanceof Error ? err.message : "Network error"; + return errorMessage("/pause", detail); + } + } + + // /resume + if (command === "/resume") { + try { + const res = await fetch("/api/orchestrator/queue/resume", { method: "POST" }); + const data = (await res.json()) as ActionResponse; + return makeMessage(formatAction("/resume", data)); + } catch (err) { + const detail = err instanceof Error ? err.message : "Network error"; + return errorMessage("/resume", detail); + } + } + + // Unknown command — show help hint + return makeMessage( + `Unknown command: \`${command}\`\n\nType \`/help\` to see available commands.` + ); + }, []); + + return { isCommand, executeCommand }; +}