feat(web): add orchestrator command system in chat interface (#521)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #521.
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user