mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
test: speed up slow test suite (#87611)
* test: speed up slow test suite * test: preserve fake timer cleanup hooks * test: avoid timeout readiness race * test: satisfy reply test types * test: restore runner and image coverage * test: restore final media runner path * test: make cli auth status fixture deterministic * test: repair runtime alias fixtures
This commit is contained in:
committed by
GitHub
parent
e0635eb6fd
commit
aab5410bd5
@@ -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();
|
||||
|
||||
+5
-1
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {}),
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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<string, { agentRuntime?: { id?: string } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
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),
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<typeof import("../model-runtime-aliases.js")>(
|
||||
"../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" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<string, { agentRuntime?: { id?: string } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
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(() => ({})),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -30,6 +30,13 @@ function createAnthropicAuthConfig(params: {
|
||||
describe("resolveCliRuntimeExecutionProvider", () => {
|
||||
beforeEach(() => {
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolvePluginSetupRegistry: () => ({
|
||||
providers: [],
|
||||
cliBackends: [],
|
||||
configMigrations: [],
|
||||
autoEnableProbes: [],
|
||||
diagnostics: [],
|
||||
}),
|
||||
resolveRuntimeCliBackends: () => [
|
||||
{
|
||||
id: "claude-cli",
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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<typeof createOpenClawCodingTools>[0];
|
||||
type MockOpenClawToolsOptions = {
|
||||
@@ -565,6 +595,12 @@ const moonshotProvider = {
|
||||
function installImageUnderstandingProviderDeps(
|
||||
providers: MediaUnderstandingProvider[],
|
||||
options?: {
|
||||
describeImageWithModel?: NonNullable<
|
||||
Parameters<typeof testing.setProviderDepsForTest>[0]
|
||||
>["describeImageWithModel"];
|
||||
describeImagesWithModel?: NonNullable<
|
||||
Parameters<typeof testing.setProviderDepsForTest>[0]
|
||||
>["describeImagesWithModel"];
|
||||
loadImageWebMediaRuntime?: NonNullable<
|
||||
Parameters<typeof testing.setProviderDepsForTest>[0]
|
||||
>["loadImageWebMediaRuntime"];
|
||||
@@ -595,8 +631,8 @@ function installImageUnderstandingProviderDeps(
|
||||
id: string,
|
||||
registry: Map<string, MediaUnderstandingProvider>,
|
||||
) => 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 }),
|
||||
|
||||
@@ -1101,6 +1101,7 @@ describe("buildContextOverflowRecoveryText", () => {
|
||||
|
||||
describe("runAgentTurnWithFallback", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
state.runEmbeddedAgentMock.mockReset();
|
||||
state.runCliAgentMock.mockReset();
|
||||
state.runWithModelFallbackMock.mockReset();
|
||||
|
||||
@@ -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<typeof import("../../agents/model-selection.js")>(
|
||||
"../../agents/model-selection.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
isCliProvider: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../agents/sandbox.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../agents/sandbox.js")>("../../agents/sandbox.js");
|
||||
return {
|
||||
...actual,
|
||||
ensureSandboxWorkspaceForSession: async () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/diagnostic-events.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/diagnostic-events.js")>(
|
||||
"../../infra/diagnostic-events.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
emitTrustedDiagnosticEvent: vi.fn(),
|
||||
isDiagnosticsEnabled: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../infra/diagnostics-timeline.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../infra/diagnostics-timeline.js")>(
|
||||
"../../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<typeof import("./agent-runner-utils.js")>("./agent-runner-utils.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveQueuedReplyExecutionConfig: async (config: unknown) => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./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<Parameters<typeof runReplyAgent>[0]> = {},
|
||||
): Parameters<typeof runReplyAgent>[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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("../../agents/model-selection.js")>(
|
||||
"../../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<typeof import("../../agents/model-runtime-aliases.js")>(
|
||||
"../../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<typeof import("../../infra/agent-events.js")>(
|
||||
"../../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<string>(),
|
||||
getScopedChannelsCommandSecretTargets: () => ({ targetIds: new Set<string>() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/sandbox.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../agents/sandbox.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureSandboxWorkspaceForSession: async () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./reply-media-paths.js", () => ({
|
||||
createReplyMediaContext: ({ workspaceDir }: { workspaceDir: string }) => {
|
||||
const cache = new Map<string, Promise<string>>();
|
||||
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<Parameters<typeof runReplyAgent>[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<typeof import("./reply-media-paths.js")>("./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<void>;
|
||||
}) => {
|
||||
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<void> {
|
||||
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: "<media:image>",
|
||||
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: "<media:image>",
|
||||
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();
|
||||
|
||||
@@ -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<typeof import("../../agents/model-selection.js")>(
|
||||
"../../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();
|
||||
|
||||
@@ -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<OpenClawConfig> = {},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<typeof import("../../config/config.js")>("../../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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, { agentRuntime?: { id?: string } }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<ReturnType<typeof createGatewaySuiteHarness>>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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> = [];
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
await import("./server.js");
|
||||
|
||||
async function expectRejectedScopeUpgradeAttempt({
|
||||
attempt,
|
||||
requestedEvent,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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")));
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<ReturnType<typeof scanPublishablePluginPackage>>
|
||||
>();
|
||||
|
||||
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}:`),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user