fix(test): repair split agent shard runs

This commit is contained in:
Vincent Koc
2026-05-24 15:26:25 +02:00
parent ce48e4c197
commit 125d82cab2
18 changed files with 183 additions and 49 deletions

View File

@@ -12,6 +12,8 @@ Docs: https://docs.openclaw.ai
- Tests: suppress the current Rolldown plugin timing warning format in the Vitest wrapper so tiny focused runs do not drown useful stderr in repeated build-timing noise.
- Models/OpenRouter: use endpoint-specific OpenRouter context limits from `top_provider` metadata so provider-routed models no longer overstate available context. (#85949) Thanks @TurboTheTurtle.
- Crabbox: sync clean sparse-checkout remote changed gates from a temporary full checkout with local-only commits overlaid as worktree changes so git-backed script checks can seed the runner repository.
- Agents: avoid loading bundled channel plugins while resolving completion delivery policy and queue defaults on subagent handoff paths.
- Tests: allow split Vitest config shards through the explicit-target preflight so CI shard jobs run their intended projects.
- Tests: make startup memory and startup bench smoke scripts build CLI startup artifacts when run from a fresh source checkout.
- iMessage: mark authorized slash-command turns as text-sourced commands so `/status`, `/new`, and `/restart` acknowledgements return to the source conversation. (#82642) thanks @homer-byte.
- Crabbox: install Corepack shims into the writable hydration `PNPM_HOME` so local AWS runner hydration no longer tries to overwrite `/usr/local/bin/pnpm`.

View File

@@ -47,11 +47,11 @@ import { isCiLikeEnv, resolveLocalFullSuiteProfile } from "./lib/vitest-local-sc
import { resolveVitestCliEntry, resolveVitestNodeArgs } from "./run-vitest.mjs";
const DEFAULT_VITEST_CONFIG = "test/vitest/vitest.unit.config.ts";
const AGENTS_VITEST_CONFIG = "test/vitest/vitest.agents.config.ts";
const AGENTS_CORE_VITEST_CONFIG = "test/vitest/vitest.agents-core.config.ts";
const AGENTS_PI_EMBEDDED_VITEST_CONFIG = "test/vitest/vitest.agents-pi-embedded.config.ts";
const AGENTS_SUPPORT_VITEST_CONFIG = "test/vitest/vitest.agents-support.config.ts";
const AGENTS_TOOLS_VITEST_CONFIG = "test/vitest/vitest.agents-tools.config.ts";
const AGENTS_VITEST_CONFIG = "test/vitest/vitest.agents.config.ts";
const ACP_VITEST_CONFIG = "test/vitest/vitest.acp.config.ts";
const AUTO_REPLY_CORE_VITEST_CONFIG = "test/vitest/vitest.auto-reply-core.config.ts";
const AUTO_REPLY_VITEST_CONFIG = "test/vitest/vitest.auto-reply.config.ts";
@@ -234,11 +234,11 @@ const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH";
const CHANGED_ARGS_PATTERN = /^--changed(?:=(.+))?$/u;
const VITEST_CONFIG_BY_KIND = {
acp: ACP_VITEST_CONFIG,
agent: AGENTS_VITEST_CONFIG,
agentCore: AGENTS_CORE_VITEST_CONFIG,
agentPiEmbedded: AGENTS_PI_EMBEDDED_VITEST_CONFIG,
agentSupport: AGENTS_SUPPORT_VITEST_CONFIG,
agentTools: AGENTS_TOOLS_VITEST_CONFIG,
agent: AGENTS_VITEST_CONFIG,
autoReplyCore: AUTO_REPLY_CORE_VITEST_CONFIG,
autoReplyReply: AUTO_REPLY_REPLY_VITEST_CONFIG,
autoReplyTopLevel: AUTO_REPLY_TOP_LEVEL_VITEST_CONFIG,
@@ -1724,6 +1724,10 @@ export function buildVitestRunPlans(
"autoReplyCore",
"autoReplyReply",
"autoReplyTopLevel",
"agentCore",
"agentPiEmbedded",
"agentSupport",
"agentTools",
"agent",
"plugin",
"ui",

View File

@@ -1,3 +1,2 @@
export function resolvePathEnvKey(env: NodeJS.ProcessEnv): string;
export function buildCmdExeCommandLine(command: string, args: string[]): string;

View File

@@ -305,9 +305,7 @@ describe("exec approval followup", () => {
expect(callGatewayTool).not.toHaveBeenCalled();
});
it("uses safe denied copy for nested-parentheses denial metadata when session resume fails", async () => {
vi.mocked(callGatewayTool).mockRejectedValueOnce(new Error("session missing"));
it("uses safe direct denied copy for nested-parentheses denial metadata without resuming the session", async () => {
await sendExecApprovalFollowup({
approvalId: "req-denied-resume-failed-nested",
sessionKey: "agent:main:telegram:-100123",
@@ -319,13 +317,11 @@ describe("exec approval followup", () => {
"Exec denied (gateway id=req-denied-resume-failed-nested, approval-timeout (allowlist-miss)): uname -a",
});
expect(sendMessage).toHaveBeenCalledWith(
expect.objectContaining({
content:
"Automatic session resume failed, so sending the status directly.\n\nCommand did not run: approval timed out.",
idempotencyKey: "exec-approval-followup:req-denied-resume-failed-nested",
}),
);
expectDirectSend({
content: "Command did not run: approval timed out.",
idempotencyKey: "exec-approval-followup:req-denied-resume-failed-nested",
});
expect(callGatewayTool).not.toHaveBeenCalled();
});
it("suppresses denied followups for subagent sessions", async () => {

View File

@@ -178,7 +178,6 @@ export async function ensureOpenClawModelsJson(
config: cfg,
env: createConfigRuntimeEnv(cfg),
...(workspaceDir ? { workspaceDir } : {}),
allowWorkspaceScopedCurrent: workspaceDir === undefined,
});
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg);
const targetPath = path.join(agentDir, "models.json");

View File

@@ -44,6 +44,7 @@ vi.mock("../pi-model-discovery.js", () => ({
}));
import type { OpenClawConfig } from "../../config/config.js";
import { resetModelDiscoveryCacheForTest } from "./model-discovery-cache.js";
import {
expectResolvedForwardCompatFallbackResult,
expectUnknownModelErrorResult,
@@ -57,6 +58,7 @@ import {
} from "./model.test-harness.js";
beforeEach(() => {
resetModelDiscoveryCacheForTest();
resetMockDiscoverModels(discoverModels);
});

View File

@@ -212,9 +212,13 @@ describe("ensureSandboxContainer config-hash recreation", () => {
});
it("recreates shared container when array-order change alters hash", async () => {
const workspaceDir = "/tmp/workspace";
const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"]);
const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"]);
const workspaceDir = makeTempDir();
const oldCfg = createSandboxConfig(["1.1.1.1", "8.8.8.8"], [
`${workspaceDir}:/workspace:rw`,
]);
const newCfg = createSandboxConfig(["8.8.8.8", "1.1.1.1"], [
`${workspaceDir}:/workspace:rw`,
]);
const oldHash = computeSandboxConfigHash({
docker: oldCfg.docker,
@@ -270,11 +274,12 @@ describe("ensureSandboxContainer config-hash recreation", () => {
});
it("recreates shared container when previously filtered explicit env becomes allowed", async () => {
const workspaceDir = "/tmp/workspace";
const workspaceDir = makeTempDir();
const cfg = createSandboxConfig(["1.1.1.1"], undefined, "rw", {
LANG: "C.UTF-8",
GEMINI_API_KEY: "dummy-gemini",
});
cfg.docker.binds = [`${workspaceDir}:/workspace:rw`];
const oldHash = computeSandboxConfigHash({
docker: cfg.docker,
@@ -316,10 +321,12 @@ describe("ensureSandboxContainer config-hash recreation", () => {
});
it("applies custom binds after workspace mounts so overlapping binds can override", async () => {
const workspaceDir = "/tmp/workspace";
const workspaceDir = makeTempDir();
const customRoot = makeTempDir();
const customUserFile = path.join(customRoot, "USER.md");
const cfg = createSandboxConfig(
["1.1.1.1"],
["/tmp/workspace-shared/USER.md:/workspace/USER.md:ro"],
[`${customUserFile}:/workspace/USER.md:ro`],
);
cfg.docker.dangerouslyAllowExternalBindSources = true;
const expectedHash = computeSandboxConfigHash({
@@ -346,8 +353,8 @@ describe("ensureSandboxContainer config-hash recreation", () => {
expect(createCall.args).toContain(`openclaw.configHash=${expectedHash}`);
const bindArgs = collectDockerFlagValues(createCall.args, "-v");
const workspaceMountIdx = bindArgs.indexOf("/tmp/workspace:/workspace:z");
const customMountIdx = bindArgs.indexOf("/tmp/workspace-shared/USER.md:/workspace/USER.md:ro");
const workspaceMountIdx = bindArgs.indexOf(`${workspaceDir}:/workspace:z`);
const customMountIdx = bindArgs.indexOf(`${customUserFile}:/workspace/USER.md:ro`);
expect(workspaceMountIdx).toBeGreaterThanOrEqual(0);
expect(customMountIdx).toBeGreaterThan(workspaceMountIdx);
});

View File

@@ -51,17 +51,18 @@ describe("appendWorkspaceMountArgs", () => {
});
it("omits agent workspace mount when paths are identical", () => {
const workspaceDir = makeTempWorkspace();
const args: string[] = [];
appendWorkspaceMountArgs({
args,
workspaceDir: "/tmp/workspace",
agentWorkspaceDir: "/tmp/workspace",
workspaceDir,
agentWorkspaceDir: workspaceDir,
workdir: "/workspace",
workspaceAccess: "rw",
});
const mounts = args.filter((arg) => arg.startsWith("/tmp/"));
expect(mounts).toEqual(["/tmp/workspace:/workspace:z"]);
const mounts = args.filter((arg) => arg.startsWith(workspaceDir));
expect(mounts).toEqual([`${workspaceDir}:/workspace:z`]);
});
it("marks split agent workspace mounts shared for SELinux", () => {

View File

@@ -247,9 +247,11 @@ async function deliverTelegramDirectMessageCompletion(params: {
sendMessage?: typeof runtimeSendMessage;
internalEvents?: AgentInternalEvent[];
isActive?: boolean;
requesterSessionId?: string | null;
queueEmbeddedPiMessageWithOutcome?: QueueEmbeddedPiMessageWithOutcome;
requesterSessionKey?: string;
sourceTool?: string;
runtimeConfig?: Record<string, unknown>;
origin?: {
channel: "telegram";
to: string;
@@ -266,10 +268,13 @@ async function deliverTelegramDirectMessageCompletion(params: {
testing.setDepsForTest({
callGateway: params.callGateway,
getRequesterSessionActivity: () => ({
sessionId: "requester-session-telegram",
sessionId:
params.requesterSessionId === null
? undefined
: (params.requesterSessionId ?? "requester-session-telegram"),
isActive: params.isActive === true,
}),
getRuntimeConfig: () => ({}) as never,
getRuntimeConfig: () => (params.runtimeConfig ?? {}) as never,
sendMessage: params.sendMessage ?? runtimeSendMessage,
...(params.queueEmbeddedPiMessageWithOutcome
? { queueEmbeddedPiMessageWithOutcome: params.queueEmbeddedPiMessageWithOutcome }
@@ -1416,13 +1421,35 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
});
it("reports failure for Telegram DMs when announce-agent delivery fails", async () => {
const callGateway = vi.fn(async () => {
throw new Error("UNAVAILABLE: requester wake failed");
}) as unknown as typeof runtimeCallGateway;
const callGateway = createGatewayMock({
result: {
deliveryStatus: {
status: "failed",
errorMessage: "requester wake failed",
},
},
});
const sendMessage = createSendMessageMock();
const result = await deliverTelegramDirectMessageCompletion({
callGateway,
sendMessage,
queueEmbeddedPiMessageWithOutcome: createQueueOutcomeMock(false),
requesterSessionId: null,
requesterSessionKey: "agent:main:telegram:direct:123456789",
origin: {
channel: "telegram",
to: "direct:123456789",
accountId: "bot-1",
},
runtimeConfig: {
agents: {
defaults: {
subagents: {
announceTimeoutMs: 10,
},
},
},
},
internalEvents: [
{
type: "task_completion",
@@ -1442,7 +1469,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expectRecordFields(result, {
delivered: false,
path: "direct",
error: "UNAVAILABLE: requester wake failed",
error: "requester wake failed",
});
expect(sendMessage).not.toHaveBeenCalled();
});
@@ -1460,6 +1487,15 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
sendMessage,
isActive: true,
queueEmbeddedPiMessageWithOutcome,
runtimeConfig: {
agents: {
defaults: {
subagents: {
announceTimeoutMs: 10,
},
},
},
},
internalEvents: [
{
type: "task_completion",
@@ -1496,7 +1532,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
steeringMode: "all",
debounceMs: 500,
waitForTranscriptCommit: true,
deliveryTimeoutMs: 120_000,
deliveryTimeoutMs: 10,
},
);
expect(callGateway).toHaveBeenCalledTimes(1);

View File

@@ -60,6 +60,7 @@ vi.mock("./subagent-announce.runtime.js", () => ({
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
getRuntimeConfig: () => mockConfig,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
readSessionMessagesAsync: vi.fn(async () => []),
readSessionEntry: (storePath: string, sessionKey: string) =>
(loadSessionStoreMock(storePath) as Record<string, unknown>)[sessionKey],
resolveAgentIdFromSessionKey: (sessionKey: string) =>

View File

@@ -191,6 +191,7 @@ vi.mock("./subagent-announce.runtime.js", () => ({
},
getRuntimeConfig: () => configOverride,
loadSessionStore: vi.fn(() => sessionStore),
readSessionMessagesAsync: vi.fn(async () => []),
readSessionEntry: (_storePath: string, sessionKey: string) => sessionStore[sessionKey],
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions-main.json",

View File

@@ -82,7 +82,6 @@ export function loadCapabilityMetadataSnapshot(params: {
config: params.config ?? {},
env: params.env ?? process.env,
...(workspaceDir ? { workspaceDir } : {}),
allowWorkspaceScopedCurrent: workspaceDir === undefined,
});
}

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
completionRequiresMessageToolDelivery,
resolveCompletionChatType,
@@ -6,13 +6,6 @@ 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",

View File

@@ -1,6 +1,6 @@
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { deriveSessionChatType } from "../../sessions/session-chat-type.js";
import { deriveSessionChatTypeFromKey } from "../../sessions/session-chat-type-shared.js";
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js";
@@ -26,7 +26,7 @@ export function resolveCompletionChatType(params: {
}
for (const key of [params.targetRequesterSessionKey, params.requesterSessionKey]) {
const derived = deriveSessionChatType(key);
const derived = deriveSessionChatTypeFromKey(key);
if (derived !== "unknown") {
return derived;
}
@@ -60,7 +60,7 @@ export function completionRequiresMessageToolDelivery(params: {
export function shouldRouteCompletionThroughRequesterSession(
sessionKey: string | undefined | null,
): boolean {
const chatType = deriveSessionChatType(sessionKey);
const chatType = deriveSessionChatTypeFromKey(sessionKey);
return chatType === "group" || chatType === "channel";
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn());
vi.mock("../../../channels/plugins/index.js", () => ({
getLoadedChannelPlugin: getLoadedChannelPluginMock,
}));
describe("resolveQueueSettings runtime defaults", () => {
it("uses defaults from already-loaded channel plugins", async () => {
getLoadedChannelPluginMock.mockReturnValueOnce({
defaults: {
queue: {
debounceMs: 125,
},
},
});
const { resolveQueueSettings } = await import("./settings-runtime.js");
expect(resolveQueueSettings({ cfg: {} as OpenClawConfig, channel: "demo" })).toEqual({
mode: "steer",
debounceMs: 125,
cap: 20,
dropPolicy: "summarize",
});
expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("demo");
});
it("falls back without loading bundled channel plugins", async () => {
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
const { resolveQueueSettings } = await import("./settings-runtime.js");
expect(resolveQueueSettings({ cfg: {} as OpenClawConfig, channel: "telegram" })).toEqual({
mode: "steer",
debounceMs: 500,
cap: 20,
dropPolicy: "summarize",
});
expect(getLoadedChannelPluginMock).toHaveBeenCalledWith("telegram");
});
});

View File

@@ -1,4 +1,4 @@
import { getChannelPlugin } from "../../../channels/plugins/index.js";
import { getLoadedChannelPlugin } from "../../../channels/plugins/index.js";
import { normalizeOptionalLowercaseString } from "../../../shared/string-coerce.js";
import { resolveQueueSettings as resolveQueueSettingsCore } from "./settings.js";
import type { QueueSettings, ResolveQueueSettingsParams } from "./types.js";
@@ -7,7 +7,7 @@ function resolvePluginDebounce(channelKey: string | undefined): number | undefin
if (!channelKey) {
return undefined;
}
const plugin = getChannelPlugin(channelKey);
const plugin = getLoadedChannelPlugin(channelKey);
const value = plugin?.defaults?.queue?.debounceMs;
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : undefined;
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
@@ -106,8 +107,23 @@ const {
) => number;
};
const VITEST_CLI_ENTRY = path.join(process.cwd(), "node_modules", "vitest", "vitest.mjs");
const VITEST_NODE_PREFIX = ["exec", "node", "--no-maglev", VITEST_CLI_ENTRY];
const require = createRequire(import.meta.url);
const resolveExpectedVitestCliEntry = () => {
const vitestPackageJson = require.resolve("vitest/package.json");
return path.join(path.dirname(vitestPackageJson), "vitest.mjs");
};
const resolveExpectedVitestNodeArgs = (env: NodeJS.ProcessEnv) =>
["1", "true", "yes", "on"].includes(
env.OPENCLAW_VITEST_ENABLE_MAGLEV?.trim().toLowerCase() ?? "",
)
? []
: ["--no-maglev"];
const VITEST_NODE_PREFIX = [
"exec",
"node",
...resolveExpectedVitestNodeArgs(process.env),
resolveExpectedVitestCliEntry(),
];
describe("test-projects args", () => {
it("drops a pnpm passthrough separator while preserving targeted filters", () => {
@@ -1058,6 +1074,39 @@ describe("test-projects args", () => {
).toEqual([]);
});
it("accepts split CI Vitest config targets routed as whole config runs", () => {
expect(
findUnmatchedExplicitTestTargets([
"test/vitest/vitest.agents-core.config.ts",
"test/vitest/vitest.agents-pi-embedded.config.ts",
"test/vitest/vitest.agents-support.config.ts",
"test/vitest/vitest.agents-tools.config.ts",
]),
).toEqual([]);
});
it("keeps split CI Vitest config targets on their own configs", () => {
expect(
buildVitestRunPlans([
"test/vitest/vitest.agents-core.config.ts",
"test/vitest/vitest.agents-tools.config.ts",
]),
).toEqual([
{
config: "test/vitest/vitest.agents-core.config.ts",
forwardedArgs: [],
includePatterns: null,
watchMode: false,
},
{
config: "test/vitest/vitest.agents-tools.config.ts",
forwardedArgs: [],
includePatterns: null,
watchMode: false,
},
]);
});
it("accepts sentinel targets routed as whole config runs", () => {
expect(findUnmatchedExplicitTestTargets(["ui/src/test-helpers/control-ui-e2e.ts"])).toEqual([]);
});

View File

@@ -6,9 +6,12 @@ type GlobalWithOpenAiCodexTokenRefreshTestHook = typeof globalThis & {
};
vi.mock("@earendil-works/pi-ai/oauth", () => ({
getOAuthProvider: () => undefined,
getOAuthApiKey: () => undefined,
getOAuthProviders: () => [],
loginOpenAICodex: vi.fn(),
registerOAuthProvider: vi.fn(),
resetOAuthProviders: vi.fn(),
refreshOpenAICodexToken: vi.fn((...args: unknown[]) =>
(globalThis as GlobalWithOpenAiCodexTokenRefreshTestHook)[openAiCodexTokenRefreshTestHook]?.(
...args,