huskies: merge 950

This commit is contained in:
dave
2026-05-13 08:41:57 +00:00
parent 7491eec257
commit 4a8ed4348b
38 changed files with 354 additions and 4329 deletions
+37 -106
View File
@@ -3,27 +3,14 @@ import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
import { agentsApi, subscribeAgentStream } from "./agents";
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
const sampleAgent: AgentInfo = {
story_id: "42_story_test",
agent_name: "coder",
@@ -48,89 +35,51 @@ const sampleConfig: AgentConfigInfo = {
describe("agentsApi", () => {
describe("startAgent", () => {
it("sends POST to /agents/start with story_id", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
it("dispatches agents.start RPC with story_id and returns AgentInfo", async () => {
const rpc = installRpcMock();
rpc.respond("agents.start", sampleAgent);
const result = await agentsApi.startAgent("42_story_test");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/start",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
story_id: "42_story_test",
agent_name: undefined,
}),
}),
);
expect(rpc.calls).toEqual([
{
method: "agents.start",
params: { story_id: "42_story_test", agent_name: undefined },
},
]);
expect(result).toEqual(sampleAgent);
});
it("sends POST with optional agent_name", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
it("sends optional agent_name in params", async () => {
const rpc = installRpcMock();
rpc.respond("agents.start", sampleAgent);
await agentsApi.startAgent("42_story_test", "coder");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/start",
expect.objectContaining({
body: JSON.stringify({
story_id: "42_story_test",
agent_name: "coder",
}),
}),
);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse(sampleAgent));
await agentsApi.startAgent(
"42_story_test",
undefined,
"http://localhost:3002/api",
);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/start",
expect.objectContaining({ method: "POST" }),
);
expect(rpc.calls).toEqual([
{
method: "agents.start",
params: { story_id: "42_story_test", agent_name: "coder" },
},
]);
});
});
describe("stopAgent", () => {
it("sends POST to /agents/stop with story_id and agent_name", async () => {
mockFetch.mockResolvedValueOnce(okResponse(true));
it("dispatches agents.stop RPC with story_id and agent_name", async () => {
const rpc = installRpcMock();
rpc.respond("agents.stop", true);
const result = await agentsApi.stopAgent("42_story_test", "coder");
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/stop",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
story_id: "42_story_test",
agent_name: "coder",
}),
}),
);
expect(rpc.calls).toEqual([
{
method: "agents.stop",
params: { story_id: "42_story_test", agent_name: "coder" },
},
]);
expect(result).toBe(true);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse(false));
await agentsApi.stopAgent(
"42_story_test",
"coder",
"http://localhost:3002/api",
);
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/stop",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("getAgentConfig", () => {
@@ -157,46 +106,28 @@ describe("agentsApi", () => {
});
describe("reloadConfig", () => {
it("sends POST to /agents/config/reload", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
it("dispatches agent_config.list RPC and returns the config list", async () => {
const rpc = installRpcMock();
rpc.respond("agent_config.list", [sampleConfig]);
const result = await agentsApi.reloadConfig();
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents/config/reload",
expect.objectContaining({ method: "POST" }),
);
expect(rpc.calls).toEqual([
{ method: "agent_config.list", params: {} },
]);
expect(result).toEqual([sampleConfig]);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
await agentsApi.reloadConfig("http://localhost:3002/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents/config/reload",
expect.objectContaining({ method: "POST" }),
);
});
});
describe("error handling", () => {
it("throws on non-ok HTTP response from startAgent", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(404, "story not found"));
it("surfaces RPC errors from startAgent", async () => {
const rpc = installRpcMock();
rpc.respondError("agents.start", "story not found", "NOT_FOUND");
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
"story not found",
);
});
it("throws with status code from startAgent when body is empty", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
"Request failed (500)",
);
});
});
});
+12 -57
View File
@@ -40,60 +40,19 @@ export interface AgentConfigInfo {
max_budget_usd: number | null;
}
const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
export const agentsApi = {
startAgent(storyId: string, agentName?: string, baseUrl?: string) {
return requestJson<AgentInfo>(
"/agents/start",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
startAgent(storyId: string, agentName?: string) {
return rpcCall<AgentInfo>("agents.start", {
story_id: storyId,
agent_name: agentName,
});
},
stopAgent(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<boolean>(
"/agents/stop",
{
method: "POST",
body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
},
baseUrl,
);
stopAgent(storyId: string, agentName: string) {
return rpcCall<boolean>("agents.stop", {
story_id: storyId,
agent_name: agentName,
});
},
listAgents(_baseUrl?: string) {
@@ -104,12 +63,8 @@ export const agentsApi = {
return rpcCall<AgentConfigInfo[]>("agent_config.list");
},
reloadConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>(
"/agents/config/reload",
{ method: "POST" },
baseUrl,
);
reloadConfig() {
return rpcCall<AgentConfigInfo[]>("agent_config.list");
},
getAgentOutput(storyId: string, agentName: string, _baseUrl?: string) {
-56
View File
@@ -12,17 +12,6 @@ afterEach(() => {
vi.restoreAllMocks();
});
function okResponse(body: unknown) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
function errorResponse(status: number, text: string) {
return new Response(text, { status });
}
describe("api client", () => {
describe("getCurrentProject", () => {
it("dispatches project.current RPC and returns the path", async () => {
@@ -158,51 +147,6 @@ describe("api client", () => {
);
});
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(api.searchFiles("query")).rejects.toThrow(
"Request failed (500)",
);
});
});
describe("searchFiles", () => {
it("sends POST with query", async () => {
mockFetch.mockResolvedValueOnce(
okResponse([{ path: "src/main.rs", matches: 1 }]),
);
const result = await api.searchFiles("hello");
expect(mockFetch).toHaveBeenCalledWith(
"/api/fs/search",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ query: "hello" }),
}),
);
expect(result).toHaveLength(1);
});
});
describe("execShell", () => {
it("sends POST with command and args", async () => {
mockFetch.mockResolvedValueOnce(
okResponse({ stdout: "output", stderr: "", exit_code: 0 }),
);
const result = await api.execShell("ls", ["-la"]);
expect(mockFetch).toHaveBeenCalledWith(
"/api/shell/exec",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ command: "ls", args: ["-la"] }),
}),
);
expect(result.exit_code).toBe(0);
});
});
describe("resolveWsHost", () => {
+9 -87
View File
@@ -1,8 +1,7 @@
/**
* HTTP transport layer for the Huskies API client.
* Provides the low-level `requestJson` helper, the `callMcpTool` function
* for MCP JSON-RPC calls, the `resolveWsHost` utility, and the `api`
* object exposing all REST endpoints.
* Provides the `callMcpTool` function for MCP JSON-RPC calls, the
* `resolveWsHost` utility, and the `api` object exposing all endpoints.
*/
import { rpcCall } from "../rpc";
@@ -15,18 +14,13 @@ import type {
import type {
AllTokenUsageResponse,
AnthropicModelInfo,
CommandOutput,
FileEntry,
OAuthStatus,
SearchResult,
TestResultsResponse,
TokenCostResponse,
WorkItemContent,
} from "./types";
/** Base URL prefix for all REST API requests in production. */
export const DEFAULT_API_BASE = "/api";
/**
* Resolve the WebSocket host to connect to.
* In development, uses the injected port (or 3001); in production, uses the
@@ -40,31 +34,6 @@ export function resolveWsHost(
return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost;
}
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
return `${baseUrl}${path}`;
}
async function requestJson<T>(
path: string,
options: RequestInit = {},
baseUrl = DEFAULT_API_BASE,
): Promise<T> {
const res = await fetch(buildApiUrl(path, baseUrl), {
headers: {
"Content-Type": "application/json",
...(options.headers ?? {}),
},
...options,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Request failed (${res.status})`);
}
return res.json() as Promise<T>;
}
/**
* Invoke an MCP tool via the server's JSON-RPC `/mcp` endpoint.
* Returns the first text content block from the tool result, or an empty
@@ -92,7 +61,7 @@ export async function callMcpTool(
return text;
}
/** Typed REST and MCP wrappers for all Huskies server endpoints. */
/** Typed wrappers for all Huskies server endpoints. */
export const api = {
getCurrentProject(_baseUrl?: string) {
return rpcCall<string | null>("project.current");
@@ -137,40 +106,11 @@ export const api = {
const r = await rpcCall<OkResult>("anthropic.set_api_key", params);
return r.ok;
},
readFile(path: string, baseUrl?: string) {
return requestJson<string>(
"/fs/read",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
readFile(path: string) {
return rpcCall<string>("io.read_file", { path });
},
writeFile(path: string, content: string, baseUrl?: string) {
return requestJson<boolean>(
"/fs/write",
{ method: "POST", body: JSON.stringify({ path, content }) },
baseUrl,
);
},
listDirectory(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/fs/list",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
listDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<FileEntry[]>(
"/io/fs/list/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
},
createDirectoryAbsolute(path: string, baseUrl?: string) {
return requestJson<boolean>(
"/io/fs/create/absolute",
{ method: "POST", body: JSON.stringify({ path }) },
baseUrl,
);
listDirectoryAbsolute(path: string) {
return rpcCall<FileEntry[]>("io.list_directory_absolute", { path });
},
getHomeDirectory(_baseUrl?: string) {
return rpcCall<string>("io.home_directory");
@@ -178,20 +118,6 @@ export const api = {
listProjectFiles(_baseUrl?: string) {
return rpcCall<string[]>("io.list_project_files");
},
searchFiles(query: string, baseUrl?: string) {
return requestJson<SearchResult[]>(
"/fs/search",
{ method: "POST", body: JSON.stringify({ query }) },
baseUrl,
);
},
execShell(command: string, args: string[], baseUrl?: string) {
return requestJson<CommandOutput>(
"/shell/exec",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
},
async cancelChat(_baseUrl?: string) {
const r = await rpcCall<OkResult>("chat.cancel");
return r.ok;
@@ -237,11 +163,7 @@ export const api = {
return rpcCall<OAuthStatus>("oauth.status");
},
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
botCommand(command: string, args: string, baseUrl?: string) {
return requestJson<{ response: string }>(
"/bot/command",
{ method: "POST", body: JSON.stringify({ command, args }) },
baseUrl,
);
botCommand(command: string, args: string) {
return rpcCall<{ response: string }>("bot.command", { command, args });
},
};
+1 -1
View File
@@ -33,6 +33,6 @@ export type {
WsResponse,
} from "./types";
export { api, callMcpTool, DEFAULT_API_BASE, resolveWsHost } from "./http";
export { api, callMcpTool, resolveWsHost } from "./http";
export { ChatWebSocket } from "./websocket";
@@ -277,7 +277,6 @@ describe("Slash command handling (Story 374)", () => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"",
undefined,
);
});
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
@@ -302,7 +301,6 @@ describe("Slash command handling (Story 374)", () => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"status",
"42",
undefined,
);
});
});
@@ -324,7 +322,6 @@ describe("Slash command handling (Story 374)", () => {
expect(mockedApi.botCommand).toHaveBeenCalledWith(
"start",
"42 opus",
undefined,
);
});
expect(await screen.findByText("Started agent")).toBeInTheDocument();
@@ -348,7 +345,7 @@ describe("Slash command handling (Story 374)", () => {
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "");
});
});
@@ -370,7 +367,7 @@ describe("Slash command handling (Story 374)", () => {
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "");
});
});
@@ -446,7 +443,7 @@ describe("Slash command handling (Story 374)", () => {
});
await waitFor(() => {
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "", undefined);
expect(mockedApi.botCommand).toHaveBeenCalledWith("help", "");
});
expect(lastSendChatArgs).toBeNull();
});
+18 -21
View File
@@ -1,7 +1,6 @@
import { useCallback, useState } from "react";
import type { WizardStateData, WizardStepInfo } from "../api/client";
const API_BASE = "/api";
import { rpcCall } from "../api/rpc";
interface SetupWizardProps {
wizardState: WizardStateData;
@@ -50,27 +49,17 @@ function stepBorder(status: string, isActive: boolean): string {
/** Messages sent to the chat to trigger agent generation for each step. */
const STEP_PROMPTS: Record<string, string> = {
context:
"Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard API to store the content: PUT /api/wizard/step/context/content",
"Read the codebase and generate .huskies/specs/00_CONTEXT.md with a project context spec. Include High-Level Goal, Core Features, Domain Definition, and Glossary sections. Then call the wizard MCP tool `wizard_generate` with step=context to store the content.",
stack:
"Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard API to store the content: PUT /api/wizard/step/stack/content",
"Read the tech stack and generate .huskies/specs/tech/STACK.md with a tech stack spec. Include Core Stack, Coding Standards, Quality Gates, and Libraries sections. Then call the wizard MCP tool `wizard_generate` with step=stack to store the content.",
test_script:
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard API: PUT /api/wizard/step/test_script/content",
"Read the project structure and create script/test — a bash script that runs the project's actual test suite. Then call the wizard MCP tool `wizard_generate` with step=test_script to store the content.",
release_script:
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard API: PUT /api/wizard/step/release_script/content",
"Read the project's deployment setup and create script/release tailored to the project. Then call the wizard MCP tool `wizard_generate` with step=release_script to store the content.",
test_coverage:
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard API: PUT /api/wizard/step/test_coverage/content",
"If the stack supports coverage reporting, create script/test_coverage. Then call the wizard MCP tool `wizard_generate` with step=test_coverage to store the content.",
};
async function apiPost(path: string): Promise<WizardStateData | null> {
try {
const resp = await fetch(`${API_BASE}${path}`, { method: "POST" });
if (!resp.ok) return null;
return (await resp.json()) as WizardStateData;
} catch {
return null;
}
}
function StepCard({
step,
isActive,
@@ -272,10 +261,14 @@ export default function SetupWizard({
const handleConfirm = useCallback(
async (step: WizardStepInfo) => {
const result = await apiPost(`/wizard/step/${step.step}/confirm`);
if (result) {
try {
const result = await rpcCall<WizardStateData>("wizard.confirm_step", {
step: step.step,
});
onWizardUpdate(result);
setRefreshKey((k) => k + 1);
} catch {
// ignore — state remains unchanged
}
},
[onWizardUpdate],
@@ -283,10 +276,14 @@ export default function SetupWizard({
const handleSkip = useCallback(
async (step: WizardStepInfo) => {
const result = await apiPost(`/wizard/step/${step.step}/skip`);
if (result) {
try {
const result = await rpcCall<WizardStateData>("wizard.skip_step", {
step: step.step,
});
onWizardUpdate(result);
setRefreshKey((k) => k + 1);
} catch {
// ignore — state remains unchanged
}
},
[onWizardUpdate],
+1 -1
View File
@@ -125,7 +125,7 @@ export function useChatSend({
{ role: "user", content: messageText },
]);
try {
const result = await api.botCommand(cmd, args, undefined);
const result = await api.botCommand(cmd, args);
setMessages((prev: Message[]) => [
...prev,
{ role: "assistant", content: result.response },