diff --git a/extensions/browser/index.test.ts b/extensions/browser/index.test.ts index 92b6619d9294..604c3d86d9d3 100644 --- a/extensions/browser/index.test.ts +++ b/extensions/browser/index.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import { browserPluginNodeHostCommands, browserPluginReload, @@ -44,6 +44,10 @@ vi.mock("./src/cli/browser-cli.js", () => ({ registerBrowserCli: runtimeApiMocks.registerBrowserCli, })); +beforeAll(async () => { + await import("./register.runtime.js"); +}); + function createApi() { const registerCli = vi.fn(); const registerGatewayMethod = vi.fn(); diff --git a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts index a3c74b8c0ea1..e7176d4cee30 100644 --- a/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/extensions/browser/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import "../test-support/browser-security.mock.js"; import { BROWSER_NAVIGATION_BLOCKED_MESSAGE } from "./errors.js"; import { DEFAULT_DOWNLOAD_DIR, DEFAULT_TRACE_DIR, DEFAULT_UPLOAD_DIR } from "./paths.js"; @@ -22,6 +22,10 @@ const state = getBrowserControlServerTestState(); const pwMocks = getPwMocks(); const realFetch: BrowserTestFetch = (input, init) => getBrowserTestFetch()(input, init); +beforeAll(async () => { + await import("../server.js"); +}); + type GuardedCurrentTabRouteCase = { method: "GET" | "POST"; path: string; diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 0c599567898f..60075e71220f 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -146,7 +146,7 @@ describe("startCodexAttemptThread", () => { }); it("clears the shared app-server when startup abandons an in-flight thread request", async () => { - const { harness, run } = startThreadWithHarness(2_000); + const { harness, run } = startThreadWithHarness(1_100); const runError = run.then( () => undefined, (error: unknown) => error, diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index d9a22cf67856..5c68483018f6 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -1,5 +1,5 @@ import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; @@ -18,6 +18,10 @@ vi.mock("./runtime.js", async () => { return createFeishuRuntimeMockModule(); }); +beforeAll(async () => { + await import("./monitor.account.js"); +}); + function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig { return { channels: { diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts index f3acd6992132..80a6f8cc7005 100644 --- a/extensions/feishu/src/monitor.webhook-e2e.test.ts +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createFeishuRuntimeMockModule } from "./monitor.test-mocks.js"; import { withRunningWebhookMonitor } from "./monitor.webhook.test-helpers.js"; @@ -21,6 +21,10 @@ vi.mock("./runtime.js", () => createFeishuRuntimeMockModule()); import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js"; +beforeAll(async () => { + await import("./monitor.account.js"); +}); + function signFeishuPayload(params: { encryptKey: string; rawBody: string; diff --git a/extensions/feishu/src/monitor.webhook-security.test.ts b/extensions/feishu/src/monitor.webhook-security.test.ts index 7e72c4daffef..9a6b3c10f232 100644 --- a/extensions/feishu/src/monitor.webhook-security.test.ts +++ b/extensions/feishu/src/monitor.webhook-security.test.ts @@ -1,6 +1,6 @@ import type { IncomingMessage } from "node:http"; import { createConnection } from "node:net"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createFeishuClientMockModule, createFeishuRuntimeMockModule, @@ -49,6 +49,10 @@ import { import { buildFeishuWebhookRateLimitKeyForTest, monitorWebhook } from "./monitor.transport.js"; import type { ResolvedFeishuAccount } from "./types.js"; +beforeAll(async () => { + await import("./monitor.account.js"); +}); + async function waitForSlowBodyTimeoutResponse( url: string, timeoutMs: number, diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 92df0150c7a0..6941a291e5a7 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -48,6 +48,7 @@ describe("sendMessageIMessage receipts", () => { afterEach(() => { clearIMessageApprovalReactionTargetsForTest(); vi.unstubAllEnvs(); + vi.useRealTimers(); }); it("attaches a text receipt for native send ids", async () => { @@ -424,31 +425,32 @@ describe("sendMessageIMessage receipts", () => { }); it("does not use the local default chat.db path for custom cliPath wrappers", async () => { + vi.useFakeTimers(); vi.stubEnv("HOME", "/Users/me"); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); const approvalText = createApprovalText("approval-remote"); - await expect( - sendMessageIMessage("chat_id:42", approvalText, { - config: { - channels: { - imessage: { - accounts: { - default: { - remoteHost: "bot@gateway-host", - }, + const send = sendMessageIMessage("chat_id:42", approvalText, { + config: { + channels: { + imessage: { + accounts: { + default: { + remoteHost: "bot@gateway-host", }, }, }, }, - client, - cliPath: "/Users/me/.openclaw/scripts/imsg", - runCliJson, - resolveSentMessageGuidImpl, - }), - ).rejects.toThrow("imsg rpc timeout (send)"); + }, + client, + cliPath: "/Users/me/.openclaw/scripts/imsg", + runCliJson, + resolveSentMessageGuidImpl, + }); + await vi.advanceTimersByTimeAsync(5_250); + await expect(send).rejects.toThrow("imsg rpc timeout (send)"); expect(runCliJson).not.toHaveBeenCalled(); expect(resolveSentMessageGuidImpl).toHaveBeenCalledWith({ @@ -460,6 +462,7 @@ describe("sendMessageIMessage receipts", () => { }); it("does not use the local default chat.db path for auto-detected ssh wrappers", async () => { + vi.useFakeTimers(); vi.stubEnv("HOME", "/Users/me"); const wrapperDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-imsg-wrapper-")); const wrapperPath = path.join(wrapperDir, "imsg"); @@ -470,15 +473,15 @@ describe("sendMessageIMessage receipts", () => { const approvalText = createApprovalText("approval-ssh-wrapper"); try { - await expect( - sendMessageIMessage("chat_id:42", approvalText, { - config: IMESSAGE_TEST_CFG, - client, - cliPath: wrapperPath, - runCliJson, - resolveSentMessageGuidImpl, - }), - ).rejects.toThrow("imsg rpc timeout (send)"); + const send = sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + cliPath: wrapperPath, + runCliJson, + resolveSentMessageGuidImpl, + }); + await vi.advanceTimersByTimeAsync(5_250); + await expect(send).rejects.toThrow("imsg rpc timeout (send)"); } finally { fs.rmSync(wrapperDir, { recursive: true, force: true }); } @@ -512,20 +515,21 @@ describe("sendMessageIMessage receipts", () => { }); it("throws the rpc timeout without resending when approval GUID recovery misses", async () => { + vi.useFakeTimers(); const client = createRejectingClient(new Error("imsg rpc timeout (send)")); const runCliJson = vi.fn(); const resolveSentMessageGuidImpl = vi.fn(async () => null); const approvalText = createApprovalText(); - await expect( - sendMessageIMessage("chat_id:42", approvalText, { - config: IMESSAGE_TEST_CFG, - client, - runCliJson, - dbPath: "/Users/me/Library/Messages/chat.db", - resolveSentMessageGuidImpl, - }), - ).rejects.toThrow("imsg rpc timeout (send)"); + const send = sendMessageIMessage("chat_id:42", approvalText, { + config: IMESSAGE_TEST_CFG, + client, + runCliJson, + dbPath: "/Users/me/Library/Messages/chat.db", + resolveSentMessageGuidImpl, + }); + await vi.advanceTimersByTimeAsync(5_250); + await expect(send).rejects.toThrow("imsg rpc timeout (send)"); expect(runCliJson).not.toHaveBeenCalled(); expect(resolveSentMessageGuidImpl).toHaveBeenCalled(); diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index 5ad61b8085d0..7bdbbb17c539 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -1,5 +1,5 @@ import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; import { startNostrGatewayAccount } from "./gateway.js"; import { setNostrRuntime } from "./runtime.js"; @@ -25,6 +25,10 @@ vi.mock("./nostr-key-utils.js", () => ({ normalizePubkey: mocks.normalizePubkey, })); +beforeAll(async () => { + await import("./inbound-direct-dm-runtime.js"); +}); + function createMockBus() { return { sendDm: vi.fn(async () => {}), diff --git a/scripts/test-group-report.mjs b/scripts/test-group-report.mjs index e173f01d1325..00fdc31cb2ee 100644 --- a/scripts/test-group-report.mjs +++ b/scripts/test-group-report.mjs @@ -209,9 +209,12 @@ function runVitestJsonReport(params) { encoding: "utf8", env: { ...process.env, + ...params.env, NODE_OPTIONS: [ - process.env.NODE_OPTIONS?.trim(), - ...resolveVitestNodeArgs(process.env).filter((arg) => arg !== "--no-maglev"), + (params.env?.NODE_OPTIONS ?? process.env.NODE_OPTIONS)?.trim(), + ...resolveVitestNodeArgs({ ...process.env, ...params.env }).filter( + (arg) => arg !== "--no-maglev", + ), ] .filter(Boolean) .join(" "), @@ -311,6 +314,20 @@ export function resolveRunPlans(args) { })); } +export function resolveFullSuiteVitestEnv(args, env = process.env, label = "") { + if ( + !args.fullSuite || + env.OPENCLAW_VITEST_MAX_WORKERS?.trim() || + env.OPENCLAW_TEST_WORKERS?.trim() + ) { + return {}; + } + + return { + OPENCLAW_VITEST_MAX_WORKERS: label === "commands" ? "1" : "2", + }; +} + function printRunLine(run) { console.log( `[test-group-report] ${run.label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`, @@ -365,6 +382,7 @@ async function main() { const run = runVitestJsonReport({ config: plan.config, forwardedArgs: plan.forwardedArgs, + env: resolveFullSuiteVitestEnv(args, process.env, plan.label), label: plan.label, logPath: path.join(logDir, `${slug}.log`), reportPath: path.join(reportDir, `${slug}.json`), diff --git a/src/agents/btw.test.ts b/src/agents/btw.test.ts index 6a8093308470..f66057270223 100644 --- a/src/agents/btw.test.ts +++ b/src/agents/btw.test.ts @@ -70,6 +70,30 @@ vi.mock("./model-auth.js", () => ({ requireApiKey: (...args: unknown[]) => requireApiKeyMock(...args), })); +vi.mock("./model-runtime-aliases.js", () => ({ + resolveCliRuntimeExecutionProvider: ({ + provider, + cfg, + modelId, + }: { + provider?: string; + cfg?: { + agents?: { + defaults?: { + models?: Record; + }; + }; + }; + modelId?: string; + }) => { + const key = provider && modelId ? `${provider}/${modelId}` : undefined; + const runtime = key + ? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim() + : undefined; + return runtime || undefined; + }, +})); + vi.mock("./embedded-agent-runner/runs.js", () => ({ getActiveEmbeddedRunSnapshot: (...args: unknown[]) => getActiveEmbeddedRunSnapshotMock(...args), })); diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index 92d072e95ab1..41c9fda1285e 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; // vi.mock factories are hoisted above imports, so any references inside them @@ -70,6 +70,8 @@ const baseRunParams = { runId: "test-run-id", } as const; +let runCliAgent: typeof import("./cli-runner.js").runCliAgent; + function makeStubContext(params: typeof baseRunParams & { trigger?: string }) { return { params, @@ -102,13 +104,16 @@ beforeEach(() => { closeMcpLoopbackServerMock.mockReset(); }); +beforeAll(async () => { + ({ runCliAgent } = await import("./cli-runner.js")); +}); + afterEach(() => { vi.clearAllMocks(); }); describe("runCliAgent cron before_agent_reply seam", () => { it("lets before_agent_reply claim cron runs before the CLI subprocess is invoked", async () => { - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue({ handled: true, @@ -146,7 +151,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { // Regression for PR #70950 review (greptile-apps, P1): the gate must fire // before any backend resources are allocated, otherwise preparedBackend.cleanup // is silently skipped on every claimed cron turn. - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue({ handled: true }); @@ -157,7 +161,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("re-arms setup progress when a cron hook does not claim", async () => { - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue(undefined); executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); @@ -185,7 +188,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("treats empty CLI subprocess output as a failover failure, not a green cron run", async () => { - const { runCliAgent } = await import("./cli-runner.js"); executePreparedCliRunMock.mockResolvedValue({ text: " " }); await expect(runCliAgent({ ...baseRunParams, trigger: "cron" })).rejects.toMatchObject({ @@ -198,7 +200,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("returns a silent payload when a cron hook claims without a reply body", async () => { - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue({ handled: true }); @@ -209,7 +210,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("does not invoke before_agent_reply for non-cron triggers", async () => { - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); @@ -220,7 +220,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("falls through to the CLI subprocess when no before_agent_reply hook is registered", async () => { - const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockReturnValue(false); executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); @@ -231,7 +230,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("can close temporary CLI live sessions after a run", async () => { - const { runCliAgent } = await import("./cli-runner.js"); executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); await runCliAgent({ ...baseRunParams, cleanupCliLiveSessionOnRunEnd: true }); @@ -244,7 +242,6 @@ describe("runCliAgent cron before_agent_reply seam", () => { }); it("can close temporary bundle MCP loopback resources after a run", async () => { - const { runCliAgent } = await import("./cli-runner.js"); executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); await runCliAgent({ ...baseRunParams, cleanupBundleMcpOnRunEnd: true }); diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index a3a52cd99a1b..03e8600eae1c 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -29,6 +29,30 @@ vi.mock("../provider-auth-aliases.js", () => ({ provider.trim().toLowerCase() === "codex-cli" ? "openai-codex" : provider.trim().toLowerCase(), })); +vi.mock("../model-runtime-aliases.js", async () => { + const actual = await vi.importActual( + "../model-runtime-aliases.js", + ); + return { + ...actual, + resolveCliRuntimeExecutionProvider: ({ + provider, + cfg, + modelId, + }: { + provider?: string; + cfg?: OpenClawConfig; + modelId?: string; + }) => { + const key = provider && modelId ? `${provider}/${modelId}` : undefined; + const runtime = key + ? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim() + : undefined; + return runtime || provider; + }, + }; +}); + vi.mock("../embedded-agent.js", () => ({ runEmbeddedAgent: runEmbeddedAgentMock, })); @@ -1100,7 +1124,7 @@ describe("CLI attempt execution", () => { agents: { defaults: { models: { - "openai/gpt-5.4": { agentRuntime: { id: "codex-cli" } }, + "openai/gpt-5.4": { agentRuntime: { id: "codex" } }, }, }, }, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index f41a691cfa4d..ed190fbc63eb 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -456,6 +456,41 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ buildAgentRuntimePlan: mockedBuildAgentRuntimePlan, })); + vi.doMock("../model-runtime-aliases.js", () => ({ + isCliRuntimeAliasForProvider: ({ + runtime, + provider, + }: { + runtime?: string; + provider?: string; + }) => + (provider?.trim().toLowerCase() === "anthropic" && + runtime?.trim().toLowerCase() === "claude-cli") || + (provider?.trim().toLowerCase() === "openai" && + runtime?.trim().toLowerCase() === "codex-cli"), + resolveCliRuntimeExecutionProvider: ({ + provider, + cfg, + modelId, + }: { + provider?: string; + cfg?: { + agents?: { + defaults?: { + models?: Record; + }; + }; + }; + modelId?: string; + }) => { + const key = provider && modelId ? `${provider}/${modelId}` : undefined; + const runtime = key + ? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim() + : undefined; + return runtime || undefined; + }, + })); + vi.doMock("../../plugins/provider-runtime.js", () => ({ prepareProviderRuntimeAuth: mockedPrepareProviderRuntimeAuth, resolveProviderCapabilitiesWithPlugin: vi.fn(() => ({})), diff --git a/src/agents/harness/selection.test.ts b/src/agents/harness/selection.test.ts index f182e30d0236..55f94d8809f9 100644 --- a/src/agents/harness/selection.test.ts +++ b/src/agents/harness/selection.test.ts @@ -37,6 +37,13 @@ const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME; beforeEach(() => { clearAgentHarnesses(); cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), resolveRuntimeCliBackends: () => [ { id: "claude-cli", diff --git a/src/agents/model-runtime-aliases.test.ts b/src/agents/model-runtime-aliases.test.ts index afb4dd983e8c..2996f707b9c3 100644 --- a/src/agents/model-runtime-aliases.test.ts +++ b/src/agents/model-runtime-aliases.test.ts @@ -30,6 +30,13 @@ function createAnthropicAuthConfig(params: { describe("resolveCliRuntimeExecutionProvider", () => { beforeEach(() => { cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), resolveRuntimeCliBackends: () => [ { id: "claude-cli", diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 5b6d956fc785..6a31f9c9ece6 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -125,6 +125,7 @@ async function runEnvProviderCase(params: { describe("models-config", () => { beforeAll(async () => { + vi.resetModules(); ({ clearConfigCache, clearRuntimeConfigSnapshot } = await import("../config/config.js")); ({ clearRuntimeAuthProfileStoreSnapshots } = await import("./auth-profiles/store.js")); ({ ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } = diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index 8bdbbed500a7..1dbf63d9630c 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -20,6 +20,36 @@ import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridg import { createUnsafeMountedSandbox } from "../test-helpers/unsafe-mounted-sandbox.js"; import { makeZeroUsageSnapshot } from "../usage.js"; import { testing, createImageTool, resolveImageModelConfigForTool } from "./image-tool.js"; +import { resolveMediaToolInboundRoots } from "./media-tool-shared.js"; + +const publicSurfaceLoaderMocks = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSync: vi.fn( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "imessage" && artifactBasename === "media-contract-api.js") { + return { + resolveInboundAttachmentRoots: ({ + accountId, + cfg, + }: { + accountId?: string | null; + cfg: OpenClawConfig; + }) => [ + ...((accountId + ? cfg.channels?.imessage?.accounts?.[accountId]?.attachmentRoots + : undefined) ?? []), + ...(cfg.channels?.imessage?.attachmentRoots ?? []), + "/Users/*/Library/Messages/Attachments", + ], + }; + } + throw new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); + }, + ), +})); + +vi.mock("../../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks); type CreateOpenClawCodingToolsArgs = Parameters[0]; type MockOpenClawToolsOptions = { @@ -565,6 +595,12 @@ const moonshotProvider = { function installImageUnderstandingProviderDeps( providers: MediaUnderstandingProvider[], options?: { + describeImageWithModel?: NonNullable< + Parameters[0] + >["describeImageWithModel"]; + describeImagesWithModel?: NonNullable< + Parameters[0] + >["describeImagesWithModel"]; loadImageWebMediaRuntime?: NonNullable< Parameters[0] >["loadImageWebMediaRuntime"]; @@ -595,8 +631,8 @@ function installImageUnderstandingProviderDeps( id: string, registry: Map, ) => imageProviderHarness.getMediaUnderstandingProvider(id, registry), - describeImageWithModel: describeGenericImageWithModel, - describeImagesWithModel: describeGenericImagesWithModel, + describeImageWithModel: options?.describeImageWithModel ?? describeGenericImageWithModel, + describeImagesWithModel: options?.describeImagesWithModel ?? describeGenericImagesWithModel, resolveAutoMediaKeyProviders: ({ capability }) => capability === "image" ? ["openai", "anthropic"] : [], resolveDefaultMediaModel: ({ providerId, capability }) => @@ -617,6 +653,12 @@ function installImageUnderstandingProviderStubs(...providers: MediaUnderstanding function installFastLocalImageProviderStubs(...providers: MediaUnderstandingProvider[]) { installImageUnderstandingProviderDeps(providers, { + describeImageWithModel: async () => { + throw new Error("Expected fast local image tests to use a registered image provider"); + }, + describeImagesWithModel: async () => { + throw new Error("Expected fast local image tests to use a registered image provider"); + }, resolveImageCompressionPolicy: async ({ imageCount }) => ({ imageCount }), resolveModelAsync: async (provider, model) => ({ model: { @@ -1777,6 +1819,15 @@ describe("image tool implicit imageModel config", () => { }, }; + expect(resolveMediaToolInboundRoots({ cfg })).toEqual([]); + const roots = resolveMediaToolInboundRoots({ + cfg, + channelId: "imessage", + accountId: "work", + }); + expect(roots).toContain(attachmentRoot); + expect(isInboundPathAllowed({ filePath: imagePath, roots })).toBe(true); + const withoutChannel = createRequiredImageTool({ config: cfg, agentDir }); await expect( withoutChannel.execute("t1", { prompt: "Describe.", image: imagePath }), diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts index 95ab0fa75ca6..184da838f7aa 100644 --- a/src/auto-reply/reply/agent-runner-execution.test.ts +++ b/src/auto-reply/reply/agent-runner-execution.test.ts @@ -1101,6 +1101,7 @@ describe("buildContextOverflowRecoveryText", () => { describe("runAgentTurnWithFallback", () => { beforeEach(() => { + vi.useRealTimers(); state.runEmbeddedAgentMock.mockReset(); state.runCliAgentMock.mockReset(); state.runWithModelFallbackMock.mockReset(); diff --git a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts new file mode 100644 index 000000000000..ba698087dd42 --- /dev/null +++ b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts @@ -0,0 +1,310 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TemplateContext } from "../templating.js"; +import type { AgentRunLoopResult } from "./agent-runner-execution.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; +import type { ReplyOperation } from "./reply-run-registry.js"; +import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; + +const runAgentTurnWithFallbackMock = vi.fn(); +const resolveOutboundAttachmentFromUrlMock = vi.fn(); +const enqueueFollowupRunMock = vi.fn(); +const refreshQueuedFollowupSessionMock = vi.fn(); +const scheduleFollowupDrainMock = vi.fn(); + +vi.mock("../../agents/context.js", () => ({ + resolveContextTokensForModel: () => 200_000, +})); + +vi.mock("../../agents/model-selection.js", async () => { + const actual = await vi.importActual( + "../../agents/model-selection.js", + ); + return { + ...actual, + isCliProvider: () => false, + }; +}); + +vi.mock("../../agents/sandbox.js", async () => { + const actual = + await vi.importActual("../../agents/sandbox.js"); + return { + ...actual, + ensureSandboxWorkspaceForSession: async () => null, + }; +}); + +vi.mock("../../infra/diagnostic-events.js", async () => { + const actual = await vi.importActual( + "../../infra/diagnostic-events.js", + ); + return { + ...actual, + emitTrustedDiagnosticEvent: vi.fn(), + isDiagnosticsEnabled: () => false, + }; +}); + +vi.mock("../../infra/diagnostics-timeline.js", async () => { + const actual = await vi.importActual( + "../../infra/diagnostics-timeline.js", + ); + return { + ...actual, + measureDiagnosticsTimelineSpan: async (_name: string, run: () => unknown) => await run(), + }; +}); + +vi.mock("../../media/outbound-attachment.js", () => ({ + resolveOutboundAttachmentFromUrl: (...args: unknown[]) => + resolveOutboundAttachmentFromUrlMock(...args), +})); + +vi.mock("./agent-runner-execution.js", () => ({ + buildKnownAgentRunFailureReplyPayload: vi.fn(() => undefined), + runAgentTurnWithFallback: (...args: unknown[]) => runAgentTurnWithFallbackMock(...args), +})); + +vi.mock("./agent-runner-memory.js", () => ({ + runMemoryFlushIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => sessionEntry, + runPreflightCompactionIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => + sessionEntry, +})); + +vi.mock("./agent-runner-utils.js", async () => { + const actual = + await vi.importActual("./agent-runner-utils.js"); + return { + ...actual, + resolveQueuedReplyExecutionConfig: async (config: unknown) => config, + }; +}); + +vi.mock("./queue.js", async () => { + const actual = await vi.importActual("./queue.js"); + return { + ...actual, + enqueueFollowupRun: (...args: unknown[]) => enqueueFollowupRunMock(...args), + refreshQueuedFollowupSession: (...args: unknown[]) => refreshQueuedFollowupSessionMock(...args), + scheduleFollowupDrain: (...args: unknown[]) => scheduleFollowupDrainMock(...args), + }; +}); + +vi.mock("./session-run-accounting.js", () => ({ + incrementRunCompactionCount: async () => undefined, + persistRunSessionUsage: async () => undefined, +})); + +const { runReplyAgent } = await import("./agent-runner.js"); + +function createReplyOperation(): ReplyOperation { + return { + result: undefined, + setPhase: vi.fn(), + fail: vi.fn(), + complete: vi.fn(), + completeThen: vi.fn(), + } as unknown as ReplyOperation; +} + +function makeRunReplyAgentParams( + overrides: Partial[0]> = {}, +): Parameters[0] { + const provider = "telegram"; + const workspaceDir = "/tmp/workspace"; + const prompt = "generate chart"; + + return { + commandBody: prompt, + followupRun: createMockFollowupRun({ + prompt, + run: { + agentId: "main", + agentDir: "/tmp/agent", + messageProvider: provider, + workspaceDir, + }, + }) as unknown as FollowupRun, + queueKey: "main", + resolvedQueue: { mode: "interrupt" } as QueueSettings, + shouldSteer: false, + shouldFollowup: false, + isActive: false, + isStreaming: false, + typing: createMockTypingController(), + sessionCtx: { + Provider: provider, + Surface: provider, + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + } as unknown as TemplateContext, + defaultModel: "anthropic/claude", + resolvedVerboseLevel: "off", + isNewSession: false, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + shouldInjectGroupIntro: false, + typingMode: "instant", + replyOperation: createReplyOperation(), + ...overrides, + }; +} + +describe("runReplyAgent final MEDIA replies", () => { + beforeEach(() => { + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + runAgentTurnWithFallbackMock.mockReset(); + resolveOutboundAttachmentFromUrlMock.mockReset(); + enqueueFollowupRunMock.mockReset(); + refreshQueuedFollowupSessionMock.mockReset(); + scheduleFollowupDrainMock.mockReset(); + + runAgentTurnWithFallbackMock.mockImplementation(async (params: unknown) => { + const { buildReplyPayloads } = await vi.importActual< + typeof import("./agent-runner-payloads.js") + >("./agent-runner-payloads.js"); + const runnerParams = params as { + replyMediaContext?: { + normalizePayload?: (payload: { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + }) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>; + }; + }; + const normalizeMediaPaths = runnerParams.replyMediaContext?.normalizePayload; + if (!normalizeMediaPaths) { + throw new Error("runReplyAgent did not pass replyMediaContext to the agent turn"); + } + const { replyPayloads } = await buildReplyPayloads({ + payloads: [{ text: "here is the chart\nMEDIA:./out/generated.png" }], + isHeartbeat: false, + didLogHeartbeatStrip: false, + blockStreamingEnabled: false, + blockReplyPipeline: null, + replyToMode: "all", + replyToChannel: "telegram", + currentMessageId: "msg-1", + messageProvider: "telegram", + originatingChannel: "telegram", + originatingTo: "chat-1", + accountId: "default", + normalizeMediaPaths, + }); + const payload = replyPayloads[0]; + if (!payload) { + throw new Error("expected parsed reply payload"); + } + return { + kind: "final", + payload, + } satisfies AgentRunLoopResult; + }); + resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ + path: path.join("/tmp/outbound-media", path.basename(mediaUrl)), + })); + }); + + it("normalizes final MEDIA directives through runReplyAgent", async () => { + const result = await runReplyAgent(makeRunReplyAgentParams()); + + expect(Array.isArray(result)).toBe(false); + if (!result || Array.isArray(result)) { + throw new Error("expected single reply payload"); + } + expect(result).toMatchObject({ + text: "here is the chart", + mediaUrl: "/tmp/outbound-media/generated.png", + mediaUrls: ["/tmp/outbound-media/generated.png"], + }); + expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith( + path.join("/tmp/workspace", "out", "generated.png"), + 5 * 1024 * 1024, + { mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }) }, + ); + }); + + it("uses one runReplyAgent media context for block and final MEDIA replies", async () => { + let stagedIndex = 0; + resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => { + stagedIndex += 1; + return { + path: path.join("/tmp/outbound-media", `${stagedIndex}-${path.basename(mediaUrl)}`), + }; + }); + runAgentTurnWithFallbackMock.mockImplementationOnce(async (params: unknown) => { + const { buildReplyPayloads } = await vi.importActual< + typeof import("./agent-runner-payloads.js") + >("./agent-runner-payloads.js"); + const runnerParams = params as { + replyMediaContext?: { + normalizePayload?: (payload: { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + }) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>; + }; + }; + const normalizeMediaPaths = runnerParams.replyMediaContext?.normalizePayload; + if (!normalizeMediaPaths) { + throw new Error("runReplyAgent did not pass replyMediaContext to the agent turn"); + } + const commonParams = { + isHeartbeat: false, + didLogHeartbeatStrip: false, + blockStreamingEnabled: false, + blockReplyPipeline: null, + replyToMode: "all" as const, + replyToChannel: "telegram" as const, + currentMessageId: "msg-1", + messageProvider: "telegram", + originatingChannel: "telegram" as const, + originatingTo: "chat-1", + accountId: "default", + normalizeMediaPaths, + }; + const blockPayloads = await buildReplyPayloads({ + ...commonParams, + payloads: [{ text: "block\nMEDIA:./out/chart.png" }], + }); + const finalPayloads = await buildReplyPayloads({ + ...commonParams, + payloads: [{ text: "final\nMEDIA:./out/chart.png" }], + }); + expect(blockPayloads.replyPayloads[0]).toMatchObject({ + text: "block", + mediaUrl: "/tmp/outbound-media/1-chart.png", + }); + const payload = finalPayloads.replyPayloads[0]; + if (!payload) { + throw new Error("expected parsed final payload"); + } + return { + kind: "final", + payload, + } satisfies AgentRunLoopResult; + }); + + const result = await runReplyAgent( + makeRunReplyAgentParams({ + blockStreamingEnabled: true, + opts: { onBlockReply: vi.fn(async () => {}) }, + }), + ); + + expect(Array.isArray(result)).toBe(false); + if (!result || Array.isArray(result)) { + throw new Error("expected single reply payload"); + } + expect(result).toMatchObject({ + text: "final", + mediaUrl: "/tmp/outbound-media/1-chart.png", + mediaUrls: ["/tmp/outbound-media/1-chart.png"], + }); + expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index d124b272d9ba..17752776453a 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -1,10 +1,12 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { EmbeddedAgentQueueMessageOutcome } from "../../agents/embedded-agent-runner/runs.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { TemplateContext } from "../templating.js"; import type { FollowupRun, QueueSettings } from "./queue.js"; +import type { ReplyOperation } from "./reply-run-registry.js"; import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; const runEmbeddedAgentMock = vi.fn(); @@ -30,6 +32,7 @@ const waitForEmbeddedAgentRunEndMock = vi.fn(); const enqueueFollowupRunMock = vi.fn(); const scheduleFollowupDrainMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); +const resolveCommandSecretRefsViaGatewayMock = vi.fn(); const resolveOutboundAttachmentFromUrlMock = vi.fn(); const createReplyMediaContextRuntimeMock = vi.fn(); @@ -45,6 +48,51 @@ vi.mock("../../agents/model-fallback.js", () => ({ Array.isArray((err as { attempts?: unknown[] }).attempts), })); +vi.mock("../../agents/model-selection.js", async () => { + const actual = await vi.importActual( + "../../agents/model-selection.js", + ); + return { + ...actual, + isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + const normalized = provider.trim().toLowerCase(); + return ( + normalized === "claude-cli" || + normalized === "google-gemini-cli" || + normalized === "codex-cli" || + Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized]) + ); + }, + }; +}); + +vi.mock("../../agents/model-runtime-aliases.js", async () => { + const actual = await vi.importActual( + "../../agents/model-runtime-aliases.js", + ); + const normalize = (value: string) => value.trim().toLowerCase(); + return { + ...actual, + areRuntimeModelRefsEquivalent: (left: string, right: string) => + normalize(left) === normalize(right), + }; +}); + +vi.mock("../../agents/context.js", () => ({ + resolveContextTokensForModel: () => 200_000, +})); + +vi.mock("../../infra/agent-events.js", async () => { + const actual = await vi.importActual( + "../../infra/agent-events.js", + ); + return { + ...actual, + emitAgentEvent: vi.fn(), + registerAgentRunContext: vi.fn(), + }; +}); + vi.mock("../../agents/embedded-agent.js", () => ({ abortEmbeddedAgentRun: abortEmbeddedAgentRunMock, compactEmbeddedAgentSession: compactEmbeddedAgentSessionMock, @@ -64,6 +112,123 @@ vi.mock("../../agents/embedded-agent-runner/runs.js", () => ({ queueEmbeddedAgentMessageWithOutcomeAsync: queueEmbeddedAgentMessageWithOutcomeAsyncMock, })); +vi.mock("../../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (...args: unknown[]) => + resolveCommandSecretRefsViaGatewayMock(...args), +})); + +vi.mock("../../cli/command-secret-targets.js", () => ({ + getAgentRuntimeCommandSecretTargetIds: () => new Set(), + getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set() }), +})); + +vi.mock("../../agents/sandbox.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureSandboxWorkspaceForSession: async () => null, + }; +}); + +vi.mock("./reply-media-paths.js", () => ({ + createReplyMediaContext: ({ workspaceDir }: { workspaceDir: string }) => { + const cache = new Map>(); + const normalizeSource = (media: string) => + media.startsWith("./") ? path.join(workspaceDir, media.slice(2)) : media; + const persist = async (media: string) => { + const source = normalizeSource(media); + const cached = cache.get(source); + if (cached) { + return await cached; + } + const pending = resolveOutboundAttachmentFromUrlMock(source, 5 * 1024 * 1024, { + mediaAccess: { workspaceDir }, + }).then((saved: { path: string }) => saved.path); + cache.set(source, pending); + return await pending; + }; + return { + normalizePayload: async (payload: { + mediaUrl?: string; + mediaUrls?: string[]; + text?: string; + }) => { + const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + if (mediaUrls.length === 0) { + return payload; + } + const normalized = await Promise.all(mediaUrls.map((media) => persist(media))); + return { + ...payload, + mediaUrl: normalized[0], + mediaUrls: normalized, + }; + }, + }; + }, +})); + +vi.mock("./agent-runner-payloads.js", () => ({ + buildReplyPayloads: async (params: { + payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>; + didLogHeartbeatStrip: boolean; + blockStreamingEnabled?: boolean; + blockReplyPipeline?: { didStream?: () => boolean; isAborted?: () => boolean } | null; + normalizeMediaPaths?: (payload: { + text?: string; + mediaUrl?: string; + mediaUrls?: string[]; + }) => Promise<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>; + }) => { + if ( + params.blockStreamingEnabled && + params.blockReplyPipeline?.didStream?.() === true && + params.blockReplyPipeline?.isAborted?.() !== true + ) { + return { replyPayloads: [], didLogHeartbeatStrip: params.didLogHeartbeatStrip }; + } + const replyPayloads = []; + for (const payload of params.payloads) { + const mediaUrls = [...(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []))]; + const textLines = []; + for (const line of (payload.text ?? "").split("\n")) { + const media = line + .trim() + .match(/^MEDIA:(.+)$/)?.[1] + ?.trim(); + if (media) { + mediaUrls.push(media); + } else { + textLines.push(line); + } + } + const nextPayload = { + ...payload, + text: textLines.join("\n").trim() || undefined, + mediaUrl: mediaUrls[0], + mediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined, + }; + replyPayloads.push( + params.normalizeMediaPaths && nextPayload.mediaUrls + ? await params.normalizeMediaPaths(nextPayload) + : nextPayload, + ); + } + return { replyPayloads, didLogHeartbeatStrip: params.didLogHeartbeatStrip }; + }, +})); + +vi.mock("./session-run-accounting.js", () => ({ + incrementRunCompactionCount: async () => undefined, + persistRunSessionUsage: async () => undefined, +})); + +vi.mock("./agent-runner-memory.js", () => ({ + runMemoryFlushIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => sessionEntry, + runPreflightCompactionIfNeeded: async ({ sessionEntry }: { sessionEntry?: unknown }) => + sessionEntry, +})); + vi.mock("./queue.js", () => ({ enqueueFollowupRun: enqueueFollowupRunMock, refreshQueuedFollowupSession: refreshQueuedFollowupSessionMock, @@ -88,7 +253,17 @@ vi.mock("./reply-media-paths.runtime.js", async (importOriginal) => { }; }); -let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent; +const { runReplyAgent } = await import("./agent-runner.js"); + +function createReplyOperation(): ReplyOperation { + return { + result: undefined, + setPhase: vi.fn(), + fail: vi.fn(), + complete: vi.fn(), + completeThen: vi.fn(), + } as unknown as ReplyOperation; +} function makeRunReplyAgentParams( overrides: Partial[0]> & { @@ -134,6 +309,7 @@ function makeRunReplyAgentParams( resolvedBlockStreamingBreak: "message_end", shouldInjectGroupIntro: false, typingMode: "instant", + replyOperation: createReplyOperation(), ...overrides, }; } @@ -141,10 +317,6 @@ function makeRunReplyAgentParams( describe("runReplyAgent media path normalization", () => { const cleanupPaths: string[] = []; - beforeAll(async () => { - ({ runReplyAgent } = await import("./agent-runner.js")); - }); - beforeEach(() => { runEmbeddedAgentMock.mockReset(); runWithModelFallbackMock.mockReset(); @@ -166,6 +338,13 @@ describe("runReplyAgent media path normalization", () => { enqueueFollowupRunMock.mockReset(); scheduleFollowupDrainMock.mockReset(); refreshQueuedFollowupSessionMock.mockReset(); + resolveCommandSecretRefsViaGatewayMock.mockReset(); + resolveCommandSecretRefsViaGatewayMock.mockImplementation(async ({ config }) => ({ + resolvedConfig: config, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + })); resolveOutboundAttachmentFromUrlMock.mockReset(); createReplyMediaContextRuntimeMock.mockReset(); vi.stubEnv("OPENCLAW_TEST_FAST", "1"); @@ -197,7 +376,7 @@ describe("runReplyAgent media path normalization", () => { it("normalizes final MEDIA replies against the run workspace", async () => { runEmbeddedAgentMock.mockResolvedValue({ - payloads: [{ text: "MEDIA:./out/generated.png" }], + payloads: [{ text: "here is the chart\nMEDIA:./out/generated.png" }], meta: { agentMeta: { sessionId: "session", @@ -207,26 +386,24 @@ describe("runReplyAgent media path normalization", () => { }, }); - const result = await runReplyAgent( - makeRunReplyAgentParams({ - provider: "telegram", - prompt: "generate", - }), - ); + const result = await runReplyAgent(makeRunReplyAgentParams()); expect(Array.isArray(result)).toBe(false); if (!result || Array.isArray(result)) { - throw new Error("expected single reply payload"); + throw new Error("Expected a single reply payload"); } - expect(result.mediaUrl).toBe("/tmp/outbound-media/generated.png"); - expect(result.mediaUrls).toEqual(["/tmp/outbound-media/generated.png"]); - const outboundAttachmentCall = resolveOutboundAttachmentFromUrlMock.mock.calls.at(0); - expect(outboundAttachmentCall?.[0]).toBe(path.join("/tmp/workspace", "out", "generated.png")); - expect(outboundAttachmentCall?.[1]).toBe(5 * 1024 * 1024); - const outboundAttachmentOptions = outboundAttachmentCall?.[2] as - | { mediaAccess?: { workspaceDir?: unknown } } - | undefined; - expect(outboundAttachmentOptions?.mediaAccess?.workspaceDir).toBe("/tmp/workspace"); + expect(result).toMatchObject({ + text: "here is the chart", + mediaUrl: "/tmp/outbound-media/generated.png", + mediaUrls: ["/tmp/outbound-media/generated.png"], + }); + expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith( + path.join("/tmp/workspace", "out", "generated.png"), + 5 * 1024 * 1024, + { mediaAccess: expect.objectContaining({ workspaceDir: "/tmp/workspace" }) }, + ); + expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); + expect(createReplyMediaContextRuntimeMock).not.toHaveBeenCalled(); }); it("steers active prompts in steer queue mode", async () => { @@ -298,6 +475,15 @@ describe("runReplyAgent media path normalization", () => { }); it("shares one media cache between block accumulation and final payload delivery", async () => { + const { createReplyMediaContext } = + await vi.importActual("./reply-media-paths.js"); + const mediaContext = createReplyMediaContext({ + cfg: {}, + sessionKey: "main", + workspaceDir: "/tmp/workspace", + messageProvider: "telegram", + accountId: "default", + }); let stagedIndex = 0; resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => { stagedIndex += 1; @@ -305,55 +491,78 @@ describe("runReplyAgent media path normalization", () => { path: path.join("/tmp/outbound-media", `${stagedIndex}-${path.basename(mediaUrl)}`), }; }); - const onBlockReply = vi.fn(); - runEmbeddedAgentMock.mockImplementation( - async (params: { - onBlockReply?: (payload: { text?: string; mediaUrls?: string[] }) => Promise; - }) => { - await params.onBlockReply?.({ - text: "here is the chart\nMEDIA:./out/chart.png", - }); - return { - payloads: [{ text: "here is the chart\nMEDIA:./out/chart.png" }], - meta: { - agentMeta: { - sessionId: "session", - provider: "anthropic", - model: "claude", - }, - }, - }; - }, - ); - const result = await runReplyAgent( - makeRunReplyAgentParams({ - opts: { - onBlockReply, - }, - }), - ); + const blockPayload = await mediaContext.normalizePayload({ + text: "here is the chart", + mediaUrl: "./out/chart.png", + mediaUrls: ["./out/chart.png"], + }); + const finalPayload = await mediaContext.normalizePayload({ + text: "here is the chart", + mediaUrl: "./out/chart.png", + mediaUrls: ["./out/chart.png"], + }); - expect(result).toBeUndefined(); - expect(onBlockReply).toHaveBeenCalledWith({ + expect(blockPayload).toEqual({ text: "here is the chart", mediaUrl: "/tmp/outbound-media/1-chart.png", mediaUrls: ["/tmp/outbound-media/1-chart.png"], - replyToId: "msg-1", - replyToTag: false, - audioAsVoice: false, }); + expect(finalPayload).toEqual(blockPayload); expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledTimes(1); }); - it("does not create a second media context inside runAgentTurnWithFallback when onBlockReply is provided", async () => { + async function runAgentTurnWithSessionContext( + sessionCtx: TemplateContext, + prompt = "describe this image", + ): Promise { + const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js"); + await runAgentTurnWithFallback({ + commandBody: prompt, + followupRun: createMockFollowupRun({ + prompt, + run: { + provider: "ollama", + model: "gemma4:latest", + workspaceDir: "/tmp/workspace", + config: {}, + }, + }), + sessionCtx, + typingSignals: { + mode: "instant", + shouldStartImmediately: true, + shouldStartOnMessageStart: false, + shouldStartOnText: true, + shouldStartOnReasoning: false, + signalRunStart: async () => {}, + signalMessageStart: async () => {}, + signalTextDelta: async () => {}, + signalReasoningDelta: async () => {}, + signalToolStart: async () => {}, + }, + blockReplyPipeline: null, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: "message_end", + applyReplyToMode: (payload) => payload, + shouldEmitToolResult: () => false, + shouldEmitToolOutput: () => false, + pendingToolTasks: new Set(), + resetSessionAfterRoleOrderingConflict: async () => false, + isHeartbeat: false, + sessionKey: "main", + getActiveSessionEntry: () => undefined, + resolvedVerboseLevel: "off", + replyMediaContext: { + normalizePayload: async (payload) => payload, + }, + }); + } + + it("reuses the provided media context inside runAgentTurnWithFallback", async () => { // Regression test for openclaw/openclaw#68056. - // Before the fix, runAgentTurnWithFallback created its own media context, separate from - // the one agent-runner.ts created and passed to buildReplyPayloads. Two separate caches - // meant the same source could be persisted twice (two UUID outbound files, two sends). - // - // After the fix, agent-runner.ts passes its media context into runAgentTurnWithFallback, so - // the .runtime import path is never called from inside that function. + // runAgentTurnWithFallback must use the caller-provided context so block + // replies and final replies can share one media cache. runEmbeddedAgentMock.mockResolvedValue({ payloads: [], meta: { @@ -365,17 +574,58 @@ describe("runReplyAgent media path normalization", () => { }, }); - await runReplyAgent( - makeRunReplyAgentParams({ - opts: { - onBlockReply: vi.fn(), - }, - }), - ); + const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js"); + const followupRun = createMockFollowupRun({ + prompt: "generate", + run: { + provider: "anthropic", + model: "claude", + workspaceDir: "/tmp/workspace", + config: {}, + }, + }); + await runAgentTurnWithFallback({ + commandBody: "generate", + followupRun, + sessionCtx: { + Provider: "telegram", + Surface: "telegram", + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + } as unknown as TemplateContext, + typingSignals: { + mode: "instant", + shouldStartImmediately: true, + shouldStartOnMessageStart: false, + shouldStartOnText: true, + shouldStartOnReasoning: false, + signalRunStart: async () => {}, + signalMessageStart: async () => {}, + signalTextDelta: async () => {}, + signalReasoningDelta: async () => {}, + signalToolStart: async () => {}, + }, + blockReplyPipeline: null, + blockStreamingEnabled: true, + resolvedBlockStreamingBreak: "message_end", + applyReplyToMode: (payload) => payload, + shouldEmitToolResult: () => false, + shouldEmitToolOutput: () => false, + pendingToolTasks: new Set(), + resetSessionAfterRoleOrderingConflict: async () => false, + isHeartbeat: false, + sessionKey: "main", + getActiveSessionEntry: () => undefined, + resolvedVerboseLevel: "off", + replyMediaContext: { + normalizePayload: async (payload) => payload, + }, + }); - // The .runtime import is only used by agent-runner-execution.ts. After the fix, - // runAgentTurnWithFallback receives the context from the caller and never - // creates its own. + // The .runtime import is only used by agent-runner-execution.ts. This path + // should never create its own media context when the caller provides one. expect(createReplyMediaContextRuntimeMock).not.toHaveBeenCalled(); }); @@ -401,23 +651,17 @@ describe("runReplyAgent media path normalization", () => { }, }); - await runReplyAgent( - makeRunReplyAgentParams({ - provider: "telegram", - prompt: "describe this image", - sessionCtx: { - Provider: "telegram", - Surface: "telegram", - To: "chat-1", - OriginatingTo: "chat-1", - AccountId: "default", - MessageSid: "msg-1", - MediaPaths: [imagePath], - MediaTypes: ["image/png"], - MediaWorkspaceDir: tmpDir, - } as unknown as TemplateContext, - }), - ); + await runAgentTurnWithSessionContext({ + Provider: "telegram", + Surface: "telegram", + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + MediaPaths: [imagePath], + MediaTypes: ["image/png"], + MediaWorkspaceDir: tmpDir, + } as unknown as TemplateContext); expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); const call = runEmbeddedAgentMock.mock.calls[0]?.[0] as @@ -459,28 +703,25 @@ describe("runReplyAgent media path normalization", () => { }, }); - await runReplyAgent( - makeRunReplyAgentParams({ - provider: "telegram", - prompt: "what did we discuss?", - sessionCtx: { - Provider: "telegram", - Surface: "telegram", - To: "chat-1", - OriginatingTo: "chat-1", - AccountId: "default", - MessageSid: "msg-1", - Timestamp: 1_700_000_000_000, - InboundHistory: [ - { - sender: "alice", - body: "", - timestamp: 1_700_000_000_000, - media: [{ path: imagePath, contentType: "image/png", kind: "image" }], - }, - ], - } as unknown as TemplateContext, - }), + await runAgentTurnWithSessionContext( + { + Provider: "telegram", + Surface: "telegram", + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + Timestamp: 1_700_000_000_000, + InboundHistory: [ + { + sender: "alice", + body: "", + timestamp: 1_700_000_000_000, + media: [{ path: imagePath, contentType: "image/png", kind: "image" }], + }, + ], + } as unknown as TemplateContext, + "what did we discuss?", ); expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); @@ -516,22 +757,19 @@ describe("runReplyAgent media path normalization", () => { }, }); - await runReplyAgent( - makeRunReplyAgentParams({ - provider: "telegram", - prompt: "compare these images", - sessionCtx: { - Provider: "telegram", - Surface: "telegram", - To: "chat-1", - OriginatingTo: "chat-1", - AccountId: "default", - MessageSid: "msg-1", - MediaPaths: [path.join(tmpDir, "missing.png"), imagePath], - MediaTypes: ["image/png", "image/png"], - MediaWorkspaceDir: tmpDir, - } as unknown as TemplateContext, - }), + await runAgentTurnWithSessionContext( + { + Provider: "telegram", + Surface: "telegram", + To: "chat-1", + OriginatingTo: "chat-1", + AccountId: "default", + MessageSid: "msg-1", + MediaPaths: [path.join(tmpDir, "missing.png"), imagePath], + MediaTypes: ["image/png", "image/png"], + MediaWorkspaceDir: tmpDir, + } as unknown as TemplateContext, + "compare these images", ); expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index 2028c4d6d557..a1196387c0dd 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -45,6 +45,13 @@ function createCliBackendTestConfig() { function registerCliBackendsForTest(): void { cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), resolveRuntimeCliBackends: () => [ { id: "claude-cli", @@ -113,6 +120,24 @@ vi.mock("../../agents/cli-runner.js", () => ({ runCliAgent: (...args: unknown[]) => runCliAgentMock(...args), })); +vi.mock("../../agents/model-selection.js", async () => { + const actual = await vi.importActual( + "../../agents/model-selection.js", + ); + return { + ...actual, + isCliProvider: (provider: string, cfg?: OpenClawConfig) => { + const normalized = provider.trim().toLowerCase(); + return ( + normalized === "claude-cli" || + normalized === "google-gemini-cli" || + normalized === "codex-cli" || + Boolean(cfg?.agents?.defaults?.cliBackends?.[normalized]) + ); + }, + }; +}); + vi.mock("../../runtime.js", () => { return { defaultRuntime: { @@ -211,6 +236,7 @@ function firstMockCallArg(mock: MockCallSource, label: string): unknown { } beforeEach(() => { + vi.useRealTimers(); registerCliBackendsForTest(); clearRuntimeConfigSnapshot(); resetDiagnosticEventsForTest(); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index b0aed2f5d536..3c5b1b1cee32 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -1,4 +1,5 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; @@ -31,6 +32,34 @@ const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => vi.fn()); const MODELS_ADD_DEPRECATED_TEXT = "⚠️ /models add is deprecated. Use /models to browse providers and /model to switch models."; +function setFastModelsCliBackendDeps(): void { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + { + id: "google-gemini-cli", + pluginId: "google-gemini-cli", + modelProvider: "google", + config: { command: "gemini" }, + bundleMcp: false, + }, + ], + }); +} + vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: modelCatalogMocks.loadModelCatalog, })); @@ -108,6 +137,7 @@ const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id) })); beforeAll(async () => { + setFastModelsCliBackendDeps(); modelCatalogMocks.loadModelCatalog.mockResolvedValue([ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, ]); @@ -117,6 +147,7 @@ beforeAll(async () => { }); beforeEach(() => { + setFastModelsCliBackendDeps(); modelCatalogMocks.loadModelCatalog.mockReset(); modelCatalogMocks.loadModelCatalog.mockResolvedValue([ { provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" }, @@ -166,6 +197,10 @@ beforeEach(() => { setActivePluginRegistry(registry); }); +afterEach(() => { + cliBackendsTesting.resetDepsForTest(); +}); + function buildParams( commandBodyNormalized: string, cfgOverrides: Partial = {}, diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 59d9bad800da..b4c1bdfe265e 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../../test/helpers/normalize-text.js"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; import { clearAgentHarnesses, registerAgentHarness } from "../../agents/harness/registry.js"; import type { AgentHarness } from "../../agents/harness/types.js"; import { @@ -111,6 +112,7 @@ function registerStatusCodexHarness(): void { } afterEach(() => { + cliBackendsTesting.resetDepsForTest(); clearAgentHarnesses(); providerUsageMock.loadProviderUsageSummary.mockReset(); providerUsageMock.loadProviderUsageSummary.mockResolvedValue({ @@ -156,6 +158,24 @@ function writeTranscriptUsageLog(params: { describe("buildStatusReply subagent summary", () => { beforeEach(() => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + ], + }); resetSubagentRegistryForTests(); resetTaskRegistryForTests({ persist: false }); configureInMemoryTaskRegistryStoreForTests(); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 50f86bd78fb7..4bf508d491b2 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; const authProfilesStoreMock = vi.hoisted(() => ({ profiles: {} as Record< string, @@ -251,6 +252,17 @@ function setDirectiveTestProviders(providers: ProviderPlugin[]): void { } beforeEach(() => { + vi.useRealTimers(); + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [], + }); setDirectiveTestProviders([]); clearRuntimeAuthProfileStoreSnapshots(); replaceRuntimeAuthProfileStoreSnapshots([ @@ -268,6 +280,7 @@ beforeEach(() => { }); afterEach(() => { + cliBackendsTesting.resetDepsForTest(); setDirectiveTestProviders([]); clearRuntimeAuthProfileStoreSnapshots(); clearInternalHooks(); diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 5c50dabe6cc0..b597b3cac46f 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -6812,7 +6812,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => To: "telegram:-1001234567890", AccountId: "default", MessageThreadId: 11, - SessionKey: "agent:main:telegram:group:-1001234567890:topic:11", ChatType: "group", GroupSubject: "Dev", Body: "observed message", @@ -6834,17 +6833,18 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => sourceReplyDeliveryMode: "message_tool_only", }); expect(sessionBindingMocks.touch).toHaveBeenCalledWith("binding-message-tool-only"); - expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( - "openclaw-codex-app-server", - expect.objectContaining({ - channel: "telegram", - content: "observed message", - threadId: 11, - }), - expect.objectContaining({ - pluginBinding: expect.objectContaining({ bindingId: "binding-message-tool-only" }), - }), + const claimCall = firstMockCall( + hookMocks.runner.runInboundClaimForPluginOutcome, + "plugin inbound claim", ); + expect(claimCall[0]).toBe("openclaw-codex-app-server"); + expect(claimCall[1]).toMatchObject({ + channel: "telegram", + content: "observed message", + threadId: 11, + }); + const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } }; + expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-message-tool-only" }); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); @@ -6893,7 +6893,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => To: "telegram:-1001234567890", AccountId: "default", MessageThreadId: 11, - SessionKey: "agent:main:telegram:group:-1001234567890:topic:11", ChatType: "group", GroupSubject: "Dev", Body: "observed message", @@ -6915,17 +6914,20 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => counts: { tool: 0, block: 0, final: 0 }, sourceReplyDeliveryMode: "message_tool_only", }); - expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( - "openclaw-codex-app-server", - expect.objectContaining({ - channel: "telegram", - content: "observed message", - threadId: 11, - }), - expect.objectContaining({ - pluginBinding: expect.objectContaining({ bindingId: "binding-message-tool-fallback" }), - }), + const claimCall = firstMockCall( + hookMocks.runner.runInboundClaimForPluginOutcome, + "plugin inbound claim", ); + expect(claimCall[0]).toBe("openclaw-codex-app-server"); + expect(claimCall[1]).toMatchObject({ + channel: "telegram", + content: "observed message", + threadId: 11, + }); + const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } }; + expect(claimContext.pluginBinding).toMatchObject({ + bindingId: "binding-message-tool-fallback", + }); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); @@ -6976,7 +6978,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => To: "telegram:-1001234567890", AccountId: "default", MessageThreadId: 11, - SessionKey: "agent:main:telegram:group:-1001234567890:topic:11", ChatType: "group", GroupSubject: "Dev", Body: "/reset@openclaw", @@ -7045,7 +7046,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => To: "telegram:-1001234567890", AccountId: "default", MessageThreadId: 11, - SessionKey: "agent:main:telegram:group:-1001234567890:topic:11", ChatType: "group", GroupSubject: "Dev", Body: "/status", @@ -7063,16 +7063,17 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => replyResolver, }); - expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( - "openclaw-codex-app-server", - expect.objectContaining({ - channel: "telegram", - content: "/status", - }), - expect.objectContaining({ - pluginBinding: expect.objectContaining({ bindingId: "binding-native-unauthorized" }), - }), + const claimCall = firstMockCall( + hookMocks.runner.runInboundClaimForPluginOutcome, + "plugin inbound claim", ); + expect(claimCall[0]).toBe("openclaw-codex-app-server"); + expect(claimCall[1]).toMatchObject({ + channel: "telegram", + content: "/status", + }); + const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } }; + expect(claimContext.pluginBinding).toMatchObject({ bindingId: "binding-native-unauthorized" }); expect(replyResolver).not.toHaveBeenCalled(); }); @@ -7121,7 +7122,6 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => To: "telegram:-1001234567890", AccountId: "default", MessageThreadId: 11, - SessionKey: "agent:main:telegram:group:-1001234567890:topic:11", ChatType: "group", GroupSubject: "Dev", Body: "through this", @@ -7144,16 +7144,19 @@ describe("sendPolicy deny — suppress delivery, not processing (#53328)", () => replyResolver, }); - expect(hookMocks.runner.runInboundClaimForPluginOutcome).toHaveBeenCalledWith( - "openclaw-codex-app-server", - expect.objectContaining({ - channel: "telegram", - content: "/think high through this", - }), - expect.objectContaining({ - pluginBinding: expect.objectContaining({ bindingId: "binding-structured-normal-turn" }), - }), + const claimCall = firstMockCall( + hookMocks.runner.runInboundClaimForPluginOutcome, + "plugin inbound claim", ); + expect(claimCall[0]).toBe("openclaw-codex-app-server"); + expect(claimCall[1]).toMatchObject({ + channel: "telegram", + content: "/think high through this", + }); + const claimContext = claimCall[2] as { pluginBinding?: { bindingId?: string } }; + expect(claimContext.pluginBinding).toMatchObject({ + bindingId: "binding-structured-normal-turn", + }); expect(replyResolver).not.toHaveBeenCalled(); }); diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index 53c8e76f4e74..213b78d7dc3a 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -37,6 +37,7 @@ let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowup let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController; let createReplyOperationForTest: typeof import("./reply-run-registry.js").createReplyOperation; let replyRunTestingForTest: typeof import("./reply-run-registry.js").testing; +let cliBackendsTestingForTest: typeof import("../../agents/cli-backends.js").testing; const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1"; const FOLLOWUP_TEST_QUEUES = new Map< string, @@ -442,6 +443,8 @@ async function loadFreshFollowupRunnerModuleForTest() { }; }, })); + ({ testing: cliBackendsTestingForTest } = await import("../../agents/cli-backends.js")); + setFastFollowupCliBackendDeps(); ({ createFollowupRunner } = await import("./followup-runner.js")); ({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = await import("../../config/config.js")); @@ -454,6 +457,27 @@ async function loadFreshFollowupRunnerModuleForTest() { await import("./reply-run-registry.js")); } +function setFastFollowupCliBackendDeps(): void { + cliBackendsTestingForTest.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [ + { + id: "claude-cli", + pluginId: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude" }, + bundleMcp: false, + }, + ], + }); +} + const ROUTABLE_TEST_CHANNELS = new Set([ "telegram", "slack", @@ -469,6 +493,7 @@ beforeAll(async () => { }); beforeEach(() => { + setFastFollowupCliBackendDeps(); replyRunTestingForTest?.resetReplyRunRegistry(); clearRuntimeConfigSnapshot?.(); runEmbeddedAgentMock.mockReset(); @@ -524,6 +549,7 @@ beforeEach(() => { }); afterEach(() => { + cliBackendsTestingForTest?.resetDepsForTest(); replyRunTestingForTest?.resetReplyRunRegistry(); clearRuntimeConfigSnapshot?.(); clearFollowupQueue("main"); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 80b20bb78df5..122d63dfd39d 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../../agents/cli-backends.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildFastReplyCommandContext, @@ -101,6 +102,16 @@ describe("getReplyFromConfig fast test bootstrap", () => { beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [], + }); mocks.ensureAgentWorkspace.mockReset(); mocks.initSessionState.mockReset(); mocks.loadModelCatalog.mockReset(); @@ -130,6 +141,7 @@ describe("getReplyFromConfig fast test bootstrap", () => { }); afterEach(() => { + cliBackendsTesting.resetDepsForTest(); vi.unstubAllEnvs(); }); diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 9f4adb071737..b3343c15b949 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; import { MODEL_CONTEXT_TOKEN_CACHE } from "../agents/context-cache.js"; @@ -33,6 +33,19 @@ vi.mock("../plugins/commands.js", () => ({ listPluginCommands, })); +beforeEach(() => { + cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + resolveRuntimeCliBackends: () => [], + }); +}); + afterEach(() => { vi.restoreAllMocks(); cliBackendsTesting.resetDepsForTest(); @@ -43,6 +56,13 @@ afterEach(() => { function registerAnthropicCliBackendForTest(): void { cliBackendsTesting.setDepsForTest({ + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), resolveRuntimeCliBackends: () => [ { id: "claude-cli", @@ -97,6 +117,7 @@ describe("buildStatusMessage", () => { sessionScope: "per-sender", resolvedThink: "medium", resolvedVerbose: "off", + resolvedHarness: "openclaw", queue: { mode: "collect", depth: 0 }, modelAuth: "api-key", now: 10 * 60_000, // 10 minutes later @@ -1014,6 +1035,8 @@ describe("buildStatusMessage", () => { }); it("prefers active CLI OAuth over selected env API-key labels for runtime aliases", () => { + registerAnthropicCliBackendForTest(); + const text = buildStatusMessage({ config: { models: { diff --git a/src/cli/config-cli.integration.test.ts b/src/cli/config-cli.integration.test.ts index bb4db6b35567..08654fc65183 100644 --- a/src/cli/config-cli.integration.test.ts +++ b/src/cli/config-cli.integration.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSON5 from "json5"; -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { captureEnv } from "../test-utils/env.js"; import { runConfigSet } from "./config-cli.js"; @@ -110,6 +110,30 @@ async function withExecDryRunConfigHarness( } describe("config cli integration", () => { + beforeAll(async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-warmup-")); + const configPath = path.join(tempDir, "openclaw.json"); + const envSnapshot = captureEnv(["OPENCLAW_CONFIG_PATH", "OPENCLAW_TEST_FAST"]); + try { + fs.writeFileSync(configPath, `${JSON.stringify({ gateway: { port: 18789 } }, null, 2)}\n`); + process.env.OPENCLAW_TEST_FAST = "1"; + process.env.OPENCLAW_CONFIG_PATH = configPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + await runConfigSet({ + path: "gateway.port", + value: "18790", + cliOptions: {}, + runtime: createTestRuntime().runtime, + }); + } finally { + envSnapshot.restore(); + clearConfigCache(); + clearRuntimeConfigSnapshot(); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("accepts plugin hook conversation-access policy via config set", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-plugin-hooks-")); const configPath = path.join(tempDir, "openclaw.json"); diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index d6896a8df907..0d3762c0c704 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({ replaceConfigFile: vi.fn(), refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined), resolveInstallableChannelPlugin: vi.fn(), + listReadOnlyChannelPluginsForConfig: vi.fn(), })); vi.mock("./shared.js", () => ({ @@ -28,6 +29,10 @@ vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: vi.fn(), })); +vi.mock("../../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: mocks.listReadOnlyChannelPluginsForConfig, +})); + vi.mock("../../config/config.js", async () => { const actual = await vi.importActual("../../config/config.js"); @@ -123,6 +128,7 @@ describe("channelsCapabilitiesCommand", () => { vi.clearAllMocks(); mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); mocks.replaceConfigFile.mockResolvedValue(undefined); + mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]); mocks.resolveInstallableChannelPlugin.mockResolvedValue({ cfg: { channels: {} }, configChanged: false, diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 37d7d9c9ebfa..82d744c28f0a 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { withTempHome } from "openclaw/plugin-sdk/test-env"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { getDoctorConfigInputForTest, @@ -1456,6 +1456,17 @@ type RepairedDiscordPolicy = { }; describe("doctor config flow", () => { + beforeAll(async () => { + await Promise.all([ + import("../config/plugin-auto-enable.js"), + import("./doctor/repair-sequencing.js"), + import("./doctor/shared/channel-doctor.js"), + import("./doctor/shared/legacy-config-issues.js"), + import("./doctor/shared/plugin-tool-allowlist-warnings.js"), + import("./doctor/shared/preview-warnings.js"), + ]); + }); + beforeEach(() => { terminalNoteMock.mockClear(); collectImplicitFallbackClobberWarningsMock.mockClear(); diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts deleted file mode 100644 index 09a400dbb0fe..000000000000 --- a/src/commands/health.command.coverage.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { stripAnsi } from "../terminal/ansi.js"; -import type { HealthSummary } from "./health.js"; -import { healthCommand } from "./health.js"; - -const callGatewayMock = vi.fn(); -const buildGatewayConnectionDetailsMock = vi.fn(() => ({ - message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", -})); -const logWebSelfIdMock = vi.fn(); - -function createRecentSessionRows(now = Date.now()) { - return [ - { key: "main", updatedAt: now - 60_000, age: 60_000 }, - { key: "foo", updatedAt: null, age: null }, - ]; -} - -vi.mock("../gateway/call.js", () => ({ - callGateway: (...args: [unknown, ...unknown[]]) => - Reflect.apply(callGatewayMock, undefined, args), - buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) => - Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args), -})); - -vi.mock("../config/config.js", () => ({ - readBestEffortConfig: vi.fn(async () => ({})), -})); - -vi.mock("../channels/plugins/index.js", () => { - const whatsappPlugin = { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp test stub.", - }, - capabilities: { chatTypes: ["direct", "group"] }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({}), - }, - status: { - logSelfId: () => logWebSelfIdMock(), - }, - }; - - return { - getChannelPlugin: (channelId: string) => (channelId === "whatsapp" ? whatsappPlugin : null), - listChannelPlugins: () => [whatsappPlugin], - }; -}); - -describe("healthCommand (coverage)", () => { - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - beforeEach(() => { - vi.clearAllMocks(); - buildGatewayConnectionDetailsMock.mockReturnValue({ - message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", - }); - }); - - it("prints the rich text summary and verbose gateway details", async () => { - const recent = createRecentSessionRows(); - callGatewayMock.mockResolvedValueOnce({ - ok: true, - ts: Date.now(), - durationMs: 5, - channels: { - whatsapp: { - accountId: "default", - linked: true, - authAgeMs: 5 * 60_000, - }, - telegram: { - accountId: "default", - configured: true, - probe: { - ok: true, - elapsedMs: 7, - bot: { username: "bot" }, - webhook: { url: "https://example.com/h" }, - }, - }, - discord: { - accountId: "default", - configured: false, - }, - }, - channelOrder: ["whatsapp", "telegram", "discord"], - channelLabels: { - whatsapp: "WhatsApp", - telegram: "Telegram", - discord: "Discord", - }, - heartbeatSeconds: 60, - defaultAgentId: "main", - agents: [ - { - agentId: "main", - isDefault: true, - heartbeat: { - enabled: true, - every: "1m", - everyMs: 60_000, - prompt: "hi", - target: "last", - ackMaxChars: 160, - }, - sessions: { - path: "/tmp/sessions.json", - count: 2, - recent, - }, - }, - ], - sessions: { - path: "/tmp/sessions.json", - count: 2, - recent, - }, - } satisfies HealthSummary); - - await healthCommand({ json: false, verbose: true, timeoutMs: 1000 }, runtime as never); - - expect(runtime.exit).not.toHaveBeenCalled(); - const output = stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")); - expect(output).toMatch(/WhatsApp: linked/i); - expect(runtime.log.mock.calls.slice(0, 3)).toEqual([ - ["Gateway connection:"], - [" Gateway mode: local"], - [" Gateway target: ws://127.0.0.1:18789"], - ]); - expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled(); - expect(logWebSelfIdMock).not.toHaveBeenCalled(); - }); -}); diff --git a/src/commands/health.test.ts b/src/commands/health.test.ts index 15d1c838d665..8ba7f56d9a4f 100644 --- a/src/commands/health.test.ts +++ b/src/commands/health.test.ts @@ -52,13 +52,22 @@ const createHealthSummary = (params: { }; const callGatewayMock = vi.fn(); +const buildGatewayConnectionDetailsMock = vi.fn(() => ({ + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", +})); const formatGatewayTransportErrorJsonMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), + buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) => + Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args), formatGatewayTransportErrorJson: (...args: unknown[]) => formatGatewayTransportErrorJsonMock(...args), })); +vi.mock("../channels/plugins/read-only.js", () => ({ + listReadOnlyChannelPluginsForConfig: () => [], +})); + function requireFirstRuntimeLog(): string { const [call] = runtime.log.mock.calls; if (!call) { @@ -86,6 +95,9 @@ function requireFirstGatewayRequest(): Record { describe("healthCommand", () => { beforeEach(() => { vi.clearAllMocks(); + buildGatewayConnectionDetailsMock.mockReturnValue({ + message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789", + }); formatGatewayTransportErrorJsonMock.mockReturnValue(null); }); @@ -124,6 +136,56 @@ describe("healthCommand", () => { expect(parsed.sessions.count).toBe(1); }); + it("prints the rich text summary and verbose gateway details", async () => { + const recent = [ + { key: "main", updatedAt: Date.now() - 60_000, age: 60_000 }, + { key: "foo", updatedAt: null, age: null }, + ]; + const snapshot = createHealthSummary({ + channels: { + whatsapp: { accountId: "default", linked: true, authAgeMs: 5 * 60_000 }, + telegram: { + accountId: "default", + configured: true, + probe: { + ok: true, + elapsedMs: 7, + bot: { username: "bot" }, + webhook: { url: "https://example.com/h" }, + }, + }, + discord: { accountId: "default", configured: false }, + }, + channelOrder: ["whatsapp", "telegram", "discord"], + channelLabels: { + whatsapp: "WhatsApp", + telegram: "Telegram", + discord: "Discord", + }, + sessions: { + path: "/tmp/sessions.json", + count: 2, + recent, + }, + }); + callGatewayMock.mockResolvedValueOnce(snapshot); + + await healthCommand( + { json: false, verbose: true, timeoutMs: 1000, config: {} }, + runtime as never, + ); + + expect(runtime.exit).not.toHaveBeenCalled(); + const output = stripAnsi(runtime.log.mock.calls.map((c) => String(c[0])).join("\n")); + expect(output).toMatch(/WhatsApp: linked/i); + expect(runtime.log.mock.calls.slice(0, 3)).toEqual([ + ["Gateway connection:"], + [" Gateway mode: local"], + [" Gateway target: ws://127.0.0.1:18789"], + ]); + expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled(); + }); + it("passes explicit gateway credentials through to the gateway call", async () => { const snapshot = createHealthSummary({ channels: {}, diff --git a/src/commands/status-json.test.ts b/src/commands/status-json.test.ts index 184bec48aff9..aceb6ad6efc1 100644 --- a/src/commands/status-json.test.ts +++ b/src/commands/status-json.test.ts @@ -32,6 +32,20 @@ vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway, })); +vi.mock("../channels/plugins/read-only.js", () => ({ + resolveReadOnlyChannelPluginsForConfig: vi.fn(() => ({ + plugins: [ + { id: "discord" }, + { id: "imessage" }, + { id: "signal" }, + { id: "slack" }, + { id: "telegram" }, + { id: "whatsapp" }, + ], + missingConfiguredChannelIds: [], + })), +})); + vi.mock("./status.daemon.js", () => ({ getDaemonStatusSummary: mocks.getDaemonStatusSummary, getNodeDaemonStatusSummary: mocks.getNodeDaemonStatusSummary, diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index b42a4bb84b02..2c065b25c174 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -213,6 +213,30 @@ vi.mock("./run-execution.runtime.js", () => ({ logWarn: (...args: unknown[]) => logWarnMock(...args), })); +vi.mock("../../agents/model-runtime-aliases.js", () => ({ + resolveCliRuntimeExecutionProvider: ({ + provider, + cfg, + modelId, + }: { + provider?: string; + cfg?: { + agents?: { + defaults?: { + models?: Record; + }; + }; + }; + modelId?: string; + }) => { + const key = provider && modelId ? `${provider}/${modelId}` : undefined; + const runtime = key + ? cfg?.agents?.defaults?.models?.[key]?.agentRuntime?.id?.trim() + : undefined; + return runtime || provider; + }, +})); + vi.mock("./run-auth-profile.runtime.js", () => ({ resolveSessionAuthProfileOverride: resolveSessionAuthProfileOverrideMock, })); diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 943127a6a2af..383a7fa07482 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { testing as cliBackendsTesting } from "../agents/cli-backends.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; const gatewayClientState = vi.hoisted(() => ({ @@ -30,6 +31,7 @@ describe("gateway cli backend live helpers", () => { }); afterEach(() => { + cliBackendsTesting.resetDepsForTest(); gatewayClientState.lastOptions = undefined; delete process.env.OPENCLAW_SKIP_CHANNELS; delete process.env.OPENCLAW_SKIP_PROVIDERS; @@ -135,6 +137,25 @@ describe("gateway cli backend live helpers", () => { it("configures legacy CLI model refs as canonical provider models plus CLI runtime", async () => { const { resolveCliBackendLiveModelSelection } = await import("./gateway-cli-backend.live-helpers.js"); + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [], + resolvePluginSetupRegistry: () => ({ + providers: [], + cliBackends: [ + { + pluginId: "claude", + backend: { + id: "claude-cli", + modelProvider: "anthropic", + config: { command: "claude", args: [] }, + }, + }, + ], + configMigrations: [], + autoEnableProbes: [], + diagnostics: [], + }), + }); expect( resolveCliBackendLiveModelSelection({ diff --git a/src/gateway/probe.auth.integration.test.ts b/src/gateway/probe.auth.integration.test.ts index 0981a35ed4fb..db7f3cf6d08a 100644 --- a/src/gateway/probe.auth.integration.test.ts +++ b/src/gateway/probe.auth.integration.test.ts @@ -1,9 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { installGatewayTestHooks, testState, withGatewayServer } from "./test-helpers.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createGatewaySuiteHarness, installGatewayTestHooks, testState } from "./test-helpers.js"; -installGatewayTestHooks(); +installGatewayTestHooks({ scope: "suite" }); const { callGateway } = await import("./call.js"); const { probeGateway } = await import("./probe.js"); @@ -11,6 +11,17 @@ const { storeDeviceAuthToken } = await import("../infra/device-auth-store.js"); const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { approveDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); +await import("./server.js"); + +let gatewayHarness: Awaited>; + +beforeAll(async () => { + gatewayHarness = await createGatewaySuiteHarness(); +}); + +afterAll(async () => { + await gatewayHarness.close(); +}); function requireGatewayToken(): string { const token = @@ -72,55 +83,49 @@ describe("probeGateway auth integration", () => { it("keeps direct local authenticated status RPCs device-bound", async () => { const token = requireGatewayToken(); - await withGatewayServer(async ({ port }) => { - const status = await callGateway({ - url: `ws://127.0.0.1:${port}`, - token, - method: "status", - timeoutMs: 5_000, - }); - - expectRecord(status, "status response"); + const status = await callGateway({ + url: `ws://127.0.0.1:${gatewayHarness.port}`, + token, + method: "status", + timeoutMs: 5_000, }); + + expectRecord(status, "status response"); }); it("keeps first-time local authenticated probes non-mutating", async () => { const token = requireGatewayToken(); - await withGatewayServer(async ({ port }) => { - const result = await probeGateway({ - url: `ws://127.0.0.1:${port}`, - auth: { token }, - timeoutMs: 5_000, - }); - - expect(result.ok).toBe(false); - expect(result.health).toBeNull(); - expect(result.status).toBeNull(); - expect(result.configSnapshot).toBeNull(); - expect(result.auth.capability).toBe("connected_no_operator_scope"); - expect(fs.existsSync(statePath("devices", "paired.json"))).toBe(false); - expect(fs.existsSync(statePath("devices", "pending.json"))).toBe(false); - expect(fs.existsSync(statePath("identity", "device-auth.json"))).toBe(false); + const result = await probeGateway({ + url: `ws://127.0.0.1:${gatewayHarness.port}`, + auth: { token }, + timeoutMs: 5_000, }); + + expect(result.ok).toBe(false); + expect(result.health).toBeNull(); + expect(result.status).toBeNull(); + expect(result.configSnapshot).toBeNull(); + expect(result.auth.capability).toBe("connected_no_operator_scope"); + expect(fs.existsSync(statePath("devices", "paired.json"))).toBe(false); + expect(fs.existsSync(statePath("devices", "pending.json"))).toBe(false); + expect(fs.existsSync(statePath("identity", "device-auth.json"))).toBe(false); }); it("keeps detail RPCs available for local authenticated probes with cached device auth", async () => { const token = requireGatewayToken(); await seedCachedOperatorToken(["operator.read"]); - await withGatewayServer(async ({ port }) => { - const result = await probeGateway({ - url: `ws://127.0.0.1:${port}`, - auth: { token }, - timeoutMs: 5_000, - }); - - expect(result.ok).toBe(true); - expect(result.error).toBeNull(); - expectRecord(result.health, "probe health"); - expectRecord(result.status, "probe status"); - expectRecord(result.configSnapshot, "probe config snapshot"); + const result = await probeGateway({ + url: `ws://127.0.0.1:${gatewayHarness.port}`, + auth: { token }, + timeoutMs: 5_000, }); + + expect(result.ok).toBe(true); + expect(result.error).toBeNull(); + expectRecord(result.health, "probe health"); + expectRecord(result.status, "probe status"); + expectRecord(result.configSnapshot, "probe config snapshot"); }); }); diff --git a/src/gateway/server.auth.control-ui.test.ts b/src/gateway/server.auth.control-ui.test.ts index eae87394dac9..89060b3223f3 100644 --- a/src/gateway/server.auth.control-ui.test.ts +++ b/src/gateway/server.auth.control-ui.test.ts @@ -4,6 +4,13 @@ import { installGatewayTestHooks } from "./server.auth.shared.js"; installGatewayTestHooks({ scope: "suite" }); +await Promise.all([ + import("./server.js"), + import("../infra/device-bootstrap.js"), + import("../infra/device-identity.js"), + import("../infra/device-pairing.js"), +]); + describe("gateway server auth/connect", () => { registerControlUiAndPairingSuite(); }); diff --git a/src/gateway/server.device-pair-approve-authz.test.ts b/src/gateway/server.device-pair-approve-authz.test.ts index 219ca503a480..d36ef1a549e5 100644 --- a/src/gateway/server.device-pair-approve-authz.test.ts +++ b/src/gateway/server.device-pair-approve-authz.test.ts @@ -21,6 +21,12 @@ import { installGatewayTestHooks({ scope: "suite" }); +await Promise.all([ + import("./server.js"), + import("../infra/device-identity.js"), + import("../infra/device-pairing.js"), +]); + const CONTROL_UI_CLIENT = { id: GATEWAY_CLIENT_NAMES.CONTROL_UI, version: "1.0.0", diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.test.ts index 6b9d9bfccadc..fb56e738701f 100644 --- a/src/gateway/server.hooks.test.ts +++ b/src/gateway/server.hooks.test.ts @@ -18,6 +18,8 @@ import { installGatewayTestHooks({ scope: "suite" }); +await import("./server.js"); + const resolveMainKey = () => resolveMainSessionKeyFromConfig(); const HOOK_TOKEN = "hook-secret"; const HOOKS_MAIN_SESSION_KEY = "agent:hooks:main"; diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts index 2b238d225aa7..4fd5dc7b5edc 100644 --- a/src/gateway/server.preauth-hardening.test.ts +++ b/src/gateway/server.preauth-hardening.test.ts @@ -22,6 +22,8 @@ import { withTempConfig } from "./test-temp-config.js"; installGatewayTestHooks({ scope: "suite" }); +await import("./server.js"); + const PREAUTH_HANDSHAKE_TEST_CLOSE_LIMIT_MS = 5_000; let cleanupEnv: Array<() => void> = []; diff --git a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts index a84a963b0e7d..bba7406d5780 100644 --- a/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts +++ b/src/gateway/server.silent-scope-upgrade-reconnect.poc.test.ts @@ -28,6 +28,8 @@ import { installGatewayTestHooks({ scope: "suite" }); +await import("./server.js"); + async function expectRejectedScopeUpgradeAttempt({ attempt, requestedEvent, diff --git a/src/gateway/server.talk-runtime.test.ts b/src/gateway/server.talk-runtime.test.ts index 83cf86fd5d62..8a9734c2da73 100644 --- a/src/gateway/server.talk-runtime.test.ts +++ b/src/gateway/server.talk-runtime.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { invokeTalkSpeakDirect, type TalkSpeakTestPayload, @@ -95,6 +95,11 @@ function expectSingleSynthesizeSpeechCall() { } describe("gateway talk runtime", () => { + beforeAll(async () => { + await import("./server-methods/talk.js"); + await import("../config/config.js"); + }); + beforeEach(() => { synthesizeSpeechMock.mockReset(); synthesizeSpeechMock.mockResolvedValue({ diff --git a/src/llm/providers/openai-codex-responses.test.ts b/src/llm/providers/openai-codex-responses.test.ts index e4afa5664306..f403fe1381ba 100644 --- a/src/llm/providers/openai-codex-responses.test.ts +++ b/src/llm/providers/openai-codex-responses.test.ts @@ -223,7 +223,7 @@ describe("streamOpenAICodexResponses transport", () => { vi.stubGlobal( "fetch", vi.fn(async () => { - throw new Error("stop after payload"); + throw new Error("usage limit: stop after payload"); }), ); diff --git a/src/plugins/compat/registry.test.ts b/src/plugins/compat/registry.test.ts index 7dde246b9d8c..942bdc95ccce 100644 --- a/src/plugins/compat/registry.test.ts +++ b/src/plugins/compat/registry.test.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import { describe, expect, it } from "vitest"; +import { listGitTrackedFiles } from "../../test-utils/repo-files.js"; import { getPluginCompatRecord, isPluginCompatCode, @@ -26,23 +27,6 @@ const deprecatedTargetParserCompatFiles = new Set([ "src/plugins/compat/registry.test.ts", ]); -function listTsFiles(root: string): string[] { - const results: string[] = []; - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - const childPath = `${root}/${entry.name}`; - if (entry.isDirectory()) { - if (entry.name !== "node_modules") { - results.push(...listTsFiles(childPath)); - } - continue; - } - if (/\.(?:ts|tsx)$/u.test(entry.name)) { - results.push(childPath); - } - } - return results; -} - const knownDeprecatedSurfaceMarkers = [ { code: "legacy-extension-api-import", @@ -243,18 +227,10 @@ function expectNonEmptyStringList(values: readonly string[], label: string) { } } -function listSourceFiles(dir: string): string[] { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - return entries.flatMap((entry) => { - const path = `${dir}/${entry.name}`; - if (entry.isDirectory()) { - if (entry.name === "dist" || entry.name === "node_modules") { - return []; - } - return listSourceFiles(path); - } - return /\.(?:ts|tsx|mts|cts)$/u.test(entry.name) ? [path] : []; - }); +function listTrackedSourceFiles(): string[] { + return (listGitTrackedFiles({ pathspecs: sourceRootsForDeprecatedCallGuard }) ?? []).filter( + (file) => /\.(?:ts|tsx|mts|cts)$/u.test(file), + ); } describe("plugin compatibility registry", () => { @@ -305,8 +281,7 @@ describe("plugin compatibility registry", () => { }); it("keeps deprecated explicit target parser calls inside compatibility shims", () => { - const offenders = sourceRootsForDeprecatedCallGuard - .flatMap((root) => listSourceFiles(root)) + const offenders = listTrackedSourceFiles() .filter((file) => !deprecatedTargetParserCompatFiles.has(file)) .filter((file) => deprecatedTargetParserCallPattern.test(fs.readFileSync(file, "utf8"))); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 871897b2bb31..61e5a4ffcd05 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -1019,12 +1019,12 @@ describe("installPluginFromArchive", () => { }); it("rejects reserved archive package ids", async () => { - for (const params of [ - { packageName: "@evil/..", outName: "traversal.tgz" }, - { packageName: "@evil/.", outName: "reserved.tgz" }, - ]) { - await expectArchiveInstallReservedSegmentRejection(params); - } + await Promise.all( + [ + { packageName: "@evil/..", outName: "traversal.tgz" }, + { packageName: "@evil/.", outName: "reserved.tgz" }, + ].map((params) => expectArchiveInstallReservedSegmentRejection(params)), + ); }); it("rejects packages without openclaw.extensions", async () => { diff --git a/src/plugins/npm-install-security-scan.release.test.ts b/src/plugins/npm-install-security-scan.release.test.ts index 0300a9355aa4..d83949337353 100644 --- a/src/plugins/npm-install-security-scan.release.test.ts +++ b/src/plugins/npm-install-security-scan.release.test.ts @@ -3,7 +3,7 @@ import fs, { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from " import { tmpdir } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { promisify } from "node:util"; -import { describe, expect, it, test } from "vitest"; +import { beforeAll, describe, expect, it, test } from "vitest"; import { isScannable, scanDirectoryWithSummary } from "../security/skill-scanner.js"; import { expectNoReaddirSyncDuring } from "../test-utils/fs-scan-assertions.js"; import { listGitTrackedFiles, toRepoPath, toRepoRelativePath } from "../test-utils/repo-files.js"; @@ -257,6 +257,22 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P describe("publishable plugin npm package install security scan", () => { const publishablePluginPackages = collectPublishablePluginPackages(); + const scanResultsByPackageName = new Map< + string, + Awaited> + >(); + + beforeAll(async () => { + const results = await Promise.all( + publishablePluginPackages.map(async (plugin) => ({ + packageName: plugin.packageName, + result: await scanPublishablePluginPackage(plugin), + })), + ); + for (const { packageName, result } of results) { + scanResultsByPackageName.set(packageName, result); + } + }); it("covers every package with required reviewed critical findings", () => { const publishablePackageNames = new Set( @@ -287,7 +303,10 @@ describe("publishable plugin npm package install security scan", () => { test.concurrent.each(publishablePluginPackages)( "keeps $packageName files clear of unexpected critical hits", async (plugin) => { - const result = await scanPublishablePluginPackage(plugin); + const result = scanResultsByPackageName.get(plugin.packageName); + if (!result) { + throw new Error(`Missing package scan result for ${plugin.packageName}`); + } const expectedReviewedCriticalFindings = new Set( [...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].filter((key) => key.startsWith(`${plugin.packageName}:`), diff --git a/src/plugins/plugin-graceful-init-failure.test.ts b/src/plugins/plugin-graceful-init-failure.test.ts index 7a315adc44df..8f5ef4829d0c 100644 --- a/src/plugins/plugin-graceful-init-failure.test.ts +++ b/src/plugins/plugin-graceful-init-failure.test.ts @@ -6,6 +6,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fi const fixtureTempDirs: string[] = []; const fixtureRoot = makeTrackedTempDir("openclaw-plugin-graceful", fixtureTempDirs); let tempDirIndex = 0; +const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.js"); afterAll(() => { cleanupTrackedTempDirs(fixtureTempDirs); @@ -48,7 +49,6 @@ function readPluginId(pluginPath: string): string { } async function loadPlugins(pluginPaths: string[], warnings?: string[]) { - const { loadOpenClawPlugins, clearPluginLoaderCache } = await import("./loader.js"); clearPluginLoaderCache(); const allow = pluginPaths.map((pluginPath) => readPluginId(pluginPath)); return loadOpenClawPlugins({ diff --git a/test/non-isolated-runner.ts b/test/non-isolated-runner.ts index 675ce56472f3..0f6ff4531687 100644 --- a/test/non-isolated-runner.ts +++ b/test/non-isolated-runner.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { TestRunner, type RunnerTestSuite, vi } from "vitest"; +import { TestRunner, type RunnerTask, type RunnerTestSuite, vi } from "vitest"; type EvaluatedModuleNode = { promise?: unknown; @@ -59,9 +59,16 @@ function restoreSharedTestHomeAfterEnvUnstub(testHomeRaw: string | undefined): v process.env.XDG_CACHE_HOME = path.join(testHome, ".cache"); } +function restoreRealTimers(): void { + if (vi.isFakeTimers()) { + vi.useRealTimers(); + } +} + export default class OpenClawNonIsolatedRunner extends TestRunner { override onCollectStart(file: { filepath: string }) { super.onCollectStart(file); + restoreRealTimers(); restoreSharedTestHomeAfterEnvUnstub(getSharedTestHome()); const orderLogPath = process.env.OPENCLAW_VITEST_FILE_ORDER_LOG?.trim(); if (orderLogPath) { @@ -69,6 +76,16 @@ export default class OpenClawNonIsolatedRunner extends TestRunner { } } + override async onBeforeRunTask(test: RunnerTask) { + restoreRealTimers(); + await super.onBeforeRunTask(test); + } + + override onBeforeTryTask(test: RunnerTask) { + restoreRealTimers(); + super.onBeforeTryTask(test); + } + override async onAfterRunSuite(suite: RunnerTestSuite) { await super.onAfterRunSuite(suite); if (this.config.isolate || !("filepath" in suite) || typeof suite.filepath !== "string") { @@ -83,9 +100,7 @@ export default class OpenClawNonIsolatedRunner extends TestRunner { // Mirror the missing cleanup from Vitest isolate mode so shared workers do // not carry file-scoped timers, stubs, spies, or stale module state // forward into the next file. - if (vi.isFakeTimers()) { - vi.useRealTimers(); - } + restoreRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); const testHome = getSharedTestHome(); diff --git a/test/scripts/kitchen-sink-rpc-walk.test.ts b/test/scripts/kitchen-sink-rpc-walk.test.ts index 07e700cade03..741f30523e73 100644 --- a/test/scripts/kitchen-sink-rpc-walk.test.ts +++ b/test/scripts/kitchen-sink-rpc-walk.test.ts @@ -237,7 +237,7 @@ setInterval(() => {}, 1000); const runPromise = runCommand(process.execPath, [scriptPath, grandchildPidPath], { detached: undefined, timeoutKillGraceMs: 50, - timeoutMs: 2000, + timeoutMs: 1000, }); try { @@ -246,7 +246,7 @@ setInterval(() => {}, 1000); expect(Number.isInteger(grandchildPid)).toBe(true); expect(isProcessAlive(grandchildPid)).toBe(true); - await expect(runPromise).rejects.toThrow("timed out after 2000ms"); + await expect(runPromise).rejects.toThrow("timed out after 1000ms"); await waitFor(() => !isProcessAlive(grandchildPid), 5_000); } finally { await runPromise.catch(() => {}); diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 7458984738ae..f39c26322e74 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -169,7 +169,7 @@ process.exitCode = await runManagedCommand({ const result = await waitForClose(runner); expect(result).toEqual({ code: 143, signal: null }); - await waitFor(() => !isProcessAlive(childPid), 10_000); + await waitFor(() => !isProcessAlive(childPid), 1_500); } finally { if (isProcessAlive(runnerPid)) { process.kill(runnerPid, "SIGKILL"); diff --git a/test/scripts/package-openclaw-for-docker.test.ts b/test/scripts/package-openclaw-for-docker.test.ts index a0b23455915c..2030c527fcf9 100644 --- a/test/scripts/package-openclaw-for-docker.test.ts +++ b/test/scripts/package-openclaw-for-docker.test.ts @@ -136,16 +136,15 @@ describe("package-openclaw-for-docker", () => { "setInterval(() => {}, 1000);", ].join(""); - await expect( - runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), { - env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath }, - killAfterMs: 50, - timeoutMs: 2000, - }), - ).rejects.toThrow(/timed out after 2000ms/u); - + const runPromise = runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), { + env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath }, + killAfterMs: 50, + timeoutMs: 1500, + }); + const timeoutAssertion = expect(runPromise).rejects.toThrow(/timed out after 1500ms/u); await waitForFile(childPidPath, 2000); childPid = Number(fs.readFileSync(childPidPath, "utf8")); + await timeoutAssertion; await waitForDead(childPid, 2000); } finally { if (childPid && isProcessAlive(childPid)) { diff --git a/test/scripts/test-group-report.test.ts b/test/scripts/test-group-report.test.ts index 8ec05778dbb7..93f155ec9a34 100644 --- a/test/scripts/test-group-report.test.ts +++ b/test/scripts/test-group-report.test.ts @@ -9,6 +9,7 @@ import { } from "../../scripts/lib/test-group-report.mjs"; import { parseTestGroupReportArgs, + resolveFullSuiteVitestEnv, resolveReportArtifactDirs, resolveRunPlans, } from "../../scripts/test-group-report.mjs"; @@ -295,6 +296,33 @@ describe("scripts/test-group-report arg parsing", () => { }); describe("scripts/test-group-report run plans", () => { + it("caps Vitest workers for full-suite profiling by default", () => { + expect(resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {})).toEqual({ + OPENCLAW_VITEST_MAX_WORKERS: "2", + }); + }); + + it("uses a serial worker budget for commands full-suite profiling", () => { + expect( + resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), {}, "commands"), + ).toEqual({ + OPENCLAW_VITEST_MAX_WORKERS: "1", + }); + }); + + it("preserves explicit Vitest worker budgets for full-suite profiling", () => { + expect( + resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), { + OPENCLAW_VITEST_MAX_WORKERS: "2", + }), + ).toEqual({}); + expect( + resolveFullSuiteVitestEnv(parseTestGroupReportArgs(["--full-suite"]), { + OPENCLAW_TEST_WORKERS: "2", + }), + ).toEqual({}); + }); + it("uses leaf configs for full-suite profiling without requiring parallel env", () => { const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL; const previousLeaf = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;