huskies: merge 949
This commit is contained in:
@@ -2,47 +2,17 @@
|
||||
* WS-RPC client for chat-bot transport config (Matrix / Slack / WhatsApp).
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
import type { BotConfigPayload } from "./rpcContract";
|
||||
|
||||
export interface BotConfig {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
homeserver: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
room_ids: string[] | null;
|
||||
slack_bot_token: string | null;
|
||||
slack_signing_secret: string | null;
|
||||
slack_channel_ids: string[] | null;
|
||||
}
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
|
||||
async function requestJson<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
baseUrl = DEFAULT_API_BASE,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
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>;
|
||||
}
|
||||
/** Re-export of the wire-format `BotConfigPayload` as the client-facing `BotConfig` alias. */
|
||||
export type BotConfig = BotConfigPayload;
|
||||
|
||||
export const botConfigApi = {
|
||||
getConfig(_baseUrl?: string): Promise<BotConfig> {
|
||||
return rpcCall<BotConfig>("bot_config.get");
|
||||
},
|
||||
|
||||
saveConfig(config: BotConfig, baseUrl?: string): Promise<BotConfig> {
|
||||
return requestJson<BotConfig>(
|
||||
"/bot/config",
|
||||
{ method: "PUT", body: JSON.stringify(config) },
|
||||
baseUrl,
|
||||
);
|
||||
saveConfig(config: BotConfig, _baseUrl?: string): Promise<BotConfig> {
|
||||
return rpcCall<BotConfigPayload>("bot_config.save", config);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,31 +45,88 @@ describe("api client", () => {
|
||||
});
|
||||
|
||||
describe("openProject", () => {
|
||||
it("sends POST with path", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse("/home/user/project"));
|
||||
it("dispatches project.open RPC with path and returns the canonical path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.open", { path: "/home/user/project" });
|
||||
|
||||
await api.openProject("/home/user/project");
|
||||
const result = await api.openProject("/home/user/project");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: "/home/user/project" }),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "project.open",
|
||||
params: { path: "/home/user/project" },
|
||||
},
|
||||
]);
|
||||
expect(result).toBe("/home/user/project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeProject", () => {
|
||||
it("sends DELETE to /project", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse(true));
|
||||
it("dispatches project.close RPC and returns ok", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.close", { ok: true });
|
||||
|
||||
await api.closeProject();
|
||||
const result = await api.closeProject();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/project",
|
||||
expect.objectContaining({ method: "DELETE" }),
|
||||
);
|
||||
expect(rpc.calls).toEqual([{ method: "project.close", params: {} }]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetKnownProject", () => {
|
||||
it("dispatches project.forget RPC with path", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("project.forget", { ok: true });
|
||||
|
||||
const result = await api.forgetKnownProject("/some/path");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "project.forget", params: { path: "/some/path" } },
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setModelPreference", () => {
|
||||
it("dispatches model.set_preference RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("model.set_preference", { ok: true });
|
||||
|
||||
await api.setModelPreference("claude-sonnet-4-6");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "model.set_preference",
|
||||
params: { model: "claude-sonnet-4-6" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAnthropicApiKey", () => {
|
||||
it("dispatches anthropic.set_api_key RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("anthropic.set_api_key", { ok: true });
|
||||
|
||||
await api.setAnthropicApiKey("sk-ant-xxx");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "anthropic.set_api_key",
|
||||
params: { api_key: "sk-ant-xxx" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelChat", () => {
|
||||
it("dispatches chat.cancel RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("chat.cancel", { ok: true });
|
||||
|
||||
await api.cancelChat();
|
||||
|
||||
expect(rpc.calls).toEqual([{ method: "chat.cancel", params: {} }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,10 +149,19 @@ describe("api client", () => {
|
||||
await expect(api.getCurrentProject()).rejects.toThrow("store offline");
|
||||
});
|
||||
|
||||
it("surfaces RPC errors visibly for write methods", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("project.open", "No such directory", "INTERNAL");
|
||||
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
"No such directory",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on non-ok HTTP response for legacy POST endpoints", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(500, ""));
|
||||
|
||||
await expect(api.openProject("/some/path")).rejects.toThrow(
|
||||
await expect(api.searchFiles("query")).rejects.toThrow(
|
||||
"Request failed (500)",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
*/
|
||||
|
||||
import { rpcCall } from "../rpc";
|
||||
import type {
|
||||
OkResult,
|
||||
OpenProjectResult,
|
||||
SetAnthropicApiKeyParams,
|
||||
SetModelPreferenceParams,
|
||||
} from "../rpcContract";
|
||||
import type {
|
||||
AllTokenUsageResponse,
|
||||
AnthropicModelInfo,
|
||||
@@ -94,32 +100,25 @@ export const api = {
|
||||
getKnownProjects(_baseUrl?: string) {
|
||||
return rpcCall<string[]>("project.known");
|
||||
},
|
||||
forgetKnownProject(path: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/projects/forget",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
async forgetKnownProject(path: string, _baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("project.forget", { path });
|
||||
return r.ok;
|
||||
},
|
||||
openProject(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
"/project",
|
||||
{ method: "POST", body: JSON.stringify({ path }) },
|
||||
baseUrl,
|
||||
);
|
||||
async openProject(path: string, _baseUrl?: string) {
|
||||
const r = await rpcCall<OpenProjectResult>("project.open", { path });
|
||||
return r.path;
|
||||
},
|
||||
closeProject(baseUrl?: string) {
|
||||
return requestJson<boolean>("/project", { method: "DELETE" }, baseUrl);
|
||||
async closeProject(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("project.close");
|
||||
return r.ok;
|
||||
},
|
||||
getModelPreference(_baseUrl?: string) {
|
||||
return rpcCall<string | null>("model.get_preference");
|
||||
},
|
||||
setModelPreference(model: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/model",
|
||||
{ method: "POST", body: JSON.stringify({ model }) },
|
||||
baseUrl,
|
||||
);
|
||||
async setModelPreference(model: string, _baseUrl?: string) {
|
||||
const params: SetModelPreferenceParams = { model };
|
||||
const r = await rpcCall<OkResult>("model.set_preference", params);
|
||||
return r.ok;
|
||||
},
|
||||
getOllamaModels(baseUrlParam?: string, _baseUrl?: string) {
|
||||
return rpcCall<string[]>(
|
||||
@@ -133,12 +132,10 @@ export const api = {
|
||||
getAnthropicModels(_baseUrl?: string) {
|
||||
return rpcCall<AnthropicModelInfo[]>("anthropic.list_models");
|
||||
},
|
||||
setAnthropicApiKey(api_key: string, baseUrl?: string) {
|
||||
return requestJson<boolean>(
|
||||
"/anthropic/key",
|
||||
{ method: "POST", body: JSON.stringify({ api_key }) },
|
||||
baseUrl,
|
||||
);
|
||||
async setAnthropicApiKey(api_key: string, _baseUrl?: string) {
|
||||
const params: SetAnthropicApiKeyParams = { api_key };
|
||||
const r = await rpcCall<OkResult>("anthropic.set_api_key", params);
|
||||
return r.ok;
|
||||
},
|
||||
readFile(path: string, baseUrl?: string) {
|
||||
return requestJson<string>(
|
||||
@@ -195,8 +192,9 @@ export const api = {
|
||||
baseUrl,
|
||||
);
|
||||
},
|
||||
cancelChat(baseUrl?: string) {
|
||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||
async cancelChat(_baseUrl?: string) {
|
||||
const r = await rpcCall<OkResult>("chat.cancel");
|
||||
return r.ok;
|
||||
},
|
||||
getWorkItemContent(storyId: string, _baseUrl?: string) {
|
||||
return rpcCall<WorkItemContent>("work_items.get", { story_id: storyId });
|
||||
|
||||
+62
-28
@@ -56,7 +56,7 @@ const RETRY_DELAY_MS = 250;
|
||||
*/
|
||||
function rpcAttempt<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
params: object,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
@@ -102,34 +102,68 @@ function rpcAttempt<T>(
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
let data: unknown;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (
|
||||
data.kind === "rpc_response" &&
|
||||
data.correlation_id === correlationId
|
||||
) {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (data.ok) {
|
||||
resolve(data.result as T);
|
||||
} else {
|
||||
reject(
|
||||
new RpcError(
|
||||
data.error || `RPC error: ${data.code || "UNKNOWN"}`,
|
||||
data.code,
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Ignore other frames (pipeline_state, onboarding_status, etc.)
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
/* ignore non-JSON / malformed frames */
|
||||
// Non-JSON frame is not ours — keep waiting.
|
||||
return;
|
||||
}
|
||||
if (!data || typeof data !== "object") {
|
||||
return;
|
||||
}
|
||||
const frame = data as {
|
||||
kind?: unknown;
|
||||
correlation_id?: unknown;
|
||||
ok?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
code?: unknown;
|
||||
};
|
||||
if (frame.kind !== "rpc_response" || frame.correlation_id !== correlationId) {
|
||||
// Not addressed to this call — ignore (pipeline_state, etc.).
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (typeof frame.ok !== "boolean") {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Malformed RPC response for ${method}: missing or non-boolean 'ok' field`,
|
||||
"MALFORMED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (frame.ok) {
|
||||
if (!("result" in frame)) {
|
||||
reject(
|
||||
new RpcError(
|
||||
`Malformed RPC response for ${method}: 'ok:true' frame missing 'result' field`,
|
||||
"MALFORMED",
|
||||
method,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(frame.result as T);
|
||||
} else {
|
||||
const errMsg =
|
||||
typeof frame.error === "string" ? frame.error : undefined;
|
||||
const errCode = typeof frame.code === "string" ? frame.code : undefined;
|
||||
reject(
|
||||
new RpcError(
|
||||
errMsg || `RPC error: ${errCode || "UNKNOWN"}`,
|
||||
errCode,
|
||||
method,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -183,7 +217,7 @@ function sleep(ms: number): Promise<void> {
|
||||
*/
|
||||
export async function rpcCall<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
params: object = {},
|
||||
timeoutMs = 5000,
|
||||
): Promise<T> {
|
||||
let lastErr: unknown;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"model.set_preference": {
|
||||
"params": {
|
||||
"model": "claude-sonnet-4-6"
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"anthropic.set_api_key": {
|
||||
"params": {
|
||||
"api_key": "sk-ant-..."
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"settings.put_editor": {
|
||||
"params": {
|
||||
"editor_command": "zed"
|
||||
},
|
||||
"result": {
|
||||
"editor_command": "zed"
|
||||
}
|
||||
},
|
||||
"settings.open_file": {
|
||||
"params": {
|
||||
"path": "src/main.rs",
|
||||
"line": 42
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"settings.put_project": {
|
||||
"params": {
|
||||
"default_qa": "server",
|
||||
"default_coder_model": null,
|
||||
"max_coders": null,
|
||||
"max_retries": 2,
|
||||
"base_branch": null,
|
||||
"rate_limit_notifications": true,
|
||||
"timezone": null,
|
||||
"rendezvous": null,
|
||||
"watcher_sweep_interval_secs": 60,
|
||||
"watcher_done_retention_secs": 86400
|
||||
},
|
||||
"result": {
|
||||
"default_qa": "server",
|
||||
"default_coder_model": null,
|
||||
"max_coders": null,
|
||||
"max_retries": 2,
|
||||
"base_branch": null,
|
||||
"rate_limit_notifications": true,
|
||||
"timezone": null,
|
||||
"rendezvous": null,
|
||||
"watcher_sweep_interval_secs": 60,
|
||||
"watcher_done_retention_secs": 86400
|
||||
}
|
||||
},
|
||||
"project.open": {
|
||||
"params": {
|
||||
"path": "/path/to/project"
|
||||
},
|
||||
"result": {
|
||||
"path": "/path/to/project"
|
||||
}
|
||||
},
|
||||
"project.close": {
|
||||
"params": {},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"project.forget": {
|
||||
"params": {
|
||||
"path": "/path/to/project"
|
||||
},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
},
|
||||
"bot_config.save": {
|
||||
"params": {
|
||||
"transport": "matrix",
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example",
|
||||
"username": "bot",
|
||||
"password": "secret",
|
||||
"room_ids": [
|
||||
"!room:example"
|
||||
],
|
||||
"slack_bot_token": null,
|
||||
"slack_signing_secret": null,
|
||||
"slack_channel_ids": null
|
||||
},
|
||||
"result": {
|
||||
"transport": "matrix",
|
||||
"enabled": true,
|
||||
"homeserver": "https://matrix.example",
|
||||
"username": "bot",
|
||||
"password": "secret",
|
||||
"room_ids": [
|
||||
"!room:example"
|
||||
],
|
||||
"slack_bot_token": null,
|
||||
"slack_signing_secret": null,
|
||||
"slack_channel_ids": null
|
||||
}
|
||||
},
|
||||
"chat.cancel": {
|
||||
"params": {},
|
||||
"result": {
|
||||
"ok": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Snapshot test: the frontend `CONTRACT_FIXTURES` table must match the
|
||||
* Rust-side snapshot. When the Rust contract changes, the snapshot file
|
||||
* regenerates (via `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test`) and this
|
||||
* test catches any TS shapes that have drifted.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CONTRACT_FIXTURES } from "./rpcContract";
|
||||
import snapshot from "./rpcContract.snapshot.json";
|
||||
|
||||
describe("rpcContract", () => {
|
||||
it("CONTRACT_FIXTURES matches the Rust-generated snapshot", () => {
|
||||
// Convert TS fixtures into the same shape the Rust snapshot serialises
|
||||
// to: a method-keyed object of `{ params, result }`.
|
||||
const fromTs = Object.fromEntries(
|
||||
Object.entries(CONTRACT_FIXTURES).map(([method, payloads]) => [
|
||||
method,
|
||||
{ params: payloads.params, result: payloads.result },
|
||||
]),
|
||||
);
|
||||
expect(fromTs).toEqual(snapshot);
|
||||
});
|
||||
|
||||
it("declares the same method names as the snapshot", () => {
|
||||
const tsMethods = Object.keys(CONTRACT_FIXTURES).sort();
|
||||
const rustMethods = Object.keys(snapshot).sort();
|
||||
expect(tsMethods).toEqual(rustMethods);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Frontend mirror of the Rust typed RPC contract in
|
||||
* `server/src/crdt_sync/rpc_contract.rs`.
|
||||
*
|
||||
* Every typed write method declared on the backend has matching TypeScript
|
||||
* params/result types here. The `CONTRACT_FIXTURES` table also exposes the
|
||||
* same canonical example payloads as the Rust `CONTRACT_METHODS` slice — the
|
||||
* `rpcContract.test.ts` test compares them against the committed
|
||||
* `rpcContract.snapshot.json` that the Rust test regenerates. If the Rust
|
||||
* shapes drift from the TS shapes, the snapshot drifts and one side fails in
|
||||
* CI — surfacing the mismatch as a compile / test error instead of a runtime
|
||||
* one.
|
||||
*
|
||||
* When adding a method on the backend:
|
||||
* 1. Add the params + result type here.
|
||||
* 2. Add the entry to `CONTRACT_FIXTURES` with a canonical example.
|
||||
* 3. Re-run `UPDATE_RPC_CONTRACT_SNAPSHOT=1 cargo test` to refresh
|
||||
* `rpcContract.snapshot.json`.
|
||||
*/
|
||||
|
||||
// ── Params types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Params for `model.set_preference`. */
|
||||
export interface SetModelPreferenceParams {
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** Params for `anthropic.set_api_key`. */
|
||||
export interface SetAnthropicApiKeyParams {
|
||||
api_key: string;
|
||||
}
|
||||
|
||||
/** Params for `settings.put_editor`. */
|
||||
export interface PutEditorParams {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
/** Params for `settings.open_file`. */
|
||||
export interface OpenFileParams {
|
||||
path: string;
|
||||
line: number | null;
|
||||
}
|
||||
|
||||
/** Params for `project.open`. */
|
||||
export interface OpenProjectParams {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Params for `project.forget`. */
|
||||
export interface ForgetProjectParams {
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Payload for `bot_config.save` (and result of `bot_config.get`). */
|
||||
export interface BotConfigPayload {
|
||||
transport: string | null;
|
||||
enabled: boolean | null;
|
||||
homeserver: string | null;
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
room_ids: string[] | null;
|
||||
slack_bot_token: string | null;
|
||||
slack_signing_secret: string | null;
|
||||
slack_channel_ids: string[] | null;
|
||||
}
|
||||
|
||||
/** Payload for `settings.put_project` (also returned by `settings.get_project`). */
|
||||
export interface ProjectSettingsPayload {
|
||||
default_qa: string;
|
||||
default_coder_model: string | null;
|
||||
max_coders: number | null;
|
||||
max_retries: number;
|
||||
base_branch: string | null;
|
||||
rate_limit_notifications: boolean;
|
||||
timezone: string | null;
|
||||
rendezvous: string | null;
|
||||
watcher_sweep_interval_secs: number;
|
||||
watcher_done_retention_secs: number;
|
||||
}
|
||||
|
||||
// ── Result types ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Result envelope for write methods that simply succeed or fail. */
|
||||
export interface OkResult {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/** Result for `settings.put_editor`. */
|
||||
export interface EditorSettingsResult {
|
||||
editor_command: string | null;
|
||||
}
|
||||
|
||||
/** Result for `project.open`. */
|
||||
export interface OpenProjectResult {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// ── Method → params/result mapping ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compile-time mapping from typed RPC method name to its params + result
|
||||
* shapes. Used by `callTypedRpc` to enforce that callers pass the right
|
||||
* params and receive the right return type for a method.
|
||||
*/
|
||||
export interface TypedRpcMethods {
|
||||
"model.set_preference": {
|
||||
params: SetModelPreferenceParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"anthropic.set_api_key": {
|
||||
params: SetAnthropicApiKeyParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"settings.put_editor": {
|
||||
params: PutEditorParams;
|
||||
result: EditorSettingsResult;
|
||||
};
|
||||
"settings.open_file": {
|
||||
params: OpenFileParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"settings.put_project": {
|
||||
params: ProjectSettingsPayload;
|
||||
result: ProjectSettingsPayload;
|
||||
};
|
||||
"project.open": {
|
||||
params: OpenProjectParams;
|
||||
result: OpenProjectResult;
|
||||
};
|
||||
"project.close": {
|
||||
params: Record<string, never>;
|
||||
result: OkResult;
|
||||
};
|
||||
"project.forget": {
|
||||
params: ForgetProjectParams;
|
||||
result: OkResult;
|
||||
};
|
||||
"bot_config.save": {
|
||||
params: BotConfigPayload;
|
||||
result: BotConfigPayload;
|
||||
};
|
||||
"chat.cancel": {
|
||||
params: Record<string, never>;
|
||||
result: OkResult;
|
||||
};
|
||||
}
|
||||
|
||||
/** Union of all typed RPC method names declared in the contract. */
|
||||
export type TypedRpcMethodName = keyof TypedRpcMethods;
|
||||
|
||||
// ── Canonical fixtures (mirror of Rust `CONTRACT_METHODS`) ──────────────────
|
||||
|
||||
/**
|
||||
* One canonical example payload per typed RPC method. The shape *must*
|
||||
* match the corresponding Rust `CONTRACT_METHODS` entry. Drift between this
|
||||
* table and `rpcContract.snapshot.json` (regenerated by the Rust side) fails
|
||||
* the `rpcContract.test.ts` snapshot check.
|
||||
*/
|
||||
export const CONTRACT_FIXTURES: {
|
||||
[K in TypedRpcMethodName]: {
|
||||
params: TypedRpcMethods[K]["params"];
|
||||
result: TypedRpcMethods[K]["result"];
|
||||
};
|
||||
} = {
|
||||
"model.set_preference": {
|
||||
params: { model: "claude-sonnet-4-6" },
|
||||
result: { ok: true },
|
||||
},
|
||||
"anthropic.set_api_key": {
|
||||
params: { api_key: "sk-ant-..." },
|
||||
result: { ok: true },
|
||||
},
|
||||
"settings.put_editor": {
|
||||
params: { editor_command: "zed" },
|
||||
result: { editor_command: "zed" },
|
||||
},
|
||||
"settings.open_file": {
|
||||
params: { path: "src/main.rs", line: 42 },
|
||||
result: { ok: true },
|
||||
},
|
||||
"settings.put_project": {
|
||||
params: {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
max_coders: null,
|
||||
max_retries: 2,
|
||||
base_branch: null,
|
||||
rate_limit_notifications: true,
|
||||
timezone: null,
|
||||
rendezvous: null,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
},
|
||||
result: {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
max_coders: null,
|
||||
max_retries: 2,
|
||||
base_branch: null,
|
||||
rate_limit_notifications: true,
|
||||
timezone: null,
|
||||
rendezvous: null,
|
||||
watcher_sweep_interval_secs: 60,
|
||||
watcher_done_retention_secs: 86_400,
|
||||
},
|
||||
},
|
||||
"project.open": {
|
||||
params: { path: "/path/to/project" },
|
||||
result: { path: "/path/to/project" },
|
||||
},
|
||||
"project.close": {
|
||||
params: {},
|
||||
result: { ok: true },
|
||||
},
|
||||
"project.forget": {
|
||||
params: { path: "/path/to/project" },
|
||||
result: { ok: true },
|
||||
},
|
||||
"bot_config.save": {
|
||||
params: {
|
||||
transport: "matrix",
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example",
|
||||
username: "bot",
|
||||
password: "secret",
|
||||
room_ids: ["!room:example"],
|
||||
slack_bot_token: null,
|
||||
slack_signing_secret: null,
|
||||
slack_channel_ids: null,
|
||||
},
|
||||
result: {
|
||||
transport: "matrix",
|
||||
enabled: true,
|
||||
homeserver: "https://matrix.example",
|
||||
username: "bot",
|
||||
password: "secret",
|
||||
room_ids: ["!room:example"],
|
||||
slack_bot_token: null,
|
||||
slack_signing_secret: null,
|
||||
slack_channel_ids: null,
|
||||
},
|
||||
},
|
||||
"chat.cancel": {
|
||||
params: {},
|
||||
result: { ok: true },
|
||||
},
|
||||
};
|
||||
@@ -1,29 +1,13 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
/** Tests for the `settings` WS-RPC client (project settings read/write). */
|
||||
import { afterEach, 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();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
});
|
||||
|
||||
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 defaultProjectSettings: ProjectSettings = {
|
||||
default_qa: "server",
|
||||
default_coder_model: null,
|
||||
@@ -62,25 +46,25 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("putProjectSettings", () => {
|
||||
it("sends PUT to /settings with settings body", async () => {
|
||||
it("dispatches settings.put_project RPC with settings", async () => {
|
||||
const updated = { ...defaultProjectSettings, default_qa: "agent" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(updated));
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_project", updated);
|
||||
|
||||
const result = await settingsApi.putProjectSettings(updated);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify(updated),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{ method: "settings.put_project", params: updated },
|
||||
]);
|
||||
expect(result.default_qa).toBe("agent");
|
||||
});
|
||||
|
||||
it("throws on validation error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
errorResponse(400, "Invalid default_qa value"),
|
||||
it("throws on validation error from RPC", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError(
|
||||
"settings.put_project",
|
||||
"Invalid default_qa value",
|
||||
"INVALID",
|
||||
);
|
||||
await expect(
|
||||
settingsApi.putProjectSettings({
|
||||
@@ -115,47 +99,65 @@ describe("settingsApi", () => {
|
||||
});
|
||||
|
||||
describe("setEditorCommand", () => {
|
||||
it("sends PUT to /settings/editor with command body", async () => {
|
||||
const expected = { editor_command: "zed" };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
it("dispatches settings.put_editor RPC with command", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_editor", { editor_command: "zed" });
|
||||
|
||||
const result = await settingsApi.setEditorCommand("zed");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: "zed" }),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.put_editor",
|
||||
params: { editor_command: "zed" },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({ editor_command: "zed" });
|
||||
});
|
||||
|
||||
it("sends PUT with null to clear the editor command", async () => {
|
||||
const expected = { editor_command: null };
|
||||
mockFetch.mockResolvedValueOnce(okResponse(expected));
|
||||
it("dispatches settings.put_editor with null to clear", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.put_editor", { editor_command: null });
|
||||
|
||||
const result = await settingsApi.setEditorCommand(null);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"/api/settings/editor",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: null }),
|
||||
}),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.put_editor",
|
||||
params: { editor_command: null },
|
||||
},
|
||||
]);
|
||||
expect(result.editor_command).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
mockFetch.mockResolvedValueOnce(okResponse({ editor_command: "vim" }));
|
||||
describe("openFile", () => {
|
||||
it("dispatches settings.open_file RPC with path and line", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.open_file", { ok: true });
|
||||
|
||||
await settingsApi.setEditorCommand("vim", "http://localhost:4000/api");
|
||||
const result = await settingsApi.openFile("src/main.rs", 42);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:4000/api/settings/editor",
|
||||
expect.objectContaining({ method: "PUT" }),
|
||||
);
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.open_file",
|
||||
params: { path: "src/main.rs", line: 42 },
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("dispatches settings.open_file with null line when omitted", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respond("settings.open_file", { ok: true });
|
||||
|
||||
await settingsApi.openFile("src/main.rs");
|
||||
|
||||
expect(rpc.calls).toEqual([
|
||||
{
|
||||
method: "settings.open_file",
|
||||
params: { path: "src/main.rs", line: null },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,8 +171,9 @@ describe("settingsApi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on setEditorCommand error", async () => {
|
||||
mockFetch.mockResolvedValueOnce(errorResponse(403, "Forbidden"));
|
||||
it("surfaces RPC errors for setEditorCommand", async () => {
|
||||
const rpc = installRpcMock();
|
||||
rpc.respondError("settings.put_editor", "Forbidden", "FORBIDDEN");
|
||||
|
||||
await expect(settingsApi.setEditorCommand("code")).rejects.toThrow(
|
||||
"Forbidden",
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
* WS-RPC client for editor and project settings.
|
||||
*/
|
||||
import { rpcCall } from "./rpc";
|
||||
import type {
|
||||
EditorSettingsResult,
|
||||
OkResult,
|
||||
OpenFileParams,
|
||||
ProjectSettingsPayload,
|
||||
PutEditorParams,
|
||||
} from "./rpcContract";
|
||||
|
||||
export interface EditorSettings {
|
||||
editor_command: string | null;
|
||||
@@ -24,80 +31,39 @@ export interface OpenFileResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
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 settingsApi = {
|
||||
getProjectSettings(_baseUrl?: string): Promise<ProjectSettings> {
|
||||
return rpcCall<ProjectSettings>("settings.get_project");
|
||||
},
|
||||
|
||||
putProjectSettings(
|
||||
async putProjectSettings(
|
||||
settings: ProjectSettings,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<ProjectSettings> {
|
||||
return requestJson<ProjectSettings>(
|
||||
"/settings",
|
||||
{ method: "PUT", body: JSON.stringify(settings) },
|
||||
baseUrl,
|
||||
);
|
||||
const params: ProjectSettingsPayload = settings;
|
||||
return rpcCall<ProjectSettingsPayload>("settings.put_project", params);
|
||||
},
|
||||
|
||||
getEditorCommand(_baseUrl?: string): Promise<EditorSettings> {
|
||||
return rpcCall<EditorSettings>("settings.get_editor");
|
||||
},
|
||||
|
||||
setEditorCommand(
|
||||
async setEditorCommand(
|
||||
command: string | null,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<EditorSettings> {
|
||||
return requestJson<EditorSettings>(
|
||||
"/settings/editor",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ editor_command: command }),
|
||||
},
|
||||
baseUrl,
|
||||
);
|
||||
const params: PutEditorParams = { editor_command: command };
|
||||
const r = await rpcCall<EditorSettingsResult>("settings.put_editor", params);
|
||||
return { editor_command: r.editor_command };
|
||||
},
|
||||
|
||||
openFile(
|
||||
async openFile(
|
||||
path: string,
|
||||
line?: number,
|
||||
baseUrl?: string,
|
||||
_baseUrl?: string,
|
||||
): Promise<OpenFileResult> {
|
||||
const params = new URLSearchParams({ path });
|
||||
if (line !== undefined) {
|
||||
params.set("line", String(line));
|
||||
}
|
||||
return requestJson<OpenFileResult>(
|
||||
`/settings/open-file?${params.toString()}`,
|
||||
{ method: "POST" },
|
||||
baseUrl,
|
||||
);
|
||||
const params: OpenFileParams = { path, line: line ?? null };
|
||||
const r = await rpcCall<OkResult>("settings.open_file", params);
|
||||
return { success: r.ok };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user