diff --git a/src/agents/openai-responses.reasoning-replay.test.ts b/src/agents/openai-responses.reasoning-replay.test.ts index de4b10cd62d..2a94db7e3fd 100644 --- a/src/agents/openai-responses.reasoning-replay.test.ts +++ b/src/agents/openai-responses.reasoning-replay.test.ts @@ -18,198 +18,169 @@ function buildModel(): Model<"openai-responses"> { }; } -function installFailingFetchCapture() { - const originalFetch = globalThis.fetch; - let lastBody: unknown; - - const fetchImpl: typeof fetch = async (_input, init) => { - const rawBody = init?.body; - const bodyText = (() => { - if (!rawBody) { - return ""; - } - if (typeof rawBody === "string") { - return rawBody; - } - if (rawBody instanceof Uint8Array) { - return Buffer.from(rawBody).toString("utf8"); - } - if (rawBody instanceof ArrayBuffer) { - return Buffer.from(new Uint8Array(rawBody)).toString("utf8"); - } - return null; - })(); - lastBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined; - throw new Error("intentional fetch abort (test)"); - }; - - globalThis.fetch = fetchImpl; - - return { - getLastBody: () => lastBody as Record | undefined, - restore: () => { - globalThis.fetch = originalFetch; - }, - }; -} - describe("openai-responses reasoning replay", () => { it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => { - const cap = installFailingFetchCapture(); - try { - const model = buildModel(); + const model = buildModel(); + const controller = new AbortController(); + controller.abort(); + let payload: Record | undefined; - const assistantToolOnly: AssistantMessage = { - role: "assistant", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + const assistantToolOnly: AssistantMessage = { + role: "assistant", + api: "openai-responses", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "toolUse", + timestamp: Date.now(), + content: [ + { + type: "thinking", + thinking: "internal", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_test", + summary: [], + }), }, - stopReason: "toolUse", - timestamp: Date.now(), - content: [ + { + type: "toolCall", + id: "call_123|fc_123", + name: "noop", + arguments: {}, + }, + ], + }; + + const toolResult: ToolResultMessage = { + role: "toolResult", + toolCallId: "call_123|fc_123", + toolName: "noop", + content: [{ type: "text", text: "ok" }], + isError: false, + timestamp: Date.now(), + }; + + const stream = streamOpenAIResponses( + model, + { + systemPrompt: "system", + messages: [ { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_test", - summary: [], - }), + role: "user", + content: "Call noop.", + timestamp: Date.now(), }, + assistantToolOnly, + toolResult, { - type: "toolCall", - id: "call_123|fc_123", - name: "noop", - arguments: {}, + role: "user", + content: "Now reply with ok.", + timestamp: Date.now(), }, ], - }; - - const toolResult: ToolResultMessage = { - role: "toolResult", - toolCallId: "call_123|fc_123", - toolName: "noop", - content: [{ type: "text", text: "ok" }], - isError: false, - timestamp: Date.now(), - }; - - const stream = streamOpenAIResponses( - model, - { - systemPrompt: "system", - messages: [ - { - role: "user", - content: "Call noop.", - timestamp: Date.now(), - }, - assistantToolOnly, - toolResult, - { - role: "user", - content: "Now reply with ok.", - timestamp: Date.now(), - }, - ], - tools: [ - { - name: "noop", - description: "no-op", - parameters: Type.Object({}, { additionalProperties: false }), - }, - ], + tools: [ + { + name: "noop", + description: "no-op", + parameters: Type.Object({}, { additionalProperties: false }), + }, + ], + }, + { + apiKey: "test", + signal: controller.signal, + onPayload: (nextPayload) => { + payload = nextPayload as Record; }, - { apiKey: "test" }, - ); + }, + ); - await stream.result(); + await stream.result(); - const body = cap.getLastBody(); - const input = Array.isArray(body?.input) ? body?.input : []; - const types = input - .map((item) => - item && typeof item === "object" ? (item as Record).type : undefined, - ) - .filter((t): t is string => typeof t === "string"); + const input = Array.isArray(payload?.input) ? payload?.input : []; + const types = input + .map((item) => + item && typeof item === "object" ? (item as Record).type : undefined, + ) + .filter((t): t is string => typeof t === "string"); - expect(types).toContain("reasoning"); - expect(types).toContain("function_call"); - expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call")); - } finally { - cap.restore(); - } + expect(types).toContain("reasoning"); + expect(types).toContain("function_call"); + expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call")); }); it("still replays reasoning when paired with an assistant message", async () => { - const cap = installFailingFetchCapture(); - try { - const model = buildModel(); + const model = buildModel(); + const controller = new AbortController(); + controller.abort(); + let payload: Record | undefined; - const assistantWithText: AssistantMessage = { - role: "assistant", - api: "openai-responses", - provider: "openai", - model: "gpt-5.2", - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - stopReason: "stop", - timestamp: Date.now(), - content: [ - { - type: "thinking", - thinking: "internal", - thinkingSignature: JSON.stringify({ - type: "reasoning", - id: "rs_test", - summary: [], - }), - }, - { type: "text", text: "hello", textSignature: "msg_test" }, - ], - }; - - const stream = streamOpenAIResponses( - model, + const assistantWithText: AssistantMessage = { + role: "assistant", + api: "openai-responses", + provider: "openai", + model: "gpt-5.2", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + content: [ { - systemPrompt: "system", - messages: [ - { role: "user", content: "Hi", timestamp: Date.now() }, - assistantWithText, - { role: "user", content: "Ok", timestamp: Date.now() }, - ], + type: "thinking", + thinking: "internal", + thinkingSignature: JSON.stringify({ + type: "reasoning", + id: "rs_test", + summary: [], + }), }, - { apiKey: "test" }, - ); + { type: "text", text: "hello", textSignature: "msg_test" }, + ], + }; - await stream.result(); + const stream = streamOpenAIResponses( + model, + { + systemPrompt: "system", + messages: [ + { role: "user", content: "Hi", timestamp: Date.now() }, + assistantWithText, + { role: "user", content: "Ok", timestamp: Date.now() }, + ], + }, + { + apiKey: "test", + signal: controller.signal, + onPayload: (nextPayload) => { + payload = nextPayload as Record; + }, + }, + ); - const body = cap.getLastBody(); - const input = Array.isArray(body?.input) ? body?.input : []; - const types = input - .map((item) => - item && typeof item === "object" ? (item as Record).type : undefined, - ) - .filter((t): t is string => typeof t === "string"); + await stream.result(); - expect(types).toContain("reasoning"); - expect(types).toContain("message"); - } finally { - cap.restore(); - } + const input = Array.isArray(payload?.input) ? payload?.input : []; + const types = input + .map((item) => + item && typeof item === "object" ? (item as Record).type : undefined, + ) + .filter((t): t is string => typeof t === "string"); + + expect(types).toContain("reasoning"); + expect(types).toContain("message"); }); }); diff --git a/src/browser/pw-ai-state.ts b/src/browser/pw-ai-state.ts new file mode 100644 index 00000000000..58ce89f30d9 --- /dev/null +++ b/src/browser/pw-ai-state.ts @@ -0,0 +1,9 @@ +let pwAiLoaded = false; + +export function markPwAiLoaded(): void { + pwAiLoaded = true; +} + +export function isPwAiLoaded(): boolean { + return pwAiLoaded; +} diff --git a/src/browser/pw-ai.ts b/src/browser/pw-ai.ts index 72ba680c43d..6da8b410c83 100644 --- a/src/browser/pw-ai.ts +++ b/src/browser/pw-ai.ts @@ -1,3 +1,7 @@ +import { markPwAiLoaded } from "./pw-ai-state.js"; + +markPwAiLoaded(); + export { type BrowserConsoleMessage, closePageByTargetIdViaPlaywright, diff --git a/src/browser/server.ts b/src/browser/server.ts index 2f734f031d5..419bdbfdfa5 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -7,6 +7,7 @@ import { safeEqualSecret } from "../security/secret-equal.js"; import { resolveBrowserConfig, resolveProfile } from "./config.js"; import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js"; import { ensureChromeExtensionRelayServer } from "./extension-relay.js"; +import { isPwAiLoaded } from "./pw-ai-state.js"; import { registerBrowserRoutes } from "./routes/index.js"; import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js"; @@ -196,11 +197,13 @@ export async function stopBrowserControlServer(): Promise { } state = null; - // Optional: Playwright is not always available (e.g. embedded gateway builds). - try { - const mod = await import("./pw-ai.js"); - await mod.closePlaywrightBrowserConnection(); - } catch { - // ignore + // Optional: avoid importing heavy Playwright bridge when this process never used it. + if (isPwAiLoaded()) { + try { + const mod = await import("./pw-ai.js"); + await mod.closePlaywrightBrowserConnection(); + } catch { + // ignore + } } } diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts index 7c41cda9d61..fc30a0a7566 100644 --- a/src/channels/plugins/actions/discord.test.ts +++ b/src/channels/plugins/actions/discord.test.ts @@ -21,20 +21,12 @@ vi.mock("../../../discord/send.js", async () => { }; }); -const loadHandleDiscordMessageAction = async () => { - const mod = await import("./discord/handle-action.js"); - return mod.handleDiscordMessageAction; -}; - -const loadDiscordMessageActions = async () => { - const mod = await import("./discord.js"); - return mod.discordMessageActions; -}; +const { handleDiscordMessageAction } = await import("./discord/handle-action.js"); +const { discordMessageActions } = await import("./discord.js"); describe("discord message actions", () => { it("lists channel and upload actions by default", async () => { const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig; - const discordMessageActions = await loadDiscordMessageActions(); const actions = discordMessageActions.listActions?.({ cfg }) ?? []; expect(actions).toContain("emoji-upload"); @@ -46,7 +38,6 @@ describe("discord message actions", () => { const cfg = { channels: { discord: { token: "d0", actions: { channels: false } } }, } as OpenClawConfig; - const discordMessageActions = await loadDiscordMessageActions(); const actions = discordMessageActions.listActions?.({ cfg }) ?? []; expect(actions).not.toContain("channel-create"); @@ -56,7 +47,6 @@ describe("discord message actions", () => { describe("handleDiscordMessageAction", () => { it("forwards context accountId for send", async () => { sendMessageDiscord.mockClear(); - const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); await handleDiscordMessageAction({ action: "send", @@ -79,7 +69,6 @@ describe("handleDiscordMessageAction", () => { it("falls back to params accountId when context missing", async () => { sendPollDiscord.mockClear(); - const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); await handleDiscordMessageAction({ action: "poll", @@ -106,7 +95,6 @@ describe("handleDiscordMessageAction", () => { it("forwards accountId for thread replies", async () => { sendMessageDiscord.mockClear(); - const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); await handleDiscordMessageAction({ action: "thread-reply", @@ -129,7 +117,6 @@ describe("handleDiscordMessageAction", () => { it("accepts threadId for thread replies (tool compatibility)", async () => { sendMessageDiscord.mockClear(); - const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); await handleDiscordMessageAction({ action: "thread-reply", diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 164b951b538..2bd437fb092 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -27,14 +27,20 @@ vi.mock("../runtime.js", () => ({ }, })); +const { registerCronCli } = await import("./cron-cli.js"); + +function buildProgram() { + const program = new Command(); + program.exitOverride(); + registerCronCli(program); + return program; +} + describe("cron cli", () => { it("trims model and thinking on cron add", { timeout: 60_000 }, async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( [ @@ -68,10 +74,7 @@ describe("cron cli", () => { it("defaults isolated cron add to announce delivery", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( [ @@ -98,10 +101,7 @@ describe("cron cli", () => { it("infers sessionTarget from payload when --session is omitted", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( ["cron", "add", "--name", "Main reminder", "--cron", "* * * * *", "--system-event", "hi"], @@ -129,10 +129,7 @@ describe("cron cli", () => { it("supports --keep-after-run on cron add", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( [ @@ -159,10 +156,7 @@ describe("cron cli", () => { it("sends agent id on cron add", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( [ @@ -190,10 +184,7 @@ describe("cron cli", () => { it("omits empty model and thinking on cron edit", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( ["cron", "edit", "job-1", "--message", "hello", "--model", " ", "--thinking", " "], @@ -212,10 +203,7 @@ describe("cron cli", () => { it("trims model and thinking on cron edit", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( [ @@ -244,10 +232,7 @@ describe("cron cli", () => { it("sets and clears agent id on cron edit", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync(["cron", "edit", "job-1", "--agent", " Ops ", "--message", "hello"], { from: "user", @@ -269,10 +254,7 @@ describe("cron cli", () => { it("allows model/thinking updates without --message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync(["cron", "edit", "job-1", "--model", "opus", "--thinking", "low"], { from: "user", @@ -291,10 +273,7 @@ describe("cron cli", () => { it("updates delivery settings without requiring --message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( ["cron", "edit", "job-1", "--deliver", "--channel", "telegram", "--to", "19098680"], @@ -319,10 +298,7 @@ describe("cron cli", () => { it("supports --no-deliver on cron edit", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync(["cron", "edit", "job-1", "--no-deliver"], { from: "user" }); @@ -338,10 +314,7 @@ describe("cron cli", () => { it("does not include undefined delivery fields when updating message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); // Update message without delivery flags - should NOT include undefined delivery fields await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], { @@ -376,10 +349,7 @@ describe("cron cli", () => { it("includes delivery fields when explicitly provided with message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); // Update message AND delivery - should include both await program.parseAsync( @@ -416,10 +386,7 @@ describe("cron cli", () => { it("includes best-effort delivery when provided with message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( ["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"], @@ -442,10 +409,7 @@ describe("cron cli", () => { it("includes no-best-effort delivery when provided with message", async () => { callGatewayFromCli.mockClear(); - const { registerCronCli } = await import("./cron-cli.js"); - const program = new Command(); - program.exitOverride(); - registerCronCli(program); + const program = buildProgram(); await program.parseAsync( ["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"], diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 4483790a9ee..ca6a3cb1652 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -79,6 +79,17 @@ vi.mock("../runtime.js", () => ({ }, })); +const { runGatewayUpdate } = await import("../infra/update-runner.js"); +const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); +const { readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js"); +const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = + await import("../infra/update-check.js"); +const { runCommandWithTimeout } = await import("../process/exec.js"); +const { runDaemonRestart } = await import("./daemon-cli.js"); +const { defaultRuntime } = await import("../runtime.js"); +const { updateCommand, registerUpdateCli, updateStatusCommand, updateWizardCommand } = + await import("./update-cli.js"); + describe("update-cli", () => { const baseSnapshot = { valid: true, @@ -100,13 +111,8 @@ describe("update-cli", () => { }); }; - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { readConfigFileSnapshot } = await import("../config/config.js"); - const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = - await import("../infra/update-check.js"); - const { runCommandWithTimeout } = await import("../process/exec.js"); vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ @@ -154,18 +160,12 @@ describe("update-cli", () => { }); it("exports updateCommand and registerUpdateCli", async () => { - const { updateCommand, registerUpdateCli, updateWizardCommand } = - await import("./update-cli.js"); expect(typeof updateCommand).toBe("function"); expect(typeof registerUpdateCli).toBe("function"); expect(typeof updateWizardCommand).toBe("function"); }, 20_000); it("updateCommand runs update and outputs result", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -193,9 +193,6 @@ describe("update-cli", () => { }); it("updateStatusCommand prints table output", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const { updateStatusCommand } = await import("./update-cli.js"); - await updateStatusCommand({ json: false }); const logs = vi.mocked(defaultRuntime.log).mock.calls.map((call) => call[0]); @@ -203,9 +200,6 @@ describe("update-cli", () => { }); it("updateStatusCommand emits JSON", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const { updateStatusCommand } = await import("./update-cli.js"); - await updateStatusCommand({ json: true }); const last = vi.mocked(defaultRuntime.log).mock.calls.at(-1)?.[0]; @@ -215,9 +209,6 @@ describe("update-cli", () => { }); it("defaults to dev channel for git installs when unset", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); - vi.mocked(runGatewayUpdate).mockResolvedValue({ status: "ok", mode: "git", @@ -240,11 +231,6 @@ describe("update-cli", () => { "utf-8", ); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { checkUpdateStatus } = await import("../infra/update-check.js"); - const { updateCommand } = await import("./update-cli.js"); - vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: tempDir, @@ -275,10 +261,6 @@ describe("update-cli", () => { }); it("uses stored beta channel when configured", async () => { - const { readConfigFileSnapshot } = await import("../config/config.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); - vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, config: { update: { channel: "beta" } }, @@ -305,13 +287,6 @@ describe("update-cli", () => { "utf-8", ); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { readConfigFileSnapshot } = await import("../config/config.js"); - const { resolveNpmChannelTag } = await import("../infra/update-check.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); - const { checkUpdateStatus } = await import("../infra/update-check.js"); - vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, @@ -358,10 +333,6 @@ describe("update-cli", () => { "utf-8", ); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); - vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(runGatewayUpdate).mockResolvedValue({ status: "ok", @@ -380,10 +351,6 @@ describe("update-cli", () => { }); it("updateCommand outputs JSON when --json is set", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -409,10 +376,6 @@ describe("update-cli", () => { }); it("updateCommand exits with error on failure", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "error", mode: "git", @@ -430,10 +393,6 @@ describe("update-cli", () => { }); it("updateCommand restarts daemon by default", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { runDaemonRestart } = await import("./daemon-cli.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -450,10 +409,6 @@ describe("update-cli", () => { }); it("updateCommand skips restart when --no-restart is set", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { runDaemonRestart } = await import("./daemon-cli.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -469,11 +424,6 @@ describe("update-cli", () => { }); it("updateCommand skips success message when restart does not run", async () => { - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { runDaemonRestart } = await import("./daemon-cli.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -492,9 +442,6 @@ describe("update-cli", () => { }); it("updateCommand validates timeout option", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - vi.mocked(defaultRuntime.error).mockClear(); vi.mocked(defaultRuntime.exit).mockClear(); @@ -505,10 +452,6 @@ describe("update-cli", () => { }); it("persists update channel when --channel is set", async () => { - const { writeConfigFile } = await import("../config/config.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateCommand } = await import("./update-cli.js"); - const mockResult: UpdateRunResult = { status: "ok", mode: "git", @@ -537,13 +480,6 @@ describe("update-cli", () => { "utf-8", ); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { resolveNpmChannelTag } = await import("../infra/update-check.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const { checkUpdateStatus } = await import("../infra/update-check.js"); - vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: tempDir, @@ -590,13 +526,6 @@ describe("update-cli", () => { "utf-8", ); - const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); - const { resolveNpmChannelTag } = await import("../infra/update-check.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { defaultRuntime } = await import("../runtime.js"); - const { updateCommand } = await import("./update-cli.js"); - const { checkUpdateStatus } = await import("../infra/update-check.js"); - vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(tempDir); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: tempDir, @@ -634,9 +563,6 @@ describe("update-cli", () => { }); it("updateWizardCommand requires a TTY", async () => { - const { defaultRuntime } = await import("../runtime.js"); - const { updateWizardCommand } = await import("./update-cli.js"); - setTty(false); vi.mocked(defaultRuntime.error).mockClear(); vi.mocked(defaultRuntime.exit).mockClear(); @@ -656,10 +582,6 @@ describe("update-cli", () => { setTty(true); process.env.OPENCLAW_GIT_DIR = tempDir; - const { checkUpdateStatus } = await import("../infra/update-check.js"); - const { runGatewayUpdate } = await import("../infra/update-runner.js"); - const { updateWizardCommand } = await import("./update-cli.js"); - vi.mocked(checkUpdateStatus).mockResolvedValue({ root: "/test/path", installKind: "package", diff --git a/src/commands/agent/session.test.ts b/src/commands/agent/session.test.ts index 1bae455a26a..93de40b642b 100644 --- a/src/commands/agent/session.test.ts +++ b/src/commands/agent/session.test.ts @@ -22,21 +22,17 @@ vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: mocks.listAgentIds, })); +const { resolveSessionKeyForRequest } = await import("./session.js"); + describe("resolveSessionKeyForRequest", () => { beforeEach(() => { vi.clearAllMocks(); mocks.listAgentIds.mockReturnValue(["main"]); }); - async function importFresh() { - return await import("./session.js"); - } - const baseCfg: OpenClawConfig = {}; it("returns sessionKey when --to resolves a session key via context", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); mocks.loadSessionStore.mockReturnValue({ "agent:main:main": { sessionId: "sess-1", updatedAt: 0 }, @@ -50,8 +46,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("finds session by sessionId via reverse lookup in primary store", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); mocks.loadSessionStore.mockReturnValue({ "agent:main:main": { sessionId: "target-session-id", updatedAt: 0 }, @@ -65,8 +59,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("finds session by sessionId in non-primary agent store", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); mocks.resolveStorePath.mockImplementation( (_store: string | undefined, opts?: { agentId?: string }) => { @@ -94,8 +86,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("returns correct sessionStore when session found in non-primary agent store", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - const mybotStore = { "agent:mybot:main": { sessionId: "target-session-id", updatedAt: 0 }, }; @@ -123,8 +113,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("returns undefined sessionKey when sessionId not found in any store", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); mocks.resolveStorePath.mockImplementation( (_store: string | undefined, opts?: { agentId?: string }) => { @@ -144,8 +132,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("does not search other stores when explicitSessionKey is set", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); mocks.resolveStorePath.mockReturnValue("/tmp/main-store.json"); mocks.loadSessionStore.mockReturnValue({ @@ -162,8 +148,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("searches other stores when --to derives a key that does not match --session-id", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); mocks.resolveStorePath.mockImplementation( (_store: string | undefined, opts?: { agentId?: string }) => { @@ -199,8 +183,6 @@ describe("resolveSessionKeyForRequest", () => { }); it("skips already-searched primary store when iterating agents", async () => { - const { resolveSessionKeyForRequest } = await importFresh(); - mocks.listAgentIds.mockReturnValue(["main", "mybot"]); mocks.resolveStorePath.mockImplementation( (_store: string | undefined, opts?: { agentId?: string }) => { diff --git a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts index 45b9d719e7c..ac4dc516722 100644 --- a/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts +++ b/src/gateway/server-methods/skills.update.normalizes-api-key.test.ts @@ -15,10 +15,11 @@ vi.mock("../../config/config.js", () => { }; }); +const { skillsHandlers } = await import("./skills.js"); + describe("skills.update", () => { it("strips embedded CR/LF from apiKey", async () => { writtenConfig = null; - const { skillsHandlers } = await import("./skills.js"); let ok: boolean | null = null; let error: unknown = null; diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 1f15eec90ea..614c0980179 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -2,23 +2,22 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; import { resolvePluginTools } from "./tools.js"; type TempPlugin = { dir: string; file: string; id: string }; -const tempDirs: string[] = []; +const fixtureRoot = path.join(os.tmpdir(), `openclaw-plugin-tools-${randomUUID()}`); const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; -function makeTempDir() { - const dir = path.join(os.tmpdir(), `openclaw-plugin-tools-${randomUUID()}`); +function makeFixtureDir(id: string) { + const dir = path.join(fixtureRoot, id); fs.mkdirSync(dir, { recursive: true }); - tempDirs.push(dir); return dir; } function writePlugin(params: { id: string; body: string }): TempPlugin { - const dir = makeTempDir(); + const dir = makeFixtureDir(params.id); const file = path.join(dir, `${params.id}.js`); fs.writeFileSync(file, params.body, "utf-8"); fs.writeFileSync( @@ -36,18 +35,7 @@ function writePlugin(params: { id: string; body: string }): TempPlugin { return { dir, file, id: params.id }; } -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - try { - fs.rmSync(dir, { recursive: true, force: true }); - } catch { - // ignore cleanup failures - } - } -}); - -describe("resolvePluginTools optional tools", () => { - const pluginBody = ` +const pluginBody = ` export default { register(api) { api.registerTool( { @@ -63,92 +51,11 @@ export default { register(api) { } } `; - it("skips optional tools without explicit allowlist", () => { - const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - }, - }, - workspaceDir: plugin.dir, - }, - }); - expect(tools).toHaveLength(0); - }); - - it("allows optional tools by name", () => { - const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - }, - }, - workspaceDir: plugin.dir, - }, - toolAllowlist: ["optional_tool"], - }); - expect(tools.map((tool) => tool.name)).toContain("optional_tool"); - }); - - it("allows optional tools via plugin groups", () => { - const plugin = writePlugin({ id: "optional-demo", body: pluginBody }); - const toolsAll = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - }, - }, - workspaceDir: plugin.dir, - }, - toolAllowlist: ["group:plugins"], - }); - expect(toolsAll.map((tool) => tool.name)).toContain("optional_tool"); - - const toolsPlugin = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - }, - }, - workspaceDir: plugin.dir, - }, - toolAllowlist: ["optional-demo"], - }); - expect(toolsPlugin.map((tool) => tool.name)).toContain("optional_tool"); - }); - - it("rejects plugin id collisions with core tool names", () => { - const plugin = writePlugin({ id: "message", body: pluginBody }); - const tools = resolvePluginTools({ - context: { - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], - }, - }, - workspaceDir: plugin.dir, - }, - existingToolNames: new Set(["message"]), - toolAllowlist: ["message"], - }); - expect(tools).toHaveLength(0); - }); - - it("skips conflicting tool names but keeps other tools", () => { - const plugin = writePlugin({ - id: "multi", - body: ` +const optionalDemoPlugin = writePlugin({ id: "optional-demo", body: pluginBody }); +const coreNameCollisionPlugin = writePlugin({ id: "message", body: pluginBody }); +const multiToolPlugin = writePlugin({ + id: "multi", + body: ` export default { register(api) { api.registerTool({ name: "message", @@ -168,17 +75,105 @@ export default { register(api) { }); } } `, - }); +}); +afterAll(() => { + try { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } catch { + // ignore cleanup failures + } +}); + +describe("resolvePluginTools optional tools", () => { + it("skips optional tools without explicit allowlist", () => { const tools = resolvePluginTools({ context: { config: { plugins: { - load: { paths: [plugin.file] }, - allow: [plugin.id], + load: { paths: [optionalDemoPlugin.file] }, + allow: [optionalDemoPlugin.id], }, }, - workspaceDir: plugin.dir, + workspaceDir: optionalDemoPlugin.dir, + }, + }); + expect(tools).toHaveLength(0); + }); + + it("allows optional tools by name", () => { + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [optionalDemoPlugin.file] }, + allow: [optionalDemoPlugin.id], + }, + }, + workspaceDir: optionalDemoPlugin.dir, + }, + toolAllowlist: ["optional_tool"], + }); + expect(tools.map((tool) => tool.name)).toContain("optional_tool"); + }); + + it("allows optional tools via plugin groups", () => { + const toolsAll = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [optionalDemoPlugin.file] }, + allow: [optionalDemoPlugin.id], + }, + }, + workspaceDir: optionalDemoPlugin.dir, + }, + toolAllowlist: ["group:plugins"], + }); + expect(toolsAll.map((tool) => tool.name)).toContain("optional_tool"); + + const toolsPlugin = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [optionalDemoPlugin.file] }, + allow: [optionalDemoPlugin.id], + }, + }, + workspaceDir: optionalDemoPlugin.dir, + }, + toolAllowlist: ["optional-demo"], + }); + expect(toolsPlugin.map((tool) => tool.name)).toContain("optional_tool"); + }); + + it("rejects plugin id collisions with core tool names", () => { + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [coreNameCollisionPlugin.file] }, + allow: [coreNameCollisionPlugin.id], + }, + }, + workspaceDir: coreNameCollisionPlugin.dir, + }, + existingToolNames: new Set(["message"]), + toolAllowlist: ["message"], + }); + expect(tools).toHaveLength(0); + }); + + it("skips conflicting tool names but keeps other tools", () => { + const tools = resolvePluginTools({ + context: { + config: { + plugins: { + load: { paths: [multiToolPlugin.file] }, + allow: [multiToolPlugin.id], + }, + }, + workspaceDir: multiToolPlugin.dir, }, existingToolNames: new Set(["message"]), }); diff --git a/src/test-utils/ports.ts b/src/test-utils/ports.ts index 214f9ba8f4e..00fa86aa00a 100644 --- a/src/test-utils/ports.ts +++ b/src/test-utils/ports.ts @@ -62,7 +62,9 @@ export async function getDeterministicFreePortBlock(params?: { // Allocate in blocks to avoid derived-port overlaps (e.g. port+3). const blockSize = Math.max(maxOffset + 1, 8); - for (let attempt = 0; attempt < usable; attempt += 1) { + // Scan in block-size steps. Tests consume neighboring derived ports (+1/+2/...), + // so probing every single offset is wasted work and slows large suites. + for (let attempt = 0; attempt < usable; attempt += blockSize) { const start = base + ((nextTestPortOffset + attempt) % usable); // eslint-disable-next-line no-await-in-loop const ok = (await Promise.all(offsets.map((offset) => isPortFree(start + offset)))).every(