huskies: merge 948
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Test helpers for stubbing the WebSocket used by `rpcCall`.
|
||||
*
|
||||
* `rpcCall` opens a transient WebSocket, sends an `rpc_request` frame, and
|
||||
* resolves once the matching `rpc_response` arrives. `installRpcMock`
|
||||
* installs a `WebSocket` global that records sent frames and replies with
|
||||
* canned responses keyed by RPC method name.
|
||||
*/
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
interface MockSocket {
|
||||
url: string;
|
||||
sent: string[];
|
||||
onopen: ((ev: Event) => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onerror: ((ev: Event) => void) | null;
|
||||
onclose: ((ev: CloseEvent) => void) | null;
|
||||
readyState: number;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handle returned by `installMockRpcWebSocket`: records sockets and calls,
|
||||
* lets the test register canned responses (or override responses for specific
|
||||
* methods), and restores the real `WebSocket` constructor on cleanup.
|
||||
*/
|
||||
export interface MockRpcInstaller {
|
||||
/** All sockets created during the test, in order. */
|
||||
instances: MockSocket[];
|
||||
/** All RPC method names that were called. */
|
||||
calls: { method: string; params: Record<string, unknown> }[];
|
||||
/**
|
||||
* Register a result to be returned for `method`. If the value is a
|
||||
* function, it is invoked with the request params and its return value
|
||||
* (or the resolved promise) is used as the result.
|
||||
*/
|
||||
respond(method: string, result: unknown): void;
|
||||
/** Make `method` reply with an `ok:false` response. */
|
||||
respondError(method: string, error: string, code?: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a stub `WebSocket` global that synchronously resolves RPC calls
|
||||
* with results registered via the returned [`MockRpcInstaller`].
|
||||
*/
|
||||
export function installRpcMock(): MockRpcInstaller {
|
||||
const instances: MockSocket[] = [];
|
||||
const calls: { method: string; params: Record<string, unknown> }[] = [];
|
||||
const results = new Map<string, unknown>();
|
||||
const errors = new Map<string, { error: string; code?: string }>();
|
||||
|
||||
class MockWebSocket implements MockSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
sent: string[] = [];
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
readyState = 0;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
instances.push(this);
|
||||
queueMicrotask(() => {
|
||||
this.readyState = 1;
|
||||
this.onopen?.(new Event("open"));
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
let frame: {
|
||||
correlation_id?: string;
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
frame = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { correlation_id, method, params } = frame;
|
||||
if (!correlation_id || !method) return;
|
||||
calls.push({ method, params: params ?? {} });
|
||||
queueMicrotask(() => {
|
||||
const err = errors.get(method);
|
||||
if (err) {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: false,
|
||||
error: err.error,
|
||||
code: err.code,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (results.has(method)) {
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: true,
|
||||
result: results.get(method),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No registered response — synthesise NOT_FOUND so the test fails
|
||||
// loudly instead of timing out.
|
||||
this.onmessage?.({
|
||||
data: JSON.stringify({
|
||||
kind: "rpc_response",
|
||||
version: 1,
|
||||
correlation_id,
|
||||
ok: false,
|
||||
error: `no mock for ${method}`,
|
||||
code: "NOT_FOUND",
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
return {
|
||||
instances,
|
||||
calls,
|
||||
respond(method, result) {
|
||||
results.set(method, result);
|
||||
},
|
||||
respondError(method, error, code) {
|
||||
errors.set(method, { error, code });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentConfigInfo, AgentEvent, AgentInfo } from "./agents";
|
||||
import { agentsApi, subscribeAgentStream } from "./agents";
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -133,26 +134,24 @@ describe("agentsApi", () => {
|
||||
});
|
||||
|
||||
describe("getAgentConfig", () => {
|
||||
it("sends GET to /agents/config and returns config list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
it("dispatches an agent_config.list RPC and returns the config list", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("agent_config.list", [sampleConfig]);
|
||||
|
||||
const result = await agentsApi.getAgentConfig();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "agent_config.list", params: {} },
|
||||
]);
|
||||
expect(result).toEqual([sampleConfig]);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse([sampleConfig]));
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("agent_config.list", "config not found", "NOT_FOUND");
|
||||
|
||||
await agentsApi.getAgentConfig("http://localhost:3002/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3002/api/agents/config",
|
||||
expect.objectContaining({}),
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"config not found",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -183,18 +182,18 @@ describe("agentsApi", () => {
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "config not found"));
|
||||
it("throws on non-ok HTTP response from startAgent", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "story not found"));
|
||||
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
"config not found",
|
||||
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
|
||||
"story not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
it("throws with status code from startAgent when body is empty", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(agentsApi.getAgentConfig()).rejects.toThrow(
|
||||
await expect(agentsApi.startAgent("missing_story")).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ export const agentsApi = {
|
||||
return rpcCall<AgentInfo[]>("active_agents.list");
|
||||
},
|
||||
|
||||
getAgentConfig(baseUrl?: string) {
|
||||
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
|
||||
getAgentConfig(_baseUrl?: string) {
|
||||
return rpcCall<AgentConfigInfo[]>("agent_config.list");
|
||||
},
|
||||
|
||||
reloadConfig(baseUrl?: string) {
|
||||
@@ -112,12 +112,11 @@ export const agentsApi = {
|
||||
);
|
||||
},
|
||||
|
||||
getAgentOutput(storyId: string, agentName: string, baseUrl?: string) {
|
||||
return requestJson<{ output: string }>(
|
||||
`/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/output`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
getAgentOutput(storyId: string, agentName: string, _baseUrl?: string) {
|
||||
return rpcCall<{ output: string }>("agents.get_output", {
|
||||
story_id: storyId,
|
||||
agent_name: agentName,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* WS-RPC client for chat-bot transport config (Matrix / Slack / WhatsApp).
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
|
||||
export interface BotConfig {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
@@ -29,8 +34,8 @@ async function requestJson<T>(
|
||||
}
|
||||
|
||||
export const botConfigApi = {
|
||||
getConfig(baseUrl?: string): Promise<BotConfig> {
|
||||
return requestJson<BotConfig>("/bot/config", {}, baseUrl);
|
||||
getConfig(_baseUrl?: string): Promise<BotConfig> {
|
||||
return rpcCall<BotConfig>("bot_config.get");
|
||||
},
|
||||
|
||||
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, ChatWebSocket, resolveWsHost } from "./client";
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -24,20 +25,19 @@ function errorResponse(status: number, text: string) {
|
||||
|
||||
describe("api client", () => {
|
||||
describe("getCurrentProject", () => {
|
||||
it("sends GET to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
it("dispatches project.current RPC and returns the path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.current", "/home/user/project");
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([{ method: "project.current", params: {} }]);
|
||||
expect(result).toBe("/home/user/project");
|
||||
});
|
||||
|
||||
it("returns null when no project open", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(null));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.current", null);
|
||||
|
||||
const result = await api.getCurrentProject();
|
||||
expect(result).toBeNull();
|
||||
@@ -74,25 +74,28 @@ describe("api client", () => {
|
||||
});
|
||||
|
||||
describe("getKnownProjects", () => {
|
||||
it("returns array of project paths", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(["/a", "/b"]));
|
||||
it("dispatches project.known RPC and returns the path list", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.known", ["/a", "/b"]);
|
||||
|
||||
const result = await api.getKnownProjects();
|
||||
expect(rpc.calls).toEqual([{ method: "project.known", params: {} }]);
|
||||
expect(result).toEqual(["/a", "/b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on non-ok response with body text", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(404, "Not found"));
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("project.current", "store offline", "INTERNAL");
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("Not found");
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
|
||||
});
|
||||
|
||||
it("throws with status code when no body", async () => {
|
||||
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(api.getCurrentProject()).rejects.toThrow(
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* object exposing all REST endpoints.
|
||||
*/
|
||||
|
||||
import { rpcCall } from "../rpc";
|
||||
import type {
|
||||
AllTokenUsageResponse,
|
||||
AnthropicModelInfo,
|
||||
@@ -87,11 +88,11 @@ export async function callMcpTool(
|
||||
|
||||
/** Typed REST and MCP wrappers for all Huskies server endpoints. */
|
||||
export const api = {
|
||||
getCurrentProject(baseUrl?: string) {
|
||||
return requestJson<string | null>("/project", {}, baseUrl);
|
||||
getCurrentProject(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("project.current");
|
||||
},
|
||||
getKnownProjects(baseUrl?: string) {
|
||||
return requestJson<string[]>("/projects", {}, baseUrl);
|
||||
getKnownProjects(_baseUrl?: string) {
|
||||
return rpcCall<string[]>("project.known");
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
@@ -110,8 +111,8 @@ export const api = {
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
},
|
||||
getModelPreference(baseUrl?: string) {
|
||||
return requestJson<string | null>("/model", {}, baseUrl);
|
||||
getModelPreference(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("model.get_preference");
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
@@ -120,21 +121,17 @@ export const api = {
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getOllamaModels(baseUrlParam?: string, baseUrl?: string) {
|
||||
const url = new URL(
|
||||
buildApiUrl("/ollama/models", baseUrl),
|
||||
window.location.origin,
|
||||
getOllamaModels(baseUrlParam?: string, _baseUrl?: string) {
|
||||
return rpcCall<string[]>(
|
||||
"ollama.list_models",
|
||||
baseUrlParam ? { base_url: baseUrlParam } : {},
|
||||
);
|
||||
if (baseUrlParam) {
|
||||
url.searchParams.set("base_url", baseUrlParam);
|
||||
}
|
||||
return requestJson<string[]>(url.pathname + url.search, {}, "");
|
||||
},
|
||||
getAnthropicApiKeyExists(baseUrl?: string) {
|
||||
return requestJson<boolean>("/anthropic/key/exists", {}, baseUrl);
|
||||
getAnthropicApiKeyExists(_baseUrl?: string) {
|
||||
return rpcCall<boolean>("anthropic.key_exists");
|
||||
},
|
||||
getAnthropicModels(baseUrl?: string) {
|
||||
return requestJson<AnthropicModelInfo[]>("/anthropic/models", {}, baseUrl);
|
||||
getAnthropicModels(_baseUrl?: string) {
|
||||
return rpcCall<AnthropicModelInfo[]>("anthropic.list_models");
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
@@ -178,11 +175,11 @@ export const api = {
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
getHomeDirectory(baseUrl?: string) {
|
||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||
getHomeDirectory(_baseUrl?: string) {
|
||||
return rpcCall<string>("io.home_directory");
|
||||
},
|
||||
listProjectFiles(baseUrl?: string) {
|
||||
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||
listProjectFiles(_baseUrl?: string) {
|
||||
return rpcCall<string[]>("io.list_project_files");
|
||||
},
|
||||
searchFiles(query: string, baseUrl?: string) {
|
||||
return requestJson<SearchResult[]>(
|
||||
@@ -201,29 +198,21 @@ export const api = {
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
},
|
||||
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||
return requestJson<WorkItemContent>(
|
||||
`/work-items/${encodeURIComponent(storyId)}`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
getWorkItemContent(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<WorkItemContent>("work_items.get", { story_id: storyId });
|
||||
},
|
||||
getTestResults(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TestResultsResponse | null>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/test-results`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
getTestResults(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<TestResultsResponse | null>("work_items.test_results", {
|
||||
story_id: storyId,
|
||||
});
|
||||
},
|
||||
getTokenCost(storyId: string, baseUrl?: string) {
|
||||
return requestJson<TokenCostResponse>(
|
||||
`/work-items/${encodeURIComponent(storyId)}/token-cost`,
|
||||
{},
|
||||
baseUrl,
|
||||
);
|
||||
getTokenCost(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<TokenCostResponse>("work_items.token_cost", {
|
||||
story_id: storyId,
|
||||
});
|
||||
},
|
||||
getAllTokenUsage(baseUrl?: string) {
|
||||
return requestJson<AllTokenUsageResponse>("/token-usage", {}, baseUrl);
|
||||
getAllTokenUsage(_baseUrl?: string) {
|
||||
return rpcCall<AllTokenUsageResponse>("token_usage.all");
|
||||
},
|
||||
/** Trigger a server rebuild and restart. */
|
||||
rebuildAndRestart() {
|
||||
@@ -247,7 +236,7 @@ export const api = {
|
||||
},
|
||||
/** Fetch OAuth status from the server. */
|
||||
getOAuthStatus() {
|
||||
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||
return rpcCall<OAuthStatus>("oauth.status");
|
||||
},
|
||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||
botCommand(command: string, args: string, baseUrl?: string) {
|
||||
|
||||
+115
-18
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 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.
|
||||
* Each `rpcCall` opens a short-lived WebSocket, sends an `rpc_request` frame,
|
||||
* waits for the matching `rpc_response`, then closes the connection.
|
||||
*
|
||||
* On a transient connection failure the call is retried once before rejecting,
|
||||
* which lets a freshly-started backend race finish before the user sees an
|
||||
* error. Failures surface as `Error` instances whose `.message` is intended
|
||||
* to be visible (toast / banner) — callers must not swallow them silently.
|
||||
*/
|
||||
|
||||
let correlationCounter = 0;
|
||||
@@ -27,26 +32,59 @@ export interface RpcResponse<T = unknown> {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** Error subclass for RPC failures so callers can recognise them. */
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code?: string,
|
||||
public readonly method?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Maximum number of automatic retries on transient WebSocket failure. */
|
||||
const MAX_RETRIES = 1;
|
||||
|
||||
/** Delay between retry attempts (ms). */
|
||||
const RETRY_DELAY_MS = 250;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Internal: a single one-shot RPC attempt. Resolves with the result or
|
||||
* rejects with an `RpcError`.
|
||||
*/
|
||||
export function rpcCall<T = unknown>(
|
||||
function rpcAttempt<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = 5000,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const correlationId = nextCorrelationId();
|
||||
const ws = new WebSocket(buildWsUrl());
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(buildWsUrl());
|
||||
} catch (err) {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Failed to open WebSocket for ${method}: ${(err as Error).message}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
ws.close();
|
||||
reject(new Error(`RPC timeout for ${method}`));
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
reject(new RpcError(`RPC timeout for ${method}`, "TIMEOUT", method));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
@@ -66,25 +104,32 @@ export function rpcCall<T = unknown>(
|
||||
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();
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (data.ok) {
|
||||
resolve(data.result as T);
|
||||
} else {
|
||||
reject(
|
||||
new Error(data.error || `RPC error: ${data.code || "UNKNOWN"}`),
|
||||
new RpcError(
|
||||
data.error || `RPC error: ${data.code || "UNKNOWN"}`,
|
||||
data.code,
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ignore other messages (pipeline_state, onboarding_status, etc.)
|
||||
// Ignore other frames (pipeline_state, onboarding_status, etc.)
|
||||
} catch {
|
||||
// Ignore non-JSON or unparseable messages
|
||||
/* ignore non-JSON / malformed frames */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -92,7 +137,13 @@ export function rpcCall<T = unknown>(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket error during RPC call to ${method}`));
|
||||
reject(
|
||||
new RpcError(
|
||||
`WebSocket error during RPC call to ${method}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,8 +151,54 @@ export function rpcCall<T = unknown>(
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`WebSocket closed before RPC response for ${method}`));
|
||||
reject(
|
||||
new RpcError(
|
||||
`WebSocket closed before RPC response for ${method}`,
|
||||
"CONNECT_FAILED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Return true if the error is one we should retry (connection-level). */
|
||||
function isRetryable(err: unknown): boolean {
|
||||
return (
|
||||
err instanceof RpcError &&
|
||||
(err.code === "CONNECT_FAILED" || err.code === "TIMEOUT")
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a read-RPC request over a temporary WebSocket connection and return
|
||||
* the result. On transient connection failure the call is retried once
|
||||
* before rejecting. Rejects with [`RpcError`] on server-side errors,
|
||||
* timeouts, or persistent connection failures.
|
||||
*/
|
||||
export async function rpcCall<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
timeoutMs = 5000,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await rpcAttempt<T>(method, params, timeoutMs);
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
if (attempt < MAX_RETRIES && isRetryable(err)) {
|
||||
await sleep(RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// Unreachable but TypeScript can't prove it.
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProjectSettings } from "./settings";
|
||||
import { settingsApi } from "./settings";
|
||||
import { installRpcMock } from "./__test_utils__/mockRpcWebSocket";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -38,28 +39,24 @@ const defaultProjectSettings: ProjectSettings = {
|
||||
|
||||
describe("settingsApi", () => {
|
||||
describe("getProjectSettings", () => {
|
||||
it("sends GET to /settings and returns project settings", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||
it("dispatches settings.get_project RPC and returns project settings", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.get_project", defaultProjectSettings);
|
||||
|
||||
const result = await settingsApi.getProjectSettings();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.get_project", params: {} },
|
||||
]);
|
||||
expect(result).toEqual(defaultProjectSettings);
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(defaultProjectSettings));
|
||||
await settingsApi.getProjectSettings("http://localhost:4000/api");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings",
|
||||
expect.anything(),
|
||||
it("surfaces RPC errors visibly", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.get_project", "no project open", "INTERNAL");
|
||||
|
||||
await expect(settingsApi.getProjectSettings()).rejects.toThrow(
|
||||
"no project open",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -95,41 +92,26 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("getEditorCommand", () => {
|
||||
it("sends GET to /settings/editor and returns editor settings", async () => {
|
||||
it("dispatches settings.get_editor RPC and returns editor settings", async () => {
|
||||
const rpc = installRpcMock();
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
rpc.respond("settings.get_editor", expected);
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.get_editor", params: {} },
|
||||
]);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns null editor_command when not configured", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.get_editor", { editor_command: null });
|
||||
|
||||
const result = await settingsApi.getEditorCommand();
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "code" }));
|
||||
|
||||
await settingsApi.getEditorCommand("http://localhost:4000/api");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setEditorCommand", () => {
|
||||
@@ -178,19 +160,12 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws with response body text on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(400, "Bad Request"));
|
||||
it("surfaces RPC errors for getEditorCommand", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.get_editor", "store unavailable", "INTERNAL");
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Bad Request",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with status code message when response body is empty", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(settingsApi.getEditorCommand()).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
"store unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* WS-RPC client for editor and project settings.
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
|
||||
export interface EditorSettings {
|
||||
editor_command: string | null;
|
||||
}
|
||||
@@ -47,8 +52,8 @@ async function requestJson<T>(
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
getProjectSettings(baseUrl?: string): Promise<ProjectSettings> {
|
||||
return requestJson<ProjectSettings>("/settings", {}, baseUrl);
|
||||
getProjectSettings(_baseUrl?: string): Promise<ProjectSettings> {
|
||||
return rpcCall<ProjectSettings>("settings.get_project");
|
||||
},
|
||||
|
||||
putProjectSettings(
|
||||
@@ -62,8 +67,8 @@ export const settingsApi = {
|
||||
);
|
||||
},
|
||||
|
||||
getEditorCommand(baseUrl?: string): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>("/settings/editor", {}, baseUrl);
|
||||
getEditorCommand(_baseUrl?: string): Promise<EditorSettings> {
|
||||
return rpcCall<EditorSettings>("settings.get_editor");
|
||||
},
|
||||
|
||||
setEditorCommand(
|
||||
|
||||
Reference in New Issue
Block a user