diff --git a/extensions/alibaba/video-generation-provider.test.ts b/extensions/alibaba/video-generation-provider.test.ts index 56e44a4717d4..1491ef68ad26 100644 --- a/extensions/alibaba/video-generation-provider.test.ts +++ b/extensions/alibaba/video-generation-provider.test.ts @@ -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(); - }); }); diff --git a/extensions/bonjour/index.test.ts b/extensions/bonjour/index.test.ts index 59d1e174ebdf..766235fe8e32 100644 --- a/extensions/bonjour/index.test.ts +++ b/extensions/bonjour/index.test.ts @@ -37,14 +37,7 @@ describe("bonjour plugin entry", () => { let discoveryService: | Parameters["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, }, diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index 82dbb4b676f4..d7eff4cebd2b 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -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, 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; serviceState?: string; stateRef?: { value: string }; - on?: ReturnType; listenerMap?: Map void>; responder?: Record; }) { 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) => { - 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) => ({ + 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[0], -): ReturnType => - startGatewayBonjourAdvertiser(opts, { - logger, - registerUncaughtExceptionHandler: (handler) => registerUncaughtExceptionHandler(handler), - registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler), - }); +const startAdvertiser = (opts: Partial[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]>; - 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)?.lanHost).toBe("test-host.local"); - expect((gatewayCall?.[0]?.txt as Record)?.gatewayPort).toBe("18789"); - expect((gatewayCall?.[0]?.txt as Record)?.gatewayDirectReachable).toBe("1"); - expect((gatewayCall?.[0]?.txt as Record)?.sshPort).toBe("2222"); - expect((gatewayCall?.[0]?.txt as Record)?.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)?.cliPath).toBe( - "/opt/homebrew/bin/openclaw", - ); - expect((gatewayCall?.[0]?.txt as Record)?.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 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)?.lanHost).toBe("Lobster.local"); - - await started.stop(); - }); }); diff --git a/extensions/bonjour/src/ciao.test.ts b/extensions/bonjour/src/ciao.test.ts index 6b5e15cad9f9..0d5722455cb6 100644 --- a/extensions/bonjour/src/ciao.test.ts +++ b/extensions/bonjour/src/ciao.test.ts @@ -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", () => { diff --git a/extensions/bonjour/src/errors.test.ts b/extensions/bonjour/src/errors.test.ts index a7a66c2c12cb..dcd248e82e54 100644 --- a/extensions/bonjour/src/errors.test.ts +++ b/extensions/bonjour/src/errors.test.ts @@ -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"; diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts index bfab0d7cb7f1..d409ec2caac4 100644 --- a/extensions/byteplus/video-generation-provider.test.ts +++ b/extensions/byteplus/video-generation-provider.test.ts @@ -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 = {}) { + 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; - 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({ 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; - 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: "sign in" }, - { 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 | undefined; - try { - await expect( - Promise.race([ - buildBytePlusVideoGenerationProvider().generateVideo({ - provider: "byteplus", - model: "seedance-1-0-pro-250528", - prompt: "captured invalid response", - cfg: {}, - }), - new Promise((_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); diff --git a/extensions/imap/src/prompt.test.ts b/extensions/imap/src/prompt.test.ts index 475396085fc6..67c277109673 100644 --- a/extensions/imap/src/prompt.test.ts +++ b/extensions/imap/src/prompt.test.ts @@ -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])|(? { - 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("�")).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"); }); diff --git a/extensions/imap/src/sender-gate.test.ts b/extensions/imap/src/sender-gate.test.ts index 46fa57eba3dd..5cad369503b5 100644 --- a/extensions/imap/src/sender-gate.test.ts +++ b/extensions/imap/src/sender-gate.test.ts @@ -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 = {}) { @@ -22,8 +20,8 @@ function account(overrides: Record = {}) { }).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" ', + 'From: "trusted@example.com" ', "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( diff --git a/extensions/imap/src/state.test.ts b/extensions/imap/src/state.test.ts index ca4e69f3ebe6..2795157c5d7b 100644 --- a/extensions/imap/src/state.test.ts +++ b/extensions/imap/src/state.test.ts @@ -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 }); diff --git a/extensions/imap/src/watcher.test.ts b/extensions/imap/src/watcher.test.ts index 5926c31f9706..5595df55226c 100644 --- a/extensions/imap/src/watcher.test.ts +++ b/extensions/imap/src/watcher.test.ts @@ -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 | undefined; private readonly server: Server; - constructor( - private readonly supportsIdle = true, - private readonly beforeGreeting?: () => Promise, - ) { + 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 { + 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; - beforeGreeting?: () => Promise; } = {}, ) { - 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" ? "

Email content

" : "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(); - const greeting = createDeferred(); - 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((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", diff --git a/extensions/kilocode/index.test.ts b/extensions/kilocode/index.test.ts index 0a390e2d923e..c41276d6f9c3 100644 --- a/extensions/kilocode/index.test.ts +++ b/extensions/kilocode/index.test.ts @@ -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> = []; - const baseStreamFn: StreamFn = (model, _context, options) => { - const payload: Record = { 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> = []; diff --git a/extensions/kilocode/onboard.test.ts b/extensions/kilocode/onboard.test.ts index 317696dff501..66978e1b2d01 100644 --- a/extensions/kilocode/onboard.test.ts +++ b/extensions/kilocode/onboard.test.ts @@ -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", }); }); }); diff --git a/extensions/kilocode/provider-models.test.ts b/extensions/kilocode/provider-models.test.ts index 2d84e8cc8a7b..e7f5ea93d4f7 100644 --- a/extensions/kilocode/provider-models.test.ts +++ b/extensions/kilocode/provider-models.test.ts @@ -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) & { - mock: { calls: unknown[][] }; -}; - function requireModelById( models: Awaited>, 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 = {}) { 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 = {}) { 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 = {}) { }); } -async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise) { +function stubResponse(response: Response) { const release = vi.fn(async () => {}); - - fetchWithSsrFGuardMock.mockReset(); - const callMockFetch = mockFetch as unknown as ( - url: string, - init?: RequestInit, - ) => Promise; - 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", - ); - }); }); }); diff --git a/extensions/raft/src/channel.test.ts b/extensions/raft/src/channel.test.ts index 436b97232402..8047a76cd094 100644 --- a/extensions/raft/src/channel.test.ts +++ b/extensions/raft/src/channel.test.ts @@ -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, diff --git a/extensions/raft/src/gateway.test.ts b/extensions/raft/src/gateway.test.ts index f05bf47143c5..3dde394969ef 100644 --- a/extensions/raft/src/gateway.test.ts +++ b/extensions/raft/src/gateway.test.ts @@ -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, - 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, + test: (connection: { + endpoint: string; + token: string; + post: (body?: unknown, authToken?: string) => Promise; + }) => Promise, +) { + 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(); const reached = createDeferred(); @@ -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((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(); } }); }); diff --git a/extensions/talk-voice/index.test.ts b/extensions/talk-voice/index.test.ts index 1d513bbbbf36..2d8cbfe11255 100644 --- a/extensions/talk-voice/index.test.ts +++ b/extensions/talk-voice/index.test.ts @@ -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) { const runtime = { config: { current: vi.fn(() => config), - loadConfig: vi.fn(() => config), mutateConfigFile: vi.fn( async ({ mutate, @@ -41,10 +39,6 @@ function createHarness(initialConfig: Record) { }; }, ), - replaceConfigFile: vi.fn(async ({ nextConfig }: { nextConfig: Record }) => { - config = nextConfig; - }), - writeConfigFile: vi.fn().mockResolvedValue(undefined), }, tts: { listVoices: vi.fn(), @@ -60,15 +54,16 @@ function createHarness(initialConfig: Record) { }), }; 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 = {}) { + 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 }; - 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"), ); diff --git a/extensions/voyage/embedding-batch.test.ts b/extensions/voyage/embedding-batch.test.ts index d0a80b8a136f..3a489969f6bd 100644 --- a/extensions/voyage/embedding-batch.test.ts +++ b/extensions/voyage/embedding-batch.test.ts @@ -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[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 = {}) { 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({ - 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); - }); }); diff --git a/packages/session-url-contract/src/focus.test.ts b/packages/session-url-contract/src/focus.test.ts index 0ec9d0cccc70..47b125914e1b 100644 --- a/packages/session-url-contract/src/focus.test.ts +++ b/packages/session-url-contract/src/focus.test.ts @@ -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: "" }, diff --git a/packages/session-url-contract/src/index.test.ts b/packages/session-url-contract/src/index.test.ts index 4b2ee7b02de5..e9867615bc74 100644 --- a/packages/session-url-contract/src/index.test.ts +++ b/packages/session-url-contract/src/index.test.ts @@ -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[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[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", ); diff --git a/packages/session-url-contract/src/parse.test.ts b/packages/session-url-contract/src/parse.test.ts index 9222b12a6997..6b85015a03e5 100644 --- a/packages/session-url-contract/src/parse.test.ts +++ b/packages/session-url-contract/src/parse.test.ts @@ -6,45 +6,33 @@ import { type ControlUiSessionPathTarget, } from "./parse.js"; -type ParseCase = { - name: string; - pathname: string; - expected: ControlUiSessionPathTarget; - basePath?: string; -}; -type BuildCase = readonly [ - Parameters[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(); + }); }); diff --git a/src/memory-host-sdk/dreaming.test.ts b/src/memory-host-sdk/dreaming.test.ts index e4b1bffa2d7e..cf180611572e 100644 --- a/src/memory-host-sdk/dreaming.test.ts +++ b/src/memory-host-sdk/dreaming.test.ts @@ -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 } }); }); });