huskies: merge 950
This commit is contained in:
+37
-106
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user