diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 71908ebae71b..5d7a0594db22 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -16,6 +16,18 @@ function runCap(...argv: string[]): Promise { return runRegisteredCli({ register: registerCapabilityCli as (program: Command) => void, argv }); } +function runCapability(domain: string, action: string, ...argv: string[]): Promise { + return runCap("capability", domain, action, ...argv); +} + +function primeOpenAiAuthProfile(mode: "api-key" | "token" = "api-key"): void { + mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ + apiKey: mode === "token" ? "profile-openai-token" : "profile-openai-key", + source: mode === "token" ? "profile:openai:token" : "profile:openai:qa", + mode, + }); +} + type LocalAudioSelection = Awaited>; const closeEmbeddingProviderMock = vi.hoisted(() => vi.fn(async () => {})); @@ -539,10 +551,18 @@ describe("capability cli", () => { mocks.resolveMemorySearchConfig.mockReset().mockReturnValue(null); mocks.prepareSimpleCompletionModelForAgent.mockClear(); mocks.completeWithPreparedSimpleCompletionModel.mockClear(); - mocks.callGateway.mockClear().mockImplementation((async ({ method }: { method: string }) => { + mocks.callGateway.mockReset().mockImplementation((async ({ method }: { method: string }) => { if (method === "tts.status") { return { enabled: true, provider: "openai" }; } + if (method === "tts.convert") { + return { + audioPath: "/tmp/gateway-tts.mp3", + provider: "openai", + outputFormat: "mp3", + voiceCompatible: false, + }; + } if (method === "agent") { return { result: { @@ -598,33 +618,10 @@ describe("capability cli", () => { diagnostics: [], })); mocks.modelsStatusCommand.mockClear(); - mocks.callGateway.mockImplementation((async ({ method }: { method: string }) => { - if (method === "tts.status") { - return { enabled: true, provider: "openai" }; - } - if (method === "tts.convert") { - return { - audioPath: "/tmp/gateway-tts.mp3", - provider: "openai", - outputFormat: "mp3", - voiceCompatible: false, - }; - } - if (method === "agent") { - return { - result: { - payloads: [{ text: "gateway reply" }], - meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } }, - }, - }; - } - return {}; - }) as never); }); async function runModelRunWithModel(model: string, transport: "local" | "gateway") { - await runCap( - "capability", + await runCapability( "model", "run", "--model", @@ -841,7 +838,7 @@ describe("capability cli", () => { }); it("defaults model run to local transport", async () => { - await runCap("capability", "model", "run", "--prompt", "hello", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--json"); expect(mocks.prepareSimpleCompletionModelForAgent).toHaveBeenCalledTimes(1); expect(mocks.completeWithPreparedSimpleCompletionModel).toHaveBeenCalledTimes(1); @@ -851,7 +848,7 @@ describe("capability cli", () => { }); it("runs local model probes through the lean completion path", async () => { - await runCap("capability", "model", "run", "--prompt", "hello", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--json"); const preparedParams = firstPreparedModelParams(); expect(preparedParams?.agentId).toBe("main"); @@ -873,7 +870,7 @@ describe("capability cli", () => { }); it("does not enable bundled static catalog fallback without an explicit provider/model override", async () => { - await runCap("capability", "model", "run", "--prompt", "hello", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--json"); const calls = mocks.prepareSimpleCompletionModelForAgent.mock.calls as unknown as Array< [Record] @@ -889,16 +886,7 @@ describe("capability cli", () => { const tempInput = path.join(os.tmpdir(), `openclaw-model-run-image-${Date.now()}.png`); await fs.writeFile(tempInput, Buffer.from(PNG_1X1_BASE64, "base64")); - await runCap( - "capability", - "model", - "run", - "--prompt", - "describe this", - "--file", - tempInput, - "--json", - ); + await runCapability("model", "run", "--prompt", "describe this", "--file", tempInput, "--json"); const call = firstCompletionCall(); expect(call?.context?.messages?.[0]?.role).toBe("user"); @@ -933,16 +921,7 @@ describe("capability cli", () => { }, } as never); - await runCap( - "capability", - "model", - "run", - "--model", - "openai/gpt-5.5", - "--prompt", - "hello", - "--json", - ); + await runCapability("model", "run", "--model", "openai/gpt-5.5", "--prompt", "hello", "--json"); const call = firstCompletionCall(); expect(call?.context?.systemPrompt).toBe( @@ -953,7 +932,7 @@ describe("capability cli", () => { }); it("passes thinking overrides to local model probes", async () => { - await runCap("capability", "model", "run", "--prompt", "hello", "--thinking", "high", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--thinking", "high", "--json"); expect(firstCompletionCall()?.options?.reasoning).toBe("high"); }); @@ -962,8 +941,7 @@ describe("capability cli", () => { const tempInput = path.join(os.tmpdir(), `openclaw-model-run-gateway-image-${Date.now()}.png`); await fs.writeFile(tempInput, Buffer.from(PNG_1X1_BASE64, "base64")); - await runCap( - "capability", + await runCapability( "model", "run", "--prompt", @@ -993,16 +971,7 @@ describe("capability cli", () => { const tempInput = path.join(os.tmpdir(), `openclaw-model-run-image-${Date.now()}.heic`); await fs.writeFile(tempInput, Buffer.from("heic-like")); - await runCap( - "capability", - "model", - "run", - "--prompt", - "describe this", - "--file", - tempInput, - "--json", - ); + await runCapability("model", "run", "--prompt", "describe this", "--file", tempInput, "--json"); expect(mocks.convertHeicToJpeg).toHaveBeenCalledWith(Buffer.from("heic-like")); const call = firstCompletionCall(); @@ -1026,16 +995,7 @@ describe("capability cli", () => { await fs.writeFile(tempInput, Buffer.from("not really audio")); await expect( - runCap( - "capability", - "model", - "run", - "--prompt", - "transcribe this", - "--file", - tempInput, - "--json", - ), + runCapability("model", "run", "--prompt", "transcribe this", "--file", tempInput, "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Only image files are supported"); @@ -1048,9 +1008,9 @@ describe("capability cli", () => { content: [], } as never); - await expect( - runCap("capability", "model", "run", "--prompt", "hello", "--json"), - ).rejects.toThrow("exit 1"); + await expect(runCapability("model", "run", "--prompt", "hello", "--json")).rejects.toThrow( + "exit 1", + ); expectRuntimeErrorContains('No text output returned for provider "openai" model "gpt-5.4"'); expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); @@ -1063,9 +1023,9 @@ describe("capability cli", () => { errorMessage: '{"detail":"Instructions are required"}', } as never); - await expect( - runCap("capability", "model", "run", "--prompt", "hello", "--json"), - ).rejects.toThrow("exit 1"); + await expect(runCapability("model", "run", "--prompt", "hello", "--json")).rejects.toThrow( + "exit 1", + ); expectRuntimeErrorContains('{"detail":"Instructions are required"}'); expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); @@ -1091,16 +1051,7 @@ describe("capability cli", () => { } as never); await expect( - runCap( - "capability", - "model", - "run", - "--model", - "codex/gpt-5.4", - "--prompt", - "hello", - "--json", - ), + runCapability("model", "run", "--model", "codex/gpt-5.4", "--prompt", "hello", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Codex app-server agent runtime"); @@ -1111,9 +1062,9 @@ describe("capability cli", () => { it.each(["", " ", "\n\t"])( "rejects empty model run prompts before local dispatch (%j)", async (prompt) => { - await expect( - runCap("capability", "model", "run", "--prompt", prompt, "--json"), - ).rejects.toThrow("exit 1"); + await expect(runCapability("model", "run", "--prompt", prompt, "--json")).rejects.toThrow( + "exit 1", + ); expectRuntimeErrorContains("--prompt cannot be empty or whitespace-only."); expect(mocks.prepareSimpleCompletionModelForAgent).not.toHaveBeenCalled(); @@ -1148,7 +1099,7 @@ describe("capability cli", () => { ); it("runs gateway model probes in fresh raw sessions without chat-agent prompt policy or tools", async () => { - await runCap("capability", "model", "run", "--prompt", "hello", "--gateway", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--gateway", "--json"); const gatewayCall = firstGatewayCall(); const sessionId = gatewayCall?.params?.sessionId; @@ -1163,7 +1114,7 @@ describe("capability cli", () => { expect(gatewayCall?.params?.modelRun).toBe(true); expect(gatewayCall?.params?.promptMode).toBe("none"); - await runCap("capability", "model", "run", "--prompt", "again", "--gateway", "--json"); + await runCapability("model", "run", "--prompt", "again", "--gateway", "--json"); const gatewayCalls = mocks.callGateway.mock.calls as unknown as Array<[GatewayCall]>; const nextGatewayCall = gatewayCalls[1]?.[0]; @@ -1199,7 +1150,7 @@ describe("capability cli", () => { }, } as never); - await runCap("capability", "model", "run", "--prompt", "hello", "--gateway", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--gateway", "--json"); const payload = firstJsonOutput(); const attempts = payload?.attempts as Array>; @@ -1212,8 +1163,7 @@ describe("capability cli", () => { }); it("requests admin scope for gateway model probes with provider/model overrides", async () => { - await runCap( - "capability", + await runCapability( "model", "run", "--prompt", @@ -1282,8 +1232,7 @@ describe("capability cli", () => { }); it("passes thinking overrides to gateway model probes", async () => { - await runCap( - "capability", + await runCapability( "model", "run", "--prompt", @@ -1303,16 +1252,7 @@ describe("capability cli", () => { it("rejects invalid model run thinking overrides before dispatch", async () => { await expect( - runCap( - "capability", - "model", - "run", - "--prompt", - "hello", - "--thinking", - "turbo-mode", - "--json", - ), + runCapability("model", "run", "--prompt", "hello", "--thinking", "turbo-mode", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Invalid thinking level."); @@ -1324,7 +1264,7 @@ describe("capability cli", () => { it("rejects empty model run prompts before gateway dispatch", async () => { await expect( - runCap("capability", "model", "run", "--prompt", " ", "--gateway", "--json"), + runCapability("model", "run", "--prompt", " ", "--gateway", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("--prompt cannot be empty or whitespace-only."); @@ -1333,14 +1273,14 @@ describe("capability cli", () => { }); it("defaults tts status to gateway transport", async () => { - await runCap("capability", "tts", "status", "--json"); + await runCapability("tts", "status", "--json"); expect(firstGatewayCall()?.method).toBe("tts.status"); expect(firstJsonOutput()?.transport).toBe("gateway"); }); it("routes image describe through media understanding, not generation", async () => { - await runCap("capability", "image", "describe", "--file", "photo.jpg", "--json"); + await runCapability("image", "describe", "--file", "photo.jpg", "--json"); const describeCall = imageDescribeCall(); expect(path.basename(describeCall?.filePath ?? "")).toBe("photo.jpg"); @@ -1353,7 +1293,7 @@ describe("capability cli", () => { it("keeps encoded image describe HTTP URLs intact", async () => { const mediaUrl = "https://cdn.example.com/clip%2Emp4?download=1#preview"; - await runCap("capability", "image", "describe", "--file", mediaUrl, "--json"); + await runCapability("image", "describe", "--file", mediaUrl, "--json"); const describeCall = imageDescribeCall(); expect(describeCall).toMatchObject({ filePath: mediaUrl, mediaUrl }); @@ -1363,8 +1303,7 @@ describe("capability cli", () => { }); it("passes image describe prompts through media understanding", async () => { - await runCap( - "capability", + await runCapability( "image", "describe", "--file", @@ -1383,14 +1322,7 @@ describe("capability cli", () => { }); it("keeps image describe URL files as remote media references", async () => { - await runCap( - "capability", - "image", - "describe", - "--file", - "https://example.com/photo.png", - "--json", - ); + await runCapability("image", "describe", "--file", "https://example.com/photo.png", "--json"); const describeCall = imageDescribeCall(); expect(describeCall?.filePath).toBe("https://example.com/photo.png"); @@ -1400,8 +1332,7 @@ describe("capability cli", () => { }); it("uses the explicit media-understanding provider for image describe model overrides", async () => { - await runCap( - "capability", + await runCapability( "image", "describe", "--file", @@ -1428,8 +1359,7 @@ describe("capability cli", () => { }); it("keeps explicit-model image describe URL files as remote media references", async () => { - await runCap( - "capability", + await runCapability( "image", "describe", "--file", @@ -1448,8 +1378,7 @@ describe("capability cli", () => { }); it("keeps explicit-model image describe HTTP URLs as URLs", async () => { - await runCap( - "capability", + await runCapability( "image", "describe", "--file", @@ -1489,8 +1418,7 @@ describe("capability cli", () => { model: "google/gemma-4-31b-it", }); - await runCap( - "capability", + await runCapability( "image", "describe", "--file", @@ -1547,8 +1475,7 @@ describe("capability cli", () => { mocks.prepareImageDescriptionInput.mockRejectedValueOnce(new Error("image file not found")); await expect( - runCap( - "capability", + runCapability( "image", "describe", "--file", @@ -1565,8 +1492,7 @@ describe("capability cli", () => { }); it("passes describe-many prompts to each image", async () => { - await runCap( - "capability", + await runCapability( "image", "describe-many", "--file", @@ -1599,7 +1525,7 @@ describe("capability cli", () => { } as never); await expect( - runCap("capability", "image", "describe", "--file", "photo.jpg", "--json"), + runCapability("image", "describe", "--file", "photo.jpg", "--json"), ).rejects.toThrow("exit 1"); expect(runtimeErrorMessages()).toEqual([ `Error: No description returned for image: ${path.resolve("photo.jpg")}`, @@ -1651,8 +1577,7 @@ describe("capability cli", () => { await fs.rm(tempOutput, { force: true }); await fs.rm(tempOutput.replace(/\.png$/, ".jpg"), { force: true }); - await runCap( - "capability", + await runCapability( "image", "generate", "--prompt", @@ -1671,8 +1596,7 @@ describe("capability cli", () => { it("passes image generation timeout through to runtime", async () => { primeGeneratedImage("gpt-image-1", "provider-output.png"); - await runCap( - "capability", + await runCapability( "image", "generate", "--prompt", @@ -1689,8 +1613,7 @@ describe("capability cli", () => { it("passes image output format and generic background hints through to generation runtime", async () => { primeGeneratedImage("gpt-image-1.5", "transparent.png"); - await runCap( - "capability", + await runCapability( "image", "generate", "--prompt", @@ -1715,8 +1638,7 @@ describe("capability cli", () => { it("passes image quality and OpenAI moderation hints through to generation runtime", async () => { primeGeneratedImage("gpt-image-2", "draft.png"); - await runCap( - "capability", + await runCapability( "image", "generate", "--prompt", @@ -1743,8 +1665,7 @@ describe("capability cli", () => { const inputPath = path.join(os.tmpdir(), `openclaw-image-edit-${Date.now()}.png`); await fs.writeFile(inputPath, Buffer.from("png-input")); - await runCap( - "capability", + await runCapability( "image", "edit", "--file", @@ -1786,8 +1707,7 @@ describe("capability cli", () => { const inputPath = path.join(os.tmpdir(), `openclaw-image-edit-count-${Date.now()}.png`); await fs.writeFile(inputPath, Buffer.from("png-input")); - await runCap( - "capability", + await runCapability( "image", "edit", "--file", @@ -1804,8 +1724,7 @@ describe("capability cli", () => { it("rejects unsupported image output format and background hints", async () => { await expect( - runCap( - "capability", + runCapability( "image", "generate", "--prompt", @@ -1821,8 +1740,7 @@ describe("capability cli", () => { mocks.runtime.error.mockClear(); await expect( - runCap( - "capability", + runCapability( "image", "generate", "--prompt", @@ -1838,8 +1756,7 @@ describe("capability cli", () => { mocks.runtime.error.mockClear(); await expect( - runCap( - "capability", + runCapability( "image", "generate", "--prompt", @@ -1855,8 +1772,7 @@ describe("capability cli", () => { mocks.runtime.error.mockClear(); await expect( - runCap( - "capability", + runCapability( "image", "generate", "--prompt", @@ -1872,8 +1788,7 @@ describe("capability cli", () => { mocks.runtime.error.mockClear(); await expect( - runCap( - "capability", + runCapability( "image", "generate", "--prompt", @@ -1909,8 +1824,7 @@ describe("capability cli", () => { await fs.writeFile(tempInput, Buffer.from(pngBase64, "base64")); await fs.rm(tempOutput, { force: true }); - await runCap( - "capability", + await runCapability( "image", "edit", "--file", @@ -1943,7 +1857,7 @@ describe("capability cli", () => { }); it("reports the expanded image.edit flags in capability inspect", async () => { - await runCap("capability", "inspect", "--name", "image.edit", "--json"); + await runCapability("inspect", "--name", "image.edit", "--json"); expect(firstJsonOutput()?.id).toBe("image.edit"); expect(firstJsonOutput()?.flags).toEqual([ @@ -1966,7 +1880,7 @@ describe("capability cli", () => { }); it("reports the expanded image.generate flags in capability inspect", async () => { - await runCap("capability", "inspect", "--name", "image.generate", "--json"); + await runCapability("inspect", "--name", "image.generate", "--json"); expect(firstJsonOutput()?.id).toBe("image.generate"); expect(firstJsonOutput()?.flags).toEqual([ @@ -2032,8 +1946,7 @@ describe("capability cli", () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-video-generate-")); const outputBase = path.join(tempDir, "result"); - await runCap( - "capability", + await runCapability( "video", "generate", "--prompt", @@ -2072,7 +1985,7 @@ describe("capability cli", () => { vi.stubGlobal("fetch", fetchMock); await expect( - runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"), + runCapability("video", "generate", "--prompt", "friendly lobster", "--json"), ).rejects.toThrow("exit 1"); expect(fetchMock).not.toHaveBeenCalled(); @@ -2101,7 +2014,7 @@ describe("capability cli", () => { ); vi.stubGlobal("fetch", fetchMock); - await runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"); + await runCapability("video", "generate", "--prompt", "friendly lobster", "--json"); const fetchCalls = fetchMock.mock.calls as unknown as Array<[string]>; expect(fetchCalls[0]?.[0]).toBe("http://127.0.0.2:40123/private-video.mp4"); @@ -2125,8 +2038,7 @@ describe("capability cli", () => { ], }); - await runCap( - "capability", + await runCapability( "video", "generate", "--prompt", @@ -2169,7 +2081,7 @@ describe("capability cli", () => { }); await expect( - runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"), + runCapability("video", "generate", "--prompt", "friendly lobster", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Video asset at index 0 has neither buffer nor url"); }); @@ -2208,7 +2120,7 @@ describe("capability cli", () => { // No --output: forces the in-memory buffered fallback path. await expect( - runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"), + runCapability("video", "generate", "--prompt", "friendly lobster", "--json"), ).rejects.toThrow("exit 1"); // Real path was driven: the provider URL was actually fetched... @@ -2245,7 +2157,7 @@ describe("capability cli", () => { vi.stubGlobal("fetch", fetchMock); await expect( - runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"), + runCapability("video", "generate", "--prompt", "friendly lobster", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("vydra generated video download failed"); @@ -2267,7 +2179,7 @@ describe("capability cli", () => { vi.stubGlobal("fetch", fetchMock); // No --output: in-memory buffered fallback path, under cap. - await runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"); + await runCapability("video", "generate", "--prompt", "friendly lobster", "--json"); const fetchCalls = fetchMock.mock.calls as unknown as Array<[string]>; expect(fetchCalls[0]?.[0]).toBe("https://example.com/small-video.mp4"); @@ -2303,7 +2215,7 @@ describe("capability cli", () => { vi.stubGlobal("fetch", fetchMock); await expect( - runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"), + runCapability("video", "generate", "--prompt", "friendly lobster", "--json"), ).rejects.toThrow("exit 1"); // Cap resolved from config (2 MiB = 2097152), not the 16 MiB default. @@ -2325,7 +2237,7 @@ describe("capability cli", () => { ); vi.stubGlobal("fetch", fetchMock); - await runCap("capability", "video", "generate", "--prompt", "friendly lobster", "--json"); + await runCapability("video", "generate", "--prompt", "friendly lobster", "--json"); const output = firstJsonOutput(); expect(output?.capability).toBe("video.generate"); @@ -2334,7 +2246,7 @@ describe("capability cli", () => { it("rejects partial image generate count before provider dispatch", async () => { await expect( - runCap("capability", "image", "generate", "--prompt", "portrait", "--count", "2x"), + runCapability("image", "generate", "--prompt", "portrait", "--count", "2x"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("--count must be a positive integer"); expect(mocks.generateImage).not.toHaveBeenCalled(); @@ -2342,7 +2254,7 @@ describe("capability cli", () => { it("rejects partial image generate timeout before provider dispatch", async () => { await expect( - runCap("capability", "image", "generate", "--prompt", "portrait", "--timeout-ms", "1000ms"), + runCapability("image", "generate", "--prompt", "portrait", "--timeout-ms", "1000ms"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Invalid --timeout. Use a positive millisecond value"); expect(mocks.generateImage).not.toHaveBeenCalled(); @@ -2390,7 +2302,7 @@ describe("capability cli", () => { }); it("routes audio transcribe through transcription, not realtime", async () => { - await runCap("capability", "audio", "transcribe", "--file", "memo.m4a", "--json"); + await runCapability("audio", "transcribe", "--file", "memo.m4a", "--json"); expect(path.basename(firstAudioTranscriptionCall()?.filePath ?? "")).toBe("memo.m4a"); const output = firstJsonOutput(); @@ -2410,7 +2322,7 @@ describe("capability cli", () => { diagnostics: [], } as never); - await runCap("capability", "audio", "transcribe", "--file", "memo.m4a", "--json"); + await runCapability("audio", "transcribe", "--file", "memo.m4a", "--json"); expect(firstCommandConfigResolutionCall()).toEqual( expect.objectContaining({ @@ -2433,7 +2345,7 @@ describe("capability cli", () => { mocks.transcribeAudioFile.mockResolvedValueOnce({ text: undefined } as never); await expect( - runCap("capability", "audio", "transcribe", "--file", "memo.m4a", "--json"), + runCapability("audio", "transcribe", "--file", "memo.m4a", "--json"), ).rejects.toThrow("exit 1"); expect(runtimeErrorMessages()).toEqual([ `Error: No transcript returned for audio: ${path.resolve("memo.m4a")}`, @@ -2451,7 +2363,7 @@ describe("capability cli", () => { } as never); await expect( - runCap("capability", "audio", "transcribe", "--file", "memo.m4a", "--json"), + runCapability("audio", "transcribe", "--file", "memo.m4a", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("No audio transcription provider is configured or ready"); expectRuntimeErrorContains("tools.media.models"); @@ -2463,14 +2375,13 @@ describe("capability cli", () => { ); await expect( - runCap("capability", "audio", "transcribe", "--file", "memo.m4a", "--json"), + runCapability("audio", "transcribe", "--file", "memo.m4a", "--json"), ).rejects.toThrow("exit 1"); expect(runtimeErrorMessages()).toEqual(["Error: Audio transcription response missing text"]); }); it("forwards transcription prompt and language hints", async () => { - await runCap( - "capability", + await runCapability( "audio", "transcribe", "--file", @@ -2489,8 +2400,7 @@ describe("capability cli", () => { }); it("uses request-scoped TTS overrides without mutating prefs", async () => { - await runCap( - "capability", + await runCapability( "tts", "convert", "--text", @@ -2518,14 +2428,9 @@ describe("capability cli", () => { it("hydrates local TTS provider config from API-key auth profiles", async () => { const rawConfig = { tts: { providers: { openai: { voice: "coral" } } } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", + await runCapability( "tts", "convert", "--text", @@ -2555,13 +2460,9 @@ describe("capability cli", () => { it("hydrates local TTS default provider config from API-key auth profiles", async () => { const rawConfig = { tts: { provider: "openai" } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap("capability", "tts", "convert", "--text", "hello", "--json"); + await runCapability("tts", "convert", "--text", "hello", "--json"); expect(mocks.resolveApiKeyForProvider).toHaveBeenCalledWith( expect.objectContaining({ @@ -2581,22 +2482,9 @@ describe("capability cli", () => { it("hydrates local TTS channel provider config from API-key auth profiles", async () => { const rawConfig = { channels: { discord: { tts: { provider: "openai" } } } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", - "tts", - "convert", - "--text", - "hello", - "--channel", - "discord", - "--json", - ); + await runCapability("tts", "convert", "--text", "hello", "--channel", "discord", "--json"); expect(mocks.resolveTtsConfig).toHaveBeenCalledWith(rawConfig, { channelId: "discord" }); expect(mocks.resolveExplicitTtsOverrides).toHaveBeenCalledWith( @@ -2621,22 +2509,9 @@ describe("capability cli", () => { }, }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", - "tts", - "convert", - "--text", - "hello", - "--channel", - "discord", - "--json", - ); + await runCapability("tts", "convert", "--text", "hello", "--channel", "discord", "--json"); const cfg = firstTextToSpeechCall()?.cfg as { channels?: { @@ -2667,22 +2542,9 @@ describe("capability cli", () => { mocks.resolveTtsConfig.mockReturnValue({ providerConfigs: { openai: { apiKey: "config-key", speakerVoice: "nova" } }, }); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", - "tts", - "convert", - "--text", - "hello", - "--channel", - "discord", - "--json", - ); + await runCapability("tts", "convert", "--text", "hello", "--channel", "discord", "--json"); const cfg = firstTextToSpeechCall()?.cfg as { tts?: { providers?: { openai?: { apiKey?: string } } }; @@ -2698,13 +2560,9 @@ describe("capability cli", () => { it("does not hydrate local TTS provider config from token auth profiles", async () => { const rawConfig = { tts: { provider: "openai" } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-token", - source: "profile:openai:token", - mode: "token", - }); + primeOpenAiAuthProfile("token"); - await runCap("capability", "tts", "convert", "--text", "hello", "--json"); + await runCapability("tts", "convert", "--text", "hello", "--json"); const cfg = firstTextToSpeechCall()?.cfg as { tts?: { providers?: { openai?: { apiKey?: string } } }; @@ -2715,14 +2573,9 @@ describe("capability cli", () => { it("does not override existing TTS provider API keys with different casing", async () => { const rawConfig = { tts: { providers: { OpenAI: { apiKey: "config-key" } } } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", + await runCapability( "tts", "convert", "--text", @@ -2742,14 +2595,9 @@ describe("capability cli", () => { it("does not override existing direct TTS provider API keys", async () => { const rawConfig = { tts: { openai: { apiKey: "config-key" } } }; mocks.loadConfig.mockReturnValue(rawConfig); - mocks.resolveApiKeyForProvider.mockResolvedValueOnce({ - apiKey: "profile-openai-key", - source: "profile:openai:qa", - mode: "api-key", - }); + primeOpenAiAuthProfile(); - await runCap( - "capability", + await runCapability( "tts", "convert", "--text", @@ -2770,8 +2618,7 @@ describe("capability cli", () => { }); it("disables TTS fallback when explicit provider or voice/model selection is requested", async () => { - await runCap( - "capability", + await runCapability( "tts", "convert", "--text", @@ -2843,8 +2690,7 @@ describe("capability cli", () => { }); it("does not infer and forward a local provider guess for gateway TTS overrides", async () => { - await runCap( - "capability", + await runCapability( "tts", "convert", "--gateway", @@ -2869,8 +2715,7 @@ describe("capability cli", () => { }); await expect( - runCap( - "capability", + runCapability( "tts", "convert", "--gateway", @@ -2886,7 +2731,7 @@ describe("capability cli", () => { }); it("uses only embedding providers for embedding creation", async () => { - await runCap("capability", "embedding", "create", "--text", "hello", "--json"); + await runCapability("embedding", "create", "--text", "hello", "--json"); expect(firstEmbeddingProviderCall()?.provider).toBe("auto"); expect(firstEmbeddingProviderCall()?.fallback).toBe("none"); @@ -2910,9 +2755,9 @@ describe("capability cli", () => { }, }); - await expect( - runCap("capability", "embedding", "create", "--text", "hello", "--json"), - ).rejects.toThrow("exit 1"); + await expect(runCapability("embedding", "create", "--text", "hello", "--json")).rejects.toThrow( + "exit 1", + ); expect(closeEmbeddingProviderMock).toHaveBeenCalledTimes(2); expectRuntimeErrorContains("embedding failed"); @@ -2923,9 +2768,9 @@ describe("capability cli", () => { .mockRejectedValueOnce(new Error("close failed")) .mockRejectedValueOnce(new Error("close failed")); - await expect( - runCap("capability", "embedding", "create", "--text", "hello", "--json"), - ).rejects.toThrow("exit 1"); + await expect(runCapability("embedding", "create", "--text", "hello", "--json")).rejects.toThrow( + "exit 1", + ); expect(closeEmbeddingProviderMock).toHaveBeenCalledTimes(2); expectRuntimeErrorContains("close failed"); @@ -2941,7 +2786,7 @@ describe("capability cli", () => { diagnostics: [], } as never); - await runCap("capability", "model", "run", "--prompt", "hello", "--json"); + await runCapability("model", "run", "--prompt", "hello", "--json"); expect(firstCommandConfigResolutionCall()).toEqual( expect.objectContaining({ @@ -2963,8 +2808,7 @@ describe("capability cli", () => { }); it("derives the embedding provider from a provider/model override", async () => { - await runCap( - "capability", + await runCapability( "embedding", "create", "--text", @@ -3048,7 +2892,7 @@ describe("capability cli", () => { }, ); - await runCap("capability", "model", "auth", "logout", "--provider", "openai", "--json"); + await runCapability("model", "auth", "logout", "--provider", "openai", "--json"); if (updatedStore === null) { throw new Error("expected updated auth store"); @@ -3074,8 +2918,7 @@ describe("capability cli", () => { it("removes model auth profiles from the selected agent store", async () => { mocks.listProfilesForProvider.mockReturnValue(["openai:default"] as never); - await runCap( - "capability", + await runCapability( "model", "auth", "logout", @@ -3101,7 +2944,7 @@ describe("capability cli", () => { mocks.updateAuthProfileStoreWithLock.mockResolvedValueOnce(null as never); await expect( - runCap("capability", "model", "auth", "logout", "--provider", "openai", "--json"), + runCapability("model", "auth", "logout", "--provider", "openai", "--json"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("Failed to remove saved auth profiles for provider openai."); @@ -3155,7 +2998,7 @@ describe("capability cli", () => { { id: "generic", defaultModel: "generic-embed", transport: "remote" }, ] as never); - await runCap("capability", "embedding", "providers", "--json"); + await runCapability("embedding", "providers", "--json"); expect(firstJsonOutput()).toMatchObject([ { id: "generic", defaultModel: "generic-embed", transport: "remote" }, @@ -3186,7 +3029,7 @@ describe("capability cli", () => { ]), ); - await runCap("capability", "audio", "providers", "--json"); + await runCapability("audio", "providers", "--json"); expect(mocks.runtime.writeJson).toHaveBeenCalledWith([ { @@ -3314,7 +3157,7 @@ describe("capability cli", () => { selected: candidate, }); - await runCap("capability", "audio", "providers", "--json"); + await runCapability("audio", "providers", "--json"); expect(firstJsonOutput()).toEqual([ { @@ -3403,7 +3246,7 @@ describe("capability cli", () => { const webSearchRuntime = await import("../web-search/runtime.js"); vi.mocked(webSearchRuntime.runWebSearch).mockClear(); await expect( - runCap("capability", "web", "search", "--query", "ping", "--limit", "3x"), + runCapability("web", "search", "--query", "ping", "--limit", "3x"), ).rejects.toThrow("exit 1"); expectRuntimeErrorContains("--limit must be a positive integer"); expect(webSearchRuntime.runWebSearch).not.toHaveBeenCalled(); @@ -3697,7 +3540,7 @@ describe("capability cli", () => { mocks.isWebSearchProviderConfigured.mockReturnValueOnce(false).mockReturnValueOnce(true); mocks.isWebFetchProviderConfigured.mockReturnValueOnce(true); - await runCap("capability", "web", "providers", "--json"); + await runCapability("web", "providers", "--json"); expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ search: [ @@ -3739,7 +3582,7 @@ describe("capability cli", () => { { id: "gemini", defaultModel: "gemini-embedding-001", transport: "remote" }, ]); - await runCap("capability", "embedding", "providers", "--json"); + await runCapability("embedding", "providers", "--json"); expect(mocks.runtime.writeJson).toHaveBeenCalledWith([ { @@ -3776,7 +3619,7 @@ describe("capability cli", () => { { id: "openai-compatible", transport: "remote" }, ] as never); - await runCap("capability", "embedding", "providers", "--json"); + await runCapability("embedding", "providers", "--json"); expect(mocks.runtime.writeJson).toHaveBeenCalledWith([ { @@ -3823,7 +3666,7 @@ describe("capability cli", () => { { id: "openai-compatible", transport: "remote" }, ] as never); - await runCap("capability", "embedding", "providers", "--json"); + await runCapability("embedding", "providers", "--json"); expect(mocks.runtime.writeJson).toHaveBeenCalledWith([ { diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 457b01f53f3d..9644ae37c091 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -407,11 +407,11 @@ function makeProxyHandle() { }; } -async function withInteractiveTty(fn: () => Promise): Promise { +async function withCliTty(value: boolean, fn: () => Promise): Promise { const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); + Object.defineProperty(process.stdin, "isTTY", { configurable: true, value }); + Object.defineProperty(process.stdout, "isTTY", { configurable: true, value }); try { await fn(); } finally { @@ -428,6 +428,36 @@ async function withInteractiveTty(fn: () => Promise): Promise { } } +function withInteractiveTty(fn: () => Promise): Promise { + return withCliTty(true, fn); +} + +function runBareCli(): Promise { + return withInteractiveTty(() => runCli(["node", "openclaw"])); +} + +function primeBareRootConfig(sourceConfig: ConfigSnapshotStub["sourceConfig"]): void { + readConfigFileSnapshotMock.mockResolvedValueOnce({ exists: true, valid: true, sourceConfig }); +} + +async function expectNonInteractiveBareCliError( + message: string, + assert?: () => void, +): Promise { + const previousExitCode = process.exitCode; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + process.exitCode = undefined; + try { + await withCliTty(false, () => runCli(["node", "openclaw"])); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(message); + assert?.(); + } finally { + errorSpy.mockRestore(); + process.exitCode = previousExitCode; + } +} + describe("runCli exit behavior", () => { afterAll(() => { serviceEnvSnapshot.restore(); @@ -2970,7 +3000,7 @@ describe("runCli exit behavior", () => { }, ])("$name", async ({ snapshot }) => { readConfigFileSnapshotMock.mockResolvedValueOnce(snapshot); - await withInteractiveTty(async () => runCli(["node", "openclaw"])); + await runBareCli(); expect(readConfigFileSnapshotMock).toHaveBeenCalledOnce(); expect(setupWizardCommandMock).toHaveBeenCalledWith({}); @@ -2979,43 +3009,20 @@ describe("runCli exit behavior", () => { }); it("points noninteractive fresh bare root invocations to onboarding automation", async () => { - const previousExitCode = process.exitCode; - const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); - const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - process.exitCode = undefined; readConfigFileSnapshotMock.mockResolvedValueOnce({ exists: false, valid: true, sourceConfig: {}, }); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); - try { - await runCli(["node", "openclaw"]); - - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", - ); - expect(setupWizardCommandMock).not.toHaveBeenCalled(); - expect(tryRouteCliMock).not.toHaveBeenCalled(); - expect(buildProgramMock).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - process.exitCode = previousExitCode; - if (stdinDescriptor) { - Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); - } else { - Reflect.deleteProperty(process.stdin, "isTTY"); - } - if (stdoutDescriptor) { - Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor); - } else { - Reflect.deleteProperty(process.stdout, "isTTY"); - } - } + await expectNonInteractiveBareCliError( + "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", + () => { + expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(tryRouteCliMock).not.toHaveBeenCalled(); + expect(buildProgramMock).not.toHaveBeenCalled(); + }, + ); }); it("starts the gateway-backed TUI for bare root invocations when config already exists", async () => { @@ -3038,9 +3045,7 @@ describe("runCli exit behavior", () => { }); await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "gateway-ref-password" }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1); @@ -3078,9 +3083,7 @@ describe("runCli exit behavior", () => { detail: "Gateway default agent has no configured model", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).not.toHaveBeenCalled(); expect(runRemoteGatewayInferenceOnboardingMock).toHaveBeenCalledWith({ @@ -3093,21 +3096,15 @@ describe("runCli exit behavior", () => { }); it("keeps missing inference setup local for a local Gateway", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { mode: "local" }, - }, + primeBareRootConfig({ + gateway: { mode: "local" }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "missing-configured-model", detail: "Gateway default agent has no configured model", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).toHaveBeenCalledWith({}); expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled(); @@ -3115,69 +3112,36 @@ describe("runCli exit behavior", () => { }); it("does not direct non-interactive remote setup into local onboarding", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { url: "wss://gateway.example/ws", token: "remote-token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { url: "wss://gateway.example/ws", token: "remote-token" }, }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "missing-configured-model", detail: "Gateway default agent has no configured model", }); - const previousExitCode = process.exitCode; - const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); - const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); - - try { - await runCli(["node", "openclaw"]); - - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - "Remote Gateway inference setup needs an interactive TTY. Re-run `openclaw` in a terminal connected to this Gateway.", - ); - expect(setupWizardCommandMock).not.toHaveBeenCalled(); - expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - process.exitCode = previousExitCode; - if (stdinDescriptor) { - Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); - } else { - Reflect.deleteProperty(process.stdin, "isTTY"); - } - if (stdoutDescriptor) { - Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor); - } else { - Reflect.deleteProperty(process.stdout, "isTTY"); - } - } + await expectNonInteractiveBareCliError( + "Remote Gateway inference setup needs an interactive TTY. Re-run `openclaw` in a terminal connected to this Gateway.", + () => { + expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled(); + }, + ); }); it("uses the active local gateway lock port for bare root preflight and TUI handoff", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - port: 18789, - auth: { mode: "token", token: "configured-token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "local", + port: 18789, + auth: { mode: "token", token: "configured-token" }, }, }); readActiveGatewayLockPortMock.mockResolvedValueOnce(48789); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:48789", @@ -3190,23 +3154,17 @@ describe("runCli exit behavior", () => { }); it("keeps an explicit gateway port ahead of active local lock metadata", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - port: 18789, - auth: { mode: "token", token: "configured-token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "local", + port: 18789, + auth: { mode: "token", token: "configured-token" }, }, }); readActiveGatewayLockPortMock.mockResolvedValueOnce(48789); await withEnvAsync({ OPENCLAW_GATEWAY_PORT: "19001" }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(readActiveGatewayLockPortMock).not.toHaveBeenCalled(); @@ -3217,15 +3175,11 @@ describe("runCli exit behavior", () => { }); it("carries the canonical local TLS fingerprint through bare root", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - tls: { enabled: true }, - auth: { mode: "token", token: "configured-token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "local", + tls: { enabled: true }, + auth: { mode: "token", token: "configured-token" }, }, }); loadGatewayTlsRuntimeMock.mockResolvedValueOnce({ @@ -3234,9 +3188,7 @@ describe("runCli exit behavior", () => { fingerprintSha256: "sha256:local-self-signed-fingerprint", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ url: "wss://127.0.0.1:18789", @@ -3250,21 +3202,15 @@ describe("runCli exit behavior", () => { }); it("uses gateway env credentials for bare root gateway preflight", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - auth: { mode: "token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "local", + auth: { mode: "token" }, }, }); await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ @@ -3292,45 +3238,39 @@ describe("runCli exit behavior", () => { "process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret ].join(""); await withSecureTestNodeExecPath(async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - secrets: { - providers: { - tokenprovider: { - source: "exec", - command: process.execPath, - args: ["-e", tokenProgram], - allowInsecurePath: true, - }, - passwordprovider: { - source: "exec", - command: process.execPath, - args: ["-e", passwordProgram], - allowInsecurePath: true, - }, + primeBareRootConfig({ + secrets: { + providers: { + tokenprovider: { + source: "exec", + command: process.execPath, + args: ["-e", tokenProgram], + allowInsecurePath: true, + }, + passwordprovider: { + source: "exec", + command: process.execPath, + args: ["-e", passwordProgram], + allowInsecurePath: true, }, }, - gateway: { - mode: "local", - auth: { - mode: "password", - token: { source: "exec", provider: "tokenprovider", id: "TOKEN_SECRET" }, - password: { - source: "exec", - provider: "passwordprovider", - id: "PASSWORD_SECRET", - }, + }, + gateway: { + mode: "local", + auth: { + mode: "password", + token: { source: "exec", provider: "tokenprovider", id: "TOKEN_SECRET" }, + password: { + source: "exec", + provider: "passwordprovider", + id: "PASSWORD_SECRET", }, }, }, }); try { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ url: "ws://127.0.0.1:18789", @@ -3349,24 +3289,18 @@ describe("runCli exit behavior", () => { }); it("probes local gateways over loopback even when the gateway advertises a LAN bind", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - bind: "lan", - auth: { - mode: "token", - token: "local-token", - }, + primeBareRootConfig({ + gateway: { + mode: "local", + bind: "lan", + auth: { + mode: "token", + token: "local-token", }, }, }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(readActiveGatewayLockPortMock).toHaveBeenCalledTimes(1); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ @@ -3380,17 +3314,13 @@ describe("runCli exit behavior", () => { }); it("falls back to the configured local tailnet gateway URL when loopback is unavailable", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - bind: "tailnet", - auth: { - mode: "token", - token: "local-token", - }, + primeBareRootConfig({ + gateway: { + mode: "local", + bind: "tailnet", + auth: { + mode: "token", + token: "local-token", }, }, }); @@ -3409,9 +3339,7 @@ describe("runCli exit behavior", () => { .mockResolvedValueOnce({ kind: "unreachable", detail: "loopback offline" }) .mockResolvedValueOnce({ kind: "configured" }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(probeGatewayConfiguredModelMock).toHaveBeenNthCalledWith(1, { url: "ws://127.0.0.1:18789", @@ -3428,15 +3356,11 @@ describe("runCli exit behavior", () => { }); it("prefers a configured secondary Gateway over a missing-model primary probe", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "local", - bind: "tailnet", - auth: { mode: "token", token: "local-token" }, - }, + primeBareRootConfig({ + gateway: { + mode: "local", + bind: "tailnet", + auth: { mode: "token", token: "local-token" }, }, }); resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) => @@ -3451,9 +3375,7 @@ describe("runCli exit behavior", () => { }) .mockResolvedValueOnce({ kind: "configured" }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).not.toHaveBeenCalled(); expect(launchTuiCliMock).toHaveBeenCalledWith( @@ -3463,16 +3385,12 @@ describe("runCli exit behavior", () => { }); it("keeps confirmed missing inference ahead of an unverified secondary Gateway", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - agents: { defaults: { model: { primary: "openai/local-only-model" } } }, - gateway: { - mode: "local", - bind: "tailnet", - auth: { mode: "token", token: "local-token" }, - }, + primeBareRootConfig({ + agents: { defaults: { model: { primary: "openai/local-only-model" } } }, + gateway: { + mode: "local", + bind: "tailnet", + auth: { mode: "token", token: "local-token" }, }, }); resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) => @@ -3490,9 +3408,7 @@ describe("runCli exit behavior", () => { detail: "Gateway default agent has no configured model", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).toHaveBeenCalledWith({}); expect(launchTuiCliMock).not.toHaveBeenCalled(); @@ -3500,22 +3416,16 @@ describe("runCli exit behavior", () => { it("keeps a reachable unverified Gateway ahead of local inference fallback", async () => { const url = "ws://127.0.0.1:18789"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - agents: { defaults: { model: { primary: "openai/local-only-model" } } }, - gateway: { mode: "remote", remote: { url, token: "remote-token" } }, - }, + primeBareRootConfig({ + agents: { defaults: { model: { primary: "openai/local-only-model" } } }, + gateway: { mode: "remote", remote: { url, token: "remote-token" } }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "reachable-unverified", detail: "config.get: unauthorized", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).not.toHaveBeenCalled(); expect(launchTuiCliMock).toHaveBeenCalledWith( @@ -3526,21 +3436,15 @@ describe("runCli exit behavior", () => { it("keeps a configured remote Gateway authoritative across a transient cold-restart probe", async () => { const url = "wss://gateway.example/ws"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { mode: "remote", remote: { url, token: "remote-token" } }, - }, + primeBareRootConfig({ + gateway: { mode: "remote", remote: { url, token: "remote-token" } }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "unreachable", detail: "gateway restarting", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).not.toHaveBeenCalled(); expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled(); @@ -3551,21 +3455,15 @@ describe("runCli exit behavior", () => { }); it("keeps a configured local Gateway authoritative across a transient cold-restart probe", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { mode: "local", auth: { mode: "token", token: "local-token" } }, - }, + primeBareRootConfig({ + gateway: { mode: "local", auth: { mode: "token", token: "local-token" } }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "unreachable", detail: "offline", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).not.toHaveBeenCalled(); expect(launchTuiCliMock).toHaveBeenCalledWith( @@ -3575,21 +3473,15 @@ describe("runCli exit behavior", () => { }); it("starts the local TUI when no Gateway is configured and the default probe is unavailable", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, - }, + primeBareRootConfig({ + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, }); probeGatewayConfiguredModelMock.mockResolvedValueOnce({ kind: "unreachable", detail: "offline", }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {}); }); @@ -3599,24 +3491,18 @@ describe("runCli exit behavior", () => { { label: "mDNS", url: "ws://gateway.local:18789" }, { label: "Tailnet DNS", url: "ws://machine.tail123.ts.net:18789" }, ])("does not probe a plaintext remote gateway over $label without opt-in", async ({ url }) => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url, - token: "remote-token", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url, + token: "remote-token", }, }, }); await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).not.toHaveBeenCalled(); @@ -3626,24 +3512,18 @@ describe("runCli exit behavior", () => { it("probes a plaintext remote loopback gateway", async () => { const url = "ws://127.0.0.1:18789"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url, - token: "remote-token", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url, + token: "remote-token", }, }, }); await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ @@ -3659,24 +3539,18 @@ describe("runCli exit behavior", () => { it("keeps configured remote password authoritative from preflight through TUI launch", async () => { const url = "ws://127.0.0.1:18789"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url, - password: "configured-remote-password", // pragma: allowlist secret - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url, + password: "configured-remote-password", // pragma: allowlist secret }, }, }); await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "stale-env-password" }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ @@ -3691,24 +3565,20 @@ describe("runCli exit behavior", () => { it("falls back to gateway env auth when configured remote SecretRefs are unresolved", async () => { const url = "ws://127.0.0.1:18789"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url, - token: { - source: "env", - provider: "default", - id: "MISSING_REMOTE_GATEWAY_TOKEN", - }, - password: { - source: "env", - provider: "default", - id: "MISSING_REMOTE_GATEWAY_PASSWORD", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url, + token: { + source: "env", + provider: "default", + id: "MISSING_REMOTE_GATEWAY_TOKEN", + }, + password: { + source: "env", + provider: "default", + id: "MISSING_REMOTE_GATEWAY_PASSWORD", }, }, }, @@ -3722,9 +3592,7 @@ describe("runCli exit behavior", () => { OPENCLAW_GATEWAY_PASSWORD: "env-remote-password", }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }, ); @@ -3739,24 +3607,18 @@ describe("runCli exit behavior", () => { it("probes an explicitly allowed plaintext private remote gateway", async () => { const url = "ws://192.168.1.10:18789"; - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url, - token: "remote-token", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url, + token: "remote-token", }, }, }); await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1" }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ @@ -3771,24 +3633,18 @@ describe("runCli exit behavior", () => { }); it("forwards the configured TLS pin when probing a remote gateway", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url: "wss://gateway.example.com:18789", - token: "remote-token", - tlsFingerprint: "sha256:11:22:33:44", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url: "wss://gateway.example.com:18789", + token: "remote-token", + tlsFingerprint: "sha256:11:22:33:44", }, }, }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({ url: "wss://gateway.example.com:18789", @@ -3802,24 +3658,18 @@ describe("runCli exit behavior", () => { }); it("routes to inference onboarding without probing a public plaintext remote gateway", async () => { - readConfigFileSnapshotMock.mockResolvedValueOnce({ - exists: true, - valid: true, - sourceConfig: { - gateway: { - mode: "remote", - remote: { - url: "ws://gateway.example.com:18789", - token: "remote-token", - }, + primeBareRootConfig({ + gateway: { + mode: "remote", + remote: { + url: "ws://gateway.example.com:18789", + token: "remote-token", }, }, }); await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => { - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); }); expect(probeGatewayConfiguredModelMock).not.toHaveBeenCalled(); @@ -3867,73 +3717,28 @@ describe("runCli exit behavior", () => { sourceConfig: { gateway: { mode: "local" } }, }); - await withInteractiveTty(async () => { - await runCli(["node", "openclaw"]); - }); + await runBareCli(); expect(setupWizardCommandMock).toHaveBeenCalledWith({ classic: true }); expect(launchTuiCliMock).not.toHaveBeenCalled(); }); it("points noninteractive invalid config to doctor before onboarding", async () => { - const previousExitCode = process.exitCode; - const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); - const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - process.exitCode = undefined; readConfigFileSnapshotMock.mockResolvedValueOnce({ exists: true, valid: false, sourceConfig: { gateway: { mode: "local" } }, }); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false }); - - try { - await runCli(["node", "openclaw"]); - - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - "OpenClaw config is invalid. Run `openclaw doctor --fix` before onboarding.", - ); - expect(setupWizardCommandMock).not.toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - process.exitCode = previousExitCode; - if (stdinDescriptor) { - Object.defineProperty(process.stdin, "isTTY", stdinDescriptor); - } else { - Reflect.deleteProperty(process.stdin, "isTTY"); - } - if (stdoutDescriptor) { - Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor); - } else { - Reflect.deleteProperty(process.stdout, "isTTY"); - } - } + await expectNonInteractiveBareCliError( + "OpenClaw config is invalid. Run `openclaw doctor --fix` before onboarding.", + () => expect(setupWizardCommandMock).not.toHaveBeenCalled(), + ); }); it("bootstraps env proxy before bare TUI startup", async () => { hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true); - const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); - const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }); - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }); - try { - await runCli(["node", "openclaw"]); - } finally { - if (stdinTty) { - Object.defineProperty(process.stdin, "isTTY", stdinTty); - } else { - delete (process.stdin as { isTTY?: boolean }).isTTY; - } - if (stdoutTty) { - Object.defineProperty(process.stdout, "isTTY", stdoutTty); - } else { - delete (process.stdout as { isTTY?: boolean }).isTTY; - } - } + await runBareCli(); expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1); expect(launchTuiCliMock).toHaveBeenCalledOnce(); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 84fa03cf52b4..cbcddc5c77a1 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -592,6 +592,20 @@ describe("update-cli", () => { }); }; + const primeNpmChannelTag = (tag: string, version: string | null): void => { + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag, version }); + }; + + const primeServiceCommand = ( + programArguments: Array, + environment?: NodeJS.ProcessEnv, + ): void => { + serviceReadCommand.mockResolvedValue({ + programArguments, + ...(environment === undefined ? {} : { environment }), + }); + }; + const expectUpdateCallChannel = (channel: string) => { const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0]; expect(call?.channel).toBe(channel); @@ -845,9 +859,7 @@ describe("update-cli", () => { const mockOwnedGitService = () => { const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js"); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", serviceEntrypoint, "gateway", "run"], - }); + primeServiceCommand(["node", serviceEntrypoint, "gateway", "run"]); pathExists.mockImplementation( async (candidate: string) => candidate === path.join(process.cwd(), "package.json"), ); @@ -886,10 +898,7 @@ describe("update-cli", () => { readPackageVersion.mockResolvedValue("2.0.0"); mockPackageInstallStatus(tempDir); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "0.0.1", - }); + primeNpmChannelTag("latest", "0.0.1"); vi.mocked(runGatewayUpdate).mockResolvedValue({ status: "ok", mode: "npm", @@ -940,9 +949,7 @@ describe("update-cli", () => { }); } serviceLoaded.mockResolvedValue(true); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", entrypoints[0], "gateway", "run"], - }); + primeServiceCommand(["node", entrypoints[0], "gateway", "run"]); return { root, entrypoints }; }; @@ -1337,10 +1344,7 @@ describe("update-cli", () => { version: "2026.6.33", packageSpec: "openclaw@2026.6.33", }); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "9999.0.0", - }); + primeNpmChannelTag("latest", "9999.0.0"); nodeVersionSatisfiesEngine.mockReturnValue(true); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: "/test/path", @@ -1879,10 +1883,7 @@ describe("update-cli", () => { }), }); readPackageVersion.mockResolvedValue("2026.4.14"); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.10", - }); + primeNpmChannelTag("latest", "2026.4.10"); mockCurrentProcessFreshDoctor(); mockGatewayProbe("2026.4.10", "downgraded-gateway"); @@ -1908,7 +1909,7 @@ describe("update-cli", () => { }), }); readPackageVersion.mockResolvedValue("2026.4.14"); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" }); + primeNpmChannelTag("latest", "2026.4.10"); mockCurrentProcessFreshDoctor(); await updateCommand({ yes: true, tag: "2026.4.10", restart: false }); @@ -1936,7 +1937,7 @@ describe("update-cli", () => { readPackageVersion.mockImplementation(async (pkgRoot: string) => pkgRoot === downgradedRoot ? "2026.4.10" : "2026.4.14", ); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" }); + primeNpmChannelTag("latest", "2026.4.10"); delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION; let hostVersionDuringPluginUpdate: string | undefined = "unset"; @@ -3105,9 +3106,8 @@ describe("update-cli", () => { it("refuses a package target that changes after the service stops", async () => { mockPackageInstallStatus(process.cwd()); mockOwnedGitService(); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", path.join(process.cwd(), "dist", "index.js"), "gateway", "run"], - environment: { OPENCLAW_STATE_DIR: "/managed/state" }, + primeServiceCommand(["node", path.join(process.cwd(), "dist", "index.js"), "gateway", "run"], { + OPENCLAW_STATE_DIR: "/managed/state", }); serviceLoaded.mockResolvedValue(true); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ @@ -3345,10 +3345,7 @@ describe("update-cli", () => { ...baseSnapshot, config: { update: { channel: "beta" } } as OpenClawConfig, }); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "1.2.3-1", - }); + primeNpmChannelTag("latest", "1.2.3-1"); await updateCommand({}); expectPackageInstallSpec("openclaw@1.2.3-1"); @@ -3528,10 +3525,7 @@ describe("update-cli", () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); readPackageVersion.mockResolvedValue("2026.4.22"); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.22", - }); + primeNpmChannelTag("latest", "2026.4.22"); await updateCommand({ yes: true }); @@ -3551,10 +3545,7 @@ describe("update-cli", () => { setTty(false); mockPackageInstallStatus(tempDir); readPackageVersion.mockResolvedValue("2026.4.22"); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: null, - }); + primeNpmChannelTag("latest", null); mockCurrentProcessFreshDoctor(); await updateCommand({}); @@ -3633,12 +3624,9 @@ describe("update-cli", () => { it("refuses package updates from inherited gateway service env when --no-restart leaves the gateway running", async () => { mockPackageInstallStatus(createCaseDir("openclaw-update")); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - environment: { - OPENCLAW_SERVICE_MARKER: "openclaw", - OPENCLAW_SERVICE_KIND: "gateway", - }, + primeServiceCommand(["openclaw", "gateway", "run"], { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", }); serviceLoaded.mockResolvedValue(true); @@ -3670,12 +3658,9 @@ describe("update-cli", () => { "refuses package updates from inherited gateway service env when $name", async ({ setupRuntime }) => { mockPackageInstallStatus(createCaseDir("openclaw-update")); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - environment: { - OPENCLAW_SERVICE_MARKER: "openclaw", - OPENCLAW_SERVICE_KIND: "gateway", - }, + primeServiceCommand(["openclaw", "gateway", "run"], { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", }); setupRuntime(); @@ -3758,10 +3743,7 @@ describe("update-cli", () => { it("blocks package updates when the target requires a newer Node runtime", async () => { mockPackageInstallStatus(createCaseDir("openclaw-update")); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.3.23-2", - }); + primeNpmChannelTag("latest", "2026.3.23-2"); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ target: "latest", version: "2026.3.23-2", @@ -3894,10 +3876,7 @@ describe("update-cli", () => { const nodeModules = path.join(prefix, "lib", "node_modules"); const { pkgRoot } = await setupInstalledPackageAtNodeModules(nodeModules, "2026.4.20"); readPackageVersion.mockResolvedValue("2026.4.20"); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.25", - }); + primeNpmChannelTag("latest", "2026.4.25"); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); @@ -3942,18 +3921,13 @@ describe("update-cli", () => { it("runs old package doctors without fix mode when service ownership is unknown", async () => { const tempDir = await createTrackedTempDir("openclaw-update-package-"); const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageRoot(tempDir); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw-wrapper", "gateway", "run"], - }); + primeServiceCommand(["openclaw-wrapper", "gateway", "run"]); serviceLoaded.mockResolvedValue(true); serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" }); readPackageVersion.mockImplementation(async (packageRoot: string) => packageRoot === pkgRoot ? "2026.4.21" : "1.0.0", ); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.21", - }); + primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); mockNpmGlobalRoot(nodeModules); @@ -3991,10 +3965,7 @@ describe("update-cli", () => { it("continues package post-core work for explicit post-update doctor advisories", async () => { const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-warning-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.21", - }); + primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => { if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { @@ -4060,10 +4031,7 @@ describe("update-cli", () => { it("fails package updates when the post-update doctor is killed after verification", async () => { const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-timeout-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.4.21", - }); + primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { @@ -4103,10 +4071,7 @@ describe("update-cli", () => { const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageAtNodeModules( path.join(tempDir, "lib", "node_modules"), ); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.5.14", - }); + primeNpmChannelTag("latest", "2026.5.14"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (!Array.isArray(argv)) { @@ -4351,12 +4316,9 @@ describe("update-cli", () => { it("preserves both the update and Scheduled Task recovery failures", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); mockPackageInstallStatus(createCaseDir("openclaw-update-recovery-failure")); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - environment: { - OPENCLAW_SERVICE_MARKER: "openclaw", - OPENCLAW_SERVICE_KIND: "gateway", - }, + primeServiceCommand(["openclaw", "gateway", "run"], { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", }); serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" }); suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(true); @@ -4399,12 +4361,9 @@ describe("update-cli", () => { ); resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(true); mockPackageInstallStatus(createCaseDir("openclaw-update-suspension-signal")); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - environment: { - OPENCLAW_SERVICE_MARKER: "openclaw", - OPENCLAW_SERVICE_KIND: "gateway", - }, + primeServiceCommand(["openclaw", "gateway", "run"], { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", }); serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" }); @@ -4439,12 +4398,9 @@ describe("update-cli", () => { async (runtimeStatus) => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); mockPackageInstallStatus(createCaseDir("openclaw-update-stopped-task")); - serviceReadCommand.mockResolvedValue({ - programArguments: ["openclaw", "gateway", "run"], - environment: { - OPENCLAW_SERVICE_MARKER: "openclaw", - OPENCLAW_SERVICE_KIND: "gateway", - }, + primeServiceCommand(["openclaw", "gateway", "run"], { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", }); serviceReadRuntime.mockResolvedValue( runtimeStatus === "running" @@ -5025,9 +4981,7 @@ describe("update-cli", () => { "utf-8", ); mockPackageInstallStatus(shellRoot); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"], - }); + primeServiceCommand([serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"]); await updateCommand({ dryRun: true }); @@ -5053,10 +5007,8 @@ describe("update-cli", () => { "utf-8", ); mockPackageInstallStatus(shellRoot); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"], - }); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.5.20" }); + primeServiceCommand([serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"]); + primeNpmChannelTag("latest", "2026.5.20"); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ target: "latest", version: "2026.5.20", @@ -5101,14 +5053,9 @@ describe("update-cli", () => { await fs.writeFile(entrypoint, "", "utf-8"); await writePackageDistInventory(serviceRoot); mockPackageInstallStatus(shellRoot); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, entrypoint, "gateway"], - }); + primeServiceCommand([serviceNode, entrypoint, "gateway"]); serviceLoaded.mockResolvedValue(true); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.5.20", - }); + primeNpmChannelTag("latest", "2026.5.20"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { @@ -5157,9 +5104,7 @@ describe("update-cli", () => { const serviceNode = "/opt/other-node/bin/node"; const entrypoint = path.join(root, "dist", "index.js"); mockPackageInstallStatus(root); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, entrypoint, "gateway"], - }); + primeServiceCommand([serviceNode, entrypoint, "gateway"]); await updateCommand({ dryRun: true }); @@ -5195,11 +5140,9 @@ describe("update-cli", () => { await writePackageDistInventory(root); // Same package root for both shell and service. mockPackageInstallStatus(root); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, entrypoint, "gateway"], - }); + primeServiceCommand([serviceNode, entrypoint, "gateway"]); serviceLoaded.mockResolvedValue(true); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.7.1" }); + primeNpmChannelTag("latest", "2026.7.1"); vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ target: "latest", version: "2026.7.1", @@ -5270,14 +5213,9 @@ describe("update-cli", () => { await fs.writeFile(entrypoint, "", "utf-8"); await writePackageDistInventory(root); mockPackageInstallStatus(root); - serviceReadCommand.mockResolvedValue({ - programArguments: [serviceNode, entrypoint, "gateway"], - }); + primeServiceCommand([serviceNode, entrypoint, "gateway"]); serviceLoaded.mockResolvedValue(true); - vi.mocked(resolveNpmChannelTag).mockResolvedValue({ - tag: "latest", - version: "2026.5.20", - }); + primeNpmChannelTag("latest", "2026.5.20"); mockFileBackedPathExists(); // The PATH npm returns a DIFFERENT global root (simulates Node-B's npm). const nodeBGlobalRoot = path.join( @@ -6247,9 +6185,7 @@ describe("update-cli", () => { it("tries the updated install restart when package service refresh fails", async () => { const { updatedRoot, updatedEntrypoint } = setupNpmUpdatedRootRefresh(); serviceLoaded.mockResolvedValue(true); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", updatedEntrypoint, "gateway", "run"], - }); + primeServiceCommand(["node", updatedEntrypoint, "gateway", "run"]); mockGatewayInstallFailure(updatedEntrypoint); mockGatewayProbe("2026.4.24", "updated-gateway"); @@ -6289,9 +6225,7 @@ describe("update-cli", () => { candidate === updatedEntrypoint || candidate === updatedPackageJson, ); serviceLoaded.mockResolvedValue(true); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", updatedEntrypoint, "gateway", "run"], - }); + primeServiceCommand(["node", updatedEntrypoint, "gateway", "run"]); mockGatewayInstallFailure(updatedEntrypoint); mockGatewayProbe("2026.4.24", "matching-old-gateway"); @@ -6340,9 +6274,7 @@ describe("update-cli", () => { [oldEntrypoint, updatedEntrypoint, oldPackageJson, updatedPackageJson].includes(candidate), ); serviceLoaded.mockResolvedValue(true); - serviceReadCommand.mockResolvedValue({ - programArguments: ["node", oldEntrypoint, "gateway", "run"], - }); + primeServiceCommand(["node", oldEntrypoint, "gateway", "run"]); mockGatewayInstallFailure(updatedEntrypoint); mockGatewayProbe("2026.4.24", "matching-old-service"); diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 3c7dc7b6f5f0..f9bcd01611a8 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -233,16 +233,8 @@ vi.mock("../commands/model-picker.runtime.js", () => ({ })); const OPENROUTER_CATALOG = [ - { - provider: "openrouter", - id: "auto", - name: "OpenRouter Auto", - }, - { - provider: "openrouter", - id: "meta-llama/llama-3.3-70b:free", - name: "Llama 3.3 70B", - }, + catalogModel("openrouter", "auto", "OpenRouter Auto"), + catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"), ] as const; function expectRouterModelFiltering(options: Array<{ value: string }>) { @@ -256,6 +248,19 @@ function createSelectAllMultiselect() { return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value)); } +function promptDefaultPicker(params: Parameters[0]) { + return promptDefaultModel({ + allowKeep: false, + includeManual: false, + ignoreAllowlist: true, + ...params, + }); +} + +function catalogModel(provider: string, id: string, name: string): ModelCatalogEntry { + return { provider, id, name }; +} + function configuredTextModel(id: string, name: string) { return { id, @@ -428,12 +433,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const options = pickerOptions(select as MockCallSource); @@ -462,12 +464,9 @@ describe("promptDefaultModel", () => { ]); const select = vi.fn(async (params) => params.initialValue as never); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults } } as OpenClawConfig, prompter: makePrompter({ select }), - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); @@ -493,12 +492,9 @@ describe("promptDefaultModel", () => { } as OpenClawConfig; const select = vi.fn(async (params) => params.initialValue as never); - await promptDefaultModel({ + await promptDefaultPicker({ config, prompter: makePrompter({ select }), - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); @@ -537,12 +533,9 @@ describe("promptDefaultModel", () => { modelCatalogRouteVariants.value = [platform, chatGPT]; const select = vi.fn(async (params) => params.initialValue as never); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter: makePrompter({ select }), - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5"); @@ -562,12 +555,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } } }, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const values = optionValues(pickerOptions(select as MockCallSource)); @@ -587,14 +577,11 @@ describe("promptDefaultModel", () => { ]); const select = vi.fn(async (params) => params.initialValue as never); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, }, prompter: makePrompter({ select }), - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([ @@ -618,12 +605,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const values = optionValues(pickerOptions(select as MockCallSource)); @@ -659,12 +643,9 @@ describe("promptDefaultModel", () => { ]); const select = vi.fn(async (params) => params.initialValue as never); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter: makePrompter({ select }), - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([ @@ -699,12 +680,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); const values = optionValues(pickerOptions(select as MockCallSource)); @@ -723,12 +701,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.options[0]?.value as never); const prompter = makePrompter({ select }); - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); expect(result.model).toBe("google/gemini-3.1-pro-preview"); @@ -764,12 +739,9 @@ describe("promptDefaultModel", () => { agents: { defaults: {} }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); expect(loadModelCatalog).not.toHaveBeenCalled(); @@ -783,16 +755,8 @@ describe("promptDefaultModel", () => { it("treats byteplus plan models as preferred-provider matches", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - { - provider: "byteplus-plan", - id: "ark-code-latest", - name: "Ark Coding Plan", - }, + catalogModel("openai", "gpt-5.5", "GPT-5.5"), + catalogModel("byteplus-plan", "ark-code-latest", "Ark Coding Plan"), ]); const select = vi.fn(async (params) => params.initialValue as never); @@ -805,12 +769,10 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, - includeManual: false, - ignoreAllowlist: true, preferredProvider: "byteplus", }); @@ -828,11 +790,7 @@ describe("promptDefaultModel", () => { it("shows literal double-prefix labels for providers that preserve literal prefixes", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "Nemotron", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"), ]); resolvePluginProviders.mockReturnValue([ { @@ -851,12 +809,10 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - await promptDefaultModel({ + await promptDefaultPicker({ config, prompter, allowKeep: true, - includeManual: false, - ignoreAllowlist: true, }); const options = pickerOptions(select as MockCallSource); @@ -870,21 +826,9 @@ describe("promptDefaultModel", () => { it("does not double-prefix non-literal NVIDIA vendor model labels", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "Nemotron", - }, - { - provider: "nvidia", - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - }, - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"), + catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"), + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), ]); resolvePluginProviders.mockReturnValue([ { @@ -896,12 +840,9 @@ describe("promptDefaultModel", () => { const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, preferredProvider: "nvidia", }); @@ -933,12 +874,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, }); @@ -966,12 +906,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, preferredProvider: "openai", browseCatalogOnDemand: true, }); @@ -999,12 +938,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, browseCatalogOnDemand: true, loadCatalog: true, }); @@ -1023,16 +961,8 @@ describe("promptDefaultModel", () => { it("loads the full model catalog when browsing without a preferred provider", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - { - provider: "openai", - id: "gpt-5.5-pro", - name: "GPT-5.5 Pro", - }, + catalogModel("openai", "gpt-5.5", "GPT-5.5"), + catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"), ]); const select = vi .fn() @@ -1052,12 +982,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, browseCatalogOnDemand: true, }); @@ -1070,16 +999,8 @@ describe("promptDefaultModel", () => { it("loads the preferred provider catalog when the user chooses to browse", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - { - provider: "openai", - id: "gpt-5.5-pro", - name: "GPT-5.5 Pro", - }, + catalogModel("openai", "gpt-5.5", "GPT-5.5"), + catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"), ]); const select = vi .fn() @@ -1099,12 +1020,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, preferredProvider: "openai", browseCatalogOnDemand: true, }); @@ -1122,16 +1042,8 @@ describe("promptDefaultModel", () => { it("scopes on-demand preferred-provider loads before the first model prompt", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "NVIDIA Nemotron 3 Super 120B", - }, - { - provider: "nvidia", - id: "moonshotai/kimi-k2.5", - name: "Kimi K2.5", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"), + catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"), ]); const select = vi.fn(async (params) => params.options[0]?.value as never); const prompter = makePrompter({ select }); @@ -1143,12 +1055,9 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, }); @@ -1168,16 +1077,8 @@ describe("promptDefaultModel", () => { it("preselects the first live provider row when keep-current is disabled", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "NVIDIA Nemotron 3 Super 120B", - }, + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"), ]); const select = vi.fn(async (params) => params.initialValue as never); const prompter = makePrompter({ select }); @@ -1189,12 +1090,9 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, }); @@ -1214,21 +1112,9 @@ describe("promptDefaultModel", () => { it("keeps on-demand NVIDIA vendor labels single-prefixed after browsing", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "NVIDIA Nemotron 3 Super 120B", - }, - { - provider: "nvidia", - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - }, - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"), + catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"), + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), ]); resolvePluginProviders.mockReturnValue([ { @@ -1242,7 +1128,7 @@ describe("promptDefaultModel", () => { .mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b"); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: { @@ -1253,7 +1139,6 @@ describe("promptDefaultModel", () => { prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, }); @@ -1270,21 +1155,9 @@ describe("promptDefaultModel", () => { it("omits local NVIDIA static fallback rows when browsing live provider rows", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "NVIDIA Nemotron 3 Super 120B", - }, - { - provider: "nvidia", - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - }, - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"), + catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"), + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), ]); loadStaticManifestCatalogRowsForList.mockReturnValue([ manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5", "deprecated"), @@ -1302,7 +1175,7 @@ describe("promptDefaultModel", () => { .mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b"); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: { @@ -1313,7 +1186,6 @@ describe("promptDefaultModel", () => { prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, }); @@ -1329,11 +1201,7 @@ describe("promptDefaultModel", () => { it("uses the configured default agent dir for provider-scoped catalog auth", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), ]); const select = vi.fn(async (params) => params.options[0]?.value as never); const prompter = makePrompter({ select }); @@ -1350,12 +1218,9 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - await promptDefaultModel({ + await promptDefaultPicker({ config, prompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, preferredProvider: "nvidia", browseCatalogOnDemand: true, env, @@ -1371,11 +1236,7 @@ describe("promptDefaultModel", () => { it("supports configuring vLLM during setup", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.5", - }, + catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"), ]); resolveProviderModelPickerEntries.mockReturnValue([ { value: "vllm", label: "vLLM (custom)", hint: "Enter vLLM URL + API key + model" }, @@ -1413,13 +1274,10 @@ describe("promptDefaultModel", () => { const prompter = makePrompter({ select }); const config = { agents: { defaults: {} } } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, - allowKeep: false, - includeManual: false, includeProviderPluginSetups: true, - ignoreAllowlist: true, agentDir: "/tmp/openclaw-agent", runtime: {} as never, }); @@ -1443,13 +1301,7 @@ describe("promptDefaultModel", () => { }); it("prefers provider model-picker contributions when the runtime exposes them", async () => { - loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - ]); + loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]); providerModelPickerContributionRuntime.enabled = true; providerModelPickerContributionRuntime.resolve.mockReturnValue([ { @@ -1477,13 +1329,10 @@ describe("promptDefaultModel", () => { }); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, includeProviderPluginSetups: true, - ignoreAllowlist: true, agentDir: "/tmp/openclaw-agent", runtime: {} as never, }); @@ -1505,12 +1354,11 @@ describe("promptDefaultModel", () => { }, } as OpenClawConfig; - const result = await promptDefaultModel({ + const result = await promptDefaultPicker({ config, prompter, allowKeep: true, includeManual: true, - ignoreAllowlist: true, includeProviderPluginSetups: true, loadCatalog: false, agentDir: "/tmp/openclaw-agent", @@ -1529,13 +1377,7 @@ describe("promptDefaultModel", () => { }); it("surfaces NVIDIA provider model-picker contributions", async () => { - loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - }, - ]); + loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.4", "GPT-5.4")]); providerModelPickerContributionRuntime.enabled = true; providerModelPickerContributionRuntime.resolve.mockReturnValue([ { @@ -1558,13 +1400,10 @@ describe("promptDefaultModel", () => { }); const prompter = makePrompter({ select }); - await promptDefaultModel({ + await promptDefaultPicker({ config: { agents: { defaults: {} } } as OpenClawConfig, prompter, - allowKeep: false, - includeManual: false, includeProviderPluginSetups: true, - ignoreAllowlist: true, agentDir: "/tmp/openclaw-agent", runtime: {} as never, }); @@ -1579,21 +1418,9 @@ describe("promptDefaultModel", () => { describe("promptModelAllowlist", () => { it("filters to allowed keys when provided", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "anthropic", - id: "claude-opus-4-6", - name: "Claude Opus 4.5", - }, - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.5", - }, - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, + catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"), + catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"), + catalogModel("openai", "gpt-5.5", "GPT-5.5"), ]); const multiselect = createSelectAllMultiselect(); @@ -1613,13 +1440,7 @@ describe("promptModelAllowlist", () => { it("localizes the model allowlist picker", async () => { process.env.OPENCLAW_LOCALE = "zh-CN"; - loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - ]); + loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]); const multiselect = createSelectAllMultiselect(); const prompter = makePrompter({ multiselect }); @@ -1743,13 +1564,7 @@ describe("promptModelAllowlist", () => { }); it("uses configured provider models for allowlist picker without loading the full catalog in replace mode", async () => { - loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - ]); + loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]); const multiselect = createSelectAllMultiselect(); const prompter = makePrompter({ multiselect }); @@ -1782,21 +1597,9 @@ describe("promptModelAllowlist", () => { it("scopes the initial allowlist picker to the preferred provider", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.5", - }, - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - { - provider: "openai", - id: "gpt-5.4-mini", - name: "GPT-5.4 Mini", - }, + catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"), + catalogModel("openai", "gpt-5.5", "GPT-5.5"), + catalogModel("openai", "gpt-5.4-mini", "GPT-5.4 Mini"), ]); const multiselect = createSelectAllMultiselect(); @@ -1815,16 +1618,8 @@ describe("promptModelAllowlist", () => { it("includes stale configured preferred provider models in the scoped cleanup", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "openrouter", - id: "meta-llama/llama-3.3-70b:free", - name: "Llama 3.3 70B", - }, - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, + catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"), + catalogModel("openai", "gpt-5.5", "GPT-5.5"), ]); const activeModel = "openrouter/meta-llama/llama-3.3-70b:free"; @@ -1918,16 +1713,8 @@ describe("promptModelAllowlist", () => { it("keeps live preferred-provider rows before configured fallback supplements", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - }, - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "Nemotron 3 Super", - }, + catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"), + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), ]); const multiselect = createSelectAllMultiselect(); @@ -1969,36 +1756,12 @@ describe("promptModelAllowlist", () => { it("keeps provider-scoped live rows authoritative over configured provider supplements", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "Nemotron 3 Super", - }, - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, - { - provider: "nvidia", - id: "minimaxai/minimax-m2.7", - name: "MiniMax M2.7", - }, - { - provider: "nvidia", - id: "moonshotai/kimi-k2.5", - name: "Kimi K2.5", - }, - { - provider: "nvidia", - id: "minimaxai/minimax-m2.5", - name: "MiniMax M2.5", - }, - { - provider: "nvidia", - id: "z-ai/glm5", - name: "GLM5", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), + catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"), + catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"), + catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"), + catalogModel("nvidia", "z-ai/glm5", "GLM5"), ]); loadStaticManifestCatalogRowsForList.mockReturnValue([ manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"), @@ -2052,16 +1815,8 @@ describe("promptModelAllowlist", () => { it("keeps custom configured rows after provider-scoped live rows", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "nvidia/nemotron-3-super-120b-a12b", - name: "Nemotron 3 Super", - }, - { - provider: "nvidia", - id: "z-ai/glm-5.1", - name: "GLM 5.1", - }, + catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), + catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"), ]); loadStaticManifestCatalogRowsForList.mockReturnValue([ manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"), @@ -2107,16 +1862,8 @@ describe("promptModelAllowlist", () => { it("does not re-add configured static rows after filtering deprecated live rows", async () => { loadPreferredProviderPickerCatalog.mockResolvedValue([ - { - provider: "nvidia", - id: "minimaxai/minimax-m2.5", - name: "MiniMax M2.5", - }, - { - provider: "nvidia", - id: "z-ai/glm5", - name: "GLM5", - }, + catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"), + catalogModel("nvidia", "z-ai/glm5", "GLM5"), ]); loadStaticManifestCatalogRowsForList.mockReturnValue([ manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "Bundled MiniMax M2.5", "deprecated"), @@ -2196,16 +1943,8 @@ describe("promptModelAllowlist", () => { it("keeps local no-key provider models visible in allowlist choices", async () => { resolveEnvApiKey.mockReturnValue(null); loadModelCatalog.mockResolvedValue([ - { - provider: "vllm", - id: "meta-llama/Meta-Llama-3-8B-Instruct", - name: "Meta Llama", - }, - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, + catalogModel("vllm", "meta-llama/Meta-Llama-3-8B-Instruct", "Meta Llama"), + catalogModel("openai", "gpt-5.5", "GPT-5.5"), ]); const multiselect = createSelectAllMultiselect(); @@ -2232,13 +1971,7 @@ describe("promptModelAllowlist", () => { }); it("seeds existing model fallbacks into unscoped allowlist selections", async () => { - loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - ]); + loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]); const multiselect = vi.fn(async (params) => params.initialValues ?? []); const prompter = makePrompter({ multiselect }); @@ -2268,21 +2001,9 @@ describe("promptModelAllowlist", () => { it("resolves bare fallback seeds against the primary model provider", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "anthropic", - id: "claude-opus-4-6", - name: "Claude Opus 4.5", - }, - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.5", - }, - { - provider: "openai", - id: "claude-sonnet-4-6", - name: "Wrong provider", - }, + catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"), + catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"), + catalogModel("openai", "claude-sonnet-4-6", "Wrong provider"), ]); const multiselect = vi.fn(async (params) => params.initialValues ?? []); @@ -2356,21 +2077,9 @@ describe("promptModelAllowlist", () => { it("keeps provider-scoped fallback supplements within scope", async () => { loadModelCatalog.mockResolvedValue([ - { - provider: "openai", - id: "gpt-5.5", - name: "GPT-5.5", - }, - { - provider: "openai", - id: "gpt-5.4", - name: "GPT-5.4", - }, - { - provider: "anthropic", - id: "claude-sonnet-4-6", - name: "Claude Sonnet 4.5", - }, + catalogModel("openai", "gpt-5.5", "GPT-5.5"), + catalogModel("openai", "gpt-5.4", "GPT-5.4"), + catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"), ]); const multiselect = vi.fn(async (params) => params.initialValues ?? []); @@ -2515,12 +2224,9 @@ describe("router model filtering", () => { const allowlistPrompter = makePrompter({ multiselect }); const config = { agents: { defaults: {} } } as OpenClawConfig; - await promptDefaultModel({ + await promptDefaultPicker({ config, prompter: defaultPrompter, - allowKeep: false, - includeManual: false, - ignoreAllowlist: true, }); await promptModelAllowlist({ config, prompter: allowlistPrompter });