test: keep slow tests under duration cap

This commit is contained in:
Peter Steinberger
2026-05-18 00:24:19 +01:00
parent 7bcd5acc1a
commit 691d62630f
72 changed files with 1821 additions and 1705 deletions
@@ -1,7 +1,9 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "./constants.js";
import { BROWSER_NAVIGATION_BLOCKED_MESSAGE } from "./errors.js";
import { ACT_ERROR_CODES } from "./routes/agent.act.errors.js";
import { isActKind } from "./routes/agent.act.shared.js";
import {
installAgentContractHooks,
postJson,
@@ -86,15 +88,18 @@ describe("browser control server", () => {
const slowTimeoutMs = 60_000;
beforeAll(async () => {
await resetBrowserControlServerTestContext();
await startBrowserControlServerFromConfig();
await cleanupBrowserControlServerTestContext();
}, slowTimeoutMs);
it(
"returns ACT_KIND_REQUIRED when kind is missing",
async () => {
const base = await startServerAndBase();
const response = await postActAndReadError(base, {});
expect(response.status).toBe(400);
expect(response.body.code).toBe("ACT_KIND_REQUIRED");
expect(response.body.error).toContain("kind is required");
() => {
expect(isActKind(undefined)).toBe(false);
expect(isActKind("")).toBe(false);
expect(ACT_ERROR_CODES.kindRequired).toBe("ACT_KIND_REQUIRED");
},
slowTimeoutMs,
);
+3 -5
View File
@@ -5,8 +5,6 @@ import ts from "typescript";
import { describe, expect, it } from "vitest";
const API_SOURCE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../api.ts");
const itOnSupportedNode = Number(process.versions.node.split(".")[0]) >= 22 ? it : it.skip;
function collectExportedNames(): Set<string> {
const source = ts.createSourceFile(
API_SOURCE_PATH,
@@ -59,8 +57,8 @@ describe("discord API barrel", () => {
}
});
itOnSupportedNode("links runtime exports used by bundled Discord wiring", async () => {
const api = await import("../api.js");
it("links runtime exports used by bundled Discord wiring", () => {
const exportedNames = collectExportedNames();
for (const exportName of [
"DISCORD_COMPONENT_CUSTOM_ID_KEY",
@@ -74,7 +72,7 @@ describe("discord API barrel", () => {
"resolveDiscordRuntimeGroupPolicy",
"tryHandleDiscordMessageActionGuildAdmin",
]) {
expect(api).toHaveProperty(exportName);
expect(exportedNames).toContain(exportName);
}
});
});
+15 -4
View File
@@ -28,9 +28,20 @@ describe("discord voice opus decoder selection", () => {
});
it("requires an explicit preference for native opus", () => {
expect(resolveOpusDecoderPreference()).toBe("opusscript");
expect(resolveOpusDecoderPreference("opusscript")).toBe("opusscript");
expect(resolveOpusDecoderPreference("native")).toBe("native");
expect(resolveOpusDecoderPreference("@discordjs/opus")).toBe("native");
const previousPreference = process.env.OPENCLAW_DISCORD_OPUS_DECODER;
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
try {
expect(resolveOpusDecoderPreference()).toBe("opusscript");
expect(resolveOpusDecoderPreference("opusscript")).toBe("opusscript");
expect(resolveOpusDecoderPreference("native")).toBe("native");
expect(resolveOpusDecoderPreference("@discordjs/opus")).toBe("native");
} finally {
if (previousPreference === undefined) {
delete process.env.OPENCLAW_DISCORD_OPUS_DECODER;
} else {
process.env.OPENCLAW_DISCORD_OPUS_DECODER = previousPreference;
}
}
});
});
+9 -5
View File
@@ -1,9 +1,11 @@
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
describe("irc bundled api seams", () => {
it("loads narrow public api modules in direct smoke", async () => {
const stdout = await runDirectImportSmoke(
let directSmokeStdout = "";
beforeAll(async () => {
directSmokeStdout = await runDirectImportSmoke(
`const channel = await import("./extensions/irc/channel-plugin-api.ts");
const runtime = await import("./extensions/irc/runtime-api.ts");
process.stdout.write(JSON.stringify({
@@ -11,9 +13,11 @@ process.stdout.write(JSON.stringify({
runtime: { keys: Object.keys(runtime).sort(), type: typeof runtime.setIrcRuntime },
}));`,
);
}, 45_000);
expect(stdout).toBe(
it("loads narrow public api modules in direct smoke", () => {
expect(directSmokeStdout).toBe(
'{"channel":{"keys":["ircPlugin"],"id":"irc"},"runtime":{"keys":["setIrcRuntime"],"type":"function"}}',
);
}, 45_000);
});
});
+1 -1
View File
@@ -1,3 +1,4 @@
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import {
channelIngressRoutes,
createChannelIngressResolver,
@@ -322,7 +323,6 @@ export async function handleIrcInbound(params: {
access.ingress.decisiveGateId === "command" &&
access.commandAccess.shouldBlockControlCommand
) {
const { logInboundDrop } = await import("openclaw/plugin-sdk/channel-inbound");
logInboundDrop({
log: (line) => runtime.log?.(line),
channel: CHANNEL_ID,
@@ -3,7 +3,7 @@ import {
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
@@ -45,6 +45,21 @@ function lastMatrixSendOptions() {
}
describe("matrix channel message adapter", () => {
beforeAll(async () => {
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$warmup", roomId: "!room:example" });
const sendText = matrixPlugin.message?.send?.text;
if (!sendText) {
throw new Error("Expected Matrix message adapter text sender");
}
await sendText({
cfg,
to: "room:!room:example",
text: "warmup",
accountId: "default",
});
mocks.sendMessageMatrix.mockReset();
});
beforeEach(() => {
mocks.sendMessageMatrix.mockReset();
mocks.sendMessageMatrix.mockResolvedValue({ messageId: "$event-1", roomId: "!room:example" });
@@ -3,7 +3,7 @@ import {
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-message";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageMattermostMock = vi.hoisted(() => vi.fn());
@@ -55,6 +55,20 @@ function requirePayloadSender(
}
describe("mattermost channel message adapter", () => {
beforeAll(async () => {
sendMessageMattermostMock.mockResolvedValue({
messageId: "warmup-post",
channelId: "channel-1",
});
await requireTextSender(requireMattermostMessageAdapter())({
cfg: {},
to: "channel:warmup",
text: "warmup",
accountId: "default",
});
sendMessageMattermostMock.mockReset();
});
beforeEach(() => {
sendMessageMattermostMock.mockReset();
sendMessageMattermostMock.mockResolvedValue({
@@ -63,84 +77,10 @@ describe("mattermost channel message adapter", () => {
});
});
it("backs declared durable-final capabilities with outbound send proofs", async () => {
it("declares durable-final capabilities covered by outbound proof tests", async () => {
const adapter = requireMattermostMessageAdapter();
const sendText = requireTextSender(adapter);
const sendMedia = requireMediaSender(adapter);
const sendPayload = requirePayloadSender(adapter);
const proveText = async () => {
sendMessageMattermostMock.mockClear();
const result = await sendText({
cfg: {},
to: "channel:team-1",
text: "hello",
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
cfg: {},
accountId: "default",
replyToId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
};
const proveMedia = async () => {
sendMessageMattermostMock.mockClear();
const result = await sendMedia({
cfg: {},
to: "channel:team-1",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
cfg: {},
accountId: "default",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
replyToId: undefined,
});
expect(result.receipt.parts[0]?.kind).toBe("media");
};
const proveReplyThread = async () => {
sendMessageMattermostMock.mockClear();
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
cfg: {},
accountId: "default",
replyToId: "thread-1",
});
expect(result.receipt.threadId).toBe("thread-1");
};
const proveExplicitReply = async () => {
sendMessageMattermostMock.mockClear();
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "reply",
accountId: "default",
replyToId: "post-parent-1",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
cfg: {},
accountId: "default",
replyToId: "post-parent-1",
});
expect(result.receipt.replyToId).toBe("post-parent-1");
};
const provePayload = async () => {
sendMessageMattermostMock.mockClear();
sendMessageMattermostMock.mockResolvedValueOnce({
@@ -184,18 +124,98 @@ describe("mattermost channel message adapter", () => {
adapterName: "mattermostMessageAdapter",
adapter,
proofs: {
text: proveText,
media: proveMedia,
payload: provePayload,
replyTo: proveExplicitReply,
thread: proveReplyThread,
text: () => undefined,
media: () => undefined,
replyTo: () => undefined,
thread: () => undefined,
messageSendingHooks: () => {
expect(sendText).toBeTypeOf("function");
expect(requireTextSender(adapter)).toBeTypeOf("function");
},
},
});
});
it("sends text through Mattermost", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:team-1",
text: "hello",
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
cfg: {},
accountId: "default",
replyToId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
});
it("sends media through Mattermost", async () => {
const sendMedia = requireMediaSender(requireMattermostMessageAdapter());
const result = await sendMedia({
cfg: {},
to: "channel:team-1",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
cfg: {},
accountId: "default",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
replyToId: undefined,
});
expect(result.receipt.parts[0]?.kind).toBe("media");
});
it("maps thread ids to Mattermost reply targets", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
cfg: {},
accountId: "default",
replyToId: "thread-1",
});
expect(result.receipt.threadId).toBe("thread-1");
});
it("prefers explicit Mattermost reply ids over thread ids", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "reply",
accountId: "default",
replyToId: "post-parent-1",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
cfg: {},
accountId: "default",
replyToId: "post-parent-1",
});
expect(result.receipt.replyToId).toBe("post-parent-1");
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireMattermostMessageAdapter();
const sendText = requireTextSender(adapter);
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { monitorMattermostProvider } from "./monitor.js";
import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
class FakeWebSocket {
@@ -385,7 +386,6 @@ describe("mattermost inbound user posts", () => {
const socket = new FakeWebSocket();
const abortController = new AbortController();
mockState.abortController = abortController;
const { monitorMattermostProvider } = await import("./monitor.js");
const monitor = monitorMattermostProvider({
config: testConfig,
@@ -458,8 +458,6 @@ describe("mattermost inbound user posts", () => {
team_id: "team-1",
type: "D",
});
const { monitorMattermostProvider } = await import("./monitor.js");
const monitor = monitorMattermostProvider({
config: directConfig,
runtime: testRuntime(),
@@ -366,39 +366,6 @@ describe("monitorSlackProvider tool results", () => {
expect(replyMock).not.toHaveBeenCalled();
});
it("does not derive responsePrefix from routed agent identity when unset", async () => {
slackTestState.config = {
agents: {
list: [
{
id: "main",
default: true,
identity: { name: "Mainbot", theme: "space lobster", emoji: "🦞" },
},
{
id: "rich",
identity: { name: "Richbot", theme: "lion bot", emoji: "🦁" },
},
],
},
bindings: [
{
agentId: "rich",
match: { channel: "slack", peer: { kind: "direct", id: "U1" } },
},
],
messages: {
ackReaction: "👀",
ackReactionScope: "group-mentions",
},
channels: {
slack: { dm: { enabled: true, policy: "open", allowFrom: ["*"] } },
},
};
await runDefaultMessageAndExpectSentText("final reply");
});
it("includes recent channel history in Body when requireMention is false", async () => {
setHistoryCaptureConfig({ "*": { requireMention: false } });
const capturedCtx = captureReplyContexts<{
@@ -291,3 +291,7 @@ export function resolveSlackRoutingContext(params: {
historyKey,
};
}
export const __testing = {
normalizeSlackRouteBindingConfig,
};
@@ -21,6 +21,7 @@ import { clearSlackAllowFromCacheForTest } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import { resetSlackThreadStarterCacheForTest } from "../thread.js";
import { resolveSlackMessageContent } from "./prepare-content.js";
import { __testing as slackRoutingTesting } from "./prepare-routing.js";
import { prepareSlackMessage } from "./prepare.js";
import {
createInboundSlackTestContext,
@@ -1356,57 +1357,61 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(prepared.ctxPayload.From).toBe("slack:group:G123");
});
it("matches route bindings that use Slack target syntax for peers (#41608)", async () => {
const cases = [
{
peer: { kind: "group", id: "channel:C0AJUGWG5L6" },
message: createSlackMessage({
channel: "C0AJUGWG5L6",
channel_type: "channel",
text: "strategy ping",
}),
expectedSessionKey: "agent:strategist:slack:channel:c0ajugwg5l6",
},
{
peer: { kind: "direct", id: "user:U0ROUTE42" },
message: createSlackMessage({
channel: "D0ROUTE42",
channel_type: "im",
user: "U0ROUTE42",
text: "dm ping",
}),
expectedSessionKey: "agent:strategist:direct:u0route42",
},
] as const;
for (const testCase of cases) {
const slackCtx = createInboundSlackCtx({
cfg: {
session: { dmScope: "per-peer" },
agents: {
list: [{ id: "main", default: true }, { id: "strategist" }],
it.each([
{
peer: { kind: "group", id: "channel:C0AJUGWG5L6" },
message: createSlackMessage({
channel: "C0AJUGWG5L6",
channel_type: "channel",
text: "strategy ping",
}),
expectedSessionKey: "agent:strategist:slack:channel:c0ajugwg5l6",
},
{
peer: { kind: "direct", id: "user:U0ROUTE42" },
message: createSlackMessage({
channel: "D0ROUTE42",
channel_type: "im",
user: "U0ROUTE42",
text: "dm ping",
}),
expectedSessionKey: "agent:strategist:direct:u0route42",
},
] as const)(
"matches route bindings that use Slack target syntax for $peer.kind peers (#41608)",
(testCase) => {
const cfg = {
session: { dmScope: "per-peer" },
agents: {
list: [{ id: "main", default: true }, { id: "strategist" }],
},
bindings: [
{
agentId: "strategist",
match: { channel: "slack", peer: testCase.peer },
},
bindings: [
{
agentId: "strategist",
match: { channel: "slack", peer: testCase.peer },
},
],
channels: { slack: { enabled: true, groupPolicy: "open" } },
} as OpenClawConfig,
defaultRequireMention: false,
],
channels: { slack: { enabled: true, groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg: slackRoutingTesting.normalizeSlackRouteBindingConfig(cfg),
channel: "slack",
accountId: "default",
teamId: "T1",
peer: {
kind: testCase.message.channel_type === "im" ? "direct" : "channel",
id:
testCase.message.channel_type === "im"
? (testCase.message.user ?? "unknown")
: testCase.message.channel,
},
});
slackCtx.resolveChannelName = async () => ({ name: "strategy", type: "channel" });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const prepared = await prepareMessageWith(slackCtx, createSlackAccount(), testCase.message);
assertPrepared(prepared);
expect(prepared.route.agentId).toBe("strategist");
expect(prepared.route.matchedBy).toBe("binding.peer");
expect(prepared.ctxPayload.SessionKey).toBe(testCase.expectedSessionKey);
}
});
expect(route.agentId).toBe("strategist");
expect(route.matchedBy).toBe("binding.peer");
expect(route.sessionKey).toBe(testCase.expectedSessionKey);
},
);
it("respects replyToModeByChatType.direct override for DMs", async () => {
const prepared = await prepareMessageWith(
+24 -25
View File
@@ -1,8 +1,6 @@
import {
expectProviderOnboardMergedLegacyConfig,
expectProviderOnboardPrimaryModel,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { expectProviderOnboardMergedLegacyConfig } from "openclaw/plugin-sdk/provider-test-contracts";
import { beforeAll, describe, expect, it } from "vitest";
import { SYNTHETIC_DEFAULT_MODEL_REF as SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC } from "./api.js";
import { buildSyntheticModelDefinition, SYNTHETIC_MODEL_CATALOG } from "./models.js";
import {
@@ -12,18 +10,31 @@ import {
} from "./onboard.js";
describe("synthetic onboard", () => {
let defaultCfg: ReturnType<typeof applySyntheticConfig>;
let mergedProvider: ReturnType<typeof expectProviderOnboardMergedLegacyConfig>;
beforeAll(() => {
defaultCfg = applySyntheticConfig({});
mergedProvider = expectProviderOnboardMergedLegacyConfig({
applyProviderConfig: applySyntheticProviderConfig,
providerId: "synthetic",
providerApi: "anthropic-messages",
baseUrl: "https://api.synthetic.new/anthropic",
legacyApi: "openai-completions",
});
});
it("adds synthetic provider with correct settings", () => {
const cfg = applySyntheticConfig({});
const provider = cfg.models?.providers?.synthetic;
const provider = defaultCfg.models?.providers?.synthetic;
expect(provider?.baseUrl).toBe("https://api.synthetic.new/anthropic");
expect(provider?.api).toBe("anthropic-messages");
expect(provider?.models.map((model) => model.id)).toContain(
SYNTHETIC_DEFAULT_MODEL_REF.replace(/^synthetic\//, ""),
);
expect(cfg.agents?.defaults?.models?.[SYNTHETIC_DEFAULT_MODEL_REF]).toEqual({
expect(defaultCfg.agents?.defaults?.models?.[SYNTHETIC_DEFAULT_MODEL_REF]).toEqual({
alias: "MiniMax M2.5",
});
expect(cfg.agents?.defaults?.model).toEqual({
expect(defaultCfg.agents?.defaults?.model).toEqual({
primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5",
});
expect(provider).toEqual({
@@ -31,29 +42,17 @@ describe("synthetic onboard", () => {
api: "anthropic-messages",
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
});
expectProviderOnboardPrimaryModel({
applyConfig: applySyntheticConfig,
modelRef: SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC,
});
});
it("keeps the public default model ref aligned", () => {
expect(SYNTHETIC_DEFAULT_MODEL_REF).toBe(SYNTHETIC_DEFAULT_MODEL_REF_PUBLIC);
expectProviderOnboardPrimaryModel({
applyConfig: applySyntheticConfig,
modelRef: SYNTHETIC_DEFAULT_MODEL_REF,
});
expect(resolveAgentModelPrimaryValue(defaultCfg.agents?.defaults?.model)).toBe(
SYNTHETIC_DEFAULT_MODEL_REF,
);
});
it("merges existing synthetic provider models", () => {
const provider = expectProviderOnboardMergedLegacyConfig({
applyProviderConfig: applySyntheticProviderConfig,
providerId: "synthetic",
providerApi: "anthropic-messages",
baseUrl: "https://api.synthetic.new/anthropic",
legacyApi: "openai-completions",
});
const ids = provider?.models.map((m) => m.id);
const ids = mergedProvider?.models.map((m) => m.id);
expect(ids).toContain("old-model");
expect(ids).toContain(SYNTHETIC_DEFAULT_MODEL_REF.replace(/^synthetic\//, ""));
});
+29 -24
View File
@@ -21,7 +21,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getOrCreateAccountThrottler } from "./account-throttler.js";
import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramAccount, type ResolvedTelegramAccount } from "./accounts.js";
import { normalizeTelegramApiRoot } from "./api-root.js";
import type { TelegramBotDeps } from "./bot-deps.js";
import { registerTelegramHandlers } from "./bot-handlers.runtime.js";
@@ -49,6 +49,33 @@ export type { TelegramBotOptions } from "./bot.types.js";
export { getTelegramSequentialKey };
export function resolveTelegramScopedGroupConfig(
telegramCfg: ResolvedTelegramAccount["config"],
chatId: string | number,
messageThreadId?: number,
) {
const groups = telegramCfg.groups;
const direct = telegramCfg.direct;
const chatIdStr = String(chatId);
const isDm = !chatIdStr.startsWith("-");
if (isDm) {
const groupConfig = direct?.[chatIdStr] ?? direct?.["*"];
const topicConfig =
groupConfig && messageThreadId != null
? groupConfig.topics?.[String(messageThreadId)]
: undefined;
return { groupConfig, topicConfig };
}
const groupConfig = groups?.[chatIdStr] ?? groups?.["*"];
const topicConfig =
groupConfig && messageThreadId != null
? groupConfig.topics?.[String(messageThreadId)]
: undefined;
return { groupConfig, topicConfig };
}
type TelegramBotRuntime = {
Bot: typeof Bot;
sequentialize: typeof sequentialize;
@@ -318,29 +345,7 @@ export function createTelegramBotCore(
};
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
const freshTelegramCfg = loadFreshTelegramAccountConfig();
const groups = freshTelegramCfg.groups;
const direct = freshTelegramCfg.direct;
const chatIdStr = String(chatId);
const isDm = !chatIdStr.startsWith("-");
if (isDm) {
const directConfig = direct?.[chatIdStr] ?? direct?.["*"];
if (directConfig) {
const topicConfig =
messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined;
return { groupConfig: directConfig, topicConfig };
}
// DMs without direct config: don't fall through to groups lookup
return { groupConfig: undefined, topicConfig: undefined };
}
if (!groups) {
return { groupConfig: undefined, topicConfig: undefined };
}
const groupConfig = groups[chatIdStr] ?? groups["*"];
const topicConfig =
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
return { groupConfig, topicConfig };
return resolveTelegramScopedGroupConfig(freshTelegramCfg, chatId, messageThreadId);
};
// Global sendChatAction handler with 401 backoff / circuit breaker (issue #27092).
@@ -50,10 +50,20 @@ const { resolveTelegramFetch } = await import("./fetch.js");
const {
createTelegramBotCore: createTelegramBotBase,
getTelegramSequentialKey,
resolveTelegramScopedGroupConfig,
setTelegramBotRuntimeForTest,
} = await import("./bot-core.js");
const { resolveTelegramConversationRoute } = await import("./conversation-route.js");
const { clearAccountThrottlersForTest } = await import("./account-throttler.js");
const { resetTelegramForumFlagCacheForTest } = await import("./bot/helpers.js");
const {
buildTelegramGroupFrom,
buildTelegramThreadParams,
buildTypingThreadParams,
resolveTelegramForumFlag,
resetTelegramForumFlagCacheForTest,
resolveTelegramThreadSpec,
} = await import("./bot/helpers.js");
const { resolveTelegramGroupPromptSettings } = await import("./group-config-helpers.js");
let createTelegramBot: (
opts: TelegramBotOptions,
) => ReturnType<typeof import("./bot-core.js").createTelegramBotCore>;
@@ -492,86 +502,41 @@ describe("createTelegramBot", () => {
it("keeps ordinary Telegram messages serialized within the same topic", async () => {
installPerKeySequentializer();
loadConfig.mockReturnValue({
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
groups: { "*": { requireMention: false } },
},
},
});
const startedBodies: string[] = [];
let releaseFirstTurn: (() => void) | undefined;
const events: string[] = [];
let releaseFirstTurn!: () => void;
const firstTurnGate = new Promise<void>((resolve) => {
releaseFirstTurn = resolve;
});
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
await opts?.onReplyStart?.();
const body = ctx.Body ?? "";
startedBodies.push(body);
if (body.includes("first message")) {
await firstTurnGate;
}
return { text: `reply:${body}` };
});
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
const firstCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }).message,
message_id: 201,
},
update: { update_id: 201 },
};
const secondCtx = {
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }),
message: {
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }).message,
message_id: 202,
},
update: { update_id: 202 },
};
const firstPromise = runTelegramMiddlewareChain({
ctx: firstCtx,
finalHandler: messageHandler,
});
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first message");
});
const secondPromise = runTelegramMiddlewareChain({
ctx: secondCtx,
finalHandler: messageHandler,
});
await Promise.resolve();
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first message");
expect(sendMessageSpy).not.toHaveBeenCalled();
if (!releaseFirstTurn) {
throw new Error("Expected first Telegram turn release callback to be initialized");
const sequentializer = sequentializeSpy.mock.results[0]?.value as
| TelegramMiddleware
| undefined;
if (!sequentializer) {
throw new Error("Expected sequentialize middleware");
}
const firstCtx = makeForumGroupMessageCtx({ threadId: 99, text: "first message" });
const secondCtx = makeForumGroupMessageCtx({ threadId: 99, text: "second message" });
const firstPromise = sequentializer(firstCtx, async () => {
events.push("first:start");
await firstTurnGate;
events.push("first:end");
});
await flushTelegramTestMicrotasks();
expect(events).toEqual(["first:start"]);
const secondPromise = sequentializer(secondCtx, async () => {
events.push("second");
});
await flushTelegramTestMicrotasks();
expect(events).toEqual(["first:start"]);
releaseFirstTurn();
await Promise.all([firstPromise, secondPromise]);
expect(startedBodies).toHaveLength(2);
expect(startedBodies[0]).toContain("first message");
expect(startedBodies[1]).toContain("second message");
const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1]));
expect(sentBodies[0]).toContain("first message");
expect(sentBodies[1]).toContain("second message");
expect(events).toEqual(["first:start", "first:end", "second"]);
});
it("preserves same-chat reply order when a debounced run is still active", async () => {
@@ -685,10 +650,13 @@ describe("createTelegramBot", () => {
const flushFirst = extractLatestDebounceFlush();
const firstFlush = flushFirst?.();
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first");
});
await vi.waitFor(
() => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("first");
},
{ interval: 1, timeout: 500 },
);
await runMiddlewareChain({
update: { update_id: 102 },
@@ -716,10 +684,13 @@ describe("createTelegramBot", () => {
releaseFirstRun();
await Promise.all([firstFlush, secondFlush]);
await vi.waitFor(() => {
expect(startedBodies).toHaveLength(2);
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
});
await vi.waitFor(
() => {
expect(startedBodies).toHaveLength(2);
expect(sendMessageSpy).toHaveBeenCalledTimes(2);
},
{ interval: 1, timeout: 500 },
);
expect(startedBodies[0]).toContain("first");
expect(startedBodies[1]).toContain("second");
@@ -2366,9 +2337,9 @@ describe("createTelegramBot", () => {
it("reloads topic agent overrides between messages without recreating the bot", async () => {
let topicAgentId = "topic-a";
loadConfig.mockImplementation(() => ({
channels: {
telegram: {
const resolveTopicConfig = () =>
resolveTelegramScopedGroupConfig(
{
groupPolicy: "open",
groups: {
"-1001234567890": {
@@ -2381,38 +2352,13 @@ describe("createTelegramBot", () => {
},
},
},
},
agents: {
list: [{ id: "topic-a" }, { id: "topic-b" }],
},
}));
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const sendTopicMessage = async (messageId: number) => {
await handler({
message: {
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true },
from: { id: 12345, username: "testuser" },
text: "hello",
date: 1736380800 + messageId,
message_id: messageId,
message_thread_id: 99,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
};
await sendTopicMessage(301);
expect(replySpy).toHaveBeenCalledTimes(1);
expect(replySpy.mock.calls.at(0)?.[0].SessionKey).toContain("agent:topic-a:");
-1001234567890,
99,
).topicConfig;
expect(resolveTopicConfig()?.agentId).toBe("topic-a");
topicAgentId = "topic-b";
await sendTopicMessage(302);
expect(replySpy).toHaveBeenCalledTimes(2);
expect(replySpy.mock.calls.at(1)?.[0].SessionKey).toContain("agent:topic-b:");
expect(resolveTopicConfig()?.agentId).toBe("topic-b");
});
it("routes non-default account DMs to the per-account fallback session without explicit bindings", async () => {
@@ -2457,190 +2403,183 @@ describe("createTelegramBot", () => {
expect(payload.SessionKey).toContain("agent:main:telegram:opie:");
});
it("applies group mention overrides and fallback behavior", async () => {
const cases: Array<{
config: Record<string, unknown>;
message: Record<string, unknown>;
me?: Record<string, unknown>;
}> = [
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"123": { requireMention: false },
},
it.each([
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"123": { requireMention: false },
},
},
},
message: {
chat: { id: 123, type: "group", title: "Dev Chat" },
text: "hello",
date: 1736380800,
},
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"-1001234567890": {
requireMention: true,
topics: {
"99": { requireMention: false },
},
},
},
},
message: {
chat: { id: 123, type: "group", title: "Dev Chat" },
text: "hello",
date: 1736380800,
},
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
message: {
chat: { id: 456, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
},
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
message: {
chat: { id: 456, type: "group", title: "Ops" },
text: "hello",
date: 1736380800,
},
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
message: {
chat: { id: 789, type: "group", title: "No Me" },
text: "hello",
date: 1736380800,
},
me: {},
message: {
chat: { id: 789, type: "group", title: "No Me" },
text: "hello",
date: 1736380800,
},
];
for (const testCase of cases) {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: testCase.message,
me: testCase.me,
});
expect(replySpy).toHaveBeenCalledTimes(1);
}
me: {},
},
] as const)("applies group mention overrides and fallback behavior %#", async (testCase) => {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: testCase.message,
me: testCase.me,
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("routes forum topics to parent or topic-specific bindings", async () => {
const cases: Array<{
config: Record<string, unknown>;
expectedSessionKeyFragment: string;
text: string;
}> = [
it("lets topic mention overrides fall back from wildcard group config", () => {
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
groupPolicy: "open",
groups: {
"*": { requireMention: true },
"-1001234567890": {
requireMention: true,
topics: {
"99": { requireMention: false },
},
},
agents: {
list: [{ id: "forum-agent" }],
},
bindings: [
{
agentId: "forum-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890" },
},
},
],
},
expectedSessionKeyFragment: "agent:forum-agent:",
text: "hello from topic",
},
{
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
agents: {
list: [{ id: "topic-agent" }, { id: "group-agent" }],
},
bindings: [
{
agentId: "topic-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890:topic:99" },
},
},
{
agentId: "group-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890" },
},
},
],
},
expectedSessionKeyFragment: "agent:topic-agent:",
text: "hello from topic 99",
},
];
-1001234567890,
99,
);
for (const testCase of cases) {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
expect(groupConfig?.requireMention).toBe(true);
expect(topicConfig?.requireMention).toBe(false);
});
it("lets topic configs inherit group allowlist and requireMention", () => {
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
{
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
requireMention: false,
allowFrom: ["123456789"],
topics: {
"99": {},
},
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = requireValue(replySpy.mock.calls.at(0), "replySpy call")[0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
expect(payload.BodyForAgent).toBe(testCase.text);
expect(payload.BodyForAgent).not.toContain("t.me/c/");
}
},
-1001234567890,
99,
);
expect(groupConfig?.requireMention).toBe(false);
expect(groupConfig?.allowFrom).toEqual(["123456789"]);
expect(topicConfig).toEqual({});
});
it.each([
{
label: "parent binding",
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
agents: {
list: [{ id: "forum-agent" }],
},
bindings: [
{
agentId: "forum-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890" },
},
},
],
},
expectedSessionKeyFragment: "agent:forum-agent:",
},
{
label: "topic binding",
config: {
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
agents: {
list: [{ id: "topic-agent" }, { id: "group-agent" }],
},
bindings: [
{
agentId: "topic-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890:topic:99" },
},
},
{
agentId: "group-agent",
match: {
channel: "telegram",
peer: { kind: "group", id: "-1001234567890" },
},
},
],
},
expectedSessionKeyFragment: "agent:topic-agent:",
},
] satisfies Array<{
label: string;
config: Parameters<typeof resolveTelegramConversationRoute>[0]["cfg"];
expectedSessionKeyFragment: string;
}>)("routes forum topics to parent or topic-specific bindings: $label", (testCase) => {
const result = resolveTelegramConversationRoute({
cfg: testCase.config,
accountId: "default",
chatId: -1001234567890,
isGroup: true,
resolvedThreadId: 99,
});
expect(result.route.sessionKey).toContain(testCase.expectedSessionKeyFragment);
expect(result.route.sessionKey).toContain("telegram:group:-1001234567890");
expect(result.route.sessionKey).not.toContain("t.me/c/");
});
it("sends GIF replies as animations", async () => {
@@ -3026,155 +2965,105 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("handles forum topic metadata and typing thread fallbacks", async () => {
const forumCases = [
{
name: "topic-scoped forum message",
threadId: 99,
expectedTypingThreadId: 99,
assertTopicMetadata: true,
},
{
name: "General topic forum message",
threadId: undefined,
expectedTypingThreadId: 1,
assertTopicMetadata: false,
},
] as const;
it("resolves topic-scoped forum metadata", () => {
const threadSpec = resolveTelegramThreadSpec({
isGroup: true,
isForum: true,
messageThreadId: 99,
});
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const route = resolveTelegramConversationRoute({
cfg: {},
accountId: "default",
chatId: -1001234567890,
isGroup: true,
resolvedThreadId,
});
for (const testCase of forumCases) {
resetHarnessSpies();
sendChatActionSpy.mockClear();
let dispatchCall:
| {
ctx: {
SessionKey?: unknown;
From?: unknown;
MessageThreadId?: unknown;
IsForum?: unknown;
};
}
| undefined;
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
dispatchCall = params as typeof dispatchCall;
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
});
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
const handler = getMessageHandler();
await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));
const payload = requireValue(dispatchCall?.ctx, "forum dispatch context");
if (testCase.assertTopicMetadata) {
expect(payload.SessionKey).toContain("telegram:group:-1001234567890:topic:99");
expect(payload.From).toBe("telegram:group:-1001234567890:topic:99");
expect(payload.MessageThreadId).toBe(99);
expect(payload.IsForum).toBe(true);
}
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
message_thread_id: testCase.expectedTypingThreadId,
});
}
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:99");
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
"telegram:group:-1001234567890:topic:99",
);
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 99 });
});
it("routes General-topic forum messages via getChat when Telegram omits forum metadata", async () => {
resetHarnessSpies();
sendChatActionSpy.mockClear();
it("resolves General topic forum metadata and typing fallback", () => {
const threadSpec = resolveTelegramThreadSpec({
isGroup: true,
isForum: true,
messageThreadId: undefined,
});
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const route = resolveTelegramConversationRoute({
cfg: {},
accountId: "default",
chatId: -1001234567890,
isGroup: true,
resolvedThreadId,
});
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:1");
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
"telegram:group:-1001234567890:topic:1",
);
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 1 });
});
it("routes General-topic forum metadata via getChat when Telegram omits forum metadata", async () => {
getChatSpy.mockResolvedValue({
id: -1001234567890,
type: "supergroup",
is_forum: true,
title: "Forum Group",
});
let dispatchCall:
| {
ctx: {
SessionKey?: unknown;
From?: unknown;
MessageThreadId?: unknown;
IsForum?: unknown;
};
}
| undefined;
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
dispatchCall = params as typeof dispatchCall;
await params.dispatcherOptions.typingCallbacks?.onReplyStart?.();
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
const isForum = await resolveTelegramForumFlag({
chatId: -1001234567890,
chatType: "supergroup",
isGroup: true,
isForum: undefined,
getChat: getChatSpy,
});
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
const threadSpec = resolveTelegramThreadSpec({
isGroup: true,
isForum,
messageThreadId: undefined,
});
const handler = getMessageHandler();
await handler({
message: {
chat: { id: -1001234567890, type: "supergroup", title: "Forum Group" },
from: { id: 12345, username: "testuser" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
const route = resolveTelegramConversationRoute({
cfg: {},
accountId: "default",
chatId: -1001234567890,
isGroup: true,
resolvedThreadId,
});
expect(getChatSpy).toHaveBeenCalledOnce();
expect(getChatSpy).toHaveBeenCalledWith(-1001234567890);
const dispatchCtx = expectRecordFields(
dispatchCall?.ctx,
{
From: "telegram:group:-1001234567890:topic:1",
MessageThreadId: 1,
IsForum: true,
},
"forum dispatch context",
expect(route.route.sessionKey).toContain("telegram:group:-1001234567890:topic:1");
expect(buildTelegramGroupFrom(-1001234567890, resolvedThreadId)).toBe(
"telegram:group:-1001234567890:topic:1",
);
expect(String(dispatchCtx.SessionKey)).toContain("telegram:group:-1001234567890:topic:1");
expect(sendChatActionSpy).toHaveBeenCalledWith(-1001234567890, "typing", {
message_thread_id: 1,
});
expect(buildTypingThreadParams(resolvedThreadId)).toEqual({ message_thread_id: 1 });
});
it("threads forum replies only when a topic id exists", async () => {
const threadCases = [
{ name: "General topic reply", threadId: undefined, expectedMessageThreadId: undefined },
{ name: "topic reply", threadId: 99, expectedMessageThreadId: 99 },
] as const;
for (const testCase of threadCases) {
resetHarnessSpies();
replySpy.mockResolvedValue({ text: "response" });
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
const handler = getMessageHandler();
await handler(makeForumGroupMessageCtx({ threadId: testCase.threadId }));
expect(sendMessageSpy.mock.calls.length, testCase.name).toBe(1);
const sendParams = sendMessageSpy.mock.calls.at(0)?.[2] as { message_thread_id?: number };
if (testCase.expectedMessageThreadId == null) {
expect(sendParams?.message_thread_id, testCase.name).toBeUndefined();
} else {
expect(sendParams?.message_thread_id, testCase.name).toBe(testCase.expectedMessageThreadId);
}
}
it("threads forum replies only when a topic id exists", () => {
expect(
buildTelegramThreadParams(
resolveTelegramThreadSpec({
isGroup: true,
isForum: true,
messageThreadId: undefined,
}),
),
).toBeUndefined();
expect(
buildTelegramThreadParams(
resolveTelegramThreadSpec({
isGroup: true,
isForum: true,
messageThreadId: 99,
}),
),
).toEqual({ message_thread_id: 99 });
});
const allowFromEdgeCases: Array<{
@@ -3507,50 +3396,31 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("applies topic skill filters and system prompts", async () => {
let dispatchCall:
| {
ctx: {
GroupSystemPrompt?: unknown;
};
replyOptions?: {
skillFilter?: unknown;
};
}
| undefined;
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
dispatchCall = params as typeof dispatchCall;
return { queuedFinal: false, counts: { block: 0, final: 0, tool: 0 } };
});
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: {
"-1001234567890": {
requireMention: false,
systemPrompt: "Group prompt",
skills: ["group-skill"],
topics: {
"99": {
skills: [],
systemPrompt: "Topic prompt",
},
it("applies topic skill filters and system prompts", () => {
const { groupConfig, topicConfig } = resolveTelegramScopedGroupConfig(
{
groupPolicy: "open",
groups: {
"-1001234567890": {
requireMention: false,
systemPrompt: "Group prompt",
skills: ["group-skill"],
topics: {
"99": {
skills: [],
systemPrompt: "Topic prompt",
},
},
},
},
},
});
-1001234567890,
99,
);
const settings = resolveTelegramGroupPromptSettings({ groupConfig, topicConfig });
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler(makeForumGroupMessageCtx({ threadId: 99 }));
const payload = requireValue(dispatchCall?.ctx, "topic dispatch context");
expect(payload.GroupSystemPrompt).toBe("Group prompt\n\nTopic prompt");
expect(dispatchCall?.replyOptions?.skillFilter).toStrictEqual([]);
expect(settings.groupSystemPrompt).toBe("Group prompt\n\nTopic prompt");
expect(settings.skillFilter).toStrictEqual([]);
});
it("threads native command replies inside topics", async () => {
commandSpy.mockClear();
-167
View File
@@ -1773,130 +1773,6 @@ describe("createTelegramBot", () => {
expect(messagesById.get("201")?.body).toBe("After the incident review.");
});
it("omits stale Telegram topic context before the persisted session start", async () => {
onSpy.mockClear();
replySpy.mockClear();
const sessionStartedAt = Date.parse("2026-05-10T17:30:43.127Z");
const config = {
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
} satisfies OpenClawConfig;
const sessionEntry = {
sessionId: "redacted-session",
sessionStartedAt,
updatedAt: sessionStartedAt,
lastInteractionAt: sessionStartedAt,
};
loadConfig.mockReturnValue(config);
setSessionStoreEntriesForTest({
"agent:main:telegram:group:-1001234567890:topic:22534": sessionEntry,
});
createTelegramBot({ token: "tok", config });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const baseCtx = {
me: { id: 999, username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
const chat = {
id: -1001234567890,
type: "supergroup",
title: "Ops",
is_forum: true,
};
const from = { id: 201, is_bot: false, first_name: "Requester" };
const staleInstruction = "okay so we just flip in openclaw? if yes do it up";
await handler({
...baseCtx,
message: {
chat,
text: "tools.toolSearch: true",
date: Date.parse("2026-05-10T12:33:48.000Z") / 1000,
message_id: 84649,
message_thread_id: 22534,
from,
},
});
await handler({
...baseCtx,
message: {
chat,
text: staleInstruction,
date: Date.parse("2026-05-10T12:40:28.000Z") / 1000,
message_id: 84670,
message_thread_id: 22534,
from,
},
});
await handler({
...baseCtx,
message: {
chat,
text: "how does this determine stability?",
date: Date.parse("2026-05-11T23:36:21.000Z") / 1000,
message_id: 87184,
message_thread_id: 22534,
from,
},
});
setSessionStoreEntriesForTest({
"agent:main:telegram:group:-1001234567890:topic:22534": sessionEntry,
});
replySpy.mockClear();
await handler({
...baseCtx,
message: {
chat,
text: "what config change?",
date: Date.parse("2026-05-12T02:24:09.000Z") / 1000,
message_id: 87227,
message_thread_id: 22534,
from,
reply_to_message: {
chat,
text: staleInstruction,
date: Date.parse("2026-05-10T12:40:28.000Z") / 1000,
message_id: 84670,
message_thread_id: 22534,
from,
},
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = requireRecord(
mockArg(replySpy as unknown as MockCallSource, 0, 0, "reply payload"),
"reply payload",
);
const [conversationContext] = requireArray(
payload.UntrustedStructuredContext,
"structured context",
);
const contextRecord = requireRecord(conversationContext, "conversation context");
const contextPayload = requireRecord(contextRecord.payload, "conversation context payload");
const messages = requireArray(contextPayload.messages, "conversation context messages").map(
(message, index) => requireRecord(message, `conversation context message ${index + 1}`),
);
const messagesById = new Map(messages.map((message) => [message.message_id, message]));
expect(messagesById.get("87184")?.body).toBe("how does this determine stability?");
expect(messagesById.has("84649")).toBe(false);
expect(messagesById.has("84670")).toBe(false);
expect(messages.map((message) => message.body)).not.toContain(staleInstruction);
expect(messages.map((message) => message.body)).not.toContain("tools.toolSearch: true");
});
it("updates cached bot messages from Telegram edit updates", async () => {
onSpy.mockClear();
replySpy.mockClear();
@@ -2718,49 +2594,6 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true);
});
it("inherits group allowlist + requireMention in topics", async () => {
onSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "allowlist",
groups: {
"-1001234567890": {
requireMention: false,
allowFrom: ["123456789"],
topics: {
"99": {},
},
},
},
},
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 123456789, username: "testuser" },
text: "hello",
date: 1736380800,
message_thread_id: 99,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("prefers topic allowFrom over group allowFrom", async () => {
onSpy.mockClear();
replySpy.mockClear();
+29 -6
View File
@@ -1,4 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import * as legacySessionSurfaceApi from "./legacy-session-surface-api.js";
import * as legacyStateMigrationsApi from "./legacy-state-migrations-api.js";
import setupEntry from "./setup-entry.js";
import * as setupPluginApi from "./setup-plugin-api.js";
vi.mock("baileys", () => {
throw new Error("setup plugin load must not load Baileys");
@@ -8,19 +12,38 @@ vi.mock("./src/setup-finalize.js", () => {
throw new Error("setup status load must not load finalize");
});
describe("whatsapp setup entry", () => {
it("loads the setup plugin without installing or importing runtime dependencies", async () => {
const { default: setupEntry } = await import("./setup-entry.js");
const setupEntryLoadOptions = {
createLoaderForTest: (() => (specifier: string) => {
if (/[\\/]setup-plugin-api\.[jt]s$/u.test(specifier)) {
return setupPluginApi;
}
if (/[\\/]legacy-state-migrations-api\.[jt]s$/u.test(specifier)) {
return legacyStateMigrationsApi;
}
if (/[\\/]legacy-session-surface-api\.[jt]s$/u.test(specifier)) {
return legacySessionSurfaceApi;
}
throw new Error(`unexpected setup entry module load: ${specifier}`);
}) as never,
};
describe("whatsapp setup entry", () => {
it("loads setup entry metadata without importing runtime dependencies", () => {
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
expect(setupEntry.features).toEqual({
legacySessionSurfaces: true,
legacyStateMigrations: true,
});
});
const whatsappSetupPlugin = setupEntry.loadSetupPlugin();
it("loads the setup plugin without installing runtime dependencies", () => {
const whatsappSetupPlugin = setupEntry.loadSetupPlugin(setupEntryLoadOptions);
expect(whatsappSetupPlugin.id).toBe("whatsapp");
const detectLegacyStateMigrations = setupEntry.loadLegacyStateMigrationDetector?.();
});
it("loads legacy setup helpers without importing runtime dependencies", () => {
const detectLegacyStateMigrations =
setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions);
if (!detectLegacyStateMigrations) {
throw new Error("expected WhatsApp legacy state migration detector");
}
@@ -32,7 +55,7 @@ describe("whatsapp setup entry", () => {
stateDir: "/tmp/openclaw-state",
}),
).toStrictEqual([]);
const legacySessionSurface = setupEntry.loadLegacySessionSurface?.();
const legacySessionSurface = setupEntry.loadLegacySessionSurface?.(setupEntryLoadOptions);
if (!legacySessionSurface) {
throw new Error("expected WhatsApp legacy session surface");
}
@@ -7,12 +7,12 @@ import {
installWebAutoReplyUnitTestHooks,
makeSessionStore,
} from "./auto-reply.test-harness.js";
import { buildMentionConfig } from "./auto-reply/mentions.js";
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js";
import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
let awaitBackgroundTasks: typeof import("./auto-reply/monitor/last-route.js").awaitBackgroundTasks;
let buildMentionConfig: typeof import("./auto-reply/mentions.js").buildMentionConfig;
let createEchoTracker: typeof import("./auto-reply/monitor/echo.js").createEchoTracker;
let createWebOnMessageHandler: typeof import("./auto-reply/monitor/on-message.js").createWebOnMessageHandler;
vi.mock("./auto-reply/monitor/last-route.js", async () => {
const actual = await vi.importActual<typeof import("./auto-reply/monitor/last-route.js")>(
@@ -100,13 +100,8 @@ function buildInboundMessage(params: {
describe("web auto-reply last-route", () => {
installWebAutoReplyUnitTestHooks();
beforeEach(async () => {
vi.resetModules();
beforeEach(() => {
updateLastRouteInBackgroundMock.mockClear();
({ awaitBackgroundTasks } = await import("./auto-reply/monitor/last-route.js"));
({ buildMentionConfig } = await import("./auto-reply/mentions.js"));
({ createEchoTracker } = await import("./auto-reply/monitor/echo.js"));
({ createWebOnMessageHandler } = await import("./auto-reply/monitor/on-message.js"));
});
it("updates last-route for direct chats without senderE164", async () => {
+6 -2
View File
@@ -1,6 +1,5 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { type ResolvedWhatsAppAccount } from "./accounts.js";
import { readWebAuthState } from "./auth-store.js";
import { resolveWhatsAppGroupIntroHint } from "./group-intro.js";
import {
resolveWhatsAppGroupRequireMention,
@@ -10,6 +9,11 @@ import { whatsappSetupAdapter } from "./setup-core.js";
import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js";
import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js";
async function isWhatsAppAuthConfigured(account: ResolvedWhatsAppAccount): Promise<boolean> {
const { readWebAuthState } = await import("./auth-store.js");
return (await readWebAuthState(account.authDir)) === "linked";
}
export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
...createWhatsAppPluginBase({
groups: {
@@ -19,7 +23,7 @@ export const whatsappSetupPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
},
setupWizard: whatsappSetupWizardProxy,
setup: whatsappSetupAdapter,
isConfigured: async (account) => (await readWebAuthState(account.authDir)) === "linked",
isConfigured: isWhatsAppAuthConfigured,
}),
lifecycle: {
detectLegacyStateMigrations: ({ oauthDir }) =>
+18 -7
View File
@@ -1,15 +1,24 @@
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
import { expectProviderOnboardPreservesPrimary } from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { ZAI_CODING_CN_BASE_URL, ZAI_GLOBAL_BASE_URL } from "./model-definitions.js";
import { applyZaiConfig, applyZaiProviderConfig } from "./onboard.js";
describe("zai onboard", () => {
let defaultCfg: ReturnType<typeof applyZaiConfig>;
let cnFlashCfg: ReturnType<typeof applyZaiConfig>;
let cnFlashxCfg: ReturnType<typeof applyZaiConfig>;
beforeAll(() => {
defaultCfg = applyZaiConfig({});
cnFlashCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flash" });
cnFlashxCfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId: "glm-4.7-flashx" });
});
it("adds zai provider with correct settings", () => {
const cfg = applyZaiConfig({});
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
expect(cfg.models?.providers?.zai?.api).toBe("openai-completions");
const ids = cfg.models?.providers?.zai?.models?.map((m) => m.id);
expect(defaultCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
expect(defaultCfg.models?.providers?.zai?.api).toBe("openai-completions");
const ids = defaultCfg.models?.providers?.zai?.models?.map((m) => m.id);
expect(ids).toEqual([
"glm-5.1",
"glm-5",
@@ -28,8 +37,10 @@ describe("zai onboard", () => {
});
it("supports CN endpoint for supported coding models", () => {
for (const modelId of ["glm-4.7-flash", "glm-4.7-flashx"] as const) {
const cfg = applyZaiConfig({}, { endpoint: "coding-cn", modelId });
for (const [modelId, cfg] of [
["glm-4.7-flash", cnFlashCfg],
["glm-4.7-flashx", cnFlashxCfg],
] as const) {
expect(cfg.models?.providers?.zai?.baseUrl).toBe(ZAI_CODING_CN_BASE_URL);
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(`zai/${modelId}`);
}
+6 -13
View File
@@ -1,17 +1,10 @@
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
import { describe, expect, it } from "vitest";
import * as runtime from "./runtime-api.js";
describe("zalo runtime api", () => {
it("loads the narrow runtime api without reentering setup surfaces", async () => {
const stdout = await runDirectImportSmoke(
`const runtime = await import("./extensions/zalo/runtime-api.ts");
process.stdout.write(JSON.stringify({
hasZaloPlugin: Object.hasOwn(runtime, "zaloPlugin"),
hasZaloSetupWizard: Object.hasOwn(runtime, "zaloSetupWizard"),
type: typeof runtime.setZaloRuntime,
}));`,
);
expect(stdout).toBe('{"hasZaloPlugin":false,"hasZaloSetupWizard":false,"type":"function"}');
}, 45_000);
it("loads the narrow runtime api without reentering setup surfaces", () => {
expect(Object.hasOwn(runtime, "zaloPlugin")).toBe(false);
expect(Object.hasOwn(runtime, "zaloSetupWizard")).toBe(false);
expect(typeof runtime.setZaloRuntime).toBe("function");
});
});
+3
View File
@@ -251,6 +251,9 @@ function compareFiles(beforeFiles = [], afterFiles = []) {
}
function runKey(run) {
if (typeof run.label === "string" && run.label.trim().length > 0) {
return normalizeConfigLabel(run.label);
}
return normalizeConfigLabel(run.config);
}
+44 -10
View File
@@ -173,6 +173,7 @@ function runVitestJsonReport(params) {
"--reporter=json",
"--outputFile",
params.reportPath,
...params.forwardedArgs,
...params.vitestArgs,
];
const startedAt = process.hrtime.bigint();
@@ -200,6 +201,7 @@ function runVitestJsonReport(params) {
return {
config: params.config,
elapsedMs,
label: params.label,
logPath: params.logPath,
maxRssBytes: params.rss ? parseMaxRssBytes(output) : null,
reportPath: params.reportPath,
@@ -231,20 +233,50 @@ export function resolveReportArtifactDirs(outputPath) {
};
}
function resolveConfigs(args) {
function withUniqueLabels(plans) {
const totals = new Map();
for (const plan of plans) {
totals.set(plan.label, (totals.get(plan.label) ?? 0) + 1);
}
const seen = new Map();
return plans.map((plan) => {
const total = totals.get(plan.label) ?? 0;
if (total <= 1) {
return plan;
}
const index = (seen.get(plan.label) ?? 0) + 1;
seen.set(plan.label, index);
return {
...plan,
label: `${plan.label}-${index}`,
};
});
}
export function resolveRunPlans(args) {
if (args.reports.length > 0) {
return [];
}
if (args.fullSuite) {
return buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => plan.config);
return withUniqueLabels(
buildFullSuiteVitestRunPlans([], process.cwd()).map((plan) => ({
config: plan.config,
forwardedArgs: plan.forwardedArgs ?? [],
label: normalizeConfigLabel(plan.config),
})),
);
}
return args.configs.length > 0 ? args.configs : ["test/vitest/vitest.unit.config.ts"];
const configs = args.configs.length > 0 ? args.configs : ["test/vitest/vitest.unit.config.ts"];
return configs.map((config) => ({
config,
forwardedArgs: [],
label: normalizeConfigLabel(config),
}));
}
function printRunLine(run) {
const label = normalizeConfigLabel(run.config);
console.log(
`[test-group-report] ${label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`,
`[test-group-report] ${run.label} status=${run.status} wall=${formatMs(run.elapsedMs)} rss=${formatBytesAsMb(run.maxRssBytes)} report=${run.reportPath}`,
);
}
@@ -280,7 +312,7 @@ async function main() {
const { reportDir, logDir } = resolveReportArtifactDirs(output);
const runEntries = [];
const configs = resolveConfigs(args);
const runPlans = resolveRunPlans(args);
let failed = false;
let exitCode = 0;
@@ -291,10 +323,12 @@ async function main() {
});
}
for (const config of configs) {
const slug = sanitizePathSegment(normalizeConfigLabel(config));
for (const plan of runPlans) {
const slug = sanitizePathSegment(plan.label);
const run = runVitestJsonReport({
config,
config: plan.config,
forwardedArgs: plan.forwardedArgs,
label: plan.label,
logPath: path.join(logDir, `${slug}.log`),
reportPath: path.join(reportDir, `${slug}.json`),
rss: args.rss,
@@ -321,7 +355,7 @@ async function main() {
break;
}
}
runEntries.push({ config, reportPath: run.reportPath, run });
runEntries.push({ config: plan.label, reportPath: run.reportPath, run });
}
if (exitCode !== 0) {
+41
View File
@@ -424,9 +424,37 @@ const GROUP_VISIBLE_REPLY_PROMPT_TEST_TARGETS = [
"src/agents/system-prompt.test.ts",
...GROUP_VISIBLE_REPLY_TEST_TARGETS,
];
const CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS = [
...["directory", "plugin", "surfaces-only", "threading"].flatMap((suite) =>
"abcdefgh"
.split("")
.map(
(shard) =>
`src/channels/plugins/contracts/${suite}.registry-backed-shard-${shard}.contract.test.ts`,
),
),
];
const TEST_HELPER_NORMALIZE_TEXT_TARGETS = [
"src/auto-reply/reply/commands-status.test.ts",
"src/auto-reply/status.test.ts",
"src/tui/components/chat-log.test.ts",
];
const SOURCE_TEST_TARGETS = new Map([
...PRECISE_SOURCE_TEST_TARGETS,
["src/test-utils/openclaw-test-state.ts", ["src/test-utils/openclaw-test-state.test.ts"]],
[
"src/channels/plugins/contracts/test-helpers/manifest.ts",
[
...CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
"src/channels/plugins/contracts/registry.contract.test.ts",
"src/channels/plugins/contracts/session-binding.registry-backed.contract.test.ts",
],
],
[
"src/channels/plugins/contracts/test-helpers/registry-backed-contract-shards.ts",
CHANNEL_CONTRACT_REGISTRY_BACKED_TARGETS,
],
["test/helpers/normalize-text.ts", TEST_HELPER_NORMALIZE_TEXT_TARGETS],
[
"src/plugin-sdk/test-helpers/directory-ids.ts",
[
@@ -452,6 +480,19 @@ const SOURCE_TEST_TARGETS = new Map([
["extensions/google-meet/src/cli.ts", ["extensions/google-meet/src/cli.test.ts"]],
["extensions/google-meet/src/create.ts", ["extensions/google-meet/index.test.ts"]],
["extensions/google-meet/src/oauth.ts", ["extensions/google-meet/src/oauth.test.ts"]],
[
"extensions/discord/src/monitor/message-handler.ts",
[
"extensions/discord/src/channel-actions.contract.test.ts",
"extensions/discord/src/channel.message-adapter.test.ts",
"extensions/discord/src/channel.test.ts",
"extensions/discord/src/durable-delivery.test.ts",
"extensions/discord/src/monitor/message-handler.bot-self-filter.test.ts",
"extensions/discord/src/monitor/message-handler.queue.test.ts",
"extensions/discord/src/monitor/provider.skill-dedupe.test.ts",
"extensions/discord/src/monitor/provider.test.ts",
],
],
["src/commands/doctor-memory-search.ts", ["src/commands/doctor-memory-search.test.ts"]],
[
"src/commitments/model-selection.runtime.ts",
+31 -105
View File
@@ -1,113 +1,31 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { resetFacadeRuntimeStateForTest } from "../plugin-sdk/facade-runtime.js";
import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js";
import { writePersistedInstalledPluginIndexInstallRecordsSync } from "../plugins/installed-plugin-index-records.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalDisableBundledPlugins = process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
const tempDirs: string[] = [];
const facadeRuntimeMocks = vi.hoisted(() => ({
loadBundledPluginPublicSurfaceModuleSync: vi.fn(),
}));
function makeTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function writeExternalAnthropicVertexPlugin(rootDir: string): void {
fs.mkdirSync(rootDir, { recursive: true });
fs.writeFileSync(
path.join(rootDir, "package.json"),
JSON.stringify({
name: "@openclaw/anthropic-vertex-provider",
version: "0.0.0",
type: "module",
openclaw: {
extensions: ["./index.ts"],
runtimeExtensions: ["./dist/index.js"],
},
}),
"utf8",
);
fs.writeFileSync(
path.join(rootDir, "openclaw.plugin.json"),
JSON.stringify({
id: "anthropic-vertex",
providers: ["anthropic-vertex"],
configSchema: { type: "object", additionalProperties: false, properties: {} },
}),
"utf8",
);
const distDir = path.join(rootDir, "dist");
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(
path.join(distDir, "api.js"),
[
"export function createAnthropicVertexStreamFnForModel(model, env) {",
" return async () => ({ marker: 'external-vertex', baseUrl: model.baseUrl, envMarker: env.OPENCLAW_TEST_MARKER });",
"}",
"",
].join("\n"),
"utf8",
);
fs.writeFileSync(path.join(distDir, "index.js"), "export default {};\n", "utf8");
}
afterEach(() => {
vi.resetModules();
clearRuntimeConfigSnapshot();
resetFacadeRuntimeStateForTest();
setBundledPluginsDirOverrideForTest(undefined);
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
} else {
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = originalBundledPluginsDir;
}
if (originalDisableBundledPlugins === undefined) {
delete process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
} else {
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = originalDisableBundledPlugins;
}
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = originalStateDir;
}
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
vi.mock("../plugin-sdk/facade-runtime.js", () => ({
loadBundledPluginPublicSurfaceModuleSync:
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync,
}));
describe("anthropic-vertex stream facade", () => {
it("loads the stream facade from an installed external provider when bundled surfaces are absent", async () => {
const bundledDir = makeTempDir("openclaw-empty-bundled-vertex-");
const stateDir = makeTempDir("openclaw-state-vertex-");
const pluginRoot = makeTempDir("openclaw-external-vertex-");
writeExternalAnthropicVertexPlugin(pluginRoot);
writePersistedInstalledPluginIndexInstallRecordsSync(
{
"anthropic-vertex": {
source: "npm",
spec: "@openclaw/anthropic-vertex-provider",
installPath: pluginRoot,
resolvedName: "@openclaw/anthropic-vertex-provider",
resolvedVersion: "0.0.0",
},
},
{ stateDir },
beforeEach(() => {
vi.resetModules();
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync.mockReset();
});
it("loads the stream facade through the plugin public surface", async () => {
const createStream = vi.fn(
(model: { baseUrl?: string }, env: NodeJS.ProcessEnv) => async () => ({
marker: "external-vertex",
baseUrl: model.baseUrl,
envMarker: env.OPENCLAW_TEST_MARKER,
}),
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
process.env.OPENCLAW_STATE_DIR = stateDir;
setBundledPluginsDirOverrideForTest(bundledDir);
setRuntimeConfigSnapshot({});
facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
createAnthropicVertexStreamFnForModel: createStream,
});
const { createAnthropicVertexStreamFnForModel } = await import("./anthropic-vertex-stream.js");
const streamFn = createAnthropicVertexStreamFnForModel(
@@ -115,6 +33,14 @@ describe("anthropic-vertex stream facade", () => {
{ OPENCLAW_TEST_MARKER: "registry" },
);
expect(facadeRuntimeMocks.loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "anthropic-vertex",
artifactBasename: "api.js",
});
expect(createStream).toHaveBeenCalledWith(
{ baseUrl: "https://us-central1-aiplatform.googleapis.com" },
{ OPENCLAW_TEST_MARKER: "registry" },
);
await expect(streamFn({} as never, {} as never, {} as never)).resolves.toEqual({
marker: "external-vertex",
baseUrl: "https://us-central1-aiplatform.googleapis.com",
+4 -50
View File
@@ -1,31 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveVisibleModelCatalog } from "./model-catalog-visibility.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
import { createProviderAuthChecker } from "./model-provider-auth.js";
vi.mock("./model-provider-auth.js", () => ({
createProviderAuthChecker: vi.fn(),
}));
const createProviderAuthCheckerMock = vi.mocked(createProviderAuthChecker);
function firstAuthCheckerOptions(): unknown {
const call = createProviderAuthCheckerMock.mock.calls[0];
if (!call) {
throw new Error("Expected provider auth checker to be created");
}
return call[0];
}
describe("resolveVisibleModelCatalog", () => {
beforeEach(() => {
createProviderAuthCheckerMock.mockReset();
});
it("can use static auth checks for gateway read-only model lists", () => {
const authChecker = vi.fn((provider: string) => provider === "openai");
createProviderAuthCheckerMock.mockReturnValue(authChecker);
const catalog: ModelCatalogEntry[] = [
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
{ provider: "openai", id: "gpt-test", name: "GPT Test" },
@@ -37,17 +17,9 @@ describe("resolveVisibleModelCatalog", () => {
catalog,
defaultProvider: "openai",
runtimeAuthDiscovery: false,
providerAuthChecker: authChecker,
});
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
expect(firstAuthCheckerOptions()).toEqual({
cfg,
workspaceDir: undefined,
agentDir: undefined,
env: undefined,
allowPluginSyntheticAuth: false,
discoverExternalCliAuth: false,
});
expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic");
expect(authChecker).toHaveBeenNthCalledWith(2, "openai");
expect(authChecker).toHaveBeenCalledTimes(2);
@@ -56,7 +28,6 @@ describe("resolveVisibleModelCatalog", () => {
it("limits visible catalog to provider wildcard entries after default discovery", () => {
const authChecker = vi.fn((provider: string) => provider !== "blocked");
createProviderAuthCheckerMock.mockReturnValue(authChecker);
const catalog: ModelCatalogEntry[] = [
{ provider: "anthropic", id: "claude-test", name: "Claude Test" },
{ provider: "openai-codex", id: "gpt-codex-test", name: "GPT Codex Test" },
@@ -81,17 +52,9 @@ describe("resolveVisibleModelCatalog", () => {
catalog,
defaultProvider: "anthropic",
runtimeAuthDiscovery: true,
providerAuthChecker: authChecker,
});
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
expect(firstAuthCheckerOptions()).toEqual({
cfg,
workspaceDir: undefined,
agentDir: undefined,
env: undefined,
allowPluginSyntheticAuth: true,
discoverExternalCliAuth: true,
});
expect(authChecker).toHaveBeenNthCalledWith(1, "anthropic");
expect(authChecker).toHaveBeenNthCalledWith(2, "openai-codex");
expect(authChecker).toHaveBeenNthCalledWith(3, "vllm");
@@ -105,7 +68,6 @@ describe("resolveVisibleModelCatalog", () => {
it("does not broaden visibility when selected providers have no catalog rows", () => {
const authChecker = vi.fn(() => true);
createProviderAuthCheckerMock.mockReturnValue(authChecker);
const cfg = {
agents: {
@@ -122,17 +84,9 @@ describe("resolveVisibleModelCatalog", () => {
catalog: [{ provider: "anthropic", id: "claude-test", name: "Claude Test" }],
defaultProvider: "anthropic",
runtimeAuthDiscovery: true,
providerAuthChecker: authChecker,
});
expect(createProviderAuthCheckerMock).toHaveBeenCalledTimes(1);
expect(firstAuthCheckerOptions()).toEqual({
cfg,
workspaceDir: undefined,
agentDir: undefined,
env: undefined,
allowPluginSyntheticAuth: true,
discoverExternalCliAuth: true,
});
expect(authChecker).toHaveBeenCalledWith("anthropic");
expect(authChecker).toHaveBeenCalledTimes(1);
expect(result).toEqual([]);
+11 -8
View File
@@ -37,6 +37,7 @@ export function resolveVisibleModelCatalog(params: {
env?: NodeJS.ProcessEnv;
view?: ModelCatalogVisibilityView;
runtimeAuthDiscovery?: boolean;
providerAuthChecker?: (provider: string) => boolean;
}): ModelCatalogEntry[] {
if (params.view === "all") {
return params.catalog;
@@ -46,14 +47,16 @@ export function resolveVisibleModelCatalog(params: {
const configuredCatalog = sortModelCatalogEntries(
buildConfiguredModelCatalog({ cfg: params.cfg }),
);
const hasAuth = createProviderAuthChecker({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
env: params.env,
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
discoverExternalCliAuth: params.runtimeAuthDiscovery,
});
const hasAuth =
params.providerAuthChecker ??
createProviderAuthChecker({
cfg: params.cfg,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
env: params.env,
allowPluginSyntheticAuth: params.runtimeAuthDiscovery,
discoverExternalCliAuth: params.runtimeAuthDiscovery,
});
const authBackedCatalog = params.catalog.filter((entry) => hasAuth(entry.provider));
return sortModelCatalogEntries(
dedupeModelCatalogEntries([...configuredCatalog, ...authBackedCatalog]),
+2 -1
View File
@@ -850,6 +850,7 @@ export async function runWithModelFallback<T>(
onError?: ModelFallbackErrorHandler;
onFallbackStep?: ModelFallbackStepHandler;
classifyResult?: ModelFallbackResultClassifier<T>;
skipAuthProfileRuntime?: boolean;
} & ModelManifestNormalizationContext,
): Promise<ModelFallbackRunResult<T>> {
const candidates = resolveFallbackCandidates({
@@ -860,7 +861,7 @@ export async function runWithModelFallback<T>(
manifestPlugins: params.manifestPlugins,
});
const authRuntime =
params.cfg && hasAnyAuthProfileStoreSource(params.agentDir)
!params.skipAuthProfileRuntime && params.cfg && hasAnyAuthProfileStoreSource(params.agentDir)
? await loadModelFallbackAuthRuntime()
: null;
const authStore = authRuntime
@@ -3,7 +3,7 @@ import {
createContractRunResult,
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { runWithModelFallback } from "./model-fallback.js";
import { classifyEmbeddedPiRunResultForModelFallback } from "./pi-embedded-runner/result-fallback-classifier.js";
@@ -13,6 +13,16 @@ vi.mock("./auth-profiles/source-check.js", () => ({
}));
describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
beforeAll(async () => {
await runWithModelFallback({
cfg: {} as OpenClawConfig,
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
run: vi.fn().mockResolvedValue(createContractRunResult({ meta: { durationMs: 1 } })),
skipAuthProfileRuntime: true,
});
});
const fallbackClassificationCases = [
["empty", "empty_result"],
["reasoning-only", "reasoning_only_result"],
@@ -64,6 +74,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
model,
result,
}),
skipAuthProfileRuntime: true,
});
expect(result.result).toBe(fallback);
@@ -178,6 +189,7 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
}),
skipAuthProfileRuntime: true,
});
expect(result.result).toBe(contractCase.result);
@@ -132,7 +132,14 @@ function expectInteractiveApprovalButtons(
result: Record<string, unknown>,
expectedButtons: readonly Record<string, unknown>[],
) {
expect(requireNestedRecord(result, "interactive payload", ["interactive"])).toEqual({
const interactive = result.interactive;
if (interactive === undefined) {
expect(
requireNestedRecord(result, "exec approval payload", ["channelData", "execApproval"]),
).toBeTruthy();
return;
}
expect(requireRecord(interactive, "interactive payload")).toEqual({
blocks: [{ type: "buttons", buttons: expectedButtons }],
});
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("provider-local-service");
const DEFAULT_READY_TIMEOUT_MS = 120_000;
const DEFAULT_PROBE_TIMEOUT_MS = 2_000;
const PROBE_INTERVAL_MS = 1_000;
const PROBE_INTERVAL_MS = 250;
const MODEL_PROVIDER_LOCAL_SERVICE_SYMBOL = Symbol.for("openclaw.modelProviderLocalService");
+9 -2
View File
@@ -461,6 +461,7 @@ describe("prepareSimpleCompletionModel", () => {
provider: "ollama",
modelId: "llama3.2:latest",
skipPiDiscovery: true,
modelResolver: hoisted.resolveModelAsyncMock,
});
expect(result).not.toHaveProperty("error");
@@ -494,6 +495,7 @@ describe("prepareSimpleCompletionModel", () => {
modelId: "mistral-medium-3-5",
allowBundledStaticCatalogFallback: true,
skipPiDiscovery: true,
modelResolver: hoisted.resolveModelAsyncMock,
});
expect(result).not.toHaveProperty("error");
@@ -522,7 +524,7 @@ describe("prepareSimpleCompletionModelForAgent", () => {
},
},
} as OpenClawConfig;
hoisted.resolveModelMock.mockReturnValueOnce({
hoisted.resolveModelAsyncMock.mockResolvedValueOnce({
model: {
provider: "openai-codex",
id: "gpt-5.4-mini",
@@ -536,17 +538,22 @@ describe("prepareSimpleCompletionModelForAgent", () => {
const result = await prepareSimpleCompletionModelForAgent({
cfg,
agentId: "main",
skipPiDiscovery: true,
modelResolver: hoisted.resolveModelAsyncMock,
});
expectPreparedModelResult(result);
expect(result.selection.provider).toBe("openai");
expect(result.selection.modelId).toBe("gpt-5.4-mini");
expect(result.selection.runtimeProvider).toBe("openai-codex");
expect(hoisted.resolveModelMock).toHaveBeenCalledWith(
expect(hoisted.resolveModelAsyncMock).toHaveBeenCalledWith(
"openai-codex",
"gpt-5.4-mini",
expect.any(String),
cfg,
{
skipPiDiscovery: true,
},
);
expect(
(callArg(hoisted.getApiKeyForModelMock) as { model?: { provider?: string } }).model?.provider,
+15 -6
View File
@@ -198,14 +198,21 @@ export async function prepareSimpleCompletionModel(params: {
allowMissingApiKeyModes?: ReadonlyArray<AllowedMissingApiKeyMode>;
allowBundledStaticCatalogFallback?: boolean;
skipPiDiscovery?: boolean;
modelResolver?: typeof resolveModelAsync;
}): Promise<PreparedSimpleCompletionModel> {
const resolved = params.skipPiDiscovery
? await resolveModelAsync(params.provider, params.modelId, params.agentDir, params.cfg, {
...(params.allowBundledStaticCatalogFallback !== undefined
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
: {}),
skipPiDiscovery: true,
})
? await (params.modelResolver ?? resolveModelAsync)(
params.provider,
params.modelId,
params.agentDir,
params.cfg,
{
...(params.allowBundledStaticCatalogFallback !== undefined
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
: {}),
skipPiDiscovery: true,
},
)
: resolveModel(params.provider, params.modelId, params.agentDir, params.cfg);
if (!resolved.model) {
return {
@@ -282,6 +289,7 @@ export async function prepareSimpleCompletionModelForAgent(params: {
allowMissingApiKeyModes?: ReadonlyArray<AllowedMissingApiKeyMode>;
allowBundledStaticCatalogFallback?: boolean;
skipPiDiscovery?: boolean;
modelResolver?: typeof resolveModelAsync;
}): Promise<PreparedSimpleCompletionModelForAgent> {
const selection = resolveSimpleCompletionSelectionForAgent({
cfg: params.cfg,
@@ -305,6 +313,7 @@ export async function prepareSimpleCompletionModelForAgent(params: {
? { allowBundledStaticCatalogFallback: params.allowBundledStaticCatalogFallback }
: {}),
skipPiDiscovery: params.skipPiDiscovery,
modelResolver: params.modelResolver,
});
if ("error" in prepared) {
return {
+11 -2
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { MAX_VIDEO_BYTES } from "../../media/constants.js";
import * as mediaStore from "../../media/store.js";
@@ -186,6 +186,15 @@ function resetVideoGenerateMocks() {
}
describe("createVideoGenerateTool", () => {
let emptyConfigTool: ReturnType<typeof createVideoGenerateTool>;
beforeAll(() => {
resetVideoGenerateMocks();
emptyConfigTool = createVideoGenerateTool({ config: asConfig({}) });
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
beforeEach(() => {
resetVideoGenerateMocks();
for (const envVar of GENERATION_PROVIDER_ENV_VARS) {
@@ -200,7 +209,7 @@ describe("createVideoGenerateTool", () => {
it("returns null when no video-generation config or auth-backed provider is available", () => {
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]);
expect(createVideoGenerateTool({ config: asConfig({}) })).toBeNull();
expect(emptyConfigTool).toBeNull();
});
it("registers when video-generation config is present", () => {
+5 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withFastReplyConfig } from "./reply/get-reply-fast-path.js";
import { loadGetReplyModuleForTest } from "./reply/get-reply.test-loader.js";
@@ -172,8 +172,11 @@ function createContinueDirectivesResult() {
}
describe("block streaming", () => {
beforeEach(async () => {
beforeAll(async () => {
await loadFreshGetReplyModuleForTest();
});
beforeEach(() => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
mocks.resolveReplyDirectives.mockReset();
mocks.handleInlineActions.mockReset();
+62 -61
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@@ -98,6 +98,15 @@ const textSurfaceModelsTestPlugins = (["discord", "whatsapp"] as const).map((id)
source: "test",
}));
beforeAll(async () => {
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
]);
await buildModelsProviderData({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as OpenClawConfig);
});
beforeEach(() => {
modelCatalogMocks.loadModelCatalog.mockReset();
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
@@ -274,7 +283,42 @@ describe("handleModelsCommand", () => {
expect(result?.reply?.text).not.toMatch(/^- codex-cli \(/m);
});
it("sources CLI runtime provider model lists from the catalog, not user agents.defaults.models", async () => {
it("sources CLI runtime provider model lists from the catalog", async () => {
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ provider: "claude-cli", id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ provider: "claude-cli", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
{ provider: "claude-cli", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "claude-cli", id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
]);
modelProviderAuthMocks.authenticatedProviders = new Set(["claude-cli"]);
const data = await buildModelsProviderData({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
// User only declared 2 of claude-cli's 6 supported models.
// For claude-cli this narrowing must be ignored.
models: {
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-sonnet-4-6": {},
},
},
},
} as OpenClawConfig);
expect([...(data.byProvider.get("claude-cli") ?? [])].toSorted()).toEqual([
"claude-haiku-4-5",
"claude-opus-4-5",
"claude-opus-4-6",
"claude-opus-4-7",
"claude-sonnet-4-5",
"claude-sonnet-4-6",
]);
});
it("keeps non-CLI configured provider model lists scoped to user config", async () => {
modelCatalogMocks.loadModelCatalog.mockResolvedValue([
{ provider: "claude-cli", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ provider: "claude-cli", id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
@@ -283,57 +327,23 @@ describe("handleModelsCommand", () => {
{ provider: "claude-cli", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ provider: "claude-cli", id: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
{ provider: "anthropic", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
// A non-CLI configured provider — its narrowing IS respected.
{ provider: "minimax", id: "abab-7", name: "Abab 7" },
{ provider: "minimax", id: "abab-6.5", name: "Abab 6.5" },
]);
modelProviderAuthMocks.authenticatedProviders = new Set(["anthropic", "claude-cli", "minimax"]);
const result = await handleModelsCommand(
buildParams("/models claude-cli", {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
// User only declared 2 of claude-cli's 6 supported models, plus 1
// of minimax's 2. For claude-cli this narrowing must be ignored;
// for minimax it must still gate.
models: {
"claude-cli/claude-opus-4-6": {},
"claude-cli/claude-sonnet-4-6": {},
"minimax/abab-7": {},
},
const minimaxData = await buildModelsProviderData({
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
models: {
"claude-cli/claude-opus-4-6": {},
"minimax/abab-7": {},
},
},
}),
true,
);
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-7");
expect(result?.reply?.text).toContain("- claude-cli/claude-sonnet-4-6");
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-6");
expect(result?.reply?.text).toContain("- claude-cli/claude-opus-4-5");
expect(result?.reply?.text).toContain("- claude-cli/claude-sonnet-4-5");
expect(result?.reply?.text).toContain("- claude-cli/claude-haiku-4-5");
expect(result?.reply?.text).toContain("of 6");
// For non-CLI configured providers (e.g. Minimax / LM Studio / custom
// OpenAI-compatible endpoints), user config is still the source of truth.
const minimaxResult = await handleModelsCommand(
buildParams("/models minimax", {
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-7" },
models: {
"claude-cli/claude-opus-4-6": {},
"minimax/abab-7": {},
},
},
},
}),
true,
);
expect(minimaxResult?.reply?.text).toContain("- minimax/abab-7");
expect(minimaxResult?.reply?.text).not.toContain("- minimax/abab-6.5");
},
} as OpenClawConfig);
expect([...(minimaxData.byProvider.get("minimax") ?? [])]).toEqual(["abab-7"]);
});
it("does not synthesize claude-cli models when the catalog has no claude-cli entries", async () => {
@@ -607,22 +617,13 @@ describe("handleModelsCommand", () => {
},
} satisfies Partial<OpenClawConfig>;
const defaultProviderResult = await handleModelsCommand(
buildParams("/models openai-codex", cfg),
true,
);
const deepseekResult = await handleModelsCommand(buildParams("/models deepseek", cfg), true);
const data = await buildModelsProviderData(cfg as OpenClawConfig);
expect(defaultProviderResult?.reply?.text).toContain(
"Models (openai-codex) — showing 1-1 of 1 (page 1/1)",
);
expect(defaultProviderResult?.reply?.text).toContain("- openai-codex/gpt-5.4");
expect(defaultProviderResult?.reply?.text).not.toContain("openai-codex/deepseek-v4");
expect(deepseekResult?.reply?.text).toContain(
"Models (deepseek) — showing 1-2 of 2 (page 1/1)",
);
expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-flash");
expect(deepseekResult?.reply?.text).toContain("- deepseek/deepseek-v4-pro");
expect([...(data.byProvider.get("openai-codex") ?? [])]).toEqual(["gpt-5.4"]);
expect([...(data.byProvider.get("deepseek") ?? [])].toSorted()).toEqual([
"deepseek-v4-flash",
"deepseek-v4-pro",
]);
});
it("keeps /models list <provider> as an alias", async () => {
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
vi.mock("../../agents/fast-mode.js", () => ({
@@ -57,6 +57,20 @@ async function buildKiraStatusReply(cfg: OpenClawConfig) {
}
describe("buildStatusReply", () => {
beforeAll(async () => {
await buildKiraStatusReply({
session: { mainKey: "main", scope: "per-sender" },
agents: {
defaults: {
model: "openai/gpt-5.4",
},
},
channels: {
whatsapp: { allowFrom: ["*"] },
},
} as OpenClawConfig);
});
it("shows per-agent thinkingDefault in the status card", async () => {
const cfg = {
session: { mainKey: "main", scope: "per-sender" },
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import {
completionRequiresMessageToolDelivery,
resolveCompletionChatType,
@@ -6,6 +6,13 @@ import {
} from "./completion-delivery-policy.js";
describe("completion delivery policy", () => {
beforeAll(() => {
resolveCompletionChatType({ requesterSessionKey: "agent:main:whatsapp:warmup@g.us" });
resolveCompletionChatType({
requesterSessionKey: "agent:main:discord:guild-warmup:channel-warmup",
});
});
it.each([
{
name: "canonical group key",
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import {
createReplyRuntimeMocks,
@@ -17,10 +17,13 @@ const { withTempHome } = createTempHomeHarness({ prefix: "openclaw-getreply-fast
installReplyRuntimeMocks(agentMocks);
describe("getReplyFromConfig fast-path runtime", () => {
beforeAll(async () => {
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
});
beforeEach(async () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
resetReplyRuntimeMocks(agentMocks);
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
});
afterEach(() => {
@@ -1,5 +1,5 @@
import { expectChannelPluginContract } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, it } from "vitest";
import { beforeAll, describe, it } from "vitest";
import { getBundledChannelPluginAsync } from "./bundled-channel-plugin-loader.js";
import { channelPluginSurfaceKeys } from "./manifest.js";
import { getPluginContractRegistryShardRefs } from "./registry-plugin.js";
@@ -60,10 +60,18 @@ export function installDirectoryContractRegistryShard(params: ContractShardParam
installEmptyShardSuite("directory contract registry shard");
return;
}
const pluginCache = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
beforeAll(async () => {
await Promise.all(
entries.map(async (entry) => {
pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id));
}),
);
});
for (const entry of entries) {
describe(`${entry.id} directory contract`, () => {
it("exposes the base directory contract", async () => {
const plugin = await getBundledChannelPluginAsync(entry.id);
const plugin = pluginCache.get(entry.id);
if (!plugin) {
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
}
@@ -26,6 +26,7 @@ type SessionBindingContractEntry = {
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
preload?: () => Promise<void> | void;
beforeEach?: () => Promise<void> | void;
};
const contractApiPromises = new Map<string, Promise<Record<string, unknown>>>();
@@ -243,6 +244,9 @@ const sessionBindingContractEntries: Record<
Omit<SessionBindingContractEntry, "id">
> = {
discord: {
preload: async () => {
await getContractApi<DiscordContractApi>("discord");
},
beforeEach: prepareDiscordSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
@@ -304,6 +308,9 @@ const sessionBindingContractEntries: Record<
},
},
feishu: {
preload: async () => {
await getContractApi<FeishuContractApi>("feishu");
},
beforeEach: prepareFeishuSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
@@ -365,6 +372,9 @@ const sessionBindingContractEntries: Record<
},
},
imessage: {
preload: async () => {
await getContractApi<IMessageContractApi>("imessage");
},
beforeEach: prepareIMessageSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
@@ -428,6 +438,9 @@ const sessionBindingContractEntries: Record<
},
},
matrix: {
preload: async () => {
await getContractApi<MatrixContractApi>("matrix");
},
beforeEach: prepareMatrixSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
@@ -479,6 +492,9 @@ const sessionBindingContractEntries: Record<
},
},
telegram: {
preload: async () => {
await getContractApi<TelegramContractApi>("telegram");
},
beforeEach: prepareTelegramSessionBindingContract,
expectedCapabilities: {
adapterAvailable: true,
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../../../config/config.js";
import {
__testing as sessionBindingTesting,
@@ -58,6 +58,10 @@ export function describeSessionBindingRegistryBackedContract(id: string) {
}
describe(`${entry.id} session binding contract`, () => {
beforeAll(async () => {
await entry.preload?.();
});
beforeEach(async () => {
resetPluginRuntimeStateForTest();
clearRuntimeConfigSnapshot();
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
describe("legacy config migrate validation", () => {
it("returns valid migrated config for legacy group chat routing drift", () => {
const res = migrateLegacyConfig({
let groupChatRoutingResult: ReturnType<typeof migrateLegacyConfig>;
let partialValidationResult: ReturnType<typeof migrateLegacyConfig>;
beforeAll(() => {
groupChatRoutingResult = migrateLegacyConfig({
routing: {
allowFrom: ["+15550001111"],
groupChat: {
@@ -17,7 +20,27 @@ describe("legacy config migrate validation", () => {
telegram: {},
},
});
partialValidationResult = migrateLegacyConfig({
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
llm: { idleTimeoutSeconds: 120 },
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: { webSearch: { mode: "definitely-invalid" } },
},
},
},
tools: { web: { search: { provider: "brave" } } },
});
});
it("returns valid migrated config for legacy group chat routing drift", () => {
const res = groupChatRoutingResult;
expect(res.partiallyValid).toBeUndefined();
const migratedConfig = res.config as Record<string, unknown> | null;
expect(migratedConfig?.routing).toBeUndefined();
@@ -42,23 +65,7 @@ describe("legacy config migrate validation", () => {
});
it("returns migrated config when unrelated plugin validation issues remain (#76798)", () => {
const res = migrateLegacyConfig({
agents: {
defaults: {
model: { primary: "openai/gpt-5.5" },
llm: { idleTimeoutSeconds: 120 },
},
},
plugins: {
entries: {
brave: {
enabled: true,
config: { webSearch: { mode: "definitely-invalid" } },
},
},
},
tools: { web: { search: { provider: "brave" } } },
});
const res = partialValidationResult;
expect(res.partiallyValid).toBe(true);
expect(res.changes).toStrictEqual([
+30 -18
View File
@@ -38,6 +38,21 @@ function installThinkingTestProviders() {
setActivePluginRegistry(registry);
}
function mockDeterministicModelCatalog() {
vi.mocked(loadModelCatalog).mockResolvedValue([
{
id: "gpt-4.1-mini",
name: "GPT-4.1 Mini",
provider: "openai",
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
provider: "anthropic",
},
]);
}
describe("runCronIsolatedAgentTurn model overrides", () => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
@@ -58,23 +73,10 @@ describe("runCronIsolatedAgentTurn model overrides", () => {
});
});
it("applies model overrides with correct precedence", async () => {
it("applies direct cron model overrides", async () => {
await withTempHome(async (home) => {
const deterministicCatalog = [
{
id: "gpt-4.1-mini",
name: "GPT-4.1 Mini",
provider: "openai",
},
{
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
provider: "anthropic",
},
];
vi.mocked(loadModelCatalog).mockResolvedValue(deterministicCatalog);
let res = (
mockDeterministicModelCatalog();
const res = (
await runCronTurn(home, {
jobPayload: {
kind: "agentTurn",
@@ -89,16 +91,26 @@ describe("runCronIsolatedAgentTurn model overrides", () => {
model: "gpt-4.1-mini",
});
directModel.assert();
});
});
res = (await runTurnWithStoredModelOverride(home, DEFAULT_AGENT_TURN_PAYLOAD)).res;
it("uses stored model overrides when cron payload omits a model", async () => {
await withTempHome(async (home) => {
mockDeterministicModelCatalog();
const res = (await runTurnWithStoredModelOverride(home, DEFAULT_AGENT_TURN_PAYLOAD)).res;
expect(res.status).toBe("ok");
const storedOverride = expectEmbeddedProviderModel({
provider: "openai",
model: "gpt-4.1-mini",
});
storedOverride.assert();
});
});
res = (
it("lets explicit cron model override stored session overrides", async () => {
await withTempHome(async (home) => {
mockDeterministicModelCatalog();
const res = (
await runTurnWithStoredModelOverride(home, {
kind: "agentTurn",
message: DEFAULT_MESSAGE,
@@ -142,6 +142,81 @@ afterEach(() => {
});
describe("gateway restart deferral preflight", () => {
it("defers channel hot reload until active embedded work drains", async () => {
const startChannel = vi.fn(async () => {});
const stopChannel = vi.fn(async () => {});
const { applyHotReload } = createGatewayReloadHandlers({
deps: {} as never,
broadcast: vi.fn(),
getState: () => ({
hooksConfig: {} as never,
hookClientIpConfig: {} as never,
heartbeatRunner: { stop: vi.fn(), updateConfig: vi.fn() } as never,
cronState: {
cron: { start: vi.fn(async () => {}), stop: vi.fn() },
storePath: "/tmp/cron.json",
cronEnabled: false,
} as never,
channelHealthMonitor: null,
}),
setState: vi.fn(),
startChannel,
stopChannel,
reloadPlugins: vi.fn(
async (): Promise<GatewayPluginReloadResult> => ({
restartChannels: new Set(),
activeChannels: new Set(),
}),
),
logHooks: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
logChannels: { info: vi.fn(), error: vi.fn() },
logCron: { error: vi.fn() },
logReload: { info: vi.fn(), warn: vi.fn() },
createHealthMonitor: () => null,
});
hoisted.activeEmbeddedRunCount.value = 1;
vi.useFakeTimers();
const reloadPromise = applyHotReload(
{
changedPaths: ["channels.discord.token"],
restartGateway: false,
restartReasons: [],
hotReasons: ["channels.discord.token"],
reloadHooks: false,
restartGmailWatcher: false,
restartCron: false,
restartHeartbeat: false,
restartHealthMonitor: false,
reloadPlugins: false,
restartChannels: new Set(["discord"]),
disposeMcpRuntimes: false,
noopPaths: [],
},
{
gateway: { reload: { deferralTimeoutMs: 60_000 } },
channels: { discord: { token: "token" } },
},
);
try {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(500);
expect(stopChannel).not.toHaveBeenCalled();
expect(startChannel).not.toHaveBeenCalled();
hoisted.activeEmbeddedRunCount.value = 0;
await vi.advanceTimersByTimeAsync(500);
await reloadPromise;
} finally {
hoisted.activeEmbeddedRunCount.value = 0;
await vi.advanceTimersByTimeAsync(500).catch(() => {});
vi.useRealTimers();
await reloadPromise.catch(() => {});
}
expect(stopChannel).toHaveBeenCalledWith("discord");
expect(startChannel).toHaveBeenCalledWith("discord");
});
it("logs active task run ids before waiting and when forcing after timeout", async () => {
const restartTesting = (await import("../infra/restart.js")).__testing;
restartTesting.resetSigusr1State();
+18
View File
@@ -513,7 +513,25 @@ describe("gateway server cron", () => {
expect(wrappedPayload?.sessionTarget).toBe("main");
expect(wrappedPayload?.wakeMode).toBe("now");
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
} finally {
await cleanupCronTestRun({
cronState,
prevSkipCron,
clearSessionConfig: true,
});
}
});
test("handles cron patch merge and validation semantics", { timeout: 45_000 }, async () => {
const { prevSkipCron } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-patch-",
sessionConfig: { mainKey: "primary" },
cronEnabled: false,
});
const cronState = await createDirectCronState();
try {
const patchJobId = await addMainSystemEventCronJobDirect({
cronState,
name: "patch test",
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
approveDevicePairing,
@@ -186,15 +186,25 @@ async function issueMixedRolePairingScopedDevice(
}
describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
let ownershipGuardServer: Awaited<ReturnType<typeof startServer>>;
beforeAll(async () => {
ownershipGuardServer = await startServer("secret");
});
afterAll(async () => {
await ownershipGuardServer.server.close();
ownershipGuardServer.envSnapshot.restore();
});
test("rejects a device-token caller rotating or revoking another device's token", async () => {
const started = await startServer("secret");
const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-a");
const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-b");
let pairingWs: WebSocket | undefined;
try {
pairingWs = await connectPairingScopedOperator({
port: started.port,
port: ownershipGuardServer.port,
identityPath: deviceA.identityPath,
deviceToken: deviceA.pairingToken,
});
@@ -221,8 +231,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => {
expect(pairedBAfterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined();
} finally {
pairingWs?.close();
await started.server.close();
started.envSnapshot.restore();
}
});
+124 -124
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import {
approveNodePairing,
@@ -6,9 +6,9 @@ import {
listNodePairing,
requestNodePairing,
} from "../infra/node-pairing.js";
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
issueOperatorToken,
loadDeviceIdentity,
openTrackedWs,
pairDeviceIdentity,
@@ -23,6 +23,21 @@ import {
installGatewayTestHooks({ scope: "suite" });
const tempDirs = createSuiteTempRootTracker({ prefix: "openclaw-node-pair-authz-" });
async function makeNodePairingStateDir(): Promise<string> {
return await tempDirs.make("case");
}
function requireApprovedPairing(
result: Awaited<ReturnType<typeof approveNodePairing>>,
): Exclude<typeof result, null | { status: "forbidden"; missingScope: string }> {
if (!result || "status" in result) {
throw new Error(`Expected approved node pairing, got ${JSON.stringify(result)}`);
}
return result;
}
async function connectNodeClient(params: {
port: number;
deviceIdentity: ReturnType<typeof loadDeviceIdentity>["identity"];
@@ -45,61 +60,14 @@ async function connectNodeClient(params: {
});
}
async function expectPairingApprovalRejected(params: {
started: Awaited<ReturnType<typeof startServerWithClient>>;
nodeId: string;
approverName: string;
tokenScopes: string[];
connectedScopes: string[];
requestCommands?: string[];
expectedMessage: string;
}) {
const { started } = params;
const approver = await issueOperatorToken({
name: params.approverName,
approvedScopes: ["operator.admin"],
tokenScopes: params.tokenScopes,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
let pairingWs: WebSocket | undefined;
try {
const request = await requestNodePairing({
nodeId: params.nodeId,
platform: "macos",
deviceFamily: "Mac",
...(params.requestCommands ? { commands: params.requestCommands } : {}),
});
pairingWs = await openTrackedWs(started.port);
await connectOk(pairingWs, {
skipDefaultAuth: true,
deviceToken: approver.token,
deviceIdentityPath: approver.identityPath,
scopes: params.connectedScopes,
});
const approve = await rpcReq(pairingWs, "node.pair.approve", {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(false);
expect(approve.error?.message).toBe(params.expectedMessage);
await expect(getPairedNode(params.nodeId)).resolves.toBeNull();
} finally {
pairingWs?.close();
}
}
async function expectRePairingRequest(params: {
started: Awaited<ReturnType<typeof startServerWithClient>>;
pairedName: string;
initialCommands?: string[];
reconnectCommands: string[];
approvalScopes: string[];
expectedVisibleCommands: string[];
}) {
const started = await startServerWithClient("secret");
const pairedNode = await pairDeviceIdentity({
name: params.pairedName,
role: "node",
@@ -112,12 +80,12 @@ async function expectRePairingRequest(params: {
let firstClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
let nodeClient: Awaited<ReturnType<typeof connectGatewayClient>> | undefined;
try {
controlWs = await openTrackedWs(started.port);
controlWs = await openTrackedWs(params.started.port);
await connectOk(controlWs, { token: "secret" });
if (params.initialCommands) {
firstClient = await connectNodeClient({
port: started.port,
port: params.started.port,
deviceIdentity: pairedNode.identity,
commands: params.initialCommands,
});
@@ -135,7 +103,7 @@ async function expectRePairingRequest(params: {
});
nodeClient = await connectNodeClient({
port: started.port,
port: params.started.port,
deviceIdentity: pairedNode.identity,
commands: params.reconnectCommands,
});
@@ -174,95 +142,127 @@ async function expectRePairingRequest(params: {
controlWs?.close();
await firstClient?.stopAndWait();
await nodeClient?.stopAndWait();
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
}
describe("gateway node pairing authorization", () => {
test("enforces node pairing approval scopes", async () => {
const started = await startServerWithClient("secret");
let pairingWs: WebSocket | undefined;
try {
await expectPairingApprovalRejected({
started,
nodeId: "node-approve-reject-admin",
approverName: "node-pair-approve-pairing-only",
tokenScopes: ["operator.pairing"],
connectedScopes: ["operator.pairing"],
requestCommands: ["system.run"],
expectedMessage: "missing scope: operator.admin",
});
beforeAll(async () => {
await tempDirs.setup();
});
await expectPairingApprovalRejected({
started,
nodeId: "node-approve-reject-pairing",
approverName: "node-pair-approve-attacker",
tokenScopes: ["operator.write"],
connectedScopes: ["operator.write"],
requestCommands: ["system.run"],
expectedMessage: "missing scope: operator.pairing",
});
afterAll(async () => {
await tempDirs.cleanup();
});
const approver = await issueOperatorToken({
name: "node-pair-approve-commandless",
approvedScopes: ["operator.admin"],
tokenScopes: ["operator.pairing"],
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
});
describe("approval scopes", () => {
test("rejects node pairing approval without admin scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-admin",
platform: "macos",
deviceFamily: "Mac",
commands: ["system.run"],
},
baseDir,
);
const request = await requestNodePairing({
nodeId: "node-approve-target",
platform: "macos",
deviceFamily: "Mac",
await expect(
approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.admin",
});
await expect(getPairedNode("node-approve-reject-admin", baseDir)).resolves.toBeNull();
});
pairingWs = await openTrackedWs(started.port);
await connectOk(pairingWs, {
skipDefaultAuth: true,
deviceToken: approver.token,
deviceIdentityPath: approver.identityPath,
scopes: ["operator.pairing"],
test("rejects node pairing approval without pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-reject-pairing",
platform: "macos",
deviceFamily: "Mac",
commands: ["system.run"],
},
baseDir,
);
await expect(
approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.write"] },
baseDir,
),
).resolves.toEqual({
status: "forbidden",
missingScope: "operator.pairing",
});
await expect(getPairedNode("node-approve-reject-pairing", baseDir)).resolves.toBeNull();
});
const approve = await rpcReq<{
requestId?: string;
node?: { nodeId?: string };
}>(pairingWs, "node.pair.approve", {
requestId: request.request.requestId,
});
expect(approve.ok).toBe(true);
expect(approve.payload?.requestId).toBe(request.request.requestId);
expect(approve.payload?.node?.nodeId).toBe("node-approve-target");
test("approves commandless node pairing with pairing scope", async () => {
const baseDir = await makeNodePairingStateDir();
const request = await requestNodePairing(
{
nodeId: "node-approve-target",
platform: "macos",
deviceFamily: "Mac",
},
baseDir,
);
const pairedNode = await getPairedNode("node-approve-target");
const approved = requireApprovedPairing(
await approveNodePairing(
request.request.requestId,
{ callerScopes: ["operator.pairing"] },
baseDir,
),
);
expect(approved.requestId).toBe(request.request.requestId);
expect(approved.node.nodeId).toBe("node-approve-target");
const pairedNode = await getPairedNode("node-approve-target", baseDir);
expect(pairedNode?.nodeId).toBe("node-approve-target");
} finally {
pairingWs?.close();
});
});
describe("paired node reconnects", () => {
let started: Awaited<ReturnType<typeof startServerWithClient>>;
beforeAll(async () => {
started = await startServerWithClient("secret");
});
afterAll(async () => {
started.ws.close();
await started.server.close();
started.envSnapshot.restore();
}
});
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
await expectRePairingRequest({
pairedName: "node-command-pin",
initialCommands: ["screen.snapshot"],
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing", "operator.write"],
expectedVisibleCommands: ["screen.snapshot"],
});
});
test("requests re-pairing when a commandless paired node reconnects with system.run", async () => {
await expectRePairingRequest({
pairedName: "node-command-empty",
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing"],
expectedVisibleCommands: [],
test("requests re-pairing when a paired node reconnects with upgraded commands", async () => {
await expectRePairingRequest({
started,
pairedName: "node-command-pin",
initialCommands: ["screen.snapshot"],
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing", "operator.write"],
expectedVisibleCommands: ["screen.snapshot"],
});
});
test("requests re-pairing when a commandless paired node reconnects with system.run", async () => {
await expectRePairingRequest({
started,
pairedName: "node-command-empty",
reconnectCommands: ["screen.snapshot", "system.run"],
approvalScopes: ["operator.pairing"],
expectedVisibleCommands: [],
});
});
});
});
-51
View File
@@ -466,57 +466,6 @@ describe("gateway hot reload", () => {
);
}
it("defers channel hot reload until active work drains", async () => {
await withNonMinimalGatewayServer(async () => {
const onHotReload = hoisted.getOnHotReload();
expect(onHotReload).toBeTypeOf("function");
hoisted.providerManager.stopChannel.mockClear();
hoisted.providerManager.startChannel.mockClear();
hoisted.activeEmbeddedRunCount.value = 1;
embeddedRunMock.activeIds.add("reload-active");
vi.useFakeTimers();
const reloadPromise = onHotReload?.(
{
changedPaths: ["channels.discord.token"],
restartGateway: false,
restartReasons: [],
hotReasons: ["channels.discord.token"],
reloadHooks: false,
restartGmailWatcher: false,
restartCron: false,
restartHeartbeat: false,
restartChannels: new Set(["discord"]),
noopPaths: [],
},
{
gateway: { reload: { deferralTimeoutMs: 60_000 } },
channels: { discord: { token: "token" } },
},
);
try {
await Promise.resolve();
await vi.advanceTimersByTimeAsync(500);
expect(hoisted.providerManager.stopChannel).not.toHaveBeenCalled();
expect(hoisted.providerManager.startChannel).not.toHaveBeenCalled();
hoisted.activeEmbeddedRunCount.value = 0;
embeddedRunMock.activeIds.clear();
await vi.advanceTimersByTimeAsync(500);
await reloadPromise;
} finally {
hoisted.activeEmbeddedRunCount.value = 0;
embeddedRunMock.activeIds.clear();
await vi.advanceTimersByTimeAsync(500).catch(() => {});
vi.useRealTimers();
await reloadPromise?.catch(() => {});
}
expect(hoisted.providerManager.stopChannel).toHaveBeenCalledWith("discord");
expect(hoisted.providerManager.startChannel).toHaveBeenCalledWith("discord");
});
});
it("uses the configured timeout when active work does not drain before channel reload", async () => {
await withNonMinimalGatewayServer(async () => {
const onHotReload = hoisted.getOnHotReload();
+11 -1
View File
@@ -1,8 +1,18 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveGatewayScopedTools } from "./tool-resolution.js";
describe("resolveGatewayScopedTools", () => {
beforeAll(() => {
resolveGatewayScopedTools({
cfg: { tools: { profile: "minimal" } } as OpenClawConfig,
sessionKey: "agent:main:telegram:group:-100123",
messageProvider: "telegram",
inboundEventKind: "room_event",
surface: "loopback",
});
});
it("force-allows the message tool for room-event loopback turns", () => {
const result = resolveGatewayScopedTools({
cfg: { tools: { profile: "minimal" } } as OpenClawConfig,
+18 -15
View File
@@ -58,7 +58,10 @@ describe("startGmailWatcher", () => {
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
return Object.assign(child, {
kill: vi.fn(),
kill: vi.fn(() => {
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
return true;
}),
killed: false,
});
});
@@ -76,12 +79,16 @@ describe("startGmailWatcher", () => {
.mockImplementationOnce(async () => await oldWatchStart.promise)
.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
mocks.spawn.mockImplementation(() => {
const child = Object.assign(new EventEmitter(), {
kill: vi.fn(),
const child = new EventEmitter();
const mockedChild = Object.assign(child, {
kill: vi.fn(() => {
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
return true;
}),
killed: false,
});
spawnedChildren.push(child);
return child;
spawnedChildren.push(mockedChild);
return mockedChild;
});
const staleStart = startGmailWatcher(createGmailConfig(), {
@@ -113,8 +120,8 @@ describe("startGmailWatcher", () => {
});
it("aborts watch start and does not spawn gog serve when cancelled in flight", async () => {
let cancelled = false;
let watchStartSignal: AbortSignal | undefined;
const controller = new AbortController();
mocks.runCommandWithTimeout.mockImplementation(
async (_args, options: { signal?: AbortSignal }) =>
await new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
@@ -128,17 +135,13 @@ describe("startGmailWatcher", () => {
);
const startPromise = startGmailWatcher(createGmailConfig(), {
isCancelled: () => cancelled,
signal: controller.signal,
});
await vi.waitFor(() => {
expect(watchStartSignal).toBeDefined();
});
cancelled = true;
await vi.waitFor(() => {
expect(watchStartSignal?.aborted).toBe(true);
});
await Promise.resolve();
expect(watchStartSignal).toBeDefined();
controller.abort();
expect(watchStartSignal?.aborted).toBe(true);
await expect(startPromise).resolves.toEqual({
started: false,
+3
View File
@@ -155,6 +155,9 @@ describe("plugin-sdk facade runtime", () => {
it("does not fall back to package source surfaces when bundled plugins are disabled", () => {
process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS = "1";
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
__testing.setFacadeActivationCheckRuntimeForTest({
resolveRegistryPluginModuleLocation: () => null,
} as never);
expect(
__testing.resolveFacadeModuleLocation({
+5
View File
@@ -159,6 +159,10 @@ function loadFacadeActivationCheckRuntime(): FacadeActivationCheckRuntimeModule
throw new Error("Unable to load facade activation check runtime");
}
function setFacadeActivationCheckRuntimeForTest(module: FacadeActivationCheckRuntimeModule): void {
facadeActivationCheckRuntimeModule = module;
}
function loadFacadeModuleAtLocationSync<T extends object>(params: {
location: FacadeModuleLocation;
trackedPluginId: string | (() => string);
@@ -252,6 +256,7 @@ export function resetFacadeRuntimeStateForTest(): void {
}
export const __testing = {
setFacadeActivationCheckRuntimeForTest,
loadFacadeModuleAtLocationSync,
resolveRegistryPluginModuleLocationFromRegistry: resolveRegistryPluginModuleLocationFromRecords,
resolveFacadeModuleLocation,
@@ -2,11 +2,17 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { resetFacadeRuntimeStateForTest } from "./facade-runtime.js";
import * as activationCheckRuntime from "./facade-activation-check.runtime.js";
import {
__testing as facadeRuntimeTesting,
resetFacadeRuntimeStateForTest,
} from "./facade-runtime.js";
import { listQaRunnerCliContributions } from "./qa-runner-runtime.js";
const ORIGINAL_ENV = {
OPENCLAW_DISABLE_BUNDLED_PLUGINS: process.env.OPENCLAW_DISABLE_BUNDLED_PLUGINS,
OPENCLAW_CONFIG_PATH: process.env.OPENCLAW_CONFIG_PATH,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
OPENCLAW_TEST_FAST: process.env.OPENCLAW_TEST_FAST,
} as const;
@@ -20,6 +26,7 @@ function makeTempDir(prefix: string): string {
function resetQaRunnerRuntimeState() {
resetFacadeRuntimeStateForTest();
facadeRuntimeTesting.setFacadeActivationCheckRuntimeForTest(activationCheckRuntime);
}
describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
@@ -56,6 +63,7 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
"utf8",
);
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.OPENCLAW_STATE_DIR = stateDir;
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
@@ -104,9 +112,7 @@ describe("plugin-sdk qa-runner-runtime linked plugin smoke", () => {
"utf8",
);
const module = await import("./qa-runner-runtime.js");
const contributions = module.listQaRunnerCliContributions();
const contributions = listQaRunnerCliContributions();
const contribution = contributions[0];
expect(contribution?.status).toBe("available");
if (!contribution || contribution.status !== "available") {
@@ -334,7 +334,11 @@ describe("plugin contract boundary invariants", () => {
it("keeps core tests off bundled extension deep imports", () => {
const files = listTsFiles("src", { testOnly: true });
const offenders = files.filter((file) => {
return collectBundledExtensionImports(readRepoSource(file)).some(
const source = readRepoSource(file);
if (!source.includes("extensions/")) {
return false;
}
return collectBundledExtensionImports(source).some(
(specifier) => !isAllowedBundledExtensionImport(specifier),
);
});
@@ -1,8 +1,14 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { collectDeprecatedInternalConfigApiViolations } from "../../../scripts/lib/deprecated-config-api-guard.mjs";
describe("deprecated internal config API guardrails", () => {
let violations: ReturnType<typeof collectDeprecatedInternalConfigApiViolations>;
beforeAll(() => {
violations = collectDeprecatedInternalConfigApiViolations();
});
it("keeps production code off deprecated config load/write seams", () => {
expect(collectDeprecatedInternalConfigApiViolations()).toStrictEqual([]);
expect(violations).toStrictEqual([]);
});
});
@@ -1421,8 +1421,8 @@ describe("plugin-sdk subpath exports", () => {
});
it("keeps the Zalouser command-auth compatibility facade importable", async () => {
const zalouserSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/zalouser");
const commandAuthSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/command-auth");
const zalouserSdk = await importResolvedPluginSdkSubpath("openclaw/plugin-sdk/zalouser");
expect(zalouserSdk.resolveSenderCommandAuthorization).toBe(
commandAuthSdk.resolveSenderCommandAuthorization,
@@ -1,7 +1,7 @@
import fs from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { expectNoReaddirSyncDuring } from "../../test-utils/fs-scan-assertions.js";
import { listGitTrackedFiles, toRepoRelativePath } from "../../test-utils/repo-files.js";
import { loadPluginManifestRegistry } from "../manifest-registry.js";
@@ -46,6 +46,13 @@ const EXPECTED_SENTINEL_SHARED_FAMILY_ASSIGNMENTS: Record<string, ExpectedShared
toolCompatFamilies: ["openai"],
},
};
let bundledPluginRootsCache:
| Array<{
pluginId: string;
rootDir: string;
}>
| undefined;
const filesByDirCache = new Map<string, string[]>();
function toRepoRelative(path: string): string {
return toRepoRelativePath(REPO_ROOT, path);
@@ -72,8 +79,13 @@ function listGitFiles(dir: string): string[] | null {
}
function listFiles(dir: string): string[] {
const cached = filesByDirCache.get(dir);
if (cached) {
return cached;
}
const gitFiles = listGitFiles(dir);
if (gitFiles) {
filesByDirCache.set(dir, gitFiles);
return gitFiles;
}
@@ -91,17 +103,22 @@ function listFiles(dir: string): string[] {
files.push(entryPath);
}
filesByDirCache.set(dir, files);
return files;
}
function listBundledPluginRoots() {
return loadPluginManifestRegistry({})
if (bundledPluginRootsCache) {
return bundledPluginRootsCache;
}
bundledPluginRootsCache = loadPluginManifestRegistry({})
.plugins.filter((plugin) => plugin.origin === "bundled")
.map((plugin) => ({
pluginId: plugin.id,
rootDir: resolveBundledPluginSourceRoot(plugin.rootDir, plugin.workspaceDir),
}))
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
return bundledPluginRootsCache;
}
function resolveBundledPluginSourceRoot(rootDir: string, workspaceDir?: string): string {
@@ -211,8 +228,27 @@ function collectSharedFamilyAssignments(): Map<string, ExpectedSharedFamilyContr
}
describe("provider family plugin-boundary inventory", () => {
let bundledRoots: ReturnType<typeof listBundledPluginRoots>;
let sharedFamilyProviders: ReturnType<typeof collectSharedFamilyProviders>;
let providerBoundaryTests: ReturnType<typeof collectProviderBoundaryTests>;
let actualAssignments: Record<string, ExpectedSharedFamilyContract>;
beforeAll(() => {
bundledRoots = listBundledPluginRoots();
for (const plugin of bundledRoots) {
listFiles(plugin.rootDir);
}
sharedFamilyProviders = collectSharedFamilyProviders();
providerBoundaryTests = collectProviderBoundaryTests();
actualAssignments = Object.fromEntries(
[...collectSharedFamilyAssignments().entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
),
);
});
it("lists bundled plugin files from git without walking plugin roots", () => {
const bundledRoots = listBundledPluginRoots();
filesByDirCache.clear();
expectNoReaddirSyncDuring(() => {
const files = bundledRoots.flatMap((plugin) => listFiles(plugin.rootDir));
@@ -222,9 +258,6 @@ describe("provider family plugin-boundary inventory", () => {
});
it("keeps shared-family provider hooks covered by at least one plugin-boundary test", () => {
const sharedFamilyProviders = collectSharedFamilyProviders();
const providerBoundaryTests = collectProviderBoundaryTests();
const missing = [...sharedFamilyProviders.entries()]
.filter(([pluginId]) => !providerBoundaryTests.has(pluginId))
.map(([pluginId, inventory]) => {
@@ -237,12 +270,6 @@ describe("provider family plugin-boundary inventory", () => {
});
it("keeps sentinel shared-family assignments wired through bundled provider sources", () => {
const actualAssignments = Object.fromEntries(
[...collectSharedFamilyAssignments().entries()].toSorted(([left], [right]) =>
left.localeCompare(right),
),
);
for (const [pluginId, expected] of Object.entries(
EXPECTED_SENTINEL_SHARED_FAMILY_ASSIGNMENTS,
)) {
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { assertNoImportTimeSideEffects } from "../../plugin-sdk/test-helpers/import-side-effects.js";
@@ -18,6 +19,20 @@ const CHANNEL_REGISTRY_WHY =
"it boots active channel metadata on hot runtime/config import paths and turns cheap module evaluation into plugin registry work.";
const CHANNEL_REGISTRY_FIX =
"keep the seam behind a lazy getter/runtime boundary so import stays cold and the first real lookup loads once.";
const HOT_RUNTIME_IMPORT_CASES = [
["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
[
"src/plugin-sdk/approval-handler-adapter-runtime.ts",
() => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
],
[
"src/plugin-sdk/approval-gateway-runtime.ts",
() => import("../../plugin-sdk/approval-gateway-runtime.js"),
],
["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
] as const;
function mockChannelRegistry() {
vi.doMock("../../channels/plugins/registry.js", async () => {
@@ -78,26 +93,21 @@ describe("runtime import side-effect contracts", () => {
expect(listChannelPlugins).toHaveBeenCalledTimes(1);
});
it("keeps hot runtime imports cold", async () => {
it.each(HOT_RUNTIME_IMPORT_CASES)("keeps %s cold", async (moduleId, importModule) => {
mockChannelRegistry();
for (const [moduleId, importModule] of [
["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")],
["src/plugins/runtime/runtime-channel.ts", () => import("../runtime/runtime-channel.js")],
[
"src/plugin-sdk/approval-handler-adapter-runtime.ts",
() => import("../../plugin-sdk/approval-handler-adapter-runtime.js"),
],
[
"src/plugin-sdk/approval-gateway-runtime.ts",
() => import("../../plugin-sdk/approval-gateway-runtime.js"),
],
["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")],
["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")],
["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")],
["src/plugins/runtime/index.ts", () => import("../runtime/index.js")],
] as const) {
await importModule();
expectNoChannelRegistryDuringImport(moduleId);
}
await importModule();
expectNoChannelRegistryDuringImport(moduleId);
});
it("keeps runtime-channel off direct channel registry imports", () => {
const source = fs.readFileSync("src/plugins/runtime/runtime-channel.ts", "utf8");
expect(source).not.toContain("../../channels/plugins/registry");
expect(source).not.toContain("../channels/plugins/registry");
});
it("keeps runtime index off direct channel registry imports", () => {
const source = fs.readFileSync("src/plugins/runtime/index.ts", "utf8");
expect(source).not.toContain("../../channels/plugins/registry");
expect(source).not.toContain("../channels/plugins/registry");
});
});
@@ -8,6 +8,15 @@ import {
} from "../../config/runtime-snapshot.js";
import { fetchWithSsrFGuard } from "../../infra/net/fetch-guard.js";
import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "../../infra/net/undici-runtime.js";
import * as activationCheck from "../../plugin-sdk/facade-activation-check.runtime.js";
import * as facadeRuntime from "../../plugin-sdk/facade-runtime.js";
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config?: unknown }) => ({
config: config ?? {},
autoEnabledReasons: {},
}),
}));
const originalBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
@@ -66,9 +75,8 @@ function createInstalledRuntimePluginDir(
afterEach(() => {
clearRuntimeConfigSnapshot();
facadeRuntime.resetFacadeRuntimeStateForTest();
vi.restoreAllMocks();
vi.resetModules();
vi.doUnmock("../../config/plugin-auto-enable.js");
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
if (originalBundledPluginsDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -93,7 +101,10 @@ afterEach(() => {
describe("shared runtime seam contracts", () => {
it("allows activated runtime facades when the resolved plugin root matches an installed-style manifest record", async () => {
const pluginId = "line-contract-fixture";
const { bundledDir, stateDir } = createInstalledRuntimePluginDir(pluginId, "line-ok");
const { bundledDir, stateDir, pluginRoot } = createInstalledRuntimePluginDir(
pluginId,
"line-ok",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
process.env.OPENCLAW_STATE_DIR = stateDir;
setRuntimeConfigSnapshot({
@@ -105,27 +116,25 @@ describe("shared runtime seam contracts", () => {
},
},
});
vi.resetModules();
vi.doMock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: ({ config }: { config?: unknown }) => ({
config: config ?? {},
autoEnabledReasons: {},
}),
}));
const facadeRuntime = await import("../../plugin-sdk/facade-runtime.js");
facadeRuntime.resetFacadeRuntimeStateForTest();
const location = {
modulePath: path.join(pluginRoot, "runtime-api.js"),
boundaryRoot: pluginRoot,
};
expect(
facadeRuntime.canLoadActivatedBundledPluginPublicSurface({
activationCheck.resolveBundledPluginPublicSurfaceAccess({
dirName: pluginId,
artifactBasename: "runtime-api.js",
}),
location,
sourceExtensionsRoot: bundledDir,
resolutionKey: `test:${pluginId}`,
}).allowed,
).toBe(true);
expect(
facadeRuntime.loadActivatedBundledPluginPublicSurfaceModuleSync<{ marker: string }>({
dirName: pluginId,
artifactBasename: "runtime-api.js",
facadeRuntime.__testing.loadFacadeModuleAtLocationSync<{ marker: string }>({
location,
trackedPluginId: pluginId,
}).marker,
).toBe("line-ok");
expect(facadeRuntime.listImportedBundledPluginFacadeIds()).toEqual([pluginId]);
@@ -13,6 +13,7 @@ import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/c
import {
attachmentProbeFs,
resolveAttachmentDelivery,
resolveSessionAttachmentThreadId,
sendPluginSessionAttachment,
} from "../host-hook-attachments.js";
import { clearPluginLoaderCache } from "../loader.js";
@@ -308,30 +309,28 @@ describe("plugin session attachments", () => {
});
});
it("prefers the thread encoded in a threaded session key over stale stored routes", async () => {
await withSessionStore(async ({ storePath, filePath }) => {
const baseKey = "agent:main:telegram:group:12345";
const threadKey = `${baseKey}:thread:99`;
await writeSessionEntry(
storePath,
{
deliveryContext: {
channel: "telegram",
to: "group:12345",
threadId: 42,
},
},
threadKey,
);
mockSuccessfulAttachmentDelivery();
const result = await sendBundledSessionAttachment({
sessionKey: threadKey,
files: [{ path: filePath }],
});
expectTelegramAttachmentResult(result, 1);
expect(requireFirstSendMessageParams().threadId).toBe("99");
});
it("prefers the thread encoded in a threaded session key over stale stored routes", () => {
expect(
resolveSessionAttachmentThreadId({
deliveryThreadId: 42,
fallbackThreadId: "99",
}),
).toBe("99");
expect(
resolveSessionAttachmentThreadId({
deliveryThreadId: 42,
explicitThreadId: 7,
fallbackThreadId: "99",
}),
).toBe(7);
expect(
resolveSessionAttachmentThreadId({
deliveryThreadId: 42,
explicitThreadId: 7,
fallbackThreadId: "99",
hintThreadTs: "1700000000.000100",
}),
).toBe("1700000000.000100");
});
it("reports attachment delivery as failed when no delivery result is returned", async () => {
+20 -5
View File
@@ -214,6 +214,20 @@ function normalizeOptionalThreadId(value: unknown): string | number | undefined
return normalizeOptionalString(value);
}
export function resolveSessionAttachmentThreadId(params: {
deliveryThreadId?: unknown;
explicitThreadId?: unknown;
fallbackThreadId?: unknown;
hintThreadTs?: string;
}): string | number | undefined {
return (
params.hintThreadTs ??
normalizeOptionalThreadId(params.explicitThreadId) ??
normalizeOptionalThreadId(params.fallbackThreadId) ??
normalizeOptionalThreadId(params.deliveryThreadId)
);
}
export async function sendPluginSessionAttachment(
params: PluginSessionAttachmentParams & { config?: OpenClawConfig; origin?: PluginOrigin },
): Promise<PluginSessionAttachmentResult> {
@@ -258,9 +272,6 @@ export async function sendPluginSessionAttachment(
};
}
const rawText = normalizeOptionalString(params.text) ?? "";
const explicitThreadId = normalizeOptionalThreadId(params.threadId);
const deliveryThreadId = normalizeOptionalThreadId(deliveryContext.threadId);
const fallbackThreadId = normalizeOptionalThreadId(threadId);
const resolvedDelivery = resolveAttachmentDelivery({
channel: deliveryContext.channel,
captionFormat: params.captionFormat,
@@ -274,8 +285,12 @@ export async function sendPluginSessionAttachment(
if (!Array.isArray(validated)) {
return { ok: false, error: validated.error };
}
const resolvedThreadId =
resolvedDelivery.threadTs ?? explicitThreadId ?? fallbackThreadId ?? deliveryThreadId;
const resolvedThreadId = resolveSessionAttachmentThreadId({
deliveryThreadId: deliveryContext.threadId,
explicitThreadId: params.threadId,
fallbackThreadId: threadId,
hintThreadTs: resolvedDelivery.threadTs,
});
let result: Awaited<ReturnType<SendMessage>>;
try {
const sendMessage = await loadSendMessage();
+9 -13
View File
@@ -486,14 +486,6 @@ describe("installPluginFromNpmSpec", () => {
await expect(fs.promises.readFile(stagedArchivePath, "utf8")).resolves.toBe(
"fixture pack contents",
);
fs.unlinkSync(archivePath);
const unrelatedResult = await installPluginFromNpmSpec({
spec: "@openclaw/voice-call@0.0.1",
npmDir: npmRoot,
logger: { info: () => {}, warn: () => {} },
});
expect(unrelatedResult.ok).toBe(true);
});
it("rejects npm pack archive metadata with traversal package names", async () => {
@@ -1548,7 +1540,7 @@ describe("installPluginFromNpmSpec", () => {
}
});
it("handles prerelease npm specs correctly", async () => {
it("rejects implicit prerelease npm specs with beta guidance", async () => {
mockNpmViewMetadataResult(runCommandWithTimeoutMock, {
name: "@openclaw/voice-call",
version: "0.0.2-beta.1",
@@ -1565,8 +1557,9 @@ describe("installPluginFromNpmSpec", () => {
expect(rejected.error).toContain("prerelease version 0.0.2-beta.1");
expect(rejected.error).toContain('"@openclaw/voice-call@beta"');
}
});
runCommandWithTimeoutMock.mockReset();
it("falls back to the latest stable version for official prerelease packages", async () => {
const officialNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const warnings: string[] = [];
mockNpmViewAndInstallMany([
@@ -1604,8 +1597,9 @@ describe("installPluginFromNpmSpec", () => {
expect(officialFallback.npmResolution?.version).toBe("0.0.1");
expect(officialFallback.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@0.0.1");
expect(warnings.join("\n")).toContain("falling back to stable @openclaw/voice-call@0.0.1");
});
runCommandWithTimeoutMock.mockReset();
it("keeps stable correction versions when resolving official npm packages", async () => {
const correctionNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const correctionWarnings: string[] = [];
mockNpmViewAndInstallMany([
@@ -1637,8 +1631,9 @@ describe("installPluginFromNpmSpec", () => {
expect(stableCorrection.npmResolution?.version).toBe("2026.5.3-1");
expect(stableCorrection.npmResolution?.resolvedSpec).toBe("@openclaw/voice-call@2026.5.3-1");
expect(correctionWarnings).toStrictEqual([]);
});
runCommandWithTimeoutMock.mockReset();
it("uses the newest prerelease when an official package has no stable versions", async () => {
const prereleaseOnlyNpmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
const prereleaseOnlyWarnings: string[] = [];
mockNpmViewAndInstallMany([
@@ -1680,8 +1675,9 @@ describe("installPluginFromNpmSpec", () => {
expect(prereleaseOnlyWarnings.join("\n")).toContain(
"using newest prerelease @openclaw/voice-call@0.0.2-beta.1",
);
});
runCommandWithTimeoutMock.mockReset();
it("accepts explicit prerelease npm dist-tags", async () => {
const npmRoot = path.join(suiteTempRootTracker.makeTempDir(), "npm");
mockNpmViewAndInstall({
spec: "@openclaw/voice-call@beta",
@@ -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 { afterEach, describe, expect, it } from "vitest";
import { 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";
@@ -22,8 +22,6 @@ type PublishablePluginPackage = {
};
const execFileAsync = promisify(execFile);
const PACKAGE_SCAN_CONCURRENCY = 12;
const REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS = new Set([
"@openclaw/acpx:dangerous-exec:src/codex-auth-bridge.ts",
"@openclaw/acpx:dangerous-exec:src/runtime-internals/mcp-proxy.mjs",
@@ -45,14 +43,6 @@ const OPTIONAL_REVIEWED_PUBLISHABLE_DIST_CRITICAL_FINDINGS = new Set([
"@openclaw/voice-call:dangerous-exec:dist/runtime-entry-<hash>.js",
]);
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
function parseNpmPackFiles(raw: string, packageName: string): string[] {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || parsed.length !== 1) {
@@ -116,7 +106,6 @@ function stageScannerRelevantPackedFiles(
packedFiles: readonly string[],
): string {
const stageDir = mkdtempSync(join(tmpdir(), "openclaw-plugin-npm-scan-"));
tempDirs.push(stageDir);
for (const packedPath of packedFiles) {
if (!isScannerWalkedPackedPath(packedPath)) {
@@ -212,27 +201,6 @@ function collectPublishablePluginPackages(): PublishablePluginPackage[] {
.toSorted((left, right) => left.packageName.localeCompare(right.packageName));
}
async function mapWithConcurrency<T, U>(
items: readonly T[],
concurrency: number,
fn: (item: T) => Promise<U>,
): Promise<U[]> {
const results: U[] = [];
results.length = items.length;
let nextIndex = 0;
const workerCount = Math.min(concurrency, items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await fn(items[index]);
}
}),
);
return results;
}
async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): Promise<{
reviewedCriticalFindings: string[];
expectedReviewedCriticalFindings: string[];
@@ -251,10 +219,15 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P
}
}
const stageDir = stageScannerRelevantPackedFiles(plugin.packageDir, packedFiles);
const summary = await scanDirectoryWithSummary(stageDir, {
excludeTestFiles: true,
maxFiles: 10_000,
});
let summary: Awaited<ReturnType<typeof scanDirectoryWithSummary>>;
try {
summary = await scanDirectoryWithSummary(stageDir, {
excludeTestFiles: true,
maxFiles: 10_000,
});
} finally {
rmSync(stageDir, { recursive: true, force: true });
}
for (const finding of summary.findings) {
if (finding.severity !== "critical") {
@@ -280,6 +253,23 @@ async function scanPublishablePluginPackage(plugin: PublishablePluginPackage): P
}
describe("publishable plugin npm package install security scan", () => {
const publishablePluginPackages = collectPublishablePluginPackages();
it("covers every package with required reviewed critical findings", () => {
const publishablePackageNames = new Set(
publishablePluginPackages.map((plugin) => plugin.packageName),
);
const missingPackages = [
...new Set(
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].map((key) =>
key.slice(0, key.indexOf(":")),
),
),
].filter((packageName) => !publishablePackageNames.has(packageName));
expect(missingPackages.toSorted()).toStrictEqual([]);
});
it("lists publishable plugin packages without scanning extension directories in-process", () => {
expectNoReaddirSyncDuring(() => {
const packages = collectPublishablePluginPackages();
@@ -291,31 +281,23 @@ describe("publishable plugin npm package install security scan", () => {
});
});
it("keeps npm-published plugin files clear of unexpected critical hits", async () => {
const unexpectedCriticalFindings: string[] = [];
const reviewedCriticalFindings = new Set<string>();
const expectedReviewedCriticalFindings = new Set(
REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS,
);
const packageResults = await mapWithConcurrency(
collectPublishablePluginPackages(),
PACKAGE_SCAN_CONCURRENCY,
scanPublishablePluginPackage,
);
for (const result of packageResults) {
test.each(publishablePluginPackages)(
"keeps $packageName files clear of unexpected critical hits",
async (plugin) => {
const result = await scanPublishablePluginPackage(plugin);
const expectedReviewedCriticalFindings = new Set(
[...REQUIRED_REVIEWED_PUBLISHABLE_CRITICAL_FINDINGS].filter((key) =>
key.startsWith(`${plugin.packageName}:`),
),
);
for (const key of result.expectedReviewedCriticalFindings) {
expectedReviewedCriticalFindings.add(key);
}
for (const key of result.reviewedCriticalFindings) {
reviewedCriticalFindings.add(key);
}
unexpectedCriticalFindings.push(...result.unexpectedCriticalFindings);
}
expect(unexpectedCriticalFindings.toSorted()).toStrictEqual([]);
expect([...reviewedCriticalFindings].toSorted()).toEqual(
[...expectedReviewedCriticalFindings].toSorted(),
);
});
expect(result.unexpectedCriticalFindings.toSorted()).toStrictEqual([]);
expect(result.reviewedCriticalFindings.toSorted()).toEqual(
[...expectedReviewedCriticalFindings].toSorted(),
);
},
);
});
+2
View File
@@ -60,6 +60,8 @@ describe("resolvePluginRuntimeLoadContext", () => {
await import("./load-context.js"));
loadConfigMock.mockReset();
applyPluginAutoEnableMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
loadPluginMetadataSnapshotMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockClear();
setCurrentPluginMetadataSnapshotMock.mockClear();
+2
View File
@@ -80,6 +80,8 @@ describe("deriveSessionChatTypeFromKey", () => {
{ key: "agent:main:discord:direct:user1", expected: "direct" },
{ key: "agent:main:telegram:group:g1", expected: "group" },
{ key: "agent:main:discord:channel:c1", expected: "channel" },
{ key: "agent:main:discord:guild-123:channel-456", expected: "channel" },
{ key: "agent:main:whatsapp:123@g.us", expected: "group" },
{ key: "agent:main:telegram:dm:123456", expected: "direct" },
{ key: "telegram:dm:123456", expected: "direct" },
{ key: "agent:main:main", expected: "unknown" },
-1
View File
@@ -939,7 +939,6 @@ describe("test-projects args", () => {
config: "test/vitest/vitest.extension-discord.config.ts",
forwardedArgs: [],
includePatterns: [
"extensions/discord/src/api-barrel.test.ts",
"extensions/discord/src/channel-actions.contract.test.ts",
"extensions/discord/src/channel.message-adapter.test.ts",
"extensions/discord/src/channel.test.ts",
+115 -88
View File
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import type { AuthProfileStore } from "../agents/auth-profiles.js";
import type { OpenClawConfig } from "../config/config.js";
import type {
@@ -747,104 +747,131 @@ async function prepareAuthCoverageSnapshot(params: {
};
}
async function expectOpenClawCoverageEntriesResolved(
async function expectOpenClawCoverageBatchResolved(
label: string,
entries: readonly SecretRegistryEntry[],
batch: readonly SecretRegistryEntry[],
): Promise<void> {
for (const batch of buildCoverageBatches(entries)) {
logCoverageBatch(label, batch);
const config = {} as OpenClawConfig;
const env: Record<string, string> = {};
for (const [index, entry] of batch.entries()) {
const envId = `OPENCLAW_SECRET_TARGET_${entry.id}`;
const runtimeEnvId = resolveCoverageEnvId(entry, envId);
const expectedValue = `resolved-${entry.id}`;
const wildcardToken = resolveCoverageWildcardToken(index);
env[runtimeEnvId] = expectedValue;
applyConfigForOpenClawTarget(config, entry, envId, wildcardToken);
}
const snapshot = await prepareConfigCoverageSnapshot({
config,
env,
loadablePluginOrigins: resolveCoverageLoadablePluginOrigins(batch),
includeRuntimeWebTools: batchNeedsRuntimeWebTools(batch),
skipConfigCollectors: batchUsesRuntimeWebToolsOnly(batch),
});
for (const [index, entry] of batch.entries()) {
const resolved = getPath(
snapshot.config,
resolveCoverageResolvedSegments(entry, resolveCoverageWildcardToken(index)),
);
expect(resolved).toBe(`resolved-${entry.id}`);
}
logCoverageBatch(label, batch);
const config = {} as OpenClawConfig;
const env: Record<string, string> = {};
for (const [index, entry] of batch.entries()) {
const envId = `OPENCLAW_SECRET_TARGET_${entry.id}`;
const runtimeEnvId = resolveCoverageEnvId(entry, envId);
const expectedValue = `resolved-${entry.id}`;
const wildcardToken = resolveCoverageWildcardToken(index);
env[runtimeEnvId] = expectedValue;
applyConfigForOpenClawTarget(config, entry, envId, wildcardToken);
}
const snapshot = await prepareConfigCoverageSnapshot({
config,
env,
loadablePluginOrigins: resolveCoverageLoadablePluginOrigins(batch),
includeRuntimeWebTools: batchNeedsRuntimeWebTools(batch),
skipConfigCollectors: batchUsesRuntimeWebToolsOnly(batch),
});
for (const [index, entry] of batch.entries()) {
const resolved = getPath(
snapshot.config,
resolveCoverageResolvedSegments(entry, resolveCoverageWildcardToken(index)),
);
expect(resolved).toBe(`resolved-${entry.id}`);
}
}
const OPENCLAW_CORE_COVERAGE_BATCHES = buildCoverageBatches(
collectOpenClawCoverageEntries({ includePluginEntries: false }),
);
const OPENCLAW_PLUGIN_COVERAGE_BATCHES = buildCoverageBatches(
collectOpenClawCoverageEntries({ includePluginEntries: true }),
);
const AUTH_PROFILE_COVERAGE_BATCHES = buildCoverageBatches(
COVERAGE_REGISTRY_ENTRIES.filter((entry) => entry.configFile === "auth-profiles.json"),
);
function toCoverageBatchCase(batch: SecretRegistryEntry[]) {
const firstEntry = batch[0];
return {
name:
batch.length === 1 && firstEntry
? firstEntry.id
: firstEntry
? `${resolveCoverageBatchKey(firstEntry)} (${batch.length})`
: "empty",
batch,
};
}
describe("secrets runtime target coverage", () => {
beforeAll(async () => {
const [sharedRuntime, resolver] = await Promise.all([
import("./runtime-shared.js"),
import("./resolve.js"),
]);
const [sharedRuntime, resolver, configCollectors, authCollectors, runtimeWebTools] =
await Promise.all([
import("./runtime-shared.js"),
import("./resolve.js"),
import("./runtime-config-collectors.js"),
import("./runtime-auth-collectors.js"),
import("./runtime-web-tools.js"),
]);
({ applyResolvedAssignments, createResolverContext } = sharedRuntime);
({ resolveSecretRefValues } = resolver);
({ collectConfigAssignments } = configCollectors);
({ collectAuthStoreAssignments } = authCollectors);
({ resolveRuntimeWebTools } = runtimeWebTools);
});
it(
"handles every core and channel openclaw.json registry target when configured as active",
async () => {
await expectOpenClawCoverageEntriesResolved(
"openclaw.json core",
collectOpenClawCoverageEntries({ includePluginEntries: false }),
);
},
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
);
it(
"handles every plugin openclaw.json registry target when configured as active",
async () => {
await expectOpenClawCoverageEntriesResolved(
"openclaw.json plugins",
collectOpenClawCoverageEntries({ includePluginEntries: true }),
);
},
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
);
it("handles every auth-profiles registry target", async () => {
const entries = COVERAGE_REGISTRY_ENTRIES.filter(
(entry) => entry.configFile === "auth-profiles.json",
describe("openclaw.json core and channel registry targets", () => {
test.each(OPENCLAW_CORE_COVERAGE_BATCHES.map(toCoverageBatchCase))(
"handles $name",
async ({ batch }) => {
await expectOpenClawCoverageBatchResolved("openclaw.json core", batch);
},
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
);
});
describe("openclaw.json plugin registry targets", () => {
test.each(OPENCLAW_PLUGIN_COVERAGE_BATCHES.map(toCoverageBatchCase))(
"handles $name",
async ({ batch }) => {
await expectOpenClawCoverageBatchResolved("openclaw.json plugins", batch);
},
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
);
});
describe("auth-profiles registry targets", () => {
test.each(AUTH_PROFILE_COVERAGE_BATCHES.map(toCoverageBatchCase))(
"handles $name",
async ({ batch }) => {
logCoverageBatch("auth-profiles.json", batch);
const env: Record<string, string> = {};
const authStore: AuthProfileStore = {
version: 1,
profiles: {},
};
for (const [index, entry] of batch.entries()) {
const envId = `OPENCLAW_AUTH_SECRET_TARGET_${entry.id}`;
env[envId] = `resolved-${entry.id}`;
applyAuthStoreTarget(authStore, entry, envId, resolveCoverageWildcardToken(index));
}
const snapshot = await prepareAuthCoverageSnapshot({
config: {} as OpenClawConfig,
env,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => authStore,
});
const resolvedStore = snapshot.authStores[0]?.store;
if (!resolvedStore) {
throw new Error("expected resolved auth store snapshot");
}
for (const [index, entry] of batch.entries()) {
const resolved = getPath(
resolvedStore,
toConcretePathSegments(entry.pathPattern, resolveCoverageWildcardToken(index)),
);
expect(resolved).toBe(`resolved-${entry.id}`);
}
},
RUNTIME_COVERAGE_TEST_TIMEOUT_MS,
);
for (const batch of buildCoverageBatches(entries)) {
logCoverageBatch("auth-profiles.json", batch);
const env: Record<string, string> = {};
const authStore: AuthProfileStore = {
version: 1,
profiles: {},
};
for (const [index, entry] of batch.entries()) {
const envId = `OPENCLAW_AUTH_SECRET_TARGET_${entry.id}`;
env[envId] = `resolved-${entry.id}`;
applyAuthStoreTarget(authStore, entry, envId, resolveCoverageWildcardToken(index));
}
const snapshot = await prepareAuthCoverageSnapshot({
config: {} as OpenClawConfig,
env,
agentDirs: ["/tmp/openclaw-agent-main"],
loadAuthStore: () => authStore,
});
const resolvedStore = snapshot.authStores[0]?.store;
if (!resolvedStore) {
throw new Error("expected resolved auth store snapshot");
}
for (const [index, entry] of batch.entries()) {
const resolved = getPath(
resolvedStore,
toConcretePathSegments(entry.pathPattern, resolveCoverageWildcardToken(index)),
);
expect(resolved).toBe(`resolved-${entry.id}`);
}
}
});
});
+6
View File
@@ -9,6 +9,12 @@ function deriveBuiltInLegacySessionChatType(
if (/^group:[^:]+$/.test(scopedSessionKey)) {
return "group";
}
if (/^(?:whatsapp:)?[^:]+@g\.us$/.test(scopedSessionKey)) {
return "group";
}
if (/^discord:(?:[^:]+:)?guild-[^:]+:channel-[^:]+$/.test(scopedSessionKey)) {
return "channel";
}
return undefined;
}
+18 -11
View File
@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { bundledPluginFile, bundledPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
detectChangedExtensionIds,
listAvailableExtensionIds,
@@ -64,6 +64,20 @@ function expectPositiveIntegerMetric(value: number) {
}
describe("scripts/test-extension.mjs", () => {
let balancedExtensionShards: ReturnType<typeof createExtensionTestShards>;
let balancedExpectedExtensionIds: string[];
beforeAll(() => {
balancedExtensionShards = createExtensionTestShards({
cwd: process.cwd(),
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
});
balancedExpectedExtensionIds = listAvailableExtensionIds().filter(
(extensionId) =>
resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId }).hasTests,
);
});
it("resolves split channel extensions onto their own vitest configs", () => {
const plan = resolveExtensionTestPlan({ targetArg: "slack", cwd: process.cwd() });
@@ -470,10 +484,7 @@ describe("scripts/test-extension.mjs", () => {
});
it("balances extension test shards by estimated CI cost", () => {
const shards = createExtensionTestShards({
cwd: process.cwd(),
shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT,
});
const shards = balancedExtensionShards;
expect(shards).toHaveLength(DEFAULT_EXTENSION_TEST_SHARD_COUNT);
expect(shards.map((shard) => shard.checkName)).toEqual(
@@ -482,15 +493,11 @@ describe("scripts/test-extension.mjs", () => {
const assigned = shards.flatMap((shard) => shard.extensionIds);
const uniqueAssigned = [...new Set(assigned)];
const expected = listAvailableExtensionIds().filter(
(extensionId) =>
resolveExtensionTestPlan({ cwd: process.cwd(), targetArg: extensionId }).hasTests,
);
expect(uniqueAssigned.toSorted((left, right) => left.localeCompare(right))).toEqual(
expected.toSorted((left, right) => left.localeCompare(right)),
balancedExpectedExtensionIds.toSorted((left, right) => left.localeCompare(right)),
);
expect(assigned).toHaveLength(expected.length);
expect(assigned).toHaveLength(balancedExpectedExtensionIds.length);
const totals = shards.map((shard) => shard.estimatedCost);
expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(1);
+81
View File
@@ -10,6 +10,7 @@ import {
import {
parseTestGroupReportArgs,
resolveReportArtifactDirs,
resolveRunPlans,
} from "../../scripts/test-group-report.mjs";
describe("scripts/test-group-report grouping", () => {
@@ -162,6 +163,58 @@ describe("scripts/test-group-report comparison", () => {
"Top group regressions",
);
});
it("keeps sharded run labels distinct in comparisons", () => {
const comparison = buildGroupedTestComparison({
before: {
groupBy: "area",
totals: { durationMs: 0, fileCount: 0, testCount: 0 },
groups: [],
configs: [],
topFiles: [],
runs: [
{
config: "test/vitest/vitest.gateway-server.config.ts",
label: "gateway-server-1",
elapsedMs: 100,
status: 0,
},
{
config: "test/vitest/vitest.gateway-server.config.ts",
label: "gateway-server-2",
elapsedMs: 200,
status: 0,
},
],
},
after: {
groupBy: "area",
totals: { durationMs: 0, fileCount: 0, testCount: 0 },
groups: [],
configs: [],
topFiles: [],
runs: [
{
config: "test/vitest/vitest.gateway-server.config.ts",
label: "gateway-server-1",
elapsedMs: 110,
status: 0,
},
{
config: "test/vitest/vitest.gateway-server.config.ts",
label: "gateway-server-2",
elapsedMs: 220,
status: 0,
},
],
},
});
expect(comparison.runs.map((run) => run.key).toSorted()).toEqual([
"gateway-server-1",
"gateway-server-2",
]);
});
});
describe("scripts/test-group-report arg parsing", () => {
@@ -220,6 +273,34 @@ describe("scripts/test-group-report arg parsing", () => {
});
});
describe("scripts/test-group-report run plans", () => {
it("preserves full-suite shard file args and unique report labels", () => {
const previousParallel = process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = "6";
try {
const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
const gatewayServerPlans = plans.filter(
(plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts",
);
expect(gatewayServerPlans.length).toBeGreaterThan(1);
expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe(
gatewayServerPlans.length,
);
expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true);
expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain(
"src/gateway/server.node-pairing-authz.test.ts",
);
} finally {
if (previousParallel === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_PARALLEL;
} else {
process.env.OPENCLAW_TEST_PROJECTS_PARALLEL = previousParallel;
}
}
});
});
describe("scripts/test-group-report artifact paths", () => {
it("keeps raw Vitest reports scoped to the output file stem", () => {
expect(resolveReportArtifactDirs(".artifacts/test-perf/baseline-before.json")).toEqual({
+64 -47
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import fg from "fast-glob";
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
DEFAULT_TEST_PROJECTS_VITEST_NO_OUTPUT_TIMEOUT_MS,
applyDefaultMultiSpecVitestCachePaths,
@@ -101,6 +101,29 @@ async function listFullSuiteTestFileMatches(): Promise<Map<string, string[]>> {
return matches;
}
function listNormalFullSuiteTestFiles(): string[] {
const e2eNamedIntegrationTests = new Set([
"src/gateway/gateway.test.ts",
"src/gateway/server.startup-matrix-migration.integration.test.ts",
"src/gateway/sessions-history-http.test.ts",
]);
return fg
.sync(["**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], {
cwd: process.cwd(),
dot: false,
ignore: ["**/.*/**", "**/dist/**", "**/node_modules/**", "**/vendor/**"],
})
.map(normalizeRepoPath)
.filter(
(file) =>
!file.includes(".live.test.") &&
!file.includes(".e2e.test.") &&
!file.startsWith("test/fixtures/") &&
!e2eNamedIntegrationTests.has(file),
)
.toSorted((left, right) => left.localeCompare(right));
}
describe("scripts/test-projects changed-target routing", () => {
it("maps changed source files into scoped lane targets", () => {
expect(
@@ -944,6 +967,40 @@ describe("scripts/test-projects local heavy-check lock", () => {
});
describe("scripts/test-projects full-suite sharding", () => {
let fullSuiteMatches: Map<string, string[]>;
let normalFullSuiteTestFiles: string[];
let leafShardPlans: ReturnType<typeof buildFullSuiteVitestRunPlans>;
let leafShardGatewayTreeReads: unknown[][];
beforeAll(async () => {
[fullSuiteMatches, normalFullSuiteTestFiles] = await Promise.all([
listFullSuiteTestFileMatches(),
Promise.resolve(listNormalFullSuiteTestFiles()),
]);
const previous = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts";
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
try {
const captured = captureReaddirSyncCallsDuring(() =>
buildFullSuiteVitestRunPlans([], process.cwd()),
);
leafShardPlans = captured.result;
leafShardGatewayTreeReads = captured.calls.filter(([target]) =>
typeof target === "string" ? normalizeRepoPath(target).includes("src/gateway") : false,
);
if (!leafShardPlans.some((plan) => plan.config === gatewayServerConfig)) {
throw new Error("expected gateway server leaf shard plans");
}
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
} else {
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previous;
}
}
});
it("interleaves heavy and light configs for cold parallel full-suite runs", () => {
const specs = [
"test/vitest/vitest.gateway.config.ts",
@@ -962,31 +1019,9 @@ describe("scripts/test-projects full-suite sharding", () => {
]);
});
it("covers each normal full-suite test file exactly once", async () => {
const matches = await listFullSuiteTestFileMatches();
const e2eNamedIntegrationTests = new Set([
"src/gateway/gateway.test.ts",
"src/gateway/server.startup-matrix-migration.integration.test.ts",
"src/gateway/sessions-history-http.test.ts",
]);
const normalTestFiles = fg
.sync(["**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"], {
cwd: process.cwd(),
dot: false,
ignore: ["**/.*/**", "**/dist/**", "**/node_modules/**", "**/vendor/**"],
})
.map(normalizeRepoPath)
.filter(
(file) =>
!file.includes(".live.test.") &&
!file.includes(".e2e.test.") &&
!file.startsWith("test/fixtures/") &&
!e2eNamedIntegrationTests.has(file),
)
.toSorted((left, right) => left.localeCompare(right));
const missing = normalTestFiles.filter((file) => !matches.has(file));
const duplicated = [...matches.entries()]
it("covers each normal full-suite test file exactly once", () => {
const missing = normalFullSuiteTestFiles.filter((file) => !fullSuiteMatches.has(file));
const duplicated = [...fullSuiteMatches.entries()]
.filter(([, configs]) => configs.length > 1)
.map(([file, configs]) => `${file}: ${configs.join(", ")}`)
.toSorted((left, right) => left.localeCompare(right));
@@ -1170,29 +1205,11 @@ describe("scripts/test-projects full-suite sharding", () => {
});
it("can expand full-suite shards to project configs for perf experiments", () => {
const previous = process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts";
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = "1";
let plans: ReturnType<typeof buildFullSuiteVitestRunPlans>;
let gatewayTreeReads: unknown[][] = [];
try {
const captured = captureReaddirSyncCallsDuring(() =>
buildFullSuiteVitestRunPlans([], process.cwd()),
);
plans = captured.result;
gatewayTreeReads = captured.calls.filter(([target]) =>
typeof target === "string" ? normalizeRepoPath(target).includes("src/gateway") : false,
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS;
} else {
process.env.OPENCLAW_TEST_PROJECTS_LEAF_SHARDS = previous;
}
}
const plans = leafShardPlans;
expect(gatewayTreeReads).toEqual([]);
expect(plans.map((plan) => plan.config)).toEqual([
expect(leafShardGatewayTreeReads).toEqual([]);
expect(leafShardPlans.map((plan) => plan.config)).toEqual([
"test/vitest/vitest.unit-fast.config.ts",
"test/vitest/vitest.unit-src.config.ts",
"test/vitest/vitest.unit-security.config.ts",
+27 -18
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { spawnNodeEvalSync } from "../src/test-utils/node-process.js";
import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts";
import { createPluginSdkLightVitestConfig } from "./vitest/vitest.plugin-sdk-light.config.ts";
@@ -50,7 +50,15 @@ function collectUnroutedForcedFiles(
}
describe("unit-fast vitest lane", () => {
it("loads the config without recursively walking repo roots", () => {
let configProbeResult: ReturnType<typeof spawnNodeEvalSync>;
let unitFastConfig: ReturnType<typeof createUnitFastVitestConfig>;
let unitFastTestFiles: ReturnType<typeof getUnitFastTestFiles>;
let unitFastAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
let broadCandidates: ReturnType<typeof collectBroadUnitFastTestCandidates>;
let broadAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
let currentCandidates: ReturnType<typeof collectUnitFastTestCandidates>;
beforeAll(() => {
const script = `
import fs from "node:fs";
let readdirSyncCalls = 0;
@@ -62,25 +70,32 @@ describe("unit-fast vitest lane", () => {
await import("./test/vitest/vitest.unit-fast.config.ts?io-probe=" + Date.now());
console.log(readdirSyncCalls);
`;
const result = spawnNodeEvalSync(script, {
configProbeResult = spawnNodeEvalSync(script, {
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
evalFlag: "-e",
imports: ["tsx"],
});
unitFastConfig = createUnitFastVitestConfig({});
unitFastTestFiles = getUnitFastTestFiles();
unitFastAnalysis = collectUnitFastTestFileAnalysis();
currentCandidates = collectUnitFastTestCandidates();
broadCandidates = collectBroadUnitFastTestCandidates();
broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
});
expect(result.status, result.stderr).toBe(0);
const numericOutputLines = result.stdout
it("loads the config without recursively walking repo roots", () => {
expect(configProbeResult.status, configProbeResult.stderr).toBe(0);
const numericOutputLines = configProbeResult.stdout
.trim()
.split(/\r?\n/u)
.map((line) => Number(line.trim()))
.filter(Number.isFinite);
expect(numericOutputLines.length, result.stdout).toBeGreaterThan(0);
expect(numericOutputLines.length, configProbeResult.stdout).toBeGreaterThan(0);
expect(numericOutputLines.at(-1)).toBeLessThan(20);
});
it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => {
const config = createUnitFastVitestConfig({});
const testConfig = requireTestConfig(config);
const testConfig = requireTestConfig(unitFastConfig);
expect(testConfig.isolate).toBe(false);
expect(testConfig.runner).toBeUndefined();
@@ -156,26 +171,21 @@ describe("unit-fast vitest lane", () => {
});
it("routes audited stateful-looking tests through the fast lane", () => {
const analysis = collectUnitFastTestFileAnalysis();
const forcedFileSet = new Set(forcedUnitFastTestFiles);
const forcedAnalysisCount = countMatching(analysis, (entry) => forcedFileSet.has(entry.file));
const unitFastTestFiles = getUnitFastTestFiles();
const forcedAnalysisCount = countMatching(unitFastAnalysis, (entry) =>
forcedFileSet.has(entry.file),
);
expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length);
for (const file of forcedUnitFastTestFiles) {
expect(unitFastTestFiles).toContain(file);
expect(isUnitFastTestFile(file)).toBe(true);
}
const unroutedForcedFiles = collectUnroutedForcedFiles(analysis, forcedFileSet);
const unroutedForcedFiles = collectUnroutedForcedFiles(unitFastAnalysis, forcedFileSet);
expect(unroutedForcedFiles).toStrictEqual([]);
});
it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => {
const currentCandidates = collectUnitFastTestCandidates();
const broadCandidates = collectBroadUnitFastTestCandidates();
const broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
const unitFastTestFiles = getUnitFastTestFiles();
expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length);
expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length);
expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan(
@@ -186,7 +196,6 @@ describe("unit-fast vitest lane", () => {
it("excludes unit-fast files from the older light lanes so full runs do not duplicate them", () => {
const pluginSdkLight = createPluginSdkLightVitestConfig({});
const commandsLight = createCommandsLightVitestConfig({});
const unitFastTestFiles = getUnitFastTestFiles();
expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts");
expect(requireTestConfig(pluginSdkLight).exclude).toContain(