test(providers, channels, sessions): remove low-value tests (batch d047) (#159120)

* test(kilocode): deslop s308 tests

* test(bonjour): deslop s314 tests

* test(raft): deslop s315 tests

* test(byteplus): deslop s301 tests

* test(alibaba): deslop s311 tests

* test(session-url): deslop s309 tests

* test(imap): deslop s305 tests

* test(memory-host-sdk): deslop s302 tests

* test(talk-voice): deslop s318 tests

* test(voyage): deslop s304 tests

* test(voyage): retain terminal fixture error path
This commit is contained in:
Peter Steinberger 2026-09-26 13:13:30 -07:00 • committed by GitHub
parent bfff4f4cbd
commit 915e37b7dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 783 additions and 2865 deletions

View file

@ -17,27 +17,15 @@ import {
} from "openclaw/plugin-sdk/provider-http-test-mocks";
import {
expectDashscopeVideoTaskPoll,
expectExplicitVideoGenerationCapabilities,
expectSuccessfulDashscopeVideoResult,
mockSuccessfulDashscopeVideoTask,
} from "openclaw/plugin-sdk/provider-test-contracts";
// Alibaba tests cover video generation provider plugin behavior.
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import {
DASHSCOPE_WAN_VIDEO_MODELS,
DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
} from "openclaw/plugin-sdk/video-generation";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const {
resolveApiKeyForProviderMock,
postJsonRequestMock,
fetchWithTimeoutMock,
fetchWithTimeoutGuardedMock,
resolveProviderHttpRequestConfigMock,
sanitizeConfiguredModelProviderRequestMock,
} = getProviderHttpMocks();
const { resolveApiKeyForProviderMock, postJsonRequestMock, fetchWithTimeoutMock } =
getProviderHttpMocks();
let alibabaVideoGenerationProvider: typeof import("./video-generation-provider.js").alibabaVideoGenerationProvider;
@ -104,38 +92,25 @@ describe("alibaba video generation provider", () => {
});
});
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(alibabaVideoGenerationProvider);
expect(alibabaVideoGenerationProvider).toMatchObject({
id: "alibaba",
label: "Alibaba Model Studio",
defaultModel: DEFAULT_DASHSCOPE_WAN_VIDEO_MODEL,
models: [...DASHSCOPE_WAN_VIDEO_MODELS],
});
});
it("advertises Wan video generation with a config-only Standard API key", () => {
clearAlibabaAuthEnvironment();
it.each(["sk-ws-alibaba-standard-key", "sk-alibaba-legacy-standard-key"])(
"advertises Wan video generation with config-only Standard API key %s",
(apiKey) => {
clearAlibabaAuthEnvironment();
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
apiKey,
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
apiKey: "sk-alibaba-legacy-standard-key",
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
}),
).toBe(true);
},
);
},
}),
).toBe(true);
});
it("does not use Qwen Coding Plan credentials for Alibaba video discovery", () => {
clearAlibabaAuthEnvironment();
@ -157,29 +132,6 @@ describe("alibaba video generation provider", () => {
).toBe(false);
});
it.each(["", "oauth:alibaba", "custom-local", "secretref-managed"])(
"does not advertise a non-secret Alibaba credential marker %j",
(apiKey) => {
clearAlibabaAuthEnvironment();
expect(
alibabaVideoGenerationProvider.isConfigured?.({
cfg: {
models: {
providers: {
alibaba: {
apiKey,
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
},
},
},
},
}),
).toBe(false);
},
);
it("tracks whether an allowed Alibaba API-key SecretRef resolves", () => {
clearAlibabaAuthEnvironment();
vi.stubEnv("ALIBABA_QA_CONFIG_KEY", "resolved-alibaba-config-key");
@ -303,8 +255,7 @@ describe("alibaba video generation provider", () => {
it("submits async Wan generation, polls task status, and downloads the resulting video", async () => {
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
const provider = alibabaVideoGenerationProvider;
const result = await provider.generateVideo({
const result = await alibabaVideoGenerationProvider.generateVideo({
provider: "alibaba",
model: "wan2.6-r2v-flash",
prompt: "animate this shot",
@ -332,103 +283,4 @@ describe("alibaba video generation provider", () => {
expectDashscopeVideoTaskPoll(fetchWithTimeoutMock);
expectSuccessfulDashscopeVideoResult(result);
});
it("applies configured request policy to DashScope video requests", async () => {
const requestPolicy = {
allowPrivateNetwork: true,
headers: { "X-DashScope-Route": "alibaba-policy" },
};
const dispatcherPolicy = { mode: "env-proxy" as const };
resolveProviderHttpRequestConfigMock.mockImplementationOnce((params) => {
const headers = new Headers(params.defaultHeaders);
for (const [key, value] of Object.entries(params.request?.headers ?? {})) {
headers.set(key, value);
}
return {
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
allowPrivateNetwork: params.request?.allowPrivateNetwork === true,
headers,
dispatcherPolicy,
};
});
mockSuccessfulDashscopeVideoTask({ postJsonRequestMock, fetchWithTimeoutMock });
const provider = alibabaVideoGenerationProvider;
await provider.generateVideo({
provider: "alibaba",
model: "wan2.6-t2v",
prompt: "animate this shot",
cfg: {
models: {
providers: {
alibaba: {
baseUrl: "https://dashscope-intl.aliyuncs.com",
models: [],
request: requestPolicy,
},
},
},
},
});
expect(sanitizeConfiguredModelProviderRequestMock).toHaveBeenCalledWith(requestPolicy);
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "alibaba",
capability: "video",
transport: "http",
request: requestPolicy,
}),
);
const request = requireFirstPostJsonRequest(
postJsonRequestMock,
"DashScope request with request policy",
);
expect(request.allowPrivateNetwork).toBe(true);
expect(request.dispatcherPolicy).toBe(dispatcherPolicy);
expect(request.headers).toBeInstanceOf(Headers);
expect((request.headers as Headers).get("x-dashscope-route")).toBe("alibaba-policy");
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
1,
"https://dashscope-intl.aliyuncs.com/api/v1/tasks/task-1",
expect.objectContaining({
method: "GET",
headers: expect.any(Headers),
}),
expect.any(Number),
fetch,
{
ssrfPolicy: { allowPrivateNetwork: true },
dispatcherPolicy,
},
);
expect(fetchWithTimeoutGuardedMock).toHaveBeenNthCalledWith(
2,
"https://example.com/out.mp4",
{ method: "GET" },
expect.any(Number),
fetch,
{
ssrfPolicy: { allowPrivateNetwork: true },
dispatcherPolicy,
},
);
});
it("fails fast when reference inputs are local buffers instead of remote URLs", async () => {
const provider = alibabaVideoGenerationProvider;
await expect(
provider.generateVideo({
provider: "alibaba",
model: "wan2.6-i2v",
prompt: "animate this local frame",
cfg: {},
inputImages: [{ buffer: Buffer.from("png-bytes"), mimeType: "image/png" }],
}),
).rejects.toThrow(
"Alibaba Wan video generation currently requires remote http(s) URLs for reference images/videos.",
);
expect(postJsonRequestMock).not.toHaveBeenCalled();
});
});

View file

@ -37,14 +37,7 @@ describe("bonjour plugin entry", () => {
let discoveryService:
| Parameters<ReturnType<typeof createTestPluginApi>["registerGatewayDiscoveryService"]>[0]
| undefined;
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
const api = createTestPluginApi({
logger,
registerGatewayDiscoveryService(service) {
discoveryService = service;
},
@ -66,36 +59,26 @@ describe("bonjour plugin entry", () => {
const stop = vi.fn();
mocks.startGatewayBonjourAdvertiser.mockResolvedValueOnce({ stop });
const advertisedOptions = {
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
};
await expect(
discoveryService.advertise({
machineDisplayName: "Dev Box",
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
}),
discoveryService.advertise({ ...advertisedOptions, machineDisplayName: "Dev Box" }),
).resolves.toEqual({ stop });
expect(mocks.advertiserModuleLoaded).toHaveBeenCalledTimes(1);
expect(mocks.runtimeModuleLoaded).toHaveBeenCalledTimes(1);
expect(mocks.startGatewayBonjourAdvertiser).toHaveBeenCalledWith(
{ ...advertisedOptions, instanceName: "Dev Box (OpenClaw)" },
{
instanceName: "Dev Box (OpenClaw)",
gatewayPort: 3210,
gatewayTlsEnabled: true,
gatewayTlsFingerprintSha256: "abc123",
gatewayDirectReachable: true,
sshPort: 22,
tailnetDns: "dev.tailnet.ts.net",
cliPath: "/usr/local/bin/openclaw",
minimal: false,
},
{
logger,
logger: api.logger,
registerUncaughtExceptionHandler: mocks.registerUncaughtExceptionHandler,
registerUnhandledRejectionHandler: mocks.registerUnhandledRejectionHandler,
},

View file

@ -1,6 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createService: vi.fn(),
@ -22,19 +22,10 @@ const {
registerUnhandledRejectionHandler,
logger,
} = mocks;
const dnsLabelEncoder = new TextEncoder();
const stringOrFallback = (value: unknown, fallback: string) =>
typeof value === "string" && value.trim() ? value : fallback;
function expectDnsLabelByteLength(value: string, expected: number) {
expect(dnsLabelEncoder.encode(value).byteLength).toBe(expected);
}
function expectDnsLabelWithinLimit(value: string) {
expect(dnsLabelEncoder.encode(value).byteLength).toBeLessThanOrEqual(63);
}
function warnMessages(): string[] {
return logger.warn.mock.calls.map(([message]) => String(message));
}
@ -43,16 +34,7 @@ function expectWarnContaining(fragment: string) {
expect(warnMessages().join("\n")).toContain(fragment);
}
function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
const call = mock.mock.calls.at(index);
if (!call) {
throw new Error(`Expected mock call ${index}`);
}
return call;
}
function enableAdvertiserUnitMode(hostname = "test-host") {
// Allow advertiser to run in unit tests.
vi.stubEnv("VITEST", undefined);
vi.stubEnv("NODE_ENV", "development");
vi.spyOn(os, "hostname").mockReturnValue(hostname);
@ -63,49 +45,31 @@ function mockCiaoService(params?: {
advertise?: ReturnType<typeof vi.fn>;
serviceState?: string;
stateRef?: { value: string };
on?: ReturnType<typeof vi.fn>;
listenerMap?: Map<string, (value: unknown) => void>;
responder?: Record<string, unknown>;
}) {
const advertise = params?.advertise ?? vi.fn().mockResolvedValue(undefined);
const destroy = vi.fn().mockResolvedValue(undefined);
const on =
params?.on ??
vi.fn((event: string, listener: (value: unknown) => void) => {
params?.listenerMap?.set(event, listener);
});
createService.mockImplementation((options: Record<string, unknown>) => {
const service = {
advertise,
destroy,
on,
getFQDN: () =>
`${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`,
getHostname: () => stringOrFallback(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
};
Object.defineProperty(service, "serviceState", {
configurable: true,
enumerable: true,
get: () => params?.stateRef?.value ?? params?.serviceState ?? "announced",
set: (value: string) => {
if (params?.stateRef) {
params.stateRef.value = value;
}
},
});
return service;
const on = vi.fn((event: string, listener: (value: unknown) => void) => {
params?.listenerMap?.set(event, listener);
});
createService.mockImplementation((options: Record<string, unknown>) => ({
advertise,
destroy,
on,
getFQDN: () =>
`${stringOrFallback(options.type, "service")}.${stringOrFallback(options.domain, "local")}.`,
getHostname: () => stringOrFallback(options.hostname, "unknown"),
getPort: () => Number(options.port ?? -1),
get serviceState() {
return params?.stateRef?.value ?? params?.serviceState ?? "announced";
},
}));
getResponder.mockReturnValue(params?.responder ?? { createService, shutdown });
return { destroy };
}
vi.mock("@homebridge/ciao", () => {
return {
Protocol: { TCP: "tcp" },
getResponder,
};
});
vi.mock("@homebridge/ciao", () => ({ getResponder }));
const { startGatewayBonjourAdvertiser } = await import("./advertiser.js");
@ -114,16 +78,15 @@ afterAll(() => {
vi.resetModules();
});
type StartGatewayBonjourAdvertiser = typeof startGatewayBonjourAdvertiser;
const startAdvertiser = (
opts: Parameters<StartGatewayBonjourAdvertiser>[0],
): ReturnType<StartGatewayBonjourAdvertiser> =>
startGatewayBonjourAdvertiser(opts, {
logger,
registerUncaughtExceptionHandler: (handler) => registerUncaughtExceptionHandler(handler),
registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler),
});
const startAdvertiser = (opts: Partial<Parameters<typeof startGatewayBonjourAdvertiser>[0]> = {}) =>
startGatewayBonjourAdvertiser(
{ gatewayPort: 18789, sshPort: 2222, ...opts },
{
logger,
registerUncaughtExceptionHandler,
registerUnhandledRejectionHandler,
},
);
describe("gateway bonjour advertiser", () => {
type ServiceCall = {
@ -133,23 +96,19 @@ describe("gateway bonjour advertiser", () => {
txt?: unknown;
};
beforeEach(() => {
enableAdvertiserUnitMode();
mockCiaoService();
});
afterEach(() => {
createService.mockClear();
getResponder.mockReset();
shutdown.mockClear();
registerUncaughtExceptionHandler.mockClear();
registerUnhandledRejectionHandler.mockClear();
logger.info.mockClear();
logger.warn.mockClear();
logger.debug.mockClear();
vi.resetAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("does not block on advertise and publishes expected txt keys", async () => {
enableAdvertiserUnitMode();
let resolveAdvertise = () => {};
const advertise = vi.fn().mockImplementation(
async () =>
@ -160,8 +119,6 @@ describe("gateway bonjour advertiser", () => {
const { destroy } = mockCiaoService({ advertise });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
gatewayDirectReachable: true,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/openclaw",
@ -169,26 +126,24 @@ describe("gateway bonjour advertiser", () => {
});
expect(createService).toHaveBeenCalledTimes(1);
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(gatewayCall?.[0]?.type).toBe("openclaw-gw");
const gatewayType = stringOrFallback(gatewayCall?.[0]?.type, "");
expect(gatewayType.length).toBeLessThanOrEqual(15);
expect(gatewayCall?.[0]?.port).toBe(18789);
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayDirectReachable).toBe("1");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.tailnetDns).toBe(
"host.tailnet.ts.net",
expect(createService).toHaveBeenCalledWith(
expect.objectContaining({
type: "openclaw-gw",
port: 18789,
domain: "local",
hostname: "test-host",
txt: expect.objectContaining({
lanHost: "test-host.local",
gatewayPort: "18789",
gatewayDirectReachable: "1",
sshPort: "2222",
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/openclaw",
transport: "gateway",
}),
}),
);
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/openclaw",
);
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
// We don't await `advertise()`, but it should still be called for each service.
expect(advertise).toHaveBeenCalledTimes(1);
resolveAdvertise();
await Promise.resolve();
@ -199,13 +154,7 @@ describe("gateway bonjour advertiser", () => {
});
it("omits cliPath and sshPort in minimal mode", async () => {
enableAdvertiserUnitMode();
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
cliPath: "/opt/homebrew/bin/openclaw",
tailnetDns: "host.tailnet.ts.net",
minimal: true,
@ -220,107 +169,61 @@ describe("gateway bonjour advertiser", () => {
});
it("honors truthy OPENCLAW_DISABLE_BONJOUR values", async () => {
enableAdvertiserUnitMode();
vi.stubEnv("OPENCLAW_DISABLE_BONJOUR", "true");
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("auto-disables Bonjour in detected containers", async () => {
enableAdvertiserUnitMode();
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("auto-disables Bonjour on Fly Machines without Docker sentinel files", async () => {
enableAdvertiserUnitMode();
vi.stubEnv("FLY_MACHINE_ID", "3d8d5459a03038");
vi.stubEnv("FLY_APP_NAME", "openclaw-clawcks-test");
vi.spyOn(fs, "existsSync").mockReturnValue(false);
vi.spyOn(fs, "readFileSync").mockReturnValue("10:cpuset:/\n9:perf_event:/\n8:memory:/\n0::/\n");
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(createService).not.toHaveBeenCalled();
await expect(started.stop()).resolves.toBeUndefined();
});
it("honors explicit Bonjour opt-in inside detected containers", async () => {
enableAdvertiserUnitMode();
vi.stubEnv("OPENCLAW_DISABLE_BONJOUR", "0");
vi.spyOn(fs, "existsSync").mockImplementation((filePath) => String(filePath) === "/.dockerenv");
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(createService).toHaveBeenCalledTimes(1);
await started.stop();
});
it("attaches conflict listeners for services", async () => {
enableAdvertiserUnitMode();
const onCalls: Array<{ event: string }> = [];
const on = vi.fn((event: string) => {
onCalls.push({ event });
});
mockCiaoService({ on });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
// 1 service × 2 listeners
expect(onCalls.map((c) => c.event)).toEqual(["name-change", "hostname-change"]);
await started.stop();
});
it("cleans up ciao process handlers after shutdown", async () => {
enableAdvertiserUnitMode();
const order: string[] = [];
shutdown.mockImplementation(async () => {
order.push("shutdown");
});
mockCiaoService();
const cleanupException = vi.fn(() => {
order.push("cleanup-exception");
});
const cleanupRejection = vi.fn(() => {
order.push("cleanup-rejection");
});
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
registerUncaughtExceptionHandler.mockReturnValue(cleanupException);
registerUnhandledRejectionHandler.mockReturnValue(cleanupRejection);
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
await started.stop();
@ -332,16 +235,9 @@ describe("gateway bonjour advertiser", () => {
});
it("handles ciao netmask assertions at the bonjour caller", async () => {
enableAdvertiserUnitMode();
const started = await startAdvertiser();
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const exceptionHandler = mockCall(registerUncaughtExceptionHandler).at(0) as
const exceptionHandler = registerUncaughtExceptionHandler.mock.calls[0]?.[0] as
| ((reason: unknown) => boolean)
| undefined;
expect(exceptionHandler).toBeTypeOf("function");
@ -362,21 +258,15 @@ describe("gateway bonjour advertiser", () => {
});
it("logs advertise failures without starting a competing retry loop", async () => {
enableAdvertiserUnitMode();
vi.useFakeTimers();
const advertise = vi.fn().mockRejectedValue(new Error("boom"));
mockCiaoService({ advertise, serviceState: "unannounced" });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
// initial advertise attempt happens immediately
expect(advertise).toHaveBeenCalledTimes(1);
// allow promise rejection handler to run
await Promise.resolve();
expectWarnContaining("advertise failed");
@ -391,17 +281,12 @@ describe("gateway bonjour advertiser", () => {
});
it("handles advertise throwing synchronously", async () => {
enableAdvertiserUnitMode();
const advertise = vi.fn(() => {
throw new Error("sync-fail");
});
mockCiaoService({ advertise, serviceState: "unannounced" });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(advertise).toHaveBeenCalledTimes(1);
expectWarnContaining("advertise threw");
@ -410,68 +295,40 @@ describe("gateway bonjour advertiser", () => {
});
it("suppresses ciao self-probe retry console noise while advertising", async () => {
enableAdvertiserUnitMode();
const baseConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
mockCiaoService();
const started = await startAdvertiser();
const originalConsoleLog = console.log;
const baseConsoleLog = vi.fn();
console.log = baseConsoleLog as typeof console.log;
console.log(
"[test._openclaw-gw._tcp.local.] failed probing with reason: Error: Can't probe for a service which is announced already. Received announcing for service test._openclaw-gw._tcp.local.. Trying again in 2 seconds!",
);
console.log("ordinary console line");
try {
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(baseConsoleLog).toHaveBeenCalledTimes(1);
expect(baseConsoleLog).toHaveBeenCalledWith("ordinary console line");
console.log(
"[test._openclaw-gw._tcp.local.] failed probing with reason: Error: Can't probe for a service which is announced already. Received announcing for service test._openclaw-gw._tcp.local.. Trying again in 2 seconds!",
);
console.log("ordinary console line");
expect(baseConsoleLog).toHaveBeenCalledTimes(1);
expect(baseConsoleLog).toHaveBeenCalledWith("ordinary console line");
await started.stop();
} finally {
console.log = originalConsoleLog;
}
await started.stop();
});
it("suppresses transient ciao ENODEV MDNS socket warnings while advertising", async () => {
enableAdvertiserUnitMode();
const baseConsoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
mockCiaoService();
const started = await startAdvertiser();
const originalConsoleWarn = console.warn;
const baseConsoleWarn = vi.fn();
console.warn = baseConsoleWarn as typeof console.warn;
// A Docker bridge disappears between ciao's interface polls; the send to
// the removed interface fails with ENODEV, which ciao does not silence.
console.warn(
"Encountered MDNS socket error on socket 'br-abcdef123456': Error: send ENODEV 224.0.0.251:5353\n at ...",
);
console.warn("ordinary warning line");
try {
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
expect(baseConsoleWarn).toHaveBeenCalledTimes(1);
expect(baseConsoleWarn).toHaveBeenCalledWith("ordinary warning line");
// A Docker bridge disappears between ciao's interface polls; the send to
// the removed interface fails with ENODEV, which ciao does not silence.
console.warn(
"Encountered MDNS socket error on socket 'br-abcdef123456': Error: send ENODEV 224.0.0.251:5353\n at ...",
);
console.warn("ordinary warning line");
expect(baseConsoleWarn).toHaveBeenCalledTimes(1);
expect(baseConsoleWarn).toHaveBeenCalledWith("ordinary warning line");
await started.stop();
} finally {
console.warn = originalConsoleWarn;
}
await started.stop();
});
it("does not monkey-patch responder methods during shutdown", async () => {
enableAdvertiserUnitMode();
const responder = {
createService,
shutdown,
@ -480,18 +337,10 @@ describe("gateway bonjour advertiser", () => {
probe: vi.fn(),
republishService: vi.fn(),
};
const originalMethods = {
advertiseService: responder.advertiseService,
announce: responder.announce,
probe: responder.probe,
republishService: responder.republishService,
};
const originalMethods = { ...responder };
mockCiaoService({ responder });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
await started.stop();
expect(responder.advertiseService).toBe(originalMethods.advertiseService);
@ -501,32 +350,18 @@ describe("gateway bonjour advertiser", () => {
});
it("does not clobber console.log if another wrapper replaced it before shutdown", async () => {
enableAdvertiserUnitMode();
mockCiaoService();
const originalConsoleLog = console.log;
const baseConsoleLog = vi.fn();
vi.spyOn(console, "log").mockImplementation(() => {});
const replacementConsoleLog = vi.fn();
console.log = baseConsoleLog as typeof console.log;
try {
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
console.log = replacementConsoleLog as typeof console.log;
await started.stop();
console.log = replacementConsoleLog as typeof console.log;
await started.stop();
expect(console.log).toBe(replacementConsoleLog);
} finally {
console.log = originalConsoleLog;
}
expect(console.log).toBe(replacementConsoleLog);
});
it("never overlaps ciao lifecycle states or conflict handling with another advertise call", async () => {
enableAdvertiserUnitMode();
vi.useFakeTimers();
const stateRef = { value: "unannounced" };
@ -534,10 +369,7 @@ describe("gateway bonjour advertiser", () => {
const listenerMap = new Map<string, (value: unknown) => void>();
const { destroy } = mockCiaoService({ advertise, stateRef, listenerMap });
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
expect(createService).toHaveBeenCalledTimes(1);
expect(advertise).toHaveBeenCalledTimes(1);
@ -564,18 +396,13 @@ describe("gateway bonjour advertiser", () => {
});
it("makes advertiser shutdown idempotent", async () => {
enableAdvertiserUnitMode();
const cleanupException = vi.fn();
const cleanupRejection = vi.fn();
const { destroy } = mockCiaoService();
registerUncaughtExceptionHandler.mockImplementation(() => cleanupException);
registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection);
registerUncaughtExceptionHandler.mockReturnValue(cleanupException);
registerUnhandledRejectionHandler.mockReturnValue(cleanupRejection);
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
await Promise.all([started.stop(), started.stop()]);
@ -586,18 +413,10 @@ describe("gateway bonjour advertiser", () => {
});
it("normalizes hostnames with domains for service names", async () => {
// Allow advertiser to run in unit tests.
vi.stubEnv("VITEST", undefined);
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
vi.spyOn(os, "hostname").mockReturnValue("Mac.localdomain");
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.name).toBe("Mac (OpenClaw)");
@ -609,18 +428,10 @@ describe("gateway bonjour advertiser", () => {
});
it("falls back to openclaw when system hostname is invalid for DNS", async () => {
// Allow advertiser to run in unit tests.
vi.stubEnv("VITEST", undefined);
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
vi.spyOn(os, "hostname").mockReturnValue("My_Lobster Host");
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.hostname).toBe("openclaw");
@ -633,20 +444,15 @@ describe("gateway bonjour advertiser", () => {
const reportedHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf";
enableAdvertiserUnitMode(reportedHostname);
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
const serviceName = gatewayCall?.[0]?.name as string;
const hostname = gatewayCall?.[0]?.hostname as string;
expectDnsLabelByteLength(`${reportedHostname} (OpenClaw)`, 64);
expect(Buffer.byteLength(`${reportedHostname} (OpenClaw)`)).toBe(64);
expect(hostname).toBe(reportedHostname);
expectDnsLabelWithinLimit(serviceName);
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
await started.stop();
});
@ -655,22 +461,17 @@ describe("gateway bonjour advertiser", () => {
const longHostname = "app-41627eae5842473f9e05f139ea307277-7f9477f4d6-lqqzf-abcdefghij";
enableAdvertiserUnitMode(longHostname);
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
const serviceName = gatewayCall?.[0]?.name as string;
const hostname = gatewayCall?.[0]?.hostname as string;
expectDnsLabelByteLength(longHostname, 64);
expectDnsLabelByteLength(hostname, 63);
expect(Buffer.byteLength(longHostname)).toBe(64);
expect(Buffer.byteLength(hostname)).toBe(63);
expect(hostname).toBe(longHostname.slice(0, -1));
expect(hostname).not.toMatch(/-$/);
expectDnsLabelWithinLimit(serviceName);
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
await started.stop();
});
@ -680,40 +481,14 @@ describe("gateway bonjour advertiser", () => {
const cjkHostname = "你".repeat(21);
enableAdvertiserUnitMode(cjkHostname);
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const started = await startAdvertiser();
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
const serviceName = gatewayCall?.[0]?.name as string;
expectDnsLabelWithinLimit(serviceName);
expect(Buffer.byteLength(serviceName)).toBeLessThanOrEqual(63);
expect(serviceName).not.toMatch(/\uFFFD$/);
await started.stop();
});
it("uses system hostname when OPENCLAW_MDNS_HOSTNAME is unset", async () => {
// Allow advertiser to run in unit tests.
vi.stubEnv("VITEST", undefined);
vi.stubEnv("NODE_ENV", "development");
vi.stubEnv("OPENCLAW_MDNS_HOSTNAME", undefined);
vi.spyOn(os, "hostname").mockReturnValue("Lobster");
mockCiaoService();
const started = await startAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
});
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.hostname).toBe("Lobster");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Lobster.local");
await started.stop();
});
});

View file

@ -3,42 +3,17 @@ import { describe, expect, it } from "vitest";
const { classifyCiaoProcessError } = await import("./ciao.js");
describe("bonjour-ciao", () => {
it("classifies ciao netmask assertions separately from side effects", () => {
expect(
classifyCiaoProcessError(
Object.assign(
new Error(
"IP address version must match. Netmask cannot have a version different from the address!",
),
{ name: "AssertionError" },
),
),
).toEqual({
kind: "netmask-assertion",
formatted:
"AssertionError: IP address version must match. Netmask cannot have a version different from the address!",
});
});
it("classifies networkInterfaces SystemError failures (restricted sandboxes)", () => {
const err = Object.assign(
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
expect(classifyCiaoProcessError(err)).toEqual({
kind: "interface-enumeration-failure",
formatted:
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
});
});
it("suppresses networkInterfaces failures wrapped in cause chains", () => {
const inner = Object.assign(
new Error("A system error occurred: uv_interface_addresses returned Unknown system error 1"),
{ name: "SystemError" },
);
const wrapper = new Error("ciao NetworkManager init failed", { cause: inner });
expect(classifyCiaoProcessError(wrapper)).not.toBe(null);
expect(classifyCiaoProcessError(wrapper)).toEqual({
kind: "interface-enumeration-failure",
formatted:
"SystemError: A system error occurred: uv_interface_addresses returned Unknown system error 1",
});
});
it("keeps unrelated rejections visible", () => {

View file

@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
import { formatBonjourError } from "./errors.js";
describe("formatBonjourError", () => {
it("formats named errors with their type prefix", () => {
const err = new Error("timed out");
err.name = "AbortError";
expect(formatBonjourError(err)).toBe("AbortError: timed out");
});
it("avoids duplicating named errors with blank messages", () => {
const err = new Error("");
err.name = "AbortError";
expect(formatBonjourError(err)).toBe("AbortError");
});
it("treats whitespace-only messages as blank", () => {
const named = new Error(" ");
named.name = "AbortError";

View file

@ -1,11 +1,10 @@
// Byteplus tests cover video generation provider plugin behavior.
import {
getProviderHttpMocks,
installProviderHttpMockCleanup,
} from "openclaw/plugin-sdk/provider-http-test-mocks";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
import { streamedJsonResponse } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import type { VideoGenerationRequest } from "openclaw/plugin-sdk/video-generation";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
@ -15,29 +14,37 @@ beforeAll(async () => {
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
beforeEach(() => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_123" }),
release: vi.fn(async () => {}),
});
});
installProviderHttpMockCleanup();
afterEach(() => vi.useRealTimers());
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
id: "task_123",
}),
release: vi.fn(async () => {}),
function generateVideo(request: Partial<VideoGenerationRequest> = {}) {
return buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "A lantern floats upward into the night sky",
cfg: {},
...request,
});
}
function mockSuccessfulBytePlusTask(params?: { download?: Response }) {
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
video_url: "https://example.com/byteplus.mp4",
},
model: params?.model ?? "seedance-1-0-pro-250528",
content: { video_url: "https://example.com/byteplus.mp4" },
model: "seedance-1-0-pro-250528",
}),
)
.mockResolvedValueOnce(
new Response("webm-bytes", { headers: { "content-type": "video/webm" } }),
params?.download ?? new Response("webm-bytes", { headers: { "content-type": "video/webm" } }),
);
}
@ -76,29 +83,18 @@ function streamedVideoResponse(bytes: string): Response {
);
}
// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
// and whether the stream was canceled so callers can assert the body was not fully read.
function makeOversizedJsonStream(): {
body: ReadableStream<Uint8Array>;
maxBytes: number;
totalBytes: number;
state: { bytesPulled: number; canceled: boolean };
} {
const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
// Advertise twice the shared 16 MiB JSON cap to prove reads stop and cancel mid-stream.
function makeOversizedJsonStream() {
const maxBytes = 16 * 1024 * 1024;
const chunk = new Uint8Array(1024 * 1024);
const totalBytes = maxBytes * 2;
const state = { bytesPulled: 0, canceled: false };
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
if (state.bytesPulled >= totalBytes) {
controller.close();
return;
}
pulled += 1;
state.bytesPulled += chunk.length;
controller.enqueue(chunk);
},
@ -106,203 +102,58 @@ function makeOversizedJsonStream(): {
state.canceled = true;
},
});
return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
return { body, maxBytes, totalBytes, state };
}
describe("byteplus video generation provider", () => {
it("declares explicit mode capabilities", () => {
const provider = buildBytePlusVideoGenerationProvider();
expectExplicitVideoGenerationCapabilities(provider);
expect(provider.defaultModel).toBe("seedance-1-0-pro-250528");
expect(provider.models).toEqual(["seedance-1-0-pro-250528", "seedance-1-5-pro-251215"]);
});
it("creates a content-generation task, polls, and downloads the video", async () => {
mockSuccessfulBytePlusTask();
const provider = buildBytePlusVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "A lantern floats upward into the night sky",
cfg: {},
});
const result = await generateVideo();
expect(postJsonRequestMock).toHaveBeenCalledTimes(1);
const request = requireBytePlusPostRequest();
expect(request.url).toBe(
expect(requireBytePlusPostRequest().url).toBe(
"https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks",
);
expect(result.videos).toHaveLength(1);
const [video] = result.videos;
if (!video) {
throw new Error("Expected generated BytePlus video");
}
expect(video.fileName).toBe("video-1.webm");
const metadata = result.metadata as Record<string, unknown>;
expect(metadata.taskId).toBe("task_123");
});
it.each([
{ name: "JSON error", contentType: "application/json", body: '{"error":"denied"}' },
{ name: "problem JSON", contentType: "application/problem+json", body: '{"title":"denied"}' },
{ name: "HTML", contentType: "text/html; charset=utf-8", body: "<html>sign in</html>" },
{ name: "empty video", contentType: "video/mp4", body: "" },
])("rejects a successful $name response as generated video", async ({ contentType, body }) => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task-invalid-download" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task-invalid-download",
status: "succeeded",
content: { video_url: "https://example.com/invalid.mp4" },
}),
)
.mockResolvedValueOnce(new Response(body, { headers: { "content-type": contentType } }));
await expect(
buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "invalid download",
cfg: {},
}),
).rejects.toThrow("BytePlus generated video download: malformed video response");
expect(result.videos[0]?.fileName).toBe("video-1.webm");
expect(result.metadata).toMatchObject({ taskId: "task_123" });
});
it("cancels the unread response body when a generated-video MIME type is rejected", async () => {
const canceled = vi.fn();
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task-open-response" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task-open-response",
status: "succeeded",
content: { video_url: "https://example.com/invalid.mp4" },
mockSuccessfulBytePlusTask({
download: new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
},
cancel: canceled,
}),
)
.mockResolvedValueOnce(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
},
cancel: canceled,
}),
{ headers: { "content-type": "application/json" } },
),
);
{ headers: { "content-type": "application/json" } },
),
});
await expect(
buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "open invalid response",
cfg: {},
}),
).rejects.toThrow("BytePlus generated video download: malformed video response");
await expect(generateVideo()).rejects.toThrow(
"BytePlus generated video download: malformed video response",
);
expect(canceled).toHaveBeenCalledOnce();
});
it("releases a rejected download body without awaiting a debug-capture tee branch", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task-captured-response" }),
release: vi.fn(async () => {}),
});
// The debug proxy clones every captured response, so the caller-facing body is one
// branch of a live tee. Cancelling such a branch settles only once both branches
// cancel, so awaiting it here would hang the download instead of surfacing the error.
const response = new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"error":"still streaming"}'));
},
}),
{ headers: { "content-type": "application/json" } },
);
const captureClone = response.clone();
const captureReader = captureClone.body?.getReader();
await captureReader?.read();
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task-captured-response",
status: "succeeded",
content: { video_url: "https://example.com/invalid.mp4" },
}),
)
.mockResolvedValueOnce(response);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await expect(
Promise.race([
buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "captured invalid response",
cfg: {},
}),
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(new Error("BytePlus download waited for a captured response clone"));
}, 500);
}),
]),
).rejects.toThrow("BytePlus generated video download: malformed video response");
} finally {
if (timeout !== undefined) {
clearTimeout(timeout);
}
await captureReader?.cancel().catch(() => undefined);
}
});
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_too_large" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_too_large",
status: "succeeded",
content: {
video_url: "https://example.com/too-large.mp4",
},
}),
)
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
mockSuccessfulBytePlusTask({ download: streamedVideoResponse("too-large") });
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "short video",
cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } },
}),
generateVideo({ cfg: { agents: { defaults: { mediaMaxMb: 0.000001 } } } }),
).rejects.toThrow("BytePlus generated video download exceeds 1 bytes");
});
it("shares one wall-clock deadline across download headers and body", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_slow_download" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_slow_download",
id: "task_123",
status: "succeeded",
content: { video_url: "https://example.com/slow.mp4" },
}),
@ -322,32 +173,19 @@ describe("byteplus video generation provider", () => {
);
});
const result = buildBytePlusVideoGenerationProvider().generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "slow download",
timeoutMs: 100,
cfg: {},
});
const assertion = expect(result).rejects.toThrow(
const assertion = expect(generateVideo({ timeoutMs: 100 })).rejects.toThrow(
"BytePlus generated video download timed out after 100ms",
);
await vi.advanceTimersByTimeAsync(11);
await assertion;
});
it("keeps the unified model for image requests and lowercases resolution", async () => {
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
const provider = buildBytePlusVideoGenerationProvider();
await provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
mockSuccessfulBytePlusTask();
await generateVideo({
prompt: "Animate this still image",
resolution: "720P",
inputImages: [{ url: "https://example.com/first-frame.png" }],
cfg: {},
});
expect(requireBytePlusPostBody()).toEqual({
@ -365,165 +203,72 @@ describe("byteplus video generation provider", () => {
});
it("maps declared providerOptions into the request body", async () => {
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
mockSuccessfulBytePlusTask();
await generateVideo({ providerOptions: { seed: 42, draft: true, camera_fixed: false } });
const provider = buildBytePlusVideoGenerationProvider();
await provider.generateVideo({
provider: "byteplus",
expect(requireBytePlusPostBody()).toMatchObject({
model: "seedance-1-0-pro-250528",
prompt: "A cinematic lobster montage",
providerOptions: {
seed: 42,
draft: true,
camera_fixed: false,
},
cfg: {},
seed: 42,
resolution: "480p",
camera_fixed: false,
});
const body = requireBytePlusPostBody();
expect(body.model).toBe("seedance-1-0-pro-250528");
expect(body.seed).toBe(42);
expect(body.resolution).toBe("480p");
expect(body.camera_fixed).toBe(false);
});
it("drops malformed seed values before creating videos", async () => {
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
const provider = buildBytePlusVideoGenerationProvider();
await provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "A cinematic lobster montage",
providerOptions: {
seed: 1.5,
},
cfg: {},
});
mockSuccessfulBytePlusTask();
await generateVideo({ providerOptions: { seed: 1.5 } });
expect(requireBytePlusPostBody()).not.toHaveProperty("seed");
});
it("drops out-of-range duration values before creating videos", async () => {
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
const provider = buildBytePlusVideoGenerationProvider();
await provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "A cinematic lobster montage",
durationSeconds: 99,
cfg: {},
});
mockSuccessfulBytePlusTask();
await generateVideo({ durationSeconds: 99 });
expect(requireBytePlusPostBody()).not.toHaveProperty("duration");
});
it("drops malformed response duration metadata", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({
id: "task_123",
}),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
video_url: "https://example.com/byteplus.mp4",
},
content: { video_url: "https://example.com/byteplus.mp4" },
duration: 1.5,
}),
)
.mockResolvedValueOnce(streamedVideoResponse("mp4-bytes"));
const provider = buildBytePlusVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "A lantern floats upward into the night sky",
cfg: {},
});
const result = await generateVideo();
expect(result.metadata).toMatchObject({ duration: undefined });
});
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("{ not valid json"));
controller.close();
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
release,
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "bad create response",
cfg: {},
}),
).rejects.toThrow("BytePlus video generation failed: malformed JSON response");
expect(release).toHaveBeenCalledOnce();
});
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_missing_status" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_missing_status",
content: {
video_url: "https://example.com/byteplus.mp4",
},
id: "task_123",
content: { video_url: "https://example.com/byteplus.mp4" },
}),
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "missing status",
cfg: {},
}),
).rejects.toThrow("BytePlus video status response missing task status");
await expect(generateVideo()).rejects.toThrow(
"BytePlus video status response missing task status",
);
});
it("rejects malformed completed content", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_malformed_content" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock.mockResolvedValueOnce(
streamedJsonResponse({
id: "task_malformed_content",
id: "task_123",
status: "succeeded",
content: ["https://example.com/byteplus.mp4"],
}),
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "malformed content",
cfg: {},
}),
).rejects.toThrow("BytePlus video generation completed with malformed content");
await expect(generateVideo()).rejects.toThrow(
"BytePlus video generation completed with malformed content",
);
});
it("bounds the submit task JSON body and cancels an oversized stream", async () => {
@ -537,29 +282,15 @@ describe("byteplus video generation provider", () => {
release,
});
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "oversized submit response",
cfg: {},
}),
).rejects.toThrow(
await expect(generateVideo()).rejects.toThrow(
`BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
);
expect(stream.state.canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised stream.
expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
// The submit request must still be released even though the body overflowed.
expect(release).toHaveBeenCalledOnce();
});
it("bounds the poll status JSON body and cancels an oversized stream", async () => {
postJsonRequestMock.mockResolvedValue({
response: streamedJsonResponse({ id: "task_oversized_poll" }),
release: vi.fn(async () => {}),
});
const stream = makeOversizedJsonStream();
fetchWithTimeoutMock.mockResolvedValueOnce(
new Response(stream.body, {
@ -568,15 +299,7 @@ describe("byteplus video generation provider", () => {
}),
);
const provider = buildBytePlusVideoGenerationProvider();
await expect(
provider.generateVideo({
provider: "byteplus",
model: "seedance-1-0-pro-250528",
prompt: "oversized poll response",
cfg: {},
}),
).rejects.toThrow(
await expect(generateVideo()).rejects.toThrow(
`BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
);
expect(stream.state.canceled).toBe(true);

View file

@ -1,54 +1,12 @@
import type { ParsedMail } from "mailparser";
import { describe, expect, it } from "vitest";
import { simpleParser } from "mailparser";
import { expect, it } from "vitest";
import { renderImapPrompt } from "./prompt.js";
// Matches an unpaired UTF-16 surrogate (lone high or lone low), without relying
// on the ES2024 String.prototype.isWellFormed() runtime API.
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
function mail(text: string): ParsedMail {
return {
text,
subject: "test",
from: { text: "sender@example.com" },
attachments: [],
} as unknown as ParsedMail;
}
describe("renderImapPrompt", () => {
it("snippet cut does not split a surrogate pair", () => {
const body = `${"x".repeat(239)}🙂tail`;
const prompt = renderImapPrompt(mail(body), { includeBody: true, maxBytes: 20_000 }, false);
expect(UNPAIRED_SURROGATE_RE.test(prompt)).toBe(false);
const snippetLine = prompt.split("\n").find((line) => line.startsWith("Snippet: "));
expect(snippetLine).toBeDefined();
// The 240th UTF-16 code unit lands on the emoji's high surrogate; the
// safe cut backs off before it rather than stranding a lone surrogate.
expect(snippetLine?.endsWith("x")).toBe(true);
expect(snippetLine?.includes("🙂")).toBe(false);
});
it("byte cut cannot introduce replacement characters or split a code point", () => {
const body = `${"A".repeat(100)}${"🙂".repeat(50)}`;
// Emoji are 4 UTF-8 bytes each; sweep maxBytes across a range so the cut
// lands at every interior byte offset (1, 2, and 3 bytes into an emoji).
for (let maxBytes = 400; maxBytes <= 420; maxBytes++) {
const result = renderImapPrompt(mail(body), { includeBody: true, maxBytes }, false);
expect(result).toContain("[truncated: email content exceeded the configured byte limit]");
expect(Buffer.byteLength(result)).toBeLessThanOrEqual(maxBytes);
expect(UNPAIRED_SURROGATE_RE.test(result)).toBe(false);
expect(result.includes("<22>")).toBe(false);
}
});
it("sourceTruncated appends the marker even under the byte limit", () => {
const body = "hello world";
const result = renderImapPrompt(mail(body), { includeBody: true, maxBytes: 20_000 }, true);
expect(result.endsWith("[truncated: email content exceeded the configured byte limit]")).toBe(
true,
);
expect(result).toContain(body);
expect(UNPAIRED_SURROGATE_RE.test(result)).toBe(false);
});
it("sourceTruncated appends the marker even under the byte limit", async () => {
const mail = await simpleParser("From: sender@example.com\r\nSubject: test\r\n\r\nhello world");
const result = renderImapPrompt(mail, { includeBody: true, maxBytes: 20_000 }, true);
expect(result.endsWith("[truncated: email content exceeded the configured byte limit]")).toBe(
true,
);
expect(result).toContain("hello world");
});

View file

@ -1,10 +1,8 @@
import { authenticate } from "mailauth";
import { simpleParser } from "mailparser";
import type { IdentifierAuthentication } from "openclaw/plugin-sdk/channel-ingress-runtime";
import { describe, expect, it, vi } from "vitest";
import { resolveImapConfig } from "./config.js";
import { createImapAuthResult } from "./imap-test-support.js";
import { renderImapPrompt } from "./prompt.js";
import { evaluateImapSender } from "./sender-gate.js";
function account(overrides: Record<string, unknown> = {}) {
@ -22,8 +20,8 @@ function account(overrides: Record<string, unknown> = {}) {
}).accounts.inbox!;
}
async function message(headers: string[], body = "Hello from a trusted sender") {
const raw = Buffer.from([...headers, "", body].join("\r\n"));
async function message(headers: string[]) {
const raw = Buffer.from([...headers, "", "Hello from a trusted sender"].join("\r\n"));
return { raw, mail: await simpleParser(raw), internalDate: new Date() };
}
@ -31,7 +29,6 @@ describe("IMAP sender admission", () => {
it.each([
["trusted@EXAMPLE.com", ["trusted@example.COM"], true],
["person@example.com", ["@EXAMPLE.com"], true],
["trusted@evil.example", ["trusted@example.com"], false],
["Trusted@example.com", ["trusted@example.com"], false],
])("matches sender %s against the actual addr-spec", async (sender, entries, accepted) => {
const mail = await message([`From: ${sender}`, "To: reader+secret-token@example.com"]);
@ -54,7 +51,7 @@ describe("IMAP sender admission", () => {
it("rejects a spoofed display name and ignores Reply-To", async () => {
const mail = await message([
'From: "trusted@example.com" <attacker@evil.example>',
'From: "trusted@example.com" <trusted@evil.example>',
"Reply-To: trusted@example.com",
"To: reader@example.com",
]);
@ -68,11 +65,8 @@ describe("IMAP sender admission", () => {
expect(authenticator).not.toHaveBeenCalled();
});
it.each([
["From: trusted@example.com, attacker@evil.example"],
["From: attacker@evil.example", "From: trusted@example.com"],
])("rejects multi-From messages before authentication", async (...headers) => {
const mail = await message(headers);
it("rejects duplicate From headers before authentication", async () => {
const mail = await message(["From: attacker@evil.example", "From: trusted@example.com"]);
const authenticator = vi.fn(async () => createImapAuthResult("pass"));
const verdict = await evaluateImapSender({ ...mail, account: account(), authenticator });
expect(authenticator).not.toHaveBeenCalled();
@ -101,30 +95,25 @@ describe("IMAP sender admission", () => {
});
});
it.each(["neutral", "temperror", "none"] as const)(
it.each(["temperror", "none"] as const)(
"never dispatches on DMARC %s at the default verified threshold",
async (result) => {
const mail = await message([
"From: trusted@example.com",
"To: reader+wrong-token@example.com",
]);
const authentication =
result === "neutral"
? createImapAuthResult(result)
: await authenticate(mail.raw, {
disableArc: true,
disableBimi: true,
resolver: async () => {
if (result === "temperror") {
throw new Error("fixture DNS timeout");
}
return [];
},
});
const authentication = await authenticate(mail.raw, {
disableArc: true,
disableBimi: true,
resolver: async () => {
if (result === "temperror") {
throw new Error("fixture DNS timeout");
}
return [];
},
});
expect(authentication.dmarc).toMatchObject({ status: { result } });
if (result !== "neutral") {
expect(authentication.dmarc).not.toHaveProperty("alignment");
}
expect(authentication.dmarc).not.toHaveProperty("alignment");
const configured = account({
addressTokens: [{ token: "expected-token", senders: ["trusted@example.com"] }],
});
@ -158,32 +147,24 @@ describe("IMAP sender admission", () => {
},
);
it.each([
["mutable", true],
["unverified", true],
["asserted", false],
["verified", false],
] satisfies [IdentifierAuthentication, boolean][])(
"admits verified mail and applies the %s floor to unproven mail",
async (min, acceptsUnproven) => {
const mail = await message(["From: trusted@example.com"]);
const configured = account({ senderAuth: { min } });
for (const result of ["pass", "none", "temperror"] as const) {
await expect(
evaluateImapSender({
...mail,
account: configured,
authenticator: async () => createImapAuthResult(result),
}),
).resolves.toMatchObject({
accepted: result === "pass" || acceptsUnproven,
strength: result === "pass" ? "verified" : "unverified",
});
}
},
);
it("admits verified and unproven mail at an explicit unverified floor", async () => {
const mail = await message(["From: trusted@example.com"]);
const configured = account({ senderAuth: { min: "unverified" } });
for (const result of ["pass", "none", "temperror"] as const) {
await expect(
evaluateImapSender({
...mail,
account: configured,
authenticator: async () => createImapAuthResult(result),
}),
).resolves.toMatchObject({
accepted: true,
strength: result === "pass" ? "verified" : "unverified",
});
}
});
it("accepts only configured Authentication-Results authorities", async () => {
it("rejects untrusted Authentication-Results authorities", async () => {
const configured = account({
senderAuth: {
min: "asserted",
@ -203,17 +184,6 @@ describe("IMAP sender admission", () => {
strength: "unverified",
reason: "unverified-authentication",
});
const trusted = await message([
"From: trusted@example.com",
"Authentication-Results: mx.example.com; dmarc=pass header.from=example.com",
]);
await expect(
evaluateImapSender({ ...trusted, account: configured, authenticator }),
).resolves.toMatchObject({
accepted: true,
strength: "asserted",
reason: "trusted-authserv-dmarc-pass",
});
});
it("admits stale mail with a sender-bound token without evaluating authentication", async () => {
@ -283,13 +253,6 @@ describe("IMAP sender admission", () => {
});
});
it("caps rendered prompts and records truncation", async () => {
const parsed = await message(["From: trusted@example.com", "Subject: Large"], "🙂".repeat(500));
const prompt = renderImapPrompt(parsed.mail, { includeBody: true, maxBytes: 256 });
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(256);
expect(prompt).toContain("[truncated:");
});
it("keeps authenticator exceptions retryable without claiming a mutable identifier", async () => {
const mail = await message(["From: trusted@example.com"]);
await expect(

View file

@ -1,13 +1,7 @@
import { describe, expect, it } from "vitest";
import { resolveImapConfig } from "./config.js";
import { createImapTestRuntime } from "./imap-test-support.js";
import {
advanceImapCursor,
countImapSkip,
initializeImapCursor,
recordImapAttempt,
rememberImapMessage,
} from "./state.js";
import { countImapSkip, rememberImapMessage } from "./state.js";
describe("IMAP durable watcher state", () => {
it("keeps healthy accounts available when a sibling SecretRef could not resolve", () => {
@ -30,28 +24,8 @@ describe("IMAP durable watcher state", () => {
expect(Object.keys(config.accounts)).toEqual(["healthy"]);
});
it("baselines existing mail, resumes its cursor, and resets after UIDVALIDITY changes", async () => {
it("deduplicates logical Message-IDs without growing the account ring", async () => {
const { state } = createImapTestRuntime();
expect(await initializeImapCursor(state, "account", "17", 42)).toMatchObject({
kind: "baseline",
cursor: { uidValidity: "17", lastSeenUid: 41 },
});
await advanceImapCursor(state, "account", "17", 45);
expect(await initializeImapCursor(state, "account", "17", 46)).toMatchObject({
kind: "resume",
cursor: { lastSeenUid: 45 },
});
expect(await initializeImapCursor(state, "account", "18", 8)).toMatchObject({
kind: "reset",
cursor: { uidValidity: "18", lastSeenUid: 7 },
});
});
it("deduplicates claims and logical Message-IDs without growing the account ring", async () => {
const { state } = createImapTestRuntime();
const claim = { accountId: "account", uid: 1, recordedAt: 0 };
expect(await state.claims.registerIfAbsent("account:17:1", claim)).toBe(true);
expect(await state.claims.registerIfAbsent("account:17:1", claim)).toBe(false);
for (let index = 0; index < 101; index++) {
expect(await rememberImapMessage(state, "account", `<${index}@example.com>`)).toBe(true);
}
@ -59,10 +33,8 @@ describe("IMAP durable watcher state", () => {
expect((await state.messageIds.lookup("account"))?.messageIds).toHaveLength(100);
});
it("records bounded retries separately from final account skip counters", async () => {
it("increments final account skip counters", async () => {
const { state } = createImapTestRuntime();
expect(await recordImapAttempt(state, "account:17:2", "temperror")).toBe(1);
expect(await recordImapAttempt(state, "account:17:2", "temperror")).toBe(2);
await countImapSkip(state, "account", "temperror");
await countImapSkip(state, "account", "temperror");
expect(await state.skips.lookup("account:temperror")).toEqual({ count: 2 });

View file

@ -1,6 +1,5 @@
import { once } from "node:events";
import { createServer, type Server, type Socket } from "node:net";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk/plugin-entry";
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
@ -20,12 +19,9 @@ class ScriptedImapServer {
fetchGate: Promise<void> | undefined;
private readonly server: Server;
constructor(
private readonly supportsIdle = true,
private readonly beforeGreeting?: () => Promise<void>,
) {
constructor(private readonly supportsIdle = true) {
this.server = createServer((socket) => {
void this.accept(socket);
this.accept(socket);
});
}
@ -64,13 +60,12 @@ class ScriptedImapServer {
});
}
private async accept(socket: Socket): Promise<void> {
private accept(socket: Socket): void {
this.connectionCount++;
this.sockets.add(socket);
socket.on("error", () => {});
socket.once("close", () => this.sockets.delete(socket));
const capabilities = `IMAP4rev1${this.supportsIdle ? " IDLE" : ""}`;
await this.beforeGreeting?.();
socket.write(`* OK [CAPABILITY ${capabilities}] scripted IMAP ready\r\n`);
let buffered = "";
let idleTag: string | undefined;
@ -152,10 +147,9 @@ async function startWatcher(
supportsIdle?: boolean;
rejectAuthentication?: boolean;
account?: Partial<ImapAccountConfig>;
beforeGreeting?: () => Promise<void>;
} = {},
) {
const server = new ScriptedImapServer(options.supportsIdle, options.beforeGreeting);
const server = new ScriptedImapServer(options.supportsIdle);
server.rejectAuthentication = options.rejectAuthentication ?? false;
activeServers.push(server);
server.append("From: trusted@example.com\r\nSubject: Existing\r\n\r\nExisting email");
@ -221,7 +215,6 @@ async function startWatcher(
describe("IMAP watcher protocol boundary", () => {
it.each([
["unverified", "none", "", "strength=unverified", "text/plain"],
["verified", "pass", "", "strength=verified", "text/html"],
[
"asserted",
"none",
@ -244,7 +237,6 @@ describe("IMAP watcher protocol boundary", () => {
addressTokens: [{ token: "secret-token", senders: ["trusted@example.com"] }],
},
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
authenticator.mockResolvedValue(createImapAuthResult(dmarc));
const body = contentType === "text/html" ? "<p>Email <b>content</b></p>" : "Email content";
server.append(
@ -273,12 +265,12 @@ describe("IMAP watcher protocol boundary", () => {
it.each([
{ boundary: "snippet", body: `${"x".repeat(239)}🙂tail`, maxBytes: 20_000, truncated: false },
...[400, 401, 402, 403].map((maxBytes) => ({
boundary: `UTF-8 byte budget ${maxBytes}`,
{
boundary: "UTF-8 byte budget",
body: `${"A".repeat(100)}${"🙂".repeat(50)}`,
maxBytes,
maxBytes: 403,
truncated: true,
})),
},
])(
"preserves Unicode through fetched mail at the $boundary limit",
async ({ body, maxBytes, truncated }) => {
@ -322,7 +314,6 @@ describe("IMAP watcher protocol boundary", () => {
addressTokens: [{ token: "secret-token", senders: ["@evil.example"] }],
},
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
server.append(`${from}\r\nTo: reader+secret-token@example.com\r\n\r\nRejected mail`);
await waitForCursor(2);
expect(authenticator).not.toHaveBeenCalled();
@ -342,7 +333,6 @@ describe("IMAP watcher protocol boundary", () => {
await startWatcher({
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
if (failure === "rejected admission") {
dispatchHookAgentTurn.mockResolvedValueOnce({ ok: false, reason: "Gateway unavailable" });
} else if (failure === "throwing admission") {
@ -370,7 +360,6 @@ describe("IMAP watcher protocol boundary", () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
dispatchHookAgentTurn.mockRejectedValue(new Error("Gateway unavailable"));
server.append("From: trusted@example.com\r\nSubject: Exhausted\r\n\r\nNo admission");
await waitForCursor(2);
@ -390,7 +379,6 @@ describe("IMAP watcher protocol boundary", () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
dispatchHookAgentTurn.mockImplementationOnce(async () => {
// Keep admission unresolved across subsequent mailbox notifications and polls.
server.append("From: trusted@example.com\r\nSubject: Later\r\n\r\nWait for earlier mail");
@ -414,10 +402,9 @@ describe("IMAP watcher protocol boundary", () => {
});
it("stops pending admission retries when the watcher is stopped", async () => {
const { server, watcher, state, context, dispatchHookAgentTurn } = await startWatcher({
const { server, watcher, context, dispatchHookAgentTurn } = await startWatcher({
account: { watch: { mode: "auto", pollSeconds: 0.1 } },
});
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
dispatchHookAgentTurn.mockRejectedValue(new Error("Gateway unavailable"));
server.append("From: trusted@example.com\r\nSubject: Stop\r\n\r\nDo not retry after stop");
await vi.waitFor(() => expect(context.logger.warn).toHaveBeenCalled());
@ -431,25 +418,7 @@ describe("IMAP watcher protocol boundary", () => {
});
it("sweeps a pushed message through the real IMAP connection into one isolated hook dispatch", async () => {
const connected = createDeferred<void>();
const greeting = createDeferred<void>();
let initialized = false;
const starting = startWatcher({
beforeGreeting: async () => {
connected.resolve();
await greeting.promise;
},
}).then((fixture) => {
initialized = true;
return fixture;
});
try {
await connected.promise;
expect(initialized).toBe(false);
} finally {
greeting.resolve();
}
const { server, state, dispatchHookAgentTurn, waitForCursor } = await starting;
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
expect(await state.cursors.lookup("inbox")).toMatchObject({
uidValidity: "17",
lastSeenUid: 1,
@ -484,8 +453,7 @@ describe("IMAP watcher protocol boundary", () => {
});
it("delivers mail that arrived during an IDLE connection interruption", async () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
server.disconnect();
server.messages.push({
uid: 2,
@ -500,8 +468,7 @@ describe("IMAP watcher protocol boundary", () => {
});
it("coalesces a wakeup that arrives during an active sweep", async () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
expect(await state.cursors.lookup("inbox")).toMatchObject({ lastSeenUid: 1 });
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
let releaseFetch = () => {};
server.fetchGate = new Promise<void>((resolve) => {
releaseFetch = resolve;
@ -521,8 +488,7 @@ describe("IMAP watcher protocol boundary", () => {
});
it("re-baselines a rotated UIDVALIDITY without replaying existing mail", async () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
expect(await state.cursors.lookup("inbox")).toMatchObject({ uidValidity: "17" });
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher();
server.uidValidity = "18";
server.disconnect();
server.messages.push({
@ -534,11 +500,10 @@ describe("IMAP watcher protocol boundary", () => {
});
it("polls when the IMAP server does not advertise IDLE", async () => {
const { server, state, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
const { server, dispatchHookAgentTurn, waitForCursor } = await startWatcher({
supportsIdle: false,
account: { watch: { mode: "auto", pollSeconds: 0.02 } },
});
expect(await state.cursors.lookup("inbox")).toBeDefined();
server.messages.push({
uid: 2,
raw: "From: trusted@example.com\r\nSubject: Poll\r\n\r\nPolled",

View file

@ -71,35 +71,6 @@ describe("kilocode provider plugin", () => {
expect(capturedPayload).not.toHaveProperty("reasoning");
});
it("normalizes string stop to array in plugin-owned stream hook", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const payloads: Array<Record<string, unknown>> = [];
const baseStreamFn: StreamFn = (model, _context, options) => {
const payload: Record<string, unknown> = { stop: "\n" };
options?.onPayload?.(payload as never, model as never);
payloads.push(payload);
return {} as never;
};
const wrapped = provider.wrapStreamFn?.({
provider: "kilocode",
modelId: "deepseek/deepseek-v4-flash",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
api: "openai-completions",
provider: "kilocode",
id: "deepseek/deepseek-v4-flash",
} as Model<"openai-completions">,
{ messages: [] } as Context,
{},
);
expect(payloads[0]?.stop).toEqual(["\n"]);
});
it("normalizes string stop after caller payload hooks", async () => {
const provider = await registerSingleProviderPlugin(plugin);
const payloads: Array<Record<string, unknown>> = [];

View file

@ -1,8 +1,5 @@
// Kilocode tests cover onboard plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
buildKilocodeModelDefinition,
KILOCODE_DEFAULT_CONTEXT_WINDOW,
@ -13,168 +10,57 @@ import {
import { applyKilocodeConfig, KILOCODE_DEFAULT_MODEL_REF } from "./onboard.js";
import { KILOCODE_BASE_URL } from "./provider-models.js";
const emptyCfg: OpenClawConfig = {};
const KILOCODE_MODEL_IDS = ["kilo-auto/balanced"];
function requireKilocodeProvider(cfg: OpenClawConfig) {
const provider = cfg.models?.providers?.kilocode;
if (!provider) {
throw new Error("expected Kilocode provider config");
}
return provider;
}
describe("Kilo Gateway provider config", () => {
describe("constants", () => {
it("KILOCODE_BASE_URL points to kilo openrouter endpoint", () => {
expect(KILOCODE_BASE_URL).toBe("https://api.kilo.ai/api/gateway/");
});
it("KILOCODE_DEFAULT_MODEL_REF includes provider prefix", () => {
expect(KILOCODE_DEFAULT_MODEL_REF).toBe("kilocode/kilo-auto/balanced");
});
it("KILOCODE_DEFAULT_MODEL_ID is kilo-auto/balanced", () => {
expect(KILOCODE_DEFAULT_MODEL_ID).toBe("kilo-auto/balanced");
});
it("builds the public default model definition", () => {
const model = buildKilocodeModelDefinition();
expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID);
expect(model.name).toBe("Auto Balanced");
expect(model.reasoning).toBe(true);
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW);
expect(model.maxTokens).toBe(KILOCODE_DEFAULT_MAX_TOKENS);
expect(model.cost).toEqual(KILOCODE_DEFAULT_COST);
});
describe("buildKilocodeModelDefinition", () => {
it("returns correct model shape", () => {
const model = buildKilocodeModelDefinition();
expect(model.id).toBe(KILOCODE_DEFAULT_MODEL_ID);
expect(model.name).toBe("Auto Balanced");
expect(model.reasoning).toBe(true);
expect(model.input).toEqual(["text", "image"]);
expect(model.contextWindow).toBe(KILOCODE_DEFAULT_CONTEXT_WINDOW);
expect(model.maxTokens).toBe(KILOCODE_DEFAULT_MAX_TOKENS);
expect(model.cost).toEqual(KILOCODE_DEFAULT_COST);
});
it("seeds the default model in replace mode", () => {
const result = applyKilocodeConfig({ models: { mode: "replace" } });
expect(result.models?.providers?.kilocode?.models.map((model) => model.id)).toEqual([
"kilo-auto/balanced",
]);
});
describe("applyKilocodeConfig", () => {
it("registers kilocode provider with correct baseUrl and api", () => {
const result = applyKilocodeConfig(emptyCfg);
const provider = requireKilocodeProvider(result);
expect(provider.baseUrl).toBe(KILOCODE_BASE_URL);
expect(provider.api).toBe("openai-completions");
});
it("includes the default model in the provider model list", () => {
const result = applyKilocodeConfig({ models: { mode: "replace" } });
const provider = result.models?.providers?.kilocode;
const models = provider?.models;
expect(Array.isArray(models)).toBe(true);
const modelIds = models?.map((m) => m.id) ?? [];
expect(modelIds).toContain(KILOCODE_DEFAULT_MODEL_ID);
});
it("appends missing catalog models to existing Kilo provider config", () => {
it.each([undefined, "merge"] as const)(
"preserves authored rows without seeding %s config",
(mode) => {
expect(applyKilocodeConfig({ models: { mode } }).models?.providers?.kilocode?.models).toEqual(
[],
);
const authored = {
...buildKilocodeModelDefinition(),
id: "operator-model",
name: "My model",
};
const result = applyKilocodeConfig({
models: {
providers: {
kilocode: {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models: [buildKilocodeModelDefinition()],
},
},
mode,
providers: { kilocode: { baseUrl: KILOCODE_BASE_URL, models: [authored] } },
},
});
const modelIds = result.models?.providers?.kilocode?.models?.map((m) => m.id) ?? [];
for (const modelId of KILOCODE_MODEL_IDS) {
expect(modelIds).toContain(modelId);
}
});
expect(result.models?.providers?.kilocode?.models).toEqual([authored]);
},
);
it("sets Kilo Gateway alias in agent default models", () => {
const result = applyKilocodeConfig(emptyCfg);
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
expect(agentModel).toEqual({ alias: "Kilo Gateway" });
});
it.each([undefined, "merge"] as const)(
"preserves authored rows without seeding %s config",
(mode) => {
expect(
applyKilocodeConfig({ models: { mode } }).models?.providers?.kilocode?.models,
).toEqual([]);
const authored = {
...buildKilocodeModelDefinition(),
id: "operator-model",
name: "My model",
};
const result = applyKilocodeConfig({
models: {
mode,
providers: { kilocode: { baseUrl: KILOCODE_BASE_URL, models: [authored] } },
},
});
expect(result.models?.providers?.kilocode?.models).toEqual([authored]);
},
);
it("preserves existing alias if already set", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
models: {
[KILOCODE_DEFAULT_MODEL_REF]: { alias: "My Custom Alias" },
},
},
},
};
const result = applyKilocodeConfig(cfg);
const agentModel = result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF];
expect(agentModel?.alias).toBe("My Custom Alias");
});
it("does not change the default model selection", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
model: { primary: "openai/gpt-5" },
},
},
};
const result = applyKilocodeConfig(cfg);
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5");
});
});
it("sets kilocode as the default model", () => {
const result = applyKilocodeConfig(emptyCfg);
it("sets up the Kilo Gateway default for a new config", () => {
const result = applyKilocodeConfig({});
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe(
KILOCODE_DEFAULT_MODEL_REF,
"kilocode/kilo-auto/balanced",
);
const provider = requireKilocodeProvider(result);
expect(provider.baseUrl).toBe(KILOCODE_BASE_URL);
});
describe("env var resolution", () => {
it("resolves KILOCODE_API_KEY from env", () => {
vi.stubEnv("KILOCODE_API_KEY", "test-kilo-key");
try {
const result = resolveEnvApiKey("kilocode");
expect(result).toEqual({
apiKey: "test-kilo-key",
source: "env: KILOCODE_API_KEY",
});
} finally {
vi.unstubAllEnvs();
}
expect(result.agents?.defaults?.models?.[KILOCODE_DEFAULT_MODEL_REF]).toEqual({
alias: "Kilo Gateway",
});
it("returns null when KILOCODE_API_KEY is not set", () => {
vi.stubEnv("KILOCODE_API_KEY", "");
try {
const result = resolveEnvApiKey("kilocode");
expect(result).toBeNull();
} finally {
vi.unstubAllEnvs();
}
expect(result.models?.providers?.kilocode).toMatchObject({
baseUrl: "https://api.kilo.ai/api/gateway/",
api: "openai-completions",
});
});
});

View file

@ -1,6 +1,6 @@
import { jsonResponse } from "openclaw/plugin-sdk/test-env";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterAll, describe, expect, it, vi } from "vitest";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
fetchWithSsrFGuardMock: vi.fn(),
@ -20,10 +20,6 @@ import {
KILOCODE_MODELS_URL,
} from "./provider-models.js";
type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>) & {
mock: { calls: unknown[][] };
};
function requireModelById(
models: Awaited<ReturnType<typeof discoverKilocodeModels>>,
id: string,
@ -37,30 +33,16 @@ function requireModelById(
const requireRecord = createRequireRecord("record", "expected-label-record");
function requireFirstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
return call;
}
function makeGatewayModel(overrides: Record<string, unknown> = {}) {
return {
id: "anthropic/claude-sonnet-4",
name: "Anthropic: Claude Sonnet 4",
created: 1700000000,
description: "A model",
context_length: 200000,
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["text"],
tokenizer: "Claude",
},
top_provider: {
is_moderated: false,
max_completion_tokens: 8192,
},
top_provider: { max_completion_tokens: 8192 },
pricing: {
prompt: "0.000003",
completion: "0.000015",
@ -77,15 +59,7 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
id: "kilo-auto/balanced",
name: "Auto Balanced",
context_length: 1000000,
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["text"],
tokenizer: "Other",
},
top_provider: {
is_moderated: false,
max_completion_tokens: 65536,
},
top_provider: { max_completion_tokens: 65536 },
pricing: {
prompt: "0.000000325",
completion: "0.00000195",
@ -97,29 +71,17 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
});
}
async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise<void>) {
function stubResponse(response: Response) {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockReset();
const callMockFetch = mockFetch as unknown as (
url: string,
init?: RequestInit,
) => Promise<unknown>;
fetchWithSsrFGuardMock.mockImplementation(
async (params: { url: string; init?: RequestInit }) => ({
response: await callMockFetch(params.url, params.init),
release,
}),
);
try {
await runAssertions();
return release;
} finally {
fetchWithSsrFGuardMock.mockReset();
}
fetchWithSsrFGuardMock.mockResolvedValue({ response, release });
return release;
}
function stubModels(data: unknown[]) {
stubResponse(jsonResponse({ data }));
}
afterEach(() => fetchWithSsrFGuardMock.mockReset());
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
@ -129,62 +91,40 @@ describe("discoverKilocodeModels (fetch path)", () => {
it.each([503, 200])(
"preserves the public advisory builder for HTTP %s with no rows",
async (status) => {
await withFetchPathTest(
vi.fn(async () => jsonResponse({ data: [] }, status)),
async () => {
await expect(buildKilocodeProviderWithDiscovery()).resolves.toEqual(
buildKilocodeProvider(),
);
},
);
stubResponse(jsonResponse({ data: [] }, status));
await expect(buildKilocodeProviderWithDiscovery()).resolves.toEqual(buildKilocodeProvider());
},
);
it("parses gateway models with correct pricing conversion", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeAutoModel(), makeGatewayModel()],
}),
stubModels([makeAutoModel(), makeGatewayModel()]);
const models = await discoverKilocodeModels();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
const guardedFetch = requireRecord(
fetchWithSsrFGuardMock.mock.calls[0]?.[0],
"guarded fetch params",
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledOnce();
const [guardedFetchParams] = requireFirstMockCall(
fetchWithSsrFGuardMock,
"guarded fetch call",
);
const guardedFetch = requireRecord(guardedFetchParams, "guarded fetch params");
expect(guardedFetch.url).toBe(KILOCODE_MODELS_URL);
const guardedInit = requireRecord(guardedFetch.init, "guarded fetch init");
expect(Object.fromEntries(new Headers(guardedInit.headers as HeadersInit))).toEqual({
accept: "application/json",
});
expect(guardedFetch.policy).toEqual({ allowedHostnames: ["api.kilo.ai"] });
expect(guardedFetch.timeoutMs).toBeGreaterThan(0);
expect(guardedFetch.timeoutMs).toBeLessThanOrEqual(5000);
expect(guardedFetch.auditContext).toBe("kilocode.model_discovery");
expect(mockFetch).toHaveBeenCalledOnce();
const [fetchUrl, fetchOptions] = requireFirstMockCall(mockFetch, "mock fetch call");
expect(fetchUrl).toBe(KILOCODE_MODELS_URL);
const fetchInit = requireRecord(fetchOptions, "mock fetch init");
expect(Object.fromEntries(new Headers(fetchInit.headers as HeadersInit))).toEqual({
accept: "application/json",
});
expect(models.length).toBe(2);
const sonnet = requireModelById(models, "anthropic/claude-sonnet-4");
expect(sonnet.cost.input).toBeCloseTo(3);
expect(sonnet.cost.output).toBeCloseTo(15);
expect(sonnet.cost.cacheRead).toBeCloseTo(0.3);
expect(sonnet.cost.cacheWrite).toBeCloseTo(3.75);
expect(sonnet.input).toEqual(["text", "image"]);
expect(sonnet.reasoning).toBe(true);
expect(sonnet.contextWindow).toBe(200000);
expect(sonnet.maxTokens).toBe(8192);
expect(guardedFetch.url).toBe(KILOCODE_MODELS_URL);
const guardedInit = requireRecord(guardedFetch.init, "guarded fetch init");
expect(Object.fromEntries(new Headers(guardedInit.headers as HeadersInit))).toEqual({
accept: "application/json",
});
expect(guardedFetch.policy).toEqual({ allowedHostnames: ["api.kilo.ai"] });
expect(guardedFetch.timeoutMs).toBeGreaterThan(0);
expect(guardedFetch.timeoutMs).toBeLessThanOrEqual(5000);
expect(guardedFetch.auditContext).toBe("kilocode.model_discovery");
expect(models).toHaveLength(2);
const sonnet = requireModelById(models, "anthropic/claude-sonnet-4");
expect(sonnet.cost.input).toBeCloseTo(3);
expect(sonnet.cost.output).toBeCloseTo(15);
expect(sonnet.cost.cacheRead).toBeCloseTo(0.3);
expect(sonnet.cost.cacheWrite).toBeCloseTo(3.75);
expect(sonnet.input).toEqual(["text", "image"]);
expect(sonnet.reasoning).toBe(true);
expect(sonnet.contextWindow).toBe(200000);
expect(sonnet.maxTokens).toBe(8192);
});
it.each([
@ -208,253 +148,181 @@ describe("discoverKilocodeModels (fetch path)", () => {
])(
"preserves known default-model pricing for $label",
async ({ pricing, cacheRead, cacheWrite }) => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAutoModel({ pricing }),
makeGatewayModel({
id: "kilo-auto/frontier",
pricing: { prompt: "-1", completion: "-1" },
}),
makeGatewayModel({
id: "kilo-auto/free",
pricing: {
prompt: "0",
completion: "0",
input_cache_read: "0",
input_cache_write: "0",
},
}),
],
stubModels([
makeAutoModel({ pricing }),
makeGatewayModel({
id: "kilo-auto/frontier",
pricing: { prompt: "-1", completion: "-1" },
}),
);
makeGatewayModel({
id: "kilo-auto/free",
pricing: {
prompt: "0",
completion: "0",
input_cache_read: "0",
input_cache_write: "0",
},
}),
]);
const models = await discoverKilocodeModels();
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "kilo-auto/balanced").cost).toEqual({
input: KILOCODE_DEFAULT_COST.input,
output: KILOCODE_DEFAULT_COST.output,
cacheRead,
cacheWrite,
});
for (const id of ["kilo-auto/frontier", "kilo-auto/free"]) {
expect(requireModelById(models, id).cost).toEqual({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
});
}
expect(requireModelById(models, "kilo-auto/balanced").cost).toEqual({
input: KILOCODE_DEFAULT_COST.input,
output: KILOCODE_DEFAULT_COST.output,
cacheRead,
cacheWrite,
});
for (const id of ["kilo-auto/frontier", "kilo-auto/free"]) {
expect(requireModelById(models, id).cost).toEqual({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
});
}
},
);
it("propagates network errors", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("network error"));
await withFetchPathTest(mockFetch, async () => {
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"network error",
);
});
fetchWithSsrFGuardMock.mockRejectedValue(new Error("network error"));
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"network error",
);
});
it("releases the response before propagating an HTTP error", async () => {
const response = new Response("temporary failure", { status: 500 });
const cancelSpy = vi.spyOn(response.body!, "cancel").mockResolvedValue(undefined);
const mockFetch = vi.fn().mockResolvedValue(response);
const release = stubResponse(response);
const release = await withFetchPathTest(mockFetch, async () => {
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toMatchObject({
status: 500,
});
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toMatchObject({
status: 500,
});
expect(cancelSpy).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("rejects malformed model list envelopes", async () => {
for (const payload of [[], { data: {} }]) {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
await withFetchPathTest(mockFetch, async () => {
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"Kilocode model list: malformed JSON response",
);
});
stubResponse(jsonResponse(payload));
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).rejects.toThrow(
"Kilocode model list: malformed JSON response",
);
}
});
it.each([{ data: [] }, { data: [null] }])(
"does not restore seed models when no usable live rows remain: %j",
async (payload) => {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
await withFetchPathTest(mockFetch, async () => {
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).resolves.toEqual([]);
});
stubResponse(jsonResponse(payload));
await expect(discoverKilocodeModels({ discoveryMode: "strict" })).resolves.toEqual([]);
},
);
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
stubModels([
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
);
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
]);
const models = await discoverKilocodeModels();
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "some/bad-window")).toMatchObject({
contextWindow: 1000000,
maxTokens: 65536,
});
expect(requireModelById(models, "some/bad-output")).toMatchObject({
contextWindow: 1000000,
maxTokens: 65536,
});
expect(requireModelById(models, "some/bad-window")).toMatchObject({
contextWindow: 1000000,
maxTokens: 65536,
});
expect(requireModelById(models, "some/bad-output")).toMatchObject({
contextWindow: 1000000,
maxTokens: 65536,
});
});
it("prefers the primary provider context window over the catalog-wide value", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeGatewayModel({
id: "minimax/minimax-m3",
context_length: 1048576,
top_provider: {
is_moderated: false,
context_length: 524288,
max_completion_tokens: 512000,
},
}),
],
stubModels([
makeGatewayModel({
id: "minimax/minimax-m3",
context_length: 1048576,
top_provider: { context_length: 524288, max_completion_tokens: 512000 },
}),
);
]);
const models = await discoverKilocodeModels();
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "minimax/minimax-m3")).toMatchObject({
contextWindow: 524288,
maxTokens: 512000,
});
expect(requireModelById(models, "minimax/minimax-m3")).toMatchObject({
contextWindow: 524288,
maxTokens: 512000,
});
});
it("falls back to the catalog window when the provider window is unusable", async () => {
const unusable: unknown[] = [0, -1, 4096.5, Number.POSITIVE_INFINITY, null, "131072"];
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: unusable.map((context_length, index) =>
makeGatewayModel({
id: `some/provider-window-${index}`,
context_length: 200000,
top_provider: { is_moderated: false, context_length, max_completion_tokens: 8192 },
}),
),
}),
stubModels(
unusable.map((context_length, index) =>
makeGatewayModel({
id: `some/provider-window-${index}`,
context_length: 200000,
top_provider: { context_length, max_completion_tokens: 8192 },
}),
),
);
const models = await discoverKilocodeModels();
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
for (let index = 0; index < unusable.length; index++) {
expect(requireModelById(models, `some/provider-window-${index}`)).toMatchObject({
contextWindow: 200000,
maxTokens: 8192,
});
}
});
});
it("ensures kilo-auto/balanced is present even when API doesn't return it", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeGatewayModel()],
}),
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "kilo-auto/balanced").id).toBe("kilo-auto/balanced");
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
"anthropic/claude-sonnet-4",
);
});
for (let index = 0; index < unusable.length; index++) {
expect(requireModelById(models, `some/provider-window-${index}`)).toMatchObject({
contextWindow: 200000,
maxTokens: 8192,
});
}
});
it("detects text-only models without image modality", async () => {
const textOnlyModel = makeGatewayModel({
id: "some/text-model",
architecture: {
input_modalities: ["text"],
output_modalities: ["text"],
},
supported_parameters: ["max_tokens", "temperature"],
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [textOnlyModel] }));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const textModel = requireModelById(models, "some/text-model");
expect(textModel.input).toEqual(["text"]);
expect(textModel.reasoning).toBe(false);
});
stubModels([
makeGatewayModel({
id: "some/text-model",
architecture: { input_modalities: ["text"], output_modalities: ["text"] },
supported_parameters: ["max_tokens", "temperature"],
}),
]);
const textModel = requireModelById(await discoverKilocodeModels(), "some/text-model");
expect(textModel.input).toEqual(["text"]);
expect(textModel.reasoning).toBe(false);
});
it("excludes image-output models from the chat catalog", async () => {
const imageOutputModel = makeGatewayModel({
id: "google/gemini-3.1-flash-image",
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["image", "text"],
},
});
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [imageOutputModel, makeGatewayModel()] }));
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models.some((model) => model.id === "google/gemini-3.1-flash-image")).toBe(false);
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
"anthropic/claude-sonnet-4",
);
});
it("excludes image-output models while retaining chat and static routing entries", async () => {
stubModels([
makeGatewayModel({
id: "google/gemini-3.1-flash-image",
architecture: {
input_modalities: ["text", "image"],
output_modalities: ["image", "text"],
},
}),
makeGatewayModel(),
]);
expect((await discoverKilocodeModels()).map((model) => model.id)).toEqual([
"kilo-auto/balanced",
"anthropic/claude-sonnet-4",
]);
});
it("keeps a later valid duplicate when an earlier entry is malformed", async () => {
const malformedAutoModel = makeAutoModel({
name: "Broken Auto Balanced",
pricing: undefined,
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
stubModels([
makeAutoModel({ name: "Broken Auto Balanced", pricing: undefined }),
makeAutoModel(),
makeGatewayModel(),
]);
const models = await discoverKilocodeModels();
const auto = requireModelById(models, "kilo-auto/balanced");
expect(auto.name).toBe("Auto Balanced");
expect(auto.cost.input).toBeCloseTo(0.325);
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
"anthropic/claude-sonnet-4",
);
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const auto = requireModelById(models, "kilo-auto/balanced");
expect(auto.name).toBe("Auto Balanced");
expect(auto.cost.input).toBeCloseTo(0.325);
expect(requireModelById(models, "anthropic/claude-sonnet-4").id).toBe(
"anthropic/claude-sonnet-4",
);
});
});
});

View file

@ -14,18 +14,6 @@ describe("Raft channel plugin", () => {
detectBinaryMock.mockReset();
});
it("declares a wake-only direct channel", () => {
expect(raftPlugin.meta).toMatchObject({
id: "raft",
docsPath: "/channels/raft",
});
expect(raftPlugin.capabilities).toEqual({
chatTypes: ["direct"],
});
expect(raftPlugin.message).toBeUndefined();
expect(raftPlugin.outbound).toBeUndefined();
});
it.each([
{
detected: true,

View file

@ -13,7 +13,6 @@ import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedRaftAccount } from "./accounts.js";
import { startRaftGatewayAccount } from "./gateway.js";
import { dispatchRaftWake } from "./inbound.js";
const processRuntimeMocks = vi.hoisted(() => ({
killProcessTree: vi.fn(),
@ -37,6 +36,7 @@ class FakeBridge extends EventEmitter {
const tempWorkspaces: TempWorkspaceSync[] = [];
function createContext(accountId = "default") {
const controller = new AbortController();
const status = {
accountId,
running: false,
@ -85,7 +85,7 @@ function createContext(accountId = "default") {
profile: "openclaw",
},
runtime: {},
abortSignal: new AbortController().signal,
abortSignal: controller.signal,
log: {
info: vi.fn(),
warn: vi.fn(),
@ -117,7 +117,7 @@ function createContext(accountId = "default") {
};
return {
ctx: ctx as unknown as ChannelGatewayContext<ResolvedRaftAccount>,
controller: new AbortController(),
controller,
run,
buildContext,
wakeDedupe: createChannelReplayGuard<{ accountId: string; key: string }>({
@ -128,6 +128,40 @@ function createContext(accountId = "default") {
};
}
async function withGateway(
{ ctx, controller, wakeDedupe }: ReturnType<typeof createContext>,
test: (connection: {
endpoint: string;
token: string;
post: (body?: unknown, authToken?: string) => Promise<Response>;
}) => Promise<void>,
) {
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, { spawnBridge: bridge.spawn, wakeDedupe });
void start.catch(bridge.started.reject);
try {
const { endpoint, token } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
await test({
endpoint,
token,
post: (body, authToken = token) =>
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": authToken },
body: JSON.stringify(body),
}),
});
} finally {
controller.abort();
await start;
}
return bridge;
}
function createPersistentWakeDedupe(stateDir: string) {
return createChannelReplayGuard<{ accountId: string; key: string }>({
dedupe: {
@ -157,7 +191,6 @@ describe("Raft wake gateway", () => {
"joins an admitted wake during shutdown while %s is pending",
async (phase) => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
const pending = createDeferred<void>();
const reached = createDeferred<void>();
@ -215,22 +248,9 @@ describe("Raft wake gateway", () => {
},
);
it("marks the internal wake path explicitly unsupported", async () => {
const { ctx, buildContext } = createContext();
await dispatchRaftWake({ ctx });
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({ channelIngress: "unsupported" }),
);
});
it("keeps a disabled account quiescent until shutdown", async () => {
const { ctx, controller, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
Object.defineProperty(ctx, "account", {
value: {
...ctx.account,
enabled: false,
},
});
ctx.account.enabled = false;
const spawnBridge = vi.fn(() => new FakeBridge());
let settled = false;
const start = startRaftGatewayAccount(ctx, { spawnBridge, wakeDedupe }).then(() => {
@ -249,32 +269,16 @@ describe("Raft wake gateway", () => {
}
});
// Raft already answered this case through its own close-after-response teardown; the
// wire behavior must survive replacing that teardown with the shared transport owner.
it("keeps delivering 413 for an over-limit wake payload and closing the connection", async () => {
const { ctx, controller, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, {
spawnBridge: bridge.spawn,
wakeDedupe,
});
void start.catch(bridge.started.reject);
try {
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
const fixture = createContext();
await withGateway(fixture, async ({ endpoint, token }) => {
// Declared and sent in one write: the shape whose rejection used to race the flush.
const result = await postRawWebhook({
url: wakeEndpoint,
url: endpoint,
body: JSON.stringify({ deliveryId: "x".repeat(16 * 1024) }),
headers: {
"content-type": "application/json",
"x-raft-bridge-token": bridgeToken,
"x-raft-bridge-token": token,
},
});
@ -283,34 +287,15 @@ describe("Raft wake gateway", () => {
error: "Wake payload exceeds the 16 KiB limit.",
});
expect(result.closedByServer).toBe(true);
} finally {
controller.abort();
await start;
}
expect(fixture.run).not.toHaveBeenCalled();
});
});
it("accepts authenticated content-free wake hints and dedupes retry delivery ids", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
Object.defineProperty(ctx, "account", {
value: {
...ctx.account,
profile: "main'; touch /tmp/pwn; echo '",
},
});
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, {
spawnBridge: bridge.spawn,
wakeDedupe,
});
void start.catch(bridge.started.reject);
try {
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
const fixture = createContext();
const { ctx, run, buildContext } = fixture;
ctx.account.profile = "main'; touch /tmp/pwn; echo '";
const bridge = await withGateway(fixture, async ({ endpoint, token, post }) => {
expect(ctx.getStatus()).toMatchObject({
running: true,
connected: true,
@ -319,99 +304,46 @@ describe("Raft wake gateway", () => {
lastError: null,
terminalDisconnect: undefined,
});
await expect(fetch(wakeEndpoint.replace("/wake", "/health"))).resolves.toMatchObject({
await expect(fetch(endpoint.replace("/wake", "/health"))).resolves.toMatchObject({
status: 200,
});
await expect(fetch(wakeEndpoint, { method: "POST" })).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: { "x-raft-bridge-token": "x".repeat(bridgeToken.length) },
}),
).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: { "x-raft-bridge-token": "short" },
}),
).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
}),
).resolves.toMatchObject({ status: 400 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ metadata: { text: "not a wake hint" } }),
}),
).resolves.toMatchObject({ status: 400 });
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-1", timestamp: 1 }),
}),
).resolves.toMatchObject({ status: 202 });
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50")),
).resolves.toMatchObject({ status: 401 });
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
headers: {
"x-raft-bridge-token": bridgeToken,
},
}),
).resolves.toMatchObject({
status: 200,
await expect(fetch(endpoint, { method: "POST" })).resolves.toMatchObject({ status: 401 });
await expect(post(undefined, "x".repeat(token.length))).resolves.toMatchObject({
status: 401,
});
await expect(post(undefined, "short")).resolves.toMatchObject({ status: 401 });
await expect(post()).resolves.toMatchObject({ status: 400 });
await expect(
fetch(wakeEndpoint.replace("/wake", "/activity/drain?max=50"), {
headers: {
"x-raft-bridge-token": bridgeToken,
},
}).then((response) => response.json()),
).resolves.toEqual({
post({ eventId: "wake-content", metadata: { text: "not a wake hint" } }),
).resolves.toMatchObject({ status: 400 });
const accepted = await post({ eventId: "wake-1", timestamp: 1 });
expect(accepted.status).toBe(202);
await expect(accepted.json()).resolves.toMatchObject({
accepted: true,
ok: true,
runtimeSession: expect.any(String),
});
expect(run).toHaveBeenCalledTimes(1);
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({ channelIngress: "unsupported" }),
);
const drainUrl = endpoint.replace("/wake", "/activity/drain?max=50");
await expect(fetch(drainUrl)).resolves.toMatchObject({ status: 401 });
const drain = await fetch(drainUrl, { headers: { "x-raft-bridge-token": token } });
expect(drain.status).toBe(200);
await expect(drain.json()).resolves.toEqual({
dropped: 0,
events: [],
schema: "raft-activity-drain.v1",
});
await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(1));
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-1", timestamp: 2 }),
}),
).resolves.toMatchObject({ status: 202 });
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
await expect(post({ eventId: "wake-1", timestamp: 2 })).resolves.toMatchObject({
status: 202,
});
expect(run).toHaveBeenCalledTimes(1);
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({
metadata: {
sequence: 1,
source: "bridge",
},
}),
}),
).resolves.toMatchObject({ status: 400 });
await expect(post({ metadata: { sequence: 1, source: "bridge" } })).resolves.toMatchObject({
status: 400,
});
expect(run).toHaveBeenCalledTimes(1);
const input = run.mock.calls[0]?.[0].adapter.ingest({ kind: "wake" });
@ -419,10 +351,7 @@ describe("Raft wake gateway", () => {
`raft --profile 'main'"'"'; touch /tmp/pwn; echo '"'"'' message check`,
);
expect(input?.rawText).not.toContain("wake-1");
} finally {
controller.abort();
await start;
}
});
expect(processRuntimeMocks.killProcessTree).toHaveBeenCalledOnce();
expect(processRuntimeMocks.killProcessTree).toHaveBeenCalledWith(bridge.pid, {
graceMs: 5_000,
@ -430,105 +359,14 @@ describe("Raft wake gateway", () => {
});
});
it("returns the Raft bridge runtime session for accepted wakes", async () => {
const { ctx, controller, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, {
spawnBridge: bridge.spawn,
wakeDedupe,
});
void start.catch(bridge.started.reject);
try {
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
const response = await fetch(wakeEndpoint, {
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-runtime-session" }),
});
expect(response).toMatchObject({ status: 202 });
await expect(response.json()).resolves.toMatchObject({
accepted: true,
ok: true,
runtimeSession: expect.any(String),
});
} finally {
controller.abort();
await start;
}
});
it("rejects oversized payloads before queueing a wake", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, {
spawnBridge: bridge.spawn,
wakeDedupe,
});
void start.catch(bridge.started.reject);
try {
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
await expect(
fetch(wakeEndpoint, {
method: "POST",
headers: {
"content-type": "application/json",
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ event: "wake", padding: "x".repeat(17 * 1024) }),
}),
).resolves.toMatchObject({ status: 413 });
expect(run).not.toHaveBeenCalled();
} finally {
controller.abort();
await start;
}
});
it("keeps a failed delivery eligible for a bridge retry", async () => {
const { ctx, controller, run, wakeDedupe } = createContext();
Object.defineProperty(ctx, "abortSignal", { value: controller.signal });
const bridge = new FakeBridge();
const start = startRaftGatewayAccount(ctx, {
spawnBridge: bridge.spawn,
wakeDedupe,
const fixture = createContext();
await withGateway(fixture, async ({ post }) => {
fixture.run.mockRejectedValueOnce(new Error("inbound runtime unavailable"));
await expect(post({ eventId: "wake-retry" })).resolves.toMatchObject({ status: 500 });
await expect(post({ eventId: "wake-retry" })).resolves.toMatchObject({ status: 202 });
expect(fixture.run).toHaveBeenCalledTimes(2);
});
void start.catch(bridge.started.reject);
try {
const { endpoint: wakeEndpoint, token: bridgeToken } = await withTimeout(
bridge.started.promise,
500,
"Raft bridge startup",
);
run.mockRejectedValueOnce(new Error("inbound runtime unavailable"));
const request = () => ({
method: "POST",
headers: {
"x-raft-bridge-token": bridgeToken,
},
body: JSON.stringify({ eventId: "wake-retry" }),
});
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 500 });
await expect(fetch(wakeEndpoint, request())).resolves.toMatchObject({ status: 202 });
expect(run).toHaveBeenCalledTimes(2);
} finally {
controller.abort();
await start;
}
});
it("persists accepted wake dedupe across restarts without crossing accounts", async () => {
@ -537,92 +375,17 @@ describe("Raft wake gateway", () => {
prefix: "openclaw-raft-wake-dedupe-",
});
tempWorkspaces.push(workspace);
const stateDir = workspace.dir;
try {
const first = createContext();
Object.defineProperty(first.ctx, "abortSignal", { value: first.controller.signal });
const firstBridge = new FakeBridge();
const firstStart = startRaftGatewayAccount(first.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: firstBridge.spawn,
for (const [accountId, expectedCalls] of [
["default", 1],
["default", 0],
["other", 1],
] as const) {
const fixture = createContext(accountId);
fixture.wakeDedupe = createPersistentWakeDedupe(workspace.dir);
await withGateway(fixture, async ({ post }) => {
await expect(post({ eventId: "wake-persisted" })).resolves.toMatchObject({ status: 202 });
expect(fixture.run).toHaveBeenCalledTimes(expectedCalls);
});
void firstStart.catch(firstBridge.started.reject);
try {
const { endpoint, token } = await withTimeout(
firstBridge.started.promise,
500,
"Raft bridge startup",
);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(first.run).toHaveBeenCalledTimes(1);
} finally {
first.controller.abort();
await firstStart;
}
const replay = createContext();
Object.defineProperty(replay.ctx, "abortSignal", { value: replay.controller.signal });
const replayBridge = new FakeBridge();
const replayStart = startRaftGatewayAccount(replay.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: replayBridge.spawn,
});
void replayStart.catch(replayBridge.started.reject);
try {
const { endpoint, token } = await withTimeout(
replayBridge.started.promise,
500,
"Raft bridge startup",
);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(replay.run).not.toHaveBeenCalled();
} finally {
replay.controller.abort();
await replayStart;
}
const otherAccount = createContext("other");
Object.defineProperty(otherAccount.ctx, "abortSignal", {
value: otherAccount.controller.signal,
});
const otherBridge = new FakeBridge();
const otherStart = startRaftGatewayAccount(otherAccount.ctx, {
wakeDedupe: createPersistentWakeDedupe(stateDir),
spawnBridge: otherBridge.spawn,
});
void otherStart.catch(otherBridge.started.reject);
try {
const { endpoint, token } = await withTimeout(
otherBridge.started.promise,
500,
"Raft bridge startup",
);
await expect(
fetch(endpoint, {
method: "POST",
headers: { "x-raft-bridge-token": token },
body: JSON.stringify({ eventId: "wake-persisted" }),
}),
).resolves.toMatchObject({ status: 202 });
expect(otherAccount.run).toHaveBeenCalledTimes(1);
} finally {
otherAccount.controller.abort();
await otherStart;
}
} finally {
resetPluginStateStoreForTests();
}
});
});

View file

@ -1,4 +1,3 @@
// Talk Voice tests cover index plugin behavior.
import type { OpenClawPluginCommandDefinition } from "openclaw/plugin-sdk/core";
import { createDeferred } from "openclaw/plugin-sdk/extension-shared";
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
@ -16,7 +15,6 @@ function createHarness(initialConfig: Record<string, unknown>) {
const runtime = {
config: {
current: vi.fn(() => config),
loadConfig: vi.fn(() => config),
mutateConfigFile: vi.fn(
async ({
mutate,
@ -41,10 +39,6 @@ function createHarness(initialConfig: Record<string, unknown>) {
};
},
),
replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
config = nextConfig;
}),
writeConfigFile: vi.fn().mockResolvedValue(undefined),
},
tts: {
listVoices: vi.fn(),
@ -60,15 +54,16 @@ function createHarness(initialConfig: Record<string, unknown>) {
}),
};
register.register(api as never);
if (!command) {
throw new Error("talk-voice command not registered");
}
if (!tool) {
throw new Error("talk_voice tool not registered");
if (!command || !tool) {
throw new Error("talk-voice command or tool not registered");
}
return { command, tool, runtime };
}
function talkConfig(provider: string, config: Record<string, unknown> = {}) {
return { talk: { provider, providers: { [provider]: config } } };
}
function createCommandContext(
args: string,
channel = "discord",
@ -98,7 +93,7 @@ describe("talk-voice plugin", () => {
it.each([false, true])(
"rechecks owner authority after voice lookup (gateway admin: %s)",
async (gatewayAdmin) => {
const initialConfig = { talk: { provider: "microsoft", providers: { microsoft: {} } } };
const initialConfig = talkConfig("microsoft");
const { command, runtime } = createHarness(initialConfig);
let current = true;
const ctx = {
@ -106,7 +101,7 @@ describe("talk-voice plugin", () => {
"set Ava",
"discord",
gatewayAdmin ? ["operator.admin"] : undefined,
true,
gatewayAdmin ? undefined : true,
),
assertOwnerCurrent: () => {
if (!current) {
@ -124,7 +119,12 @@ describe("talk-voice plugin", () => {
await expect(pending).resolves.toMatchObject({
text: expect.stringContaining("Talk voice set to Ava"),
});
expect(runtime.config.current()).not.toEqual(initialConfig);
expect(runtime.config.current()).toStrictEqual({
talk: {
provider: "microsoft",
providers: { microsoft: { voiceId: "en-US-AvaNeural" } },
},
});
} else {
await expect(pending).rejects.toThrow("original owner revoked");
expect(runtime.config.current()).toEqual(initialConfig);
@ -197,17 +197,8 @@ describe("talk-voice plugin", () => {
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
function createElevenlabsVoiceSetHarness(channel = "webchat", scopes?: string[]) {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
function createElevenlabsVoiceSetHarness(channel: string, scopes?: string[]) {
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
return {
runtime,
@ -216,17 +207,9 @@ describe("talk-voice plugin", () => {
}
it("reports active provider status", async () => {
const { command } = createHarness({
talk: {
provider: "microsoft",
providers: {
microsoft: {
voiceId: "en-US-AvaNeural",
apiKey: "secret-token",
},
},
},
});
const { command } = createHarness(
talkConfig("microsoft", { voiceId: "en-US-AvaNeural", apiKey: "secret-token" }),
);
const result = await command.handler(createCommandContext(""));
@ -239,24 +222,12 @@ describe("talk-voice plugin", () => {
});
});
it("exposes owner status for mutating voice commands", () => {
const { command } = createHarness({});
expect(command.exposeSenderIsOwner).toBe(true);
});
it("lists voices from the active provider", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
baseUrl: "https://voices.example.test",
},
},
},
const config = talkConfig("elevenlabs", {
apiKey: "sk-eleven",
baseUrl: "https://voices.example.test",
});
const { command, runtime } = createHarness(config);
vi.mocked(runtime.tts.listVoices).mockResolvedValue([
{ id: "voice-a", name: "Claudia", category: "general" },
{ id: "voice-b", name: "Bert" },
@ -266,17 +237,7 @@ describe("talk-voice plugin", () => {
expect(runtime.tts.listVoices).toHaveBeenCalledWith({
provider: "elevenlabs",
cfg: {
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
baseUrl: "https://voices.example.test",
},
},
},
},
cfg: config,
apiKey: "sk-eleven",
baseUrl: "https://voices.example.test",
});
@ -290,16 +251,7 @@ describe("talk-voice plugin", () => {
});
it("does not coerce partial voice list limits", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
vi.mocked(runtime.tts.listVoices).mockResolvedValue(
Array.from({ length: 13 }, (_, index) => ({
id: `voice-${index}`,
@ -313,14 +265,7 @@ describe("talk-voice plugin", () => {
});
it("surfaces richer provider voice metadata when available", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "microsoft",
providers: {
microsoft: {},
},
},
});
const { command, runtime } = createHarness(talkConfig("microsoft"));
vi.mocked(runtime.tts.listVoices).mockResolvedValue([
{
id: "en-US-AvaNeural",
@ -346,16 +291,7 @@ describe("talk-voice plugin", () => {
});
it("writes only canonical provider-scoped voice config for elevenlabs", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const result = await command.handler(
@ -367,8 +303,7 @@ describe("talk-voice plugin", () => {
writeOptions: { assertCurrent: undefined },
mutate: expect.any(Function),
});
const updatedConfig = runtime.config.current() as { talk: Record<string, unknown> };
expect(updatedConfig).toEqual({
expect(runtime.config.current()).toStrictEqual({
talk: {
provider: "elevenlabs",
providers: {
@ -379,96 +314,24 @@ describe("talk-voice plugin", () => {
},
},
});
expect(Object.hasOwn(updatedConfig.talk, "voiceId")).toBe(false);
expect(result).toEqual({
text: "✅ ElevenLabs Talk voice set to Claudia\nvoice-a",
});
});
it("writes provider voice id without legacy top-level field for microsoft", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "microsoft",
providers: {
microsoft: {},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "en-US-AvaNeural", name: "Ava" }]);
await command.handler(createCommandContext("set Ava", "webchat", ["operator.admin"]));
expect(runtime.config.mutateConfigFile).toHaveBeenCalledWith({
afterWrite: { mode: "auto" },
writeOptions: { assertCurrent: undefined },
mutate: expect.any(Function),
});
expect(runtime.config.current()).toEqual({
talk: {
provider: "microsoft",
providers: {
microsoft: {
voiceId: "en-US-AvaNeural",
},
},
},
});
});
it("rejects /voice set from gateway client with only operator.write scope", async () => {
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.write"]);
it.each([
{ channel: "telegram", scopes: ["operator.write"] },
{ channel: "discord", scopes: undefined },
])("rejects unauthorized voice writes on $channel", async ({ channel, scopes }) => {
const { runtime, run } = createElevenlabsVoiceSetHarness(channel, scopes);
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
it("rejects /voice set from non-webchat gateway callers missing operator.admin", async () => {
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.write"]);
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
it("allows /voice set from gateway client with operator.admin scope", async () => {
const { runtime, run } = createElevenlabsVoiceSetHarness("webchat", ["operator.admin"]);
const result = await run();
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
expect(result.text).toContain("voice-a");
});
it("rejects /voice set from webchat channel with no scopes (TUI/internal)", async () => {
const { runtime, run } = createElevenlabsVoiceSetHarness();
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
it.each(["telegram", "discord"])(
"rejects /voice set from %s channel without operator.admin",
async (channel) => {
const { runtime, run } = createElevenlabsVoiceSetHarness(channel);
const result = await run();
expect(result.text).toContain("requires operator.admin");
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
},
);
it("keeps read-only voice commands available without operator.admin", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const status = await command.handler(createCommandContext("status", "telegram"));
@ -479,25 +342,9 @@ describe("talk-voice plugin", () => {
expect(runtime.config.mutateConfigFile).not.toHaveBeenCalled();
});
it("allows /voice set when operator.admin is present on a non-webchat channel", async () => {
const { runtime, run } = createElevenlabsVoiceSetHarness("telegram", ["operator.admin"]);
const result = await run();
expect(runtime.config.mutateConfigFile).toHaveBeenCalled();
expect(result.text).toContain("voice-a");
});
it("allows /voice set from an owner non-gateway channel without scopes", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "elevenlabs",
providers: {
elevenlabs: {
apiKey: "sk-eleven",
},
},
},
});
const { command, runtime } = createHarness(talkConfig("elevenlabs", { apiKey: "sk-eleven" }));
expect(command.exposeSenderIsOwner).toBe(true);
vi.mocked(runtime.tts.listVoices).mockResolvedValue([{ id: "voice-a", name: "Claudia" }]);
const result = await command.handler(
@ -509,14 +356,7 @@ describe("talk-voice plugin", () => {
});
it("returns provider lookup errors cleanly", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "microsoft",
providers: {
microsoft: {},
},
},
});
const { command, runtime } = createHarness(talkConfig("microsoft"));
vi.mocked(runtime.tts.listVoices).mockRejectedValue(
new Error("speech provider microsoft does not support voice listing"),
);

View file

@ -1,6 +1,5 @@
import { once } from "node:events";
import { createServer } from "node:http";
import type { RemoteEmbeddingClient } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runVoyageEmbeddingBatches } from "./embedding-batch.js";
import { createVoyageEmbeddingProvider } from "./embedding-provider.js";
@ -8,14 +7,6 @@ import { createVoyageEmbeddingProvider } from "./embedding-provider.js";
type VoyageBatchOptions = Parameters<typeof runVoyageEmbeddingBatches>[0];
type BatchStage = "upload" | "create" | "status" | "output" | "error";
function buildClient(): RemoteEmbeddingClient {
return {
baseUrl: "https://api.voyageai.test/v1",
headers: { authorization: "Bearer fixture-voyage" },
model: "voyage-3",
};
}
function resolveBatchStage(url: string, init?: RequestInit): BatchStage {
if (url.endsWith("/files") && init?.method === "POST") {
return "upload";
@ -51,13 +42,7 @@ function defaultBatchResponse(stage: BatchStage): Response {
}),
);
case "error":
return new Response(
JSON.stringify({
custom_id: "req-0",
response: { status_code: 500, message: "provider rejected request" },
error: null,
}),
);
break;
}
throw new Error("unexpected Voyage batch stage");
}
@ -80,7 +65,11 @@ function stubBatchFetch(
function runBatch(overrides: Partial<VoyageBatchOptions> = {}) {
return runVoyageEmbeddingBatches({
client: buildClient(),
client: {
baseUrl: "https://api.voyageai.test/v1",
headers: { authorization: "Bearer fixture-voyage" },
model: "voyage-3",
},
agentId: "main",
requests: [{ custom_id: "req-0", body: { input: "hello" } }],
wait: true,
@ -129,15 +118,11 @@ afterEach(() => {
describe("voyage batch bounded reads", () => {
it.each([
{ operation: "single", inputType: "query", expectedInputs: [["first"]] },
{ operation: "single", inputType: "document", expectedInputs: [["first"]] },
{ operation: "single", inputType: undefined, expectedInputs: [["first"]] },
{ operation: "batch", inputType: "query", expectedInputs: [["first"], ["second"]] },
{ operation: "batch", inputType: "document", expectedInputs: [["first", "second"]] },
{ operation: "batch", inputType: undefined, expectedInputs: [["first", "second"]] },
{ inputType: "query", expectedInputs: [["first"], ["second"]] },
{ inputType: undefined, expectedInputs: [["first", "second"]] },
] as const)(
"preserves real $operation $inputType requests, grouping, and configured query parameters",
async ({ operation, inputType, expectedInputs }) => {
"preserves real $inputType requests, grouping, and configured query parameters",
async ({ inputType, expectedInputs }) => {
const received: Array<{
url: string;
authorization: string | undefined;
@ -184,16 +169,12 @@ describe("voyage batch bounded reads", () => {
},
});
expect(provider.maxInputTokens).toBe(32000);
if (operation === "single") {
await expect(provider.embed({ text: "first" }, { inputType })).resolves.toEqual([7, 11]);
} else {
await expect(
provider.embedBatch([{ text: "first" }, "second"], { inputType }),
).resolves.toEqual([
[7, 11],
[13, 17],
]);
}
await expect(
provider.embedBatch([{ text: "first" }, "second"], { inputType }),
).resolves.toEqual([
[7, 11],
[13, 17],
]);
expect(received).toHaveLength(expectedInputs.length);
expect(received).toEqual(
expect.arrayContaining(
@ -285,10 +266,10 @@ describe("voyage batch bounded reads", () => {
}
});
it("clamps polling to the remaining batch timeout", async () => {
it("clamps polling to the remaining timeout and stops before fetching expired status", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
stubBatchFetch();
const fetchMock = stubBatchFetch();
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const nowSpy = vi.spyOn(Date, "now");
const result = runBatch({
@ -312,16 +293,6 @@ describe("voyage batch bounded reads", () => {
nowSpy.mockReturnValue(1_000);
await vi.advanceTimersByTimeAsync(500);
await rejection;
});
it("does not poll status after the batch timeout expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const fetchMock = stubBatchFetch();
const result = runBatch({ pollIntervalMs: 1_000, timeoutMs: 1_000 });
const rejection = expect(result).rejects.toThrow("voyage batch batch-0 timed out after 1000ms");
await vi.advanceTimersByTimeAsync(1_000);
await rejection;
expect(
fetchMock.mock.calls.some(([url]) => fetchInputUrl(url).endsWith("/batches/batch-0")),
).toBe(false);
@ -377,52 +348,6 @@ describe("voyage batch bounded reads", () => {
).toHaveLength(1);
});
it("uses the shared output reader and stops after the expected result", async () => {
let canceled = false;
const encoder = new TextEncoder();
const output = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(
encoder.encode(
`${JSON.stringify({
custom_id: "req-0",
response: { status_code: 200, body: { data: [{ embedding: [1, 2] }] } },
})}\n`,
),
);
},
cancel() {
canceled = true;
},
}),
);
stubBatchFetch((stage) => (stage === "output" ? output : undefined));
await expect(runBatch()).resolves.toEqual(new Map([["req-0", [1, 2]]]));
expect(canceled).toBe(true);
});
it("reads a completed error file before downloading successful output", async () => {
const fetchMock = stubBatchFetch((stage) =>
stage === "status"
? Response.json({
id: "batch-0",
status: "completed",
output_file_id: "output-0",
error_file_id: "error-0",
})
: undefined,
);
await expect(runBatch()).rejects.toThrow(
"voyage batch batch-0 completed: provider rejected request",
);
expect(
fetchMock.mock.calls.some(([url]) => fetchInputUrl(url).includes("/files/output-0/")),
).toBe(false);
});
it("preserves authentication and batch request details on the real fetch boundary", async () => {
const fetchMock = stubBatchFetch();
@ -445,26 +370,4 @@ describe("voyage batch bounded reads", () => {
);
expect(status?.[1]?.signal).toBeInstanceOf(AbortSignal);
});
it("retries transient batch creation failures through the shared HTTP policy", async () => {
let attempts = 0;
stubBatchFetch((stage) => {
if (stage !== "create" || ++attempts > 1) {
return undefined;
}
return Response.json({ error: { message: "retry this request" } }, { status: 503 });
});
await expect(runBatch()).resolves.toEqual(new Map([["req-0", [1, 2]]]));
expect(attempts).toBe(2);
});
it("does not poll or download when waiting is disabled", async () => {
const fetchMock = stubBatchFetch();
await expect(runBatch({ wait: false })).rejects.toThrow(
"voyage batch batch-0 submitted; enable remote.batch.wait to await completion",
);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});

View file

@ -7,19 +7,12 @@ import {
describe("Control UI focus locations", () => {
it.each([
["dashboard main", "/focus/dashboard/roboclaw", undefined, "/dashboard/roboclaw"],
[
"dashboard short reference",
"/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
undefined,
"/dashboard/roboclaw/the-daily-claw-6d7c9ccb",
],
[
"dashboard literal key",
"/focus/dashboard/roboclaw/~key/12345678",
undefined,
"/dashboard/roboclaw/~key/12345678",
],
[
"base-path dashboard",
"/openclaw/focus/dashboard/roboclaw/the-daily-claw-6d7c9ccb/",
@ -49,29 +42,11 @@ describe("Control UI focus locations", () => {
selector: { kind: "source", value: "environment:Mac Studio/QA & demo" },
},
],
[
"desktop session",
"/focus/desktop/session/agent%3Amain%3Amobile%20session",
{
kind: "desktop",
control: false,
selector: { kind: "session", value: "agent:main:mobile session" },
},
],
[
"controlled desktop",
"/focus/desktop/control",
{ kind: "desktop", control: true, selector: null },
],
[
"controlled source",
"/focus/desktop/control/source/node%3Aworker-1",
{
kind: "desktop",
control: true,
selector: { kind: "source", value: "node:worker-1" },
},
],
[
"controlled session",
"/focus/desktop/control/session/agent%3Amain%3Amobile",
@ -91,12 +66,9 @@ describe("Control UI focus locations", () => {
it.each([
"/focus",
"/focus/unknown",
"/focus/terminal/extra",
"/focus/desktop/source",
"/focus/desktop/session/%",
"/focus/desktop/control/unknown/value",
"/focus/dashboard",
])("rejects malformed or unsupported target %s", (pathname) => {
expect(parseControlUiFocusLocation(pathname, "")).toEqual({
status: "unsupported",
@ -104,16 +76,12 @@ describe("Control UI focus locations", () => {
});
});
it.each([
"/?view=dashboard&session=agent%3Amain%3Awork",
"/?view=terminal",
"/?view=desktop",
"/terminal",
"/desktop",
"/focused/terminal",
])("does not parse query aliases or lookalike location %s", (pathname) => {
expect(parseControlUiFocusLocation(pathname, "")).toBeNull();
});
it.each(["/?view=dashboard&session=agent%3Amain%3Awork", "/focused/terminal"])(
"does not parse query aliases or lookalike location %s",
(pathname) => {
expect(parseControlUiFocusLocation(pathname, "")).toBeNull();
},
);
it("infers focus-aware base paths without overriding an explicit base", () => {
expect(inferControlUiFocusBasePath("/focus/terminal")).toBe("");
@ -184,12 +152,6 @@ describe("buildControlUiFocusPath", () => {
"",
"/focus/desktop/control/source/node%3Aworker-1",
],
[
"controlled session",
{ kind: "desktop", control: true, session: "agent:main:mobile" },
"",
"/focus/desktop/control/session/agent%3Amain%3Amobile",
],
[
"empty values",
{ kind: "desktop", source: " ", session: "" },

View file

@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import {
buildControlUiCatalogSessionUrl,
buildControlUiSessionPath,
controlUiSessionSlug,
} from "./index.js";
import { buildControlUiCatalogSessionUrl, buildControlUiSessionPath } from "./index.js";
import { buildControlUiCatalogSharePath } from "./share-build.js";
const SHARE_ROUTE = {
@ -22,32 +18,23 @@ type ChatParams = Omit<Parameters<typeof buildControlUiSessionPath>[0], "namespa
const UUID_KEY = "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef";
const buildChatPath = (params: ChatParams) =>
buildControlUiSessionPath({ namespace: "chat", ...params });
const THREAD_ID = "0123456789abcdef0123456789abcdef";
const buildSharePath = (
params: Omit<Parameters<typeof buildControlUiCatalogSharePath>[0], "shareRoute" | "threadId">,
) => buildControlUiCatalogSharePath({ shareRoute: SHARE_ROUTE, threadId: THREAD_ID, ...params });
describe("buildControlUiCatalogSessionUrl", () => {
it.each([
{
label: "root base path",
agentId: "main",
basePath: undefined,
expected: "/chat/main?catalog=beam&host=gateway&thread=beam-1",
},
{
label: "nested base path and non-main agent",
agentId: "research",
basePath: "/admin/openclaw/",
expected: "/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=beam-1",
},
])("builds a canonical URL for $label", ({ agentId, basePath, expected }) => {
it("builds a canonical URL under a nested base path for a non-main agent", () => {
expect(
buildControlUiCatalogSessionUrl({
namespace: "chat",
agentId,
basePath,
agentId: "research",
basePath: "/admin/openclaw/",
catalog: "beam",
host: "gateway",
thread: "beam-1",
}),
).toBe(expected);
).toBe("/admin/openclaw/chat/research?catalog=beam&host=gateway&thread=beam-1");
});
it("encodes reserved query characters", () => {
@ -84,80 +71,34 @@ describe("buildControlUiCatalogSessionUrl", () => {
describe("buildControlUiCatalogSharePath", () => {
it.each([
["Fix: upload flow!", "fix-upload-flow-"],
["Deploy face deadbeef", "deploy-"],
["🦞", ""],
["x".repeat(60), `${"x".repeat(48)}-`],
])("uses the session title slug for %s", (displayName, prefix) => {
expect(
buildControlUiCatalogSharePath({
shareRoute: SHARE_ROUTE,
threadId: "0123456789abcdef0123456789abcdef",
displayName,
}),
).toBe(`/beam/${prefix}0123456789ab`);
expect(buildSharePath({ displayName })).toBe(`/beam/${prefix}0123456789ab`);
});
it.each([
{
label: "root path",
basePath: undefined,
expected: "/beam/0123456789ab",
},
{
label: "nested base path",
basePath: "/admin/openclaw/",
expected: "/admin/openclaw/beam/0123456789ab",
},
])("builds a lowercase 12-character share id for $label", ({ basePath, expected }) => {
expect(
buildControlUiCatalogSharePath({
shareRoute: SHARE_ROUTE,
threadId: "0123456789abcdef0123456789abcdef",
basePath,
}),
).toBe(expected);
it("builds a lowercase 12-character share id under a nested base path", () => {
expect(buildSharePath({ basePath: "/admin/openclaw/" })).toBe(
"/admin/openclaw/beam/0123456789ab",
);
});
it("can retain the full id for an unambiguous fallback", () => {
expect(
buildControlUiCatalogSharePath({
shareRoute: SHARE_ROUTE,
threadId: "0123456789abcdef0123456789abcdef",
prefixLength: SHARE_ROUTE.fullLength,
}),
).toBe("/beam/0123456789abcdef0123456789abcdef");
expect(buildSharePath({ prefixLength: SHARE_ROUTE.fullLength })).toBe(
"/beam/0123456789abcdef0123456789abcdef",
);
});
it.each([
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "chat" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "focus" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "plugin" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "settings" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "Beam" },
threadId: "0123456789abcdef0123456789abcdef",
},
{
shareRoute: { ...SHARE_ROUTE, routeSegment: "beam/extra" },
threadId: "0123456789abcdef0123456789abcdef",
},
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ab" },
{ shareRoute: SHARE_ROUTE, threadId: "0123456789ABCDEF0123456789ABCDEF" },
{ shareRoute: SHARE_ROUTE, threadId: "not-hex" },
])("rejects invalid catalog share input %#", ({ shareRoute, threadId }) => {
expect(buildControlUiCatalogSharePath({ shareRoute, threadId })).toBeNull();
["chat", THREAD_ID],
["Beam", THREAD_ID],
["beam/extra", THREAD_ID],
["beam", "0123456789ab"],
["beam", "0123456789ABCDEF0123456789ABCDEF"],
])("rejects invalid catalog share input %#", (routeSegment, threadId) => {
expect(
buildControlUiCatalogSharePath({ shareRoute: { ...SHARE_ROUTE, routeSegment }, threadId }),
).toBeNull();
});
});
@ -185,7 +126,6 @@ describe("buildControlUiSessionPath", () => {
{ sessionKey: "agent:research:main", mainKey: "workspace" },
"/chat/research/main",
],
["global", { sessionKey: "global", fallbackAgentId: "ops" }, "/chat/ops"],
[
"global under a configured main key",
{ sessionKey: "global", fallbackAgentId: "ops", mainKey: "workspace", exactKey: true },
@ -246,11 +186,6 @@ describe("buildControlUiSessionPath", () => {
`/chat/main/${surface}/incognito-12345678-90ab-cdef-1234-567890abcdef`,
],
),
[
"UUID slug",
{ sessionKey: UUID_KEY, displayName: "Deploy Monitor" },
"/chat/main/deploy-monitor-12345678",
],
[
"reserved short ref",
{
@ -266,23 +201,19 @@ describe("buildControlUiSessionPath", () => {
},
);
it.each([
["agent:ops:telegram:12345", "/control/dashboard/ops/telegram/12345"],
["agent:research:global", "/control/dashboard/research/~key/global"],
])("preserves base paths and namespaces for %s", (sessionKey, expected) => {
it("preserves base paths and namespaces for qualified global keys", () => {
expect(
buildControlUiSessionPath({
namespace: "dashboard",
sessionKey,
sessionKey: "agent:research:global",
basePath: " /control/// ",
}),
).toBe(expected);
).toBe("/control/dashboard/research/~key/global");
});
it.each([
["OPS_TEAM", "ops_team"],
["Research Agent!", "research-agent"],
["..", "main"],
["Kelvin", "kelvin"],
["ſ", "main"],
])("normalizes fallback agent %j", (fallbackAgentId, expectedAgentId) => {
@ -302,15 +233,12 @@ describe("buildControlUiSessionPath", () => {
{ sessionKey: "agent::control-link" },
{ sessionKey: "agent:main:" },
{ sessionKey: "agent:main:telegram::12345" },
{ sessionKey: "agent:ops:room::part" },
{ sessionKey: "agent:ops::main" },
{ sessionKey: "agent:ops:cron:" },
] satisfies readonly ChatParams[])("rejects invalid input %#", (params) => {
expect(buildChatPath(params)).toBeNull();
});
it("removes trailing hex tokens from UUID display slugs", () => {
expect(controlUiSessionSlug("Deploy face deadbeef")).toBe("deploy");
expect(buildChatPath({ sessionKey: UUID_KEY, displayName: "Deploy face deadbeef" })).toBe(
"/chat/main/deploy-12345678",
);

View file

@ -6,45 +6,33 @@ import {
type ControlUiSessionPathTarget,
} from "./parse.js";
type ParseCase = {
name: string;
pathname: string;
expected: ControlUiSessionPathTarget;
basePath?: string;
};
type BuildCase = readonly [
Parameters<typeof buildControlUiSessionPath>[0],
ControlUiSessionPathTarget,
type ParseCase = readonly [
pathname: string,
expected: ControlUiSessionPathTarget,
basePath?: string,
];
describe("parseControlUiSessionPath", () => {
it.each([
{
name: "main",
pathname: "/chat/main",
expected: { namespace: "chat", kind: "main", agentId: "main" },
},
{
name: "base path",
pathname: "/control/dashboard/OPS-Team",
expected: { namespace: "dashboard", kind: "main", agentId: "ops-team" },
basePath: "/control",
},
{
name: "short ref",
pathname: "/dashboard/main/12345678",
expected: {
["/chat/main", { namespace: "chat", kind: "main", agentId: "main" }],
[
"/control/dashboard/OPS-Team",
{ namespace: "dashboard", kind: "main", agentId: "ops-team" },
"/control",
],
[
"/dashboard/main/12345678",
{
namespace: "dashboard",
kind: "short",
agentId: "main",
shortId: "12345678",
literalSessionKey: "agent:main:12345678",
},
},
{
name: "slugged short ref",
pathname: "/chat/wrong/wrong-slug-1234567890AB",
expected: {
],
[
"/chat/wrong/wrong-slug-1234567890AB",
{
namespace: "chat",
kind: "short",
agentId: "wrong",
@ -52,66 +40,40 @@ describe("parseControlUiSessionPath", () => {
literalSessionKey: "agent:wrong:wrong-slug-1234567890AB",
slugHint: "wrong-slug",
},
},
{
name: "literal",
pathname: "/chat/main/not-a-short-id",
expected: {
],
[
"/chat/main/not-a-short-id",
{
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:not-a-short-id",
slugCandidate: "not-a-short-id",
},
},
{
name: "multi-segment literal",
pathname: "/chat/ops/cron/nightly/run/8821",
expected: {
namespace: "chat",
kind: "literal",
agentId: "ops",
sessionKey: "agent:ops:cron:nightly:run:8821",
},
},
{
name: "forced literal",
pathname: "/chat/main/~key/release-deadbeef",
expected: {
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:release-deadbeef",
},
},
{
name: "dot escapes",
pathname: "/chat/main/cron/~dot/~dotdot/run",
expected: {
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:cron:.:..:run",
},
},
{
name: "tilde escape",
pathname: "/chat/main/channel/~~dot",
expected: {
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:channel:~dot",
},
},
] satisfies readonly ParseCase[])("parses $name", ({ pathname, expected, basePath }) => {
],
] satisfies readonly ParseCase[])("parses %s", (pathname, expected, basePath = "") => {
expect(parseControlUiSessionPath(pathname, basePath)).toEqual(expected);
});
it.each(["main", "global", "boot", "sessions"])("keeps reserved %s literal", (reserved) => {
expect(parseControlUiSessionPath(`/chat/main/${reserved}`)).toMatchObject({
it.each([
["/chat/main/~key/release-deadbeef", "agent:main:release-deadbeef"],
["/chat/main/cron/~dot/~dotdot/run", "agent:main:cron:.:..:run"],
["/chat/main/channel/~~dot", "agent:main:channel:~dot"],
])("decodes literal path %s", (pathname, sessionKey) => {
expect(parseControlUiSessionPath(pathname)).toEqual({
namespace: "chat",
kind: "literal",
sessionKey: `agent:main:${reserved}`,
agentId: "main",
sessionKey,
});
});
it("keeps reserved boot literal", () => {
expect(parseControlUiSessionPath("/chat/main/boot")).toEqual({
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:boot",
});
});
@ -131,8 +93,6 @@ describe("parseControlUiSessionPath", () => {
it.each([
["%C5%BF", "main"],
["%E2%84%AAelvin", "kelvin"],
["OPS-Team", "ops-team"],
["..%21", "main"],
])("normalizes URL agent %s", (encodedAgentId, agentId) => {
expect(parseControlUiSessionPath(`/chat/${encodedAgentId}`)).toMatchObject({ agentId });
});
@ -140,7 +100,6 @@ describe("parseControlUiSessionPath", () => {
it.each([
"/chat/%",
"/chat/main/%",
"/chat/main/~key/%",
"/chat/main/~key",
"/chat/main/telegram//12345",
"/other/main",
@ -148,171 +107,56 @@ describe("parseControlUiSessionPath", () => {
expect(parseControlUiSessionPath(pathname)).toBeNull();
});
it("round-trips main, literal, and slugged UUID paths", () => {
const cases: readonly BuildCase[] = [
[
{ namespace: "chat", sessionKey: "agent:research:workspace", mainKey: "workspace" },
{ namespace: "chat", kind: "main", agentId: "research" },
],
[
{ namespace: "dashboard", sessionKey: "agent:research:global", basePath: "/control" },
{
namespace: "dashboard",
kind: "literal",
agentId: "research",
sessionKey: "agent:research:global",
},
],
[
{ namespace: "chat", sessionKey: "agent:main:telegram:group:12345" },
{
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:telegram:group:12345",
},
],
[
{
namespace: "dashboard",
sessionKey: "agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
basePath: "/control",
displayName: "Deploy Monitor",
},
{
namespace: "dashboard",
kind: "short",
agentId: "main",
shortId: "12345678",
literalSessionKey: "agent:main:deploy-monitor-12345678",
slugHint: "deploy-monitor",
},
],
];
for (const [params, expected] of cases) {
const path = buildControlUiSessionPath(params);
expect(parseControlUiSessionPath(path ?? "", params.basePath, params.mainKey)).toEqual(
expected,
);
}
});
it.each([
["agent:main:main", "/chat/main", "main"],
["agent:research:global", "/chat/research/~key/global", "literal"],
["agent:main:standup", "/chat/main/standup", "literal"],
["agent:main:sessions", "/chat/main/~key/sessions", "literal"],
["agent:main:12345678", "/chat/main/~key/12345678", "literal"],
["agent:main:standup", "/chat/main/standup", "standup"],
["agent:main:sessions", "/chat/main/~key/sessions", undefined],
["agent:main:12345678", "/chat/main/~key/12345678", undefined],
[
"agent:main:12345678-90ab-cdef-1234-567890abcdef",
"/chat/main/~key/12345678-90ab-cdef-1234-567890abcdef",
"literal",
undefined,
],
[
"agent:main:dashboard:12345678-90ab-cdef-1234-567890abcdef",
"/chat/main/dashboard/12345678-90ab-cdef-1234-567890abcdef",
"literal",
undefined,
],
] as const)("round-trips exact key %s", (sessionKey, expectedPath, expectedKind) => {
] as const)("round-trips exact key %s", (sessionKey, expectedPath, slugCandidate) => {
const path = buildControlUiSessionPath({ namespace: "chat", sessionKey, exactKey: true });
expect(path).toBe(expectedPath);
const parsed = parseControlUiSessionPath(path ?? "");
expect(parsed?.kind).toBe(expectedKind);
if (parsed?.kind === "literal") {
expect(parsed.sessionKey).toBe(sessionKey);
}
expect(parseControlUiSessionPath(path ?? "")).toEqual({
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey,
slugCandidate,
});
});
it.each([
{
sessionKey: "agent:main:main",
agentId: "main",
expected: { namespace: "chat", kind: "main", agentId: "main" },
},
{
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
it("parses a tool-composed forced literal with multiple segments", () => {
expect(
parseControlUiSessionPath(
"/control/chat/roboclaw/~key/dashboard/2139bddb-3211-4641-b993-10f619f124e6",
"/control",
),
).toEqual({
namespace: "chat",
kind: "literal",
agentId: "roboclaw",
expected: {
namespace: "chat",
kind: "literal",
agentId: "roboclaw",
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
},
},
{
sessionKey: "agent:x:telegram:group:12345",
agentId: "x",
expected: {
namespace: "chat",
kind: "literal",
agentId: "x",
sessionKey: "agent:x:telegram:group:12345",
},
},
{
sessionKey: "agent:x:discord:direct:9",
agentId: "x",
expected: {
namespace: "chat",
kind: "literal",
agentId: "x",
sessionKey: "agent:x:discord:direct:9",
},
},
{
sessionKey: "agent:x:standup",
agentId: "x",
expected: {
namespace: "chat",
kind: "literal",
agentId: "x",
sessionKey: "agent:x:standup",
},
},
{
sessionKey: "agent:main:2139bddb-3211-4641-b993-10f619f124e6",
agentId: "main",
expected: {
namespace: "chat",
kind: "literal",
agentId: "main",
sessionKey: "agent:main:2139bddb-3211-4641-b993-10f619f124e6",
},
},
] satisfies ReadonlyArray<{
sessionKey: string;
agentId: string;
expected: ControlUiSessionPathTarget;
}>)("parses the tool-composed URL for $sessionKey", ({ sessionKey, agentId, expected }) => {
const base = "https://gateway.example/control";
const url =
sessionKey === "agent:main:main"
? `${base}/chat/main`
: `${base}/chat/${agentId}/~key/${sessionKey
.slice(`agent:${agentId}:`.length)
.replaceAll(":", "/")}`;
expect(parseControlUiSessionPath(new URL(url).pathname, "/control")).toEqual(expected);
sessionKey: "agent:roboclaw:dashboard:2139bddb-3211-4641-b993-10f619f124e6",
});
});
});
describe("matchControlUiCatalogSharePath", () => {
it.each([
["/beam/0123456789ab", undefined, "0123456789ab"],
["/beam/fix-upload-flow-0123456789ab", undefined, "0123456789ab"],
["/beam/old-title-0123456789ab", undefined, "0123456789ab"],
[
"/openclaw/beam/fix-upload-flow-0123456789abcdef0123456789abcdef",
"/openclaw",
"0123456789abcdef0123456789abcdef",
],
[
"/openclaw/beam/0123456789abcdef0123456789abcdef",
"/openclaw",
"0123456789abcdef0123456789abcdef",
],
] as const)("parses %s", (pathname, basePath, shortId) => {
expect(matchControlUiCatalogSharePath({ pathname, basePath })).toEqual({
routeSegment: "beam",
@ -320,37 +164,26 @@ describe("matchControlUiCatalogSharePath", () => {
});
});
it.each(["/beam/0123456789AB", "/beam/0123456789abcdef0123456789abcdef0", "/beam/nothexvaluezz"])(
"parses the route owner before descriptor validation for %s",
(pathname) => {
expect(matchControlUiCatalogSharePath({ pathname })).toEqual({
routeSegment: "beam",
shortId: pathname.slice("/beam/".length),
});
},
);
it.each([
"/chat/0123456789ab",
"/focus/0123456789ab",
"/plugin/0123456789ab",
"/settings/0123456789ab",
"/systems/0123456789ab",
"/ui/chat",
"/ui/config",
"/concepts/agent-workspace",
"/control/avatar/main",
"/beam/0123456789a",
"/beam/not-hex-value",
"/beam/0123456789ab/extra",
])("rejects ordinary, resource, and implausible share paths for %s", (pathname) => {
expect(matchControlUiCatalogSharePath({ pathname })).toBeNull();
it("parses the route owner before descriptor validation", () => {
expect(matchControlUiCatalogSharePath({ pathname: "/beam/nothexvaluezz" })).toEqual({
routeSegment: "beam",
shortId: "nothexvaluezz",
});
});
it.each(["/other/0123456789ab", "/beam/0123456789ab", "/wrong/openclaw/beam/0123456789ab"])(
"ignores unrelated or outside-base path %s",
it.each(["/chat/0123456789ab", "/ui/chat", "/beam/0123456789a", "/beam/0123456789ab/extra"])(
"rejects ordinary, resource, and implausible share paths for %s",
(pathname) => {
expect(matchControlUiCatalogSharePath({ pathname, basePath: "/openclaw" })).toBeNull();
expect(matchControlUiCatalogSharePath({ pathname })).toBeNull();
},
);
it("ignores paths outside the configured base", () => {
expect(
matchControlUiCatalogSharePath({
pathname: "/wrong/openclaw/beam/0123456789ab",
basePath: "/openclaw",
}),
).toBeNull();
});
});

View file

@ -1,4 +1,3 @@
// Memory host dreaming tests cover dreaming artifact persistence and lookup.
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@ -25,10 +24,7 @@ describe("memory dreaming host helpers", () => {
frequency: "0 */4 * * *",
timezone: "Europe/London",
model: " anthropic/claude-sonnet-4-6 ",
storage: {
mode: "both",
separateReports: true,
},
storage: { mode: "inline", separateReports: true },
phases: {
deep: {
limit: "5",
@ -48,14 +44,11 @@ describe("memory dreaming host helpers", () => {
expect(resolved.frequency).toBe("0 */4 * * *");
expect(resolved.timezone).toBe("Europe/London");
expect(resolved.execution.defaults.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.light.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.deep.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.phases.rem.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(resolved.storage).toEqual({
mode: "both",
separateReports: true,
});
expect(resolved.phases.deep.cron).toBe("0 */4 * * *");
for (const phase of Object.values(resolved.phases)) {
expect(phase.execution.model).toBe("anthropic/claude-sonnet-4-6");
expect(phase.cron).toBe("0 */4 * * *");
}
expect(resolved.storage).toEqual({ mode: "inline", separateReports: true });
expect(resolved.phases.deep.limit).toBe(5);
expect(resolved.phases.deep.minScore).toBe(0.9);
expect(resolved.phases.deep.minRecallCount).toBe(4);
@ -127,17 +120,9 @@ describe("memory dreaming host helpers", () => {
pluginConfig: {
dreaming: {
model: "anthropic/claude-haiku-4-5",
execution: {
defaults: {
model: "openai/gpt-5.4",
},
},
execution: { defaults: { model: "openai/gpt-5.4" } },
phases: {
rem: {
execution: {
model: "xai/grok-4.1-fast",
},
},
rem: { execution: { model: "xai/grok-4.1-fast" } },
},
},
},
@ -149,18 +134,10 @@ describe("memory dreaming host helpers", () => {
expect(resolved.phases.rem.execution.model).toBe("xai/grok-4.1-fast");
});
it("enables dreaming by default and falls back to cfg timezone and deep defaults", () => {
const cfg = {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
} as OpenClawConfig;
it("defaults to enabled dreaming with separate storage and the cfg timezone", () => {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: {},
cfg,
cfg: { agents: { defaults: { userTimezone: "America/Los_Angeles" } } },
});
expect(resolved.enabled).toBe(true);
@ -171,81 +148,18 @@ describe("memory dreaming host helpers", () => {
expect(resolved.phases.deep.minScore).toBe(0.75);
expect(resolved.phases.deep.recencyHalfLifeDays).toBe(14);
expect(resolved.phases.deep.maxAgeDays).toBe(30);
});
it("defaults storage mode to separate so phase blocks do not pollute daily memory files", () => {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: {},
});
expect(resolved.storage).toEqual({
mode: "separate",
separateReports: false,
});
});
it("preserves explicit inline storage mode for callers that opt in", () => {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: {
dreaming: {
storage: {
mode: "inline",
},
},
},
});
expect(resolved.storage.mode).toBe("inline");
});
it("applies top-level dreaming frequency across all phases", () => {
const resolved = resolveMemoryDreamingConfig({
pluginConfig: {
dreaming: {
enabled: true,
frequency: "15 */8 * * *",
},
},
});
expect(resolved.frequency).toBe("15 */8 * * *");
expect(resolved.phases.light.cron).toBe("15 */8 * * *");
expect(resolved.phases.deep.cron).toBe("15 */8 * * *");
expect(resolved.phases.rem.cron).toBe("15 */8 * * *");
});
it("dedupes shared workspaces across all configured agents", () => {
const cfg = {
agents: {
list: [
{ id: "alpha", default: true, workspace: "/workspace/shared" },
{ id: "beta", workspace: "/workspace/beta" },
{ id: "gamma", workspace: "/workspace/shared" },
],
},
} as OpenClawConfig;
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
{
workspaceDir: "/workspace/shared",
agentIds: ["alpha", "gamma"],
},
{
workspaceDir: "/workspace/beta",
agentIds: ["beta"],
},
]);
expect(resolved.storage).toEqual({ mode: "separate", separateReports: false });
});
it("uses canonical roster identities when agent aliases share a workspace", () => {
const cfg = {
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "Team Alpha", workspace: "/workspace/shared" },
{ id: "team-alpha", workspace: "/workspace/shared" },
],
},
} as OpenClawConfig;
};
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
{ workspaceDir: "/workspace/shared", agentIds: ["team-alpha"] },
@ -253,7 +167,7 @@ describe("memory dreaming host helpers", () => {
});
it("does not require a default owner when no primary workspace is supplied", () => {
const cfg = {
const cfg: OpenClawConfig = {
agents: {
ownership: "explicit",
list: [
@ -261,41 +175,38 @@ describe("memory dreaming host helpers", () => {
{ id: "beta", workspace: "/workspace/beta" },
],
},
} as OpenClawConfig;
};
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
{
workspaceDir: "/workspace/alpha",
agentIds: ["alpha"],
},
{
workspaceDir: "/workspace/beta",
agentIds: ["beta"],
},
{ workspaceDir: "/workspace/alpha", agentIds: ["alpha"] },
{ workspaceDir: "/workspace/beta", agentIds: ["beta"] },
]);
});
it("dedupes configured workspace symlink aliases across agents", async () => {
it("dedupes non-adjacent workspace symlink aliases across agents", async () => {
const rootDir = tempDirs.make("openclaw-dreaming-workspace-");
const workspaceDir = path.join(rootDir, "workspace");
const workspaceAliasDir = path.join(rootDir, "workspace-alias");
const otherWorkspaceDir = path.join(rootDir, "other-workspace");
await fs.mkdir(workspaceDir);
await fs.symlink(
workspaceDir,
workspaceAliasDir,
process.platform === "win32" ? "junction" : "dir",
);
const cfg = {
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "alpha", default: true, workspace: workspaceDir },
{ id: "gamma", workspace: otherWorkspaceDir },
{ id: "beta", workspace: workspaceAliasDir },
],
},
} as OpenClawConfig;
};
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
{ workspaceDir, agentIds: ["alpha", "beta"] },
{ workspaceDir: otherWorkspaceDir, agentIds: ["gamma"] },
]);
expect(resolveMemoryDreamingWorkspace(cfg, workspaceAliasDir)).toEqual({
workspaceDir,
@ -304,14 +215,14 @@ describe("memory dreaming host helpers", () => {
});
it("includes the runtime primary workspace alongside configured subagent workspaces", () => {
const cfg = {
const cfg: OpenClawConfig = {
agents: {
list: [
{ id: "agi-ceo", default: true, workspace: "/workspace/agi-ceo" },
{ id: "agi-cdo", workspace: "/workspace/agi-cdo" },
],
},
} as OpenClawConfig;
};
expect(
resolveMemoryDreamingWorkspaces(cfg, {
@ -319,36 +230,22 @@ describe("memory dreaming host helpers", () => {
primaryAgentId: "main",
}),
).toEqual([
{
workspaceDir: "/workspace/agi-ceo",
agentIds: ["agi-ceo"],
},
{
workspaceDir: "/workspace/agi-cdo",
agentIds: ["agi-cdo"],
},
{
workspaceDir: "/workspace/main",
agentIds: ["main"],
},
{ workspaceDir: "/workspace/agi-ceo", agentIds: ["agi-ceo"] },
{ workspaceDir: "/workspace/agi-cdo", agentIds: ["agi-cdo"] },
{ workspaceDir: "/workspace/main", agentIds: ["main"] },
]);
});
it("uses default agent fallback", () => {
const cfg = {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: "/workspace",
},
defaults: { workspace: "/workspace" },
entries: { main: { default: true } },
},
} as OpenClawConfig;
};
expect(resolveMemoryDreamingWorkspaces(cfg)).toEqual([
{
workspaceDir: "/workspace",
agentIds: ["main"],
},
{ workspaceDir: "/workspace", agentIds: ["main"] },
]);
});
@ -389,68 +286,19 @@ describe("memory dreaming host helpers", () => {
).toBe(false);
});
it("resolves the configured memory-slot plugin id", () => {
expect(
resolveMemoryDreamingPluginId({
plugins: {
slots: {
memory: "memos-local-openclaw-plugin",
},
},
} as OpenClawConfig),
).toBe("memos-local-openclaw-plugin");
});
it("reads dreaming config from the configured memory-slot owner", () => {
expect(
resolveMemoryDreamingPluginConfig({
plugins: {
slots: {
memory: "memos-local-openclaw-plugin",
},
entries: {
"memos-local-openclaw-plugin": {
config: {
dreaming: {
enabled: true,
},
},
},
},
},
} as OpenClawConfig),
).toEqual({
dreaming: {
enabled: true,
},
});
});
it("reads dreaming config from memory-lancedb when it owns the memory slot", () => {
expect(
resolveMemoryDreamingPluginConfig({
plugins: {
slots: {
memory: "memory-lancedb",
},
slots: { memory: "memory-lancedb" },
entries: {
"memory-lancedb": {
config: {
dreaming: {
enabled: true,
frequency: "0 */6 * * *",
},
},
config: { dreaming: { enabled: true, frequency: "0 */6 * * *" } },
},
},
},
} as OpenClawConfig),
).toEqual({
dreaming: {
enabled: true,
frequency: "0 */6 * * *",
},
});
}),
).toEqual({ dreaming: { enabled: true, frequency: "0 */6 * * *" } });
});
it("falls back to memory-core when no memory slot override is configured", () => {
@ -458,55 +306,29 @@ describe("memory dreaming host helpers", () => {
resolveMemoryDreamingPluginConfig({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
"memory-core": { config: { dreaming: { enabled: true } } },
},
},
} as OpenClawConfig),
).toEqual({
dreaming: {
enabled: true,
},
});
}),
).toEqual({ dreaming: { enabled: true } });
});
it('falls back to memory-core when memory slot is "none" or blank', () => {
expect(
resolveMemoryDreamingPluginId({
plugins: {
slots: {
memory: "none",
},
},
} as OpenClawConfig),
plugins: { slots: { memory: "none" } },
}),
).toBe("memory-core");
expect(
resolveMemoryDreamingPluginConfig({
plugins: {
slots: {
memory: " ",
},
slots: { memory: " " },
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
},
},
},
"memory-core": { config: { dreaming: { enabled: true } } },
},
},
} as OpenClawConfig),
).toEqual({
dreaming: {
enabled: true,
},
});
}),
).toEqual({ dreaming: { enabled: true } });
});
});