feat(web): add orchestrator command system in chat interface (CT-ORCH-001)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRef } from "react";
|
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 { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
||||||
import { Chat, type ChatRef } from "./Chat";
|
import { Chat, type ChatRef } from "./Chat";
|
||||||
import * as useChatModule from "@/hooks/useChat";
|
import * as useChatModule from "@/hooks/useChat";
|
||||||
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
||||||
import * as authModule from "@/lib/auth/auth-context";
|
import * as authModule from "@/lib/auth/auth-context";
|
||||||
|
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
||||||
|
|
||||||
// Mock scrollIntoView (not available in JSDOM)
|
// Mock scrollIntoView (not available in JSDOM)
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
@@ -39,9 +40,14 @@ vi.mock("./ChatInput", () => ({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
}): React.ReactElement => (
|
}): React.ReactElement => (
|
||||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
<>
|
||||||
Send
|
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||||
</button>
|
Send
|
||||||
|
</button>
|
||||||
|
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
|
||||||
|
Send Command
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
DEFAULT_TEMPERATURE: 0.7,
|
DEFAULT_TEMPERATURE: 0.7,
|
||||||
DEFAULT_MAX_TOKENS: 4096,
|
DEFAULT_MAX_TOKENS: 4096,
|
||||||
@@ -54,11 +60,18 @@ vi.mock("./ChatInput", () => ({
|
|||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useOrchestratorCommands", () => ({
|
||||||
|
useOrchestratorCommands: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
||||||
const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule.useChat>;
|
const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule.useChat>;
|
||||||
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
||||||
typeof useWebSocketModule.useWebSocket
|
typeof useWebSocketModule.useWebSocket
|
||||||
>;
|
>;
|
||||||
|
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
||||||
|
typeof orchestratorModule.useOrchestratorCommands
|
||||||
|
>;
|
||||||
|
|
||||||
function createMockUseChatReturn(
|
function createMockUseChatReturn(
|
||||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||||
@@ -107,6 +120,12 @@ describe("Chat", () => {
|
|||||||
socket: null,
|
socket: null,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default: no commands intercepted
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: vi.fn().mockReturnValue(false),
|
||||||
|
executeCommand: vi.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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(<Chat />);
|
||||||
|
|
||||||
|
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(<Chat />);
|
||||||
|
|
||||||
|
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(<Chat />);
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useChat } from "@/hooks/useChat";
|
import { useChat } from "@/hooks/useChat";
|
||||||
|
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
||||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||||
@@ -79,6 +80,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
abortStream,
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
|
setMessages,
|
||||||
clearError,
|
clearError,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
@@ -89,6 +91,8 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||||
|
|
||||||
|
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
const [loadingQuip, setLoadingQuip] = useState<string | null>(null);
|
||||||
@@ -192,9 +196,27 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
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);
|
await sendMessage(content);
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[isCommand, executeCommand, setMessages, sendMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback((prompt: string): void => {
|
const handleSuggestionClick = useCallback((prompt: string): void => {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file ChatInput.test.tsx
|
* @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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
ChatInput,
|
ChatInput,
|
||||||
@@ -291,3 +292,195 @@ describe("ChatInput — send behavior", () => {
|
|||||||
expect(onStop).toHaveBeenCalledOnce();
|
expect(onStop).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ChatInput — command autocomplete", () => {
|
||||||
|
it("shows no autocomplete for regular text", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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(<ChatInput onSend={vi.fn()} />);
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { KeyboardEvent, RefObject } from "react";
|
import type { KeyboardEvent, RefObject } from "react";
|
||||||
import { useCallback, useState, useEffect, useRef } from "react";
|
import { useCallback, useState, useEffect, useRef } from "react";
|
||||||
|
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
|
||||||
|
|
||||||
export const AVAILABLE_MODELS = [
|
export const AVAILABLE_MODELS = [
|
||||||
{ id: "llama3.2", label: "Llama 3.2" },
|
{ id: "llama3.2", label: "Llama 3.2" },
|
||||||
@@ -94,6 +95,11 @@ export function ChatInput({
|
|||||||
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||||
const [isParamsOpen, setIsParamsOpen] = useState(false);
|
const [isParamsOpen, setIsParamsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Command autocomplete state
|
||||||
|
const [commandSuggestions, setCommandSuggestions] = useState<typeof ORCHESTRATOR_COMMANDS>([]);
|
||||||
|
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
|
||||||
|
const commandDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const paramsDropdownRef = useRef<HTMLDivElement>(null);
|
const paramsDropdownRef = useRef<HTMLDivElement>(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
|
// Close dropdowns on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (e: MouseEvent): void => {
|
const handleClickOutside = (e: MouseEvent): void => {
|
||||||
@@ -156,6 +191,9 @@ export function ChatInput({
|
|||||||
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
|
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
|
||||||
setIsParamsOpen(false);
|
setIsParamsOpen(false);
|
||||||
}
|
}
|
||||||
|
if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
@@ -174,8 +212,48 @@ export function ChatInput({
|
|||||||
onStopStreaming?.();
|
onStopStreaming?.();
|
||||||
}, [onStopStreaming]);
|
}, [onStopStreaming]);
|
||||||
|
|
||||||
|
const acceptCommand = useCallback((cmdName: string): void => {
|
||||||
|
setMessage(cmdName + " ");
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// 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) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -185,7 +263,7 @@ export function ChatInput({
|
|||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSubmit]
|
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
@@ -462,6 +540,55 @@ export function ChatInput({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Command Autocomplete Dropdown */}
|
||||||
|
{commandSuggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={commandDropdownRef}
|
||||||
|
className="rounded-lg border shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Command suggestions"
|
||||||
|
data-testid="command-autocomplete"
|
||||||
|
>
|
||||||
|
{commandSuggestions.map((cmd, idx) => (
|
||||||
|
<button
|
||||||
|
key={cmd.name}
|
||||||
|
role="option"
|
||||||
|
aria-selected={idx === highlightedCommandIndex}
|
||||||
|
onClick={() => {
|
||||||
|
acceptCommand(cmd.name);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
idx === highlightedCommandIndex
|
||||||
|
? "rgb(var(--accent-primary) / 0.1)"
|
||||||
|
: "transparent",
|
||||||
|
color: "rgb(var(--text-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs font-semibold"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
>
|
||||||
|
{cmd.name}
|
||||||
|
</span>
|
||||||
|
{cmd.aliases && cmd.aliases.length > 0 && (
|
||||||
|
<span className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
({cmd.aliases.join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs ml-auto" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input Container */}
|
{/* Input Container */}
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg border transition-all duration-150"
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
|
|||||||
293
apps/web/src/hooks/useOrchestratorCommands.test.ts
Normal file
293
apps/web/src/hooks/useOrchestratorCommands.test.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useOrchestratorCommands hook
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useOrchestratorCommands } from "./useOrchestratorCommands";
|
||||||
|
import type { Message } from "./useChat";
|
||||||
|
|
||||||
|
// Mock fetch globally
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
function makeOkResponse(data: unknown): Response {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => 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<Message | null>,
|
||||||
|
content: string
|
||||||
|
): Promise<Message | null> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
356
apps/web/src/hooks/useOrchestratorCommands.ts
Normal file
@@ -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<Message | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrchestratorCommands(): UseOrchestratorCommandsReturn {
|
||||||
|
const isCommand = useCallback((content: string): boolean => {
|
||||||
|
return content.trim().startsWith("/");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeCommand = useCallback(async (content: string): Promise<Message | null> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user