huskies: merge 770

This commit is contained in:
dave
2026-04-28 15:31:29 +00:00
parent 1946709681
commit f63464852b
13 changed files with 212 additions and 266 deletions
+5 -35
View File
@@ -132,38 +132,6 @@ describe("agentsApi", () => {
});
});
describe("listAgents", () => {
it("sends GET to /agents and returns agent list", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleAgent]));
const result = await agentsApi.listAgents();
expect(mockFetch).toHaveBeenCalledWith(
"/api/agents",
expect.objectContaining({}),
);
expect(result).toEqual([sampleAgent]);
});
it("returns empty array when no agents running", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
const result = await agentsApi.listAgents();
expect(result).toEqual([]);
});
it("uses custom baseUrl when provided", async () => {
mockFetch.mockResolvedValueOnce(okResponse([]));
await agentsApi.listAgents("http://localhost:3002/api");
expect(mockFetch).toHaveBeenCalledWith(
"http://localhost:3002/api/agents",
expect.objectContaining({}),
);
});
});
describe("getAgentConfig", () => {
it("sends GET to /agents/config and returns config list", async () => {
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
@@ -216,15 +184,17 @@ describe("agentsApi", () => {
describe("error handling", () => {
it("throws on non-ok response with body text", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(404, "agent not found"));
mockFetch.mockResolvedValueOnce(errorResponse(404, "config not found"));
await expect(agentsApi.listAgents()).rejects.toThrow("agent not found");
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
"config not found",
);
});
it("throws with status code when no body", async () => {
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
await expect(agentsApi.listAgents()).rejects.toThrow(
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
"Request failed (500)",
);
});
+4 -2
View File
@@ -1,3 +1,5 @@
import { rpcCall } from "./rpc";
export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
export interface AgentInfo {
@@ -94,8 +96,8 @@ export const agentsApi = {
);
},
listAgents(baseUrl?: string) {
return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
listAgents(_baseUrl?: string) {
return rpcCall<AgentInfo[]>("active_agents.list");
},
getAgentConfig(baseUrl?: string) {
+107
View File
@@ -0,0 +1,107 @@
/**
* Lightweight read-RPC client over the `/ws` WebSocket.
*
* Opens a short-lived WebSocket, sends an `rpc_request` frame, waits for the
* matching `rpc_response`, then closes the connection.
*/
let correlationCounter = 0;
function nextCorrelationId(): string {
return `rpc-${Date.now()}-${++correlationCounter}`;
}
/**
* Build the WebSocket URL for the `/ws` endpoint, deriving the protocol
* (ws/wss) and host from the current page location.
*/
function buildWsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${window.location.host}/ws`;
}
export interface RpcResponse<T = unknown> {
ok: boolean;
result?: T;
error?: string;
code?: string;
}
/**
* Send a read-RPC request over a temporary WebSocket connection and return
* the result. Rejects if the server responds with `ok: false` or if the
* connection times out.
*/
export function rpcCall<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = 5000,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const correlationId = nextCorrelationId();
const ws = new WebSocket(buildWsUrl());
let settled = false;
const timer = setTimeout(() => {
if (!settled) {
settled = true;
ws.close();
reject(new Error(`RPC timeout for ${method}`));
}
}, timeoutMs);
ws.onopen = () => {
ws.send(
JSON.stringify({
kind: "rpc_request",
version: 1,
correlation_id: correlationId,
ttl_ms: timeoutMs,
method,
params,
}),
);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Only process rpc_response frames matching our correlation ID.
if (
data.kind === "rpc_response" &&
data.correlation_id === correlationId
) {
settled = true;
clearTimeout(timer);
ws.close();
if (data.ok) {
resolve(data.result as T);
} else {
reject(
new Error(data.error || `RPC error: ${data.code || "UNKNOWN"}`),
);
}
}
// Ignore other messages (pipeline_state, onboarding_status, etc.)
} catch {
// Ignore non-JSON or unparseable messages
}
};
ws.onerror = () => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(new Error(`WebSocket error during RPC call to ${method}`));
}
};
ws.onclose = () => {
if (!settled) {
settled = true;
clearTimeout(timer);
reject(new Error(`WebSocket closed before RPC response for ${method}`));
}
};
});
}
+1 -1
View File
@@ -10,7 +10,7 @@ beforeEach(() => {
vi.fn((input: string | URL | Request) => {
const url = typeof input === "string" ? input : input.toString();
// Endpoints that return arrays need [] not {} to avoid "not iterable" errors.
const arrayEndpoints = ["/agents", "/agents/config"];
const arrayEndpoints = ["/agents/config"];
const body = arrayEndpoints.some((ep) => url.endsWith(ep))
? JSON.stringify([])
: JSON.stringify({});