Files
clawdbot/src/gateway/server.models-voicewake.test.ts

300 lines
8.5 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
bridgeListConnected,
bridgeSendEvent,
bridgeStartCalls,
connectOk,
installGatewayTestHooks,
onceMessage,
piSdkMock,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
installGatewayTestHooks();
describe("gateway server models + voicewake", () => {
const setTempHome = (homeDir: string) => {
const prevHome = process.env.HOME;
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
const prevUserProfile = process.env.USERPROFILE;
const prevHomeDrive = process.env.HOMEDRIVE;
const prevHomePath = process.env.HOMEPATH;
process.env.HOME = homeDir;
process.env.CLAWDBOT_STATE_DIR = path.join(homeDir, ".clawdbot");
process.env.USERPROFILE = homeDir;
if (process.platform === "win32") {
const parsed = path.parse(homeDir);
process.env.HOMEDRIVE = parsed.root.replace(/\\$/, "");
process.env.HOMEPATH = homeDir.slice(Math.max(parsed.root.length - 1, 0));
}
return () => {
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
if (prevStateDir === undefined) {
delete process.env.CLAWDBOT_STATE_DIR;
} else {
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
}
if (prevUserProfile === undefined) {
delete process.env.USERPROFILE;
} else {
process.env.USERPROFILE = prevUserProfile;
}
if (process.platform === "win32") {
if (prevHomeDrive === undefined) {
delete process.env.HOMEDRIVE;
} else {
process.env.HOMEDRIVE = prevHomeDrive;
}
if (prevHomePath === undefined) {
delete process.env.HOMEPATH;
} else {
process.env.HOMEPATH = prevHomePath;
}
}
};
};
test(
"voicewake.get returns defaults and voicewake.set broadcasts",
{ timeout: 15_000 },
async () => {
const homeDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-home-"),
);
const restoreHome = setTempHome(homeDir);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(initial.ok).toBe(true);
expect(initial.payload?.triggers).toEqual([
"clawd",
"claude",
"computer",
]);
const changedP = onceMessage<{
type: "event";
event: string;
payload?: unknown;
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: [" hi ", "", "there"],
});
expect(setRes.ok).toBe(true);
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
const changed = await changedP;
expect(changed.event).toBe("voicewake.changed");
expect(
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
).toEqual(["hi", "there"]);
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
expect(after.ok).toBe(true);
expect(after.payload?.triggers).toEqual(["hi", "there"]);
const onDisk = JSON.parse(
await fs.readFile(
path.join(homeDir, ".clawdbot", "settings", "voicewake.json"),
"utf8",
),
) as { triggers?: unknown; updatedAtMs?: unknown };
expect(onDisk.triggers).toEqual(["hi", "there"]);
expect(typeof onDisk.updatedAtMs).toBe("number");
ws.close();
await server.close();
restoreHome();
},
);
test("pushes voicewake.changed to nodes on connect and on updates", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir);
bridgeSendEvent.mockClear();
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
await startCall?.onAuthenticated?.({ nodeId: "n1" });
const first = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(first?.payloadJSON).toBeTruthy();
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
triggers?: unknown;
};
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
bridgeSendEvent.mockClear();
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: ["clawd", "computer"],
});
expect(setRes.ok).toBe(true);
const broadcast = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(broadcast?.payloadJSON).toBeTruthy();
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
triggers?: unknown;
};
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
ws.close();
await server.close();
restoreHome();
});
test("models.list returns model catalog", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [
{ id: "gpt-test-z", provider: "openai", contextWindow: 0 },
{
id: "gpt-test-a",
name: "A-Model",
provider: "openai",
contextWindow: 8000,
},
{
id: "claude-test-b",
name: "B-Model",
provider: "anthropic",
contextWindow: 1000,
},
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res1 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
const res2 = await rpcReq<{
models: Array<{
id: string;
name: string;
provider: string;
contextWindow?: number;
}>;
}>(ws, "models.list");
expect(res1.ok).toBe(true);
expect(res2.ok).toBe(true);
const models = res1.payload?.models ?? [];
expect(models).toEqual([
{
id: "claude-test-a",
name: "A-Model",
provider: "anthropic",
contextWindow: 200_000,
},
{
id: "claude-test-b",
name: "B-Model",
provider: "anthropic",
contextWindow: 1000,
},
{
id: "gpt-test-a",
name: "A-Model",
provider: "openai",
contextWindow: 8000,
},
{
id: "gpt-test-z",
name: "gpt-test-z",
provider: "openai",
},
]);
expect(piSdkMock.discoverCalls).toBe(1);
ws.close();
await server.close();
});
test("models.list rejects unknown params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "models.list", { extra: true });
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toMatch(/invalid models\.list params/i);
ws.close();
await server.close();
});
test("bridge RPC supports models.list and validates params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const okRes = await startCall?.onRequest?.("n1", {
id: "1",
method: "models.list",
paramsJSON: "{}",
});
expect(okRes?.ok).toBe(true);
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
models?: unknown;
};
expect(Array.isArray(okPayload.models)).toBe(true);
const badRes = await startCall?.onRequest?.("n1", {
id: "2",
method: "models.list",
paramsJSON: JSON.stringify({ extra: true }),
});
expect(badRes?.ok).toBe(false);
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe(
"INVALID_REQUEST",
);
ws.close();
await server.close();
});
});