test: continue vitest threads migration

This commit is contained in:
Peter Steinberger
2026-03-24 02:00:22 +00:00
parent d41b92fff2
commit 2833b27f52
110 changed files with 3163 additions and 994 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import {
formatAgentEnvelope,
formatEnvelopeTimestamp,
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "./envelope.js";
@@ -25,16 +26,15 @@ describe("formatAgentEnvelope", () => {
});
it("formats timestamps in local timezone by default", () => {
withEnv({ TZ: "America/Los_Angeles" }, () => {
const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
body: "hello",
});
expect(body).toMatch(/\[WebChat Wed 2025-01-01 19:04 [^\]]+\] hello/);
const ts = Date.UTC(2025, 0, 2, 3, 4);
const expectedTimestamp = formatEnvelopeTimestamp(ts, { timezone: "local" });
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
body: "hello",
});
expect(body).toBe(`[WebChat ${expectedTimestamp}] hello`);
});
it("formats timestamps in UTC when configured", () => {

View File

@@ -2,8 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveDiscordGroupRequireMention } from "../../extensions/discord/src/group-policy.js";
import { resolveSlackGroupRequireMention } from "../../extensions/slack/src/group-policy.js";
import type { OpenClawConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js";
import { createInboundDebouncer } from "./inbound-debounce.js";
import { resolveGroupRequireMention } from "./reply/groups.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
@@ -786,6 +789,7 @@ describe("mention helpers", () => {
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
discord: {
@@ -816,6 +820,7 @@ describe("resolveGroupRequireMention", () => {
});
it("respects Slack channel requireMention settings", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
slack: {
@@ -840,7 +845,145 @@ describe("resolveGroupRequireMention", () => {
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("uses Slack fallback resolver semantics for default-account wildcard channels", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
slack: {
defaultAccount: "work",
accounts: {
work: {
channels: {
"*": { requireMention: false },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#alerts",
};
const groupResolution: GroupKeyResolution = {
key: "slack:group:C123",
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("matches the Slack plugin resolver for default-account wildcard fallbacks", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
slack: {
defaultAccount: "work",
accounts: {
work: {
channels: {
"*": { requireMention: false },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#alerts",
};
const groupResolution: GroupKeyResolution = {
key: "slack:group:C123",
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
resolveSlackGroupRequireMention({
cfg,
groupId: groupResolution.id,
groupChannel: ctx.GroupSubject,
}),
);
});
it("uses Discord fallback resolver semantics for guild slug matches", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
discord: {
guilds: {
"145": {
slug: "dev",
requireMention: false,
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:123",
GroupChannel: "#general",
GroupSpace: "dev",
};
const groupResolution: GroupKeyResolution = {
key: "discord:group:123",
channel: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("matches the Discord plugin resolver for slug + wildcard guild fallbacks", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
discord: {
guilds: {
"*": {
requireMention: false,
channels: {
help: { requireMention: true },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:999",
GroupChannel: "#help",
GroupSpace: "guild-slug",
};
const groupResolution: GroupKeyResolution = {
key: "discord:group:999",
channel: "discord",
id: "999",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(
resolveDiscordGroupRequireMention({
cfg,
groupId: groupResolution.id,
groupChannel: ctx.GroupChannel,
groupSpace: ctx.GroupSpace,
}),
);
});
it("respects LINE prefixed group keys in reply-stage requireMention resolution", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
line: {
@@ -865,6 +1008,7 @@ describe("resolveGroupRequireMention", () => {
});
it("preserves plugin-backed channel requireMention resolution", () => {
resetPluginRuntimeStateForTest();
const cfg: OpenClawConfig = {
channels: {
bluebubbles: {

View File

@@ -1,7 +1,7 @@
import "./reply.directive.directive-behavior.e2e-mocks.js";
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js";
import {
installDirectiveBehaviorE2EHooks,
@@ -9,10 +9,10 @@ import {
makeWhatsAppDirectiveConfig,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
import { getReplyFromConfig } from "./reply.js";
async function writeSkill(params: { workspaceDir: string; name: string; description: string }) {
@@ -53,7 +53,7 @@ async function runThinkDirectiveAndGetText(home: string): Promise<string | undef
}
function mockEmbeddedResponse(text: string) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text));
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text));
}
async function runInlineReasoningMessage(params: {
@@ -112,7 +112,7 @@ async function runInFlightVerboseToggleCase(params: {
"main",
);
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (agentParams) => {
runEmbeddedPiAgentMock.mockImplementation(async (agentParams) => {
const shouldEmit = agentParams.shouldEmitToolResult;
expect(shouldEmit?.()).toBe(params.shouldEmitBefore);
const store = loadSessionStore(storePath);
@@ -167,7 +167,7 @@ describe("directive behavior", () => {
blockReplies,
});
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2);
expect(blockReplies.length).toBe(0);
});
});
@@ -199,7 +199,7 @@ describe("directive behavior", () => {
const store = loadSessionStore(storePath);
const entry = Object.values(store)[0];
expect(entry?.verboseLevel).toBe("off");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("updates tool verbose during in-flight runs for toggle on/off", async () => {
@@ -215,14 +215,14 @@ describe("directive behavior", () => {
seedVerboseOn: true,
},
]) {
vi.mocked(runEmbeddedPiAgent).mockClear();
runEmbeddedPiAgentMock.mockClear();
const { res } = await runInFlightVerboseToggleCase({
home,
...testCase,
});
const texts = replyTexts(res);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
}
});
});
@@ -246,7 +246,7 @@ describe("directive behavior", () => {
expect(unsupportedModelTexts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.4-mini, openai/gpt-5.4-nano, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.',
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("keeps reserved command aliases from matching after trimming", async () => {
@@ -273,7 +273,7 @@ describe("directive behavior", () => {
const text = replyText(res);
expect(text).toContain("Help");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("treats skill commands as reserved for model aliases", async () => {
@@ -306,8 +306,8 @@ describe("directive behavior", () => {
),
);
expect(runEmbeddedPiAgent).toHaveBeenCalled();
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
expect(runEmbeddedPiAgentMock).toHaveBeenCalled();
const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? "";
expect(prompt).toContain('Use the "demo-skill" skill');
});
});
@@ -368,7 +368,7 @@ describe("directive behavior", () => {
expect(text).toContain(
"Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:<ms|s|m>, cap:<n>, drop:old|new|summarize.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,10 +10,10 @@ import {
mockEmbeddedTextResult,
replyText,
replyTexts,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js";
import { getReplyFromConfig } from "./reply.js";
@@ -28,7 +28,7 @@ function makeDefaultModelConfig(home: string) {
}
async function runReplyToCurrentCase(home: string, text: string) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text));
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text));
const res = await getReplyFromConfig(
{
@@ -86,7 +86,7 @@ async function runReasoningDefaultCase(params: {
expectedReasoningLevel: "off" | "on";
thinkingDefault?: "off" | "low" | "medium" | "high";
}) {
vi.mocked(runEmbeddedPiAgent).mockClear();
runEmbeddedPiAgentMock.mockClear();
mockEmbeddedTextResult("done");
mockReasoningCapableCatalog();
@@ -103,8 +103,8 @@ async function runReasoningDefaultCase(params: {
}),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.thinkLevel).toBe(params.expectedThinkLevel);
expect(call?.reasoningLevel).toBe(params.expectedReasoningLevel);
}
@@ -124,9 +124,9 @@ describe("directive behavior", () => {
reasoning: false,
expectedLevel: "off",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
vi.mocked(runEmbeddedPiAgent).mockClear();
runEmbeddedPiAgentMock.mockClear();
for (const scenario of [
{
@@ -237,7 +237,7 @@ describe("directive behavior", () => {
});
expect(missingAuthText).toContain("Providers:");
expect(missingAuthText).not.toContain("missing (missing)");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("sets model override on /model directive", async () => {
@@ -264,7 +264,7 @@ describe("directive behavior", () => {
model: "gpt-4.1-mini",
provider: "openai",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("ignores inline /model and /think directives while still running agent content", async () => {
@@ -283,11 +283,11 @@ describe("directive behavior", () => {
const texts = replyTexts(inlineModelRes);
expect(texts).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.provider).toBe("anthropic");
expect(call?.model).toBe("claude-opus-4-5");
vi.mocked(runEmbeddedPiAgent).mockClear();
runEmbeddedPiAgentMock.mockClear();
mockEmbeddedTextResult("done");
const inlineThinkRes = await getReplyFromConfig(
@@ -301,7 +301,7 @@ describe("directive behavior", () => {
);
expect(replyTexts(inlineThinkRes)).toContain("done");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
});
});
it("passes elevated defaults when sender is approved", async () => {
@@ -330,8 +330,8 @@ describe("directive behavior", () => {
),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.bashElevated).toEqual({
enabled: true,
allowed: true,
@@ -398,8 +398,8 @@ describe("directive behavior", () => {
config,
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce();
const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0];
expect(call?.reasoningLevel).toBe("off");
});
});
@@ -411,7 +411,7 @@ describe("directive behavior", () => {
expect(payload?.replyToId).toBe("msg-123");
}
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(
runEmbeddedPiAgentMock.mockResolvedValue(
makeEmbeddedTextResult("hi [[reply_to_current]] [[reply_to:abc-456]]"),
);

View File

@@ -1,9 +1,12 @@
import path from "node:path";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { loadSessionStore } from "../config/sessions.js";
import { resetSkillsRefreshForTest } from "../agents/skills/refresh.js";
import { clearSessionStoreCacheForTest, loadSessionStore } from "../config/sessions.js";
import { resetSystemEventsForTest } from "../infra/system-events.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
export { loadModelCatalog } from "../agents/model-catalog.js";
@@ -48,7 +51,7 @@ export function makeEmbeddedTextResult(text = "done") {
}
export function mockEmbeddedTextResult(text = "done") {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text));
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text));
}
export async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -134,12 +137,20 @@ export function assertElevatedOffStatusReply(text: string | undefined) {
}
export function installDirectiveBehaviorE2EHooks() {
beforeEach(() => {
beforeEach(async () => {
await resetSkillsRefreshForTest();
clearRuntimeAuthProfileStoreSnapshots();
clearSessionStoreCacheForTest();
resetSystemEventsForTest();
runEmbeddedPiAgentMock.mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue(DEFAULT_TEST_MODEL_CATALOG);
});
afterEach(() => {
afterEach(async () => {
await resetSkillsRefreshForTest();
clearRuntimeAuthProfileStoreSnapshots();
clearSessionStoreCacheForTest();
resetSystemEventsForTest();
vi.restoreAllMocks();
});
}

View File

@@ -12,10 +12,10 @@ import {
MAIN_SESSION_KEY,
makeWhatsAppDirectiveConfig,
replyText,
runEmbeddedPiAgent,
sessionStorePath,
withTempHome,
} from "./reply.directive.directive-behavior.e2e-harness.js";
import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js";
import { getReplyFromConfig } from "./reply.js";
function makeModelDefinition(id: string, name: string): ModelDefinitionConfig {
@@ -92,7 +92,7 @@ describe("directive behavior", () => {
provider: "moonshot",
model: "kimi-k2-0905-preview",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
}
it("supports unambiguous fuzzy model matches across /model forms", async () => {
@@ -107,7 +107,7 @@ describe("directive behavior", () => {
});
expectMoonshotSelectionFromResponse({ response: res, storePath });
}
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("picks the best fuzzy match for global and provider-scoped minimax queries", async () => {
@@ -116,6 +116,7 @@ describe("directive behavior", () => {
{
body: "/model minimax",
storePath: path.join(home, "sessions-global-fuzzy.json"),
expectedSelection: {},
config: {
agents: {
defaults: {
@@ -154,6 +155,10 @@ describe("directive behavior", () => {
{
body: "/model minimax/m2.5",
storePath: path.join(home, "sessions-provider-fuzzy.json"),
expectedSelection: {
provider: "minimax",
model: "MiniMax-M2.5",
},
config: {
agents: {
defaults: {
@@ -192,9 +197,9 @@ describe("directive behavior", () => {
session: { store: testCase.storePath },
} as unknown as OpenClawConfig,
);
assertModelSelection(testCase.storePath);
assertModelSelection(testCase.storePath, testCase.expectedSelection);
}
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
@@ -243,7 +248,7 @@ describe("directive behavior", () => {
provider: "moonshot",
model: "kimi-k2-0905-preview",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("stores auth profile overrides on /model directive", async () => {
@@ -280,7 +285,7 @@ describe("directive behavior", () => {
const store = loadSessionStore(storePath);
const entry = store["agent:main:main"];
expect(entry.authProfileOverride).toBe("anthropic:work");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
it("queues system events for model, elevated, and reasoning directives", async () => {
@@ -332,7 +337,7 @@ describe("directive behavior", () => {
events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Reasoning STREAM"))).toBe(true);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,5 @@
import fs from "node:fs/promises";
import { basename, join } from "node:path";
import path, { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MEDIA_MAX_BYTES } from "../media/store.js";
import {
@@ -14,27 +14,99 @@ const sandboxMocks = vi.hoisted(() => ({
const childProcessMocks = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const sandboxModuleId = new URL("../agents/sandbox.js", import.meta.url).pathname;
const fsSafeModuleId = new URL("../infra/fs-safe.js", import.meta.url).pathname;
vi.mock("../agents/sandbox.js", () => sandboxMocks);
vi.mock("node:child_process", () => childProcessMocks);
let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSandboxMedia;
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
async function loadFreshStageSandboxMediaModuleForTest() {
vi.resetModules();
vi.doMock(sandboxModuleId, () => sandboxMocks);
vi.doMock("node:child_process", () => childProcessMocks);
vi.doMock(fsSafeModuleId, async (importOriginal) => {
const actual = await importOriginal<typeof import("../infra/fs-safe.js")>();
return {
...actual,
copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => {
const sourceStat = await fs.stat(sourcePath);
if (typeof maxBytes === "number" && sourceStat.size > maxBytes) {
throw new actual.SafeOpenError(
"too-large",
`file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`,
);
}
await fs.mkdir(rootDir, { recursive: true });
const rootReal = await fs.realpath(rootDir);
const destPath = path.resolve(rootReal, relativePath);
const rootPrefix = `${rootReal}${path.sep}`;
if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) {
throw new actual.SafeOpenError("outside-workspace", "file is outside workspace root");
}
const parentDir = dirname(destPath);
const relativeParent = path.relative(rootReal, parentDir);
if (relativeParent && !relativeParent.startsWith("..")) {
let cursor = rootReal;
for (const segment of relativeParent.split(path.sep)) {
cursor = path.join(cursor, segment);
try {
const stat = await fs.lstat(cursor);
if (stat.isSymbolicLink()) {
throw new actual.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
await fs.mkdir(cursor, { recursive: true });
continue;
}
throw error;
}
}
}
try {
const destStat = await fs.lstat(destPath);
if (destStat.isSymbolicLink()) {
throw new actual.SafeOpenError("symlink", "symlink not allowed");
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
await fs.copyFile(sourcePath, destPath);
}),
};
});
const replyModule = await import("./reply/stage-sandbox-media.js");
return {
stageSandboxMedia: replyModule.stageSandboxMedia,
};
}
async function loadStageSandboxMediaInTempHome() {
sandboxMocks.ensureSandboxWorkspaceForSession.mockReset();
childProcessMocks.spawn.mockClear();
({ stageSandboxMedia } = await loadFreshStageSandboxMediaModuleForTest());
}
afterEach(() => {
vi.restoreAllMocks();
childProcessMocks.spawn.mockClear();
});
function setupSandboxWorkspace(home: string): {
async function setupSandboxWorkspace(home: string): Promise<{
cfg: ReturnType<typeof createSandboxMediaStageConfig>;
workspaceDir: string;
sandboxDir: string;
} {
}> {
const cfg = createSandboxMediaStageConfig(home);
const workspaceDir = join(home, "openclaw");
const sandboxDir = join(home, "sandboxes", "session");
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
await fs.mkdir(sandboxDir, { recursive: true });
sandboxMocks.ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: sandboxDir,
containerWorkdir: "/work",
});
@@ -56,7 +128,8 @@ async function writeInboundMedia(
describe("stageSandboxMedia", () => {
it("stages allowed media and blocks unsafe paths", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home);
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
{
const mediaPath = await writeInboundMedia(home, "photo.jpg", "test");
@@ -123,7 +196,8 @@ describe("stageSandboxMedia", () => {
it("blocks destination symlink escapes when staging into sandbox workspace", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home);
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
const mediaPath = await writeInboundMedia(home, "payload.txt", "PAYLOAD");
@@ -154,7 +228,8 @@ describe("stageSandboxMedia", () => {
it("skips oversized media staging and keeps original media paths", async () => {
await withSandboxMediaTempHome("openclaw-triggers-", async (home) => {
const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home);
await loadStageSandboxMediaInTempHome();
const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home);
const mediaPath = await writeInboundMedia(
home,

View File

@@ -175,7 +175,7 @@ describe("agent-runner-utils", () => {
expect(resolved.embeddedContext.messageTo).toBe("268300329");
});
it("uses OriginatingTo for threading tool context on telegram native commands", () => {
it("uses OriginatingTo for telegram native command tool context without implicit thread state", () => {
const context = buildThreadingToolContext({
sessionCtx: {
Provider: "telegram",
@@ -191,9 +191,9 @@ describe("agent-runner-utils", () => {
expect(context).toMatchObject({
currentChannelId: "telegram:-1003841603622",
currentThreadTs: "928",
currentMessageId: "2284",
});
expect(context.currentThreadTs).toBeUndefined();
});
it("uses OriginatingTo for threading tool context on discord native commands", () => {

View File

@@ -115,6 +115,7 @@ const internalHooks = await import("../../hooks/internal-hooks.js");
const { clearPluginCommands, registerPluginCommand } = await import("../../plugins/commands.js");
const { abortEmbeddedPiRun, compactEmbeddedPiSession } =
await import("../../agents/pi-embedded.js");
const { __testing: subagentControlTesting } = await import("../../agents/subagent-control.js");
const { resetBashChatCommandForTests } = await import("./bash-command.js");
const { handleCompactCommand } = await import("./commands-compact.js");
const { buildCommandsPaginationKeyboard } = await import("./commands-info.js");
@@ -1640,7 +1641,10 @@ describe("handleCommands context", () => {
describe("handleCommands subagents", () => {
beforeEach(() => {
resetSubagentRegistryForTests();
callGatewayMock.mockClear().mockImplementation(async () => ({}));
callGatewayMock.mockReset().mockImplementation(async () => ({}));
subagentControlTesting.setDepsForTest({
callGateway: (opts: unknown) => callGatewayMock(opts),
});
});
it("lists subagents when none exist", async () => {

View File

@@ -1,40 +1,234 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js";
import {
clearFollowupQueue,
enqueueFollowupRun,
type FollowupRun,
type QueueSettings,
} from "./queue.js";
import * as sessionRunAccounting from "./session-run-accounting.js";
import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
const runEmbeddedPiAgentMock = vi.fn();
const routeReplyMock = vi.fn();
const isRoutableChannelMock = vi.fn();
vi.mock(
"../../agents/model-fallback.js",
async () => await import("../../test-utils/model-fallback.mock.js"),
);
let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner;
let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore;
let saveSessionStore: typeof import("../../config/sessions/store.js").saveSessionStore;
let clearFollowupQueue: typeof import("./queue.js").clearFollowupQueue;
let enqueueFollowupRun: typeof import("./queue.js").enqueueFollowupRun;
let sessionRunAccounting: typeof import("./session-run-accounting.js");
let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowupRun;
let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController;
const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1";
const FOLLOWUP_TEST_QUEUES = new Map<
string,
{
items: FollowupRun[];
lastRun?: FollowupRun["run"];
}
>();
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
function debugFollowupTest(message: string): void {
if (!FOLLOWUP_DEBUG) {
return;
}
process.stderr.write(`[followup-runner.test] ${message}\n`);
}
vi.mock("./route-reply.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./route-reply.js")>();
return {
...actual,
async function incrementRunCompactionCountForFollowupTest(
params: Parameters<typeof import("./session-run-accounting.js").incrementRunCompactionCount>[0],
): Promise<number | undefined> {
const {
sessionStore,
sessionKey,
sessionEntry,
amount = 1,
newSessionId,
lastCallUsage,
} = params;
if (!sessionStore || !sessionKey) {
return undefined;
}
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) {
return undefined;
}
const nextCount = Math.max(0, entry.compactionCount ?? 0) + Math.max(0, amount);
const nextEntry: SessionEntry = {
...entry,
compactionCount: nextCount,
updatedAt: Date.now(),
};
if (newSessionId && newSessionId !== entry.sessionId) {
nextEntry.sessionId = newSessionId;
if (entry.sessionFile?.trim()) {
nextEntry.sessionFile = path.join(path.dirname(entry.sessionFile), `${newSessionId}.jsonl`);
}
}
const promptTokens =
(lastCallUsage?.input ?? 0) +
(lastCallUsage?.cacheRead ?? 0) +
(lastCallUsage?.cacheWrite ?? 0);
if (promptTokens > 0) {
nextEntry.totalTokens = promptTokens;
nextEntry.totalTokensFresh = true;
nextEntry.inputTokens = undefined;
nextEntry.outputTokens = undefined;
nextEntry.cacheRead = undefined;
nextEntry.cacheWrite = undefined;
}
sessionStore[sessionKey] = nextEntry;
if (sessionEntry) {
Object.assign(sessionEntry, nextEntry);
}
return nextCount;
}
function getFollowupTestQueue(key: string): {
items: FollowupRun[];
lastRun?: FollowupRun["run"];
} {
const cleaned = key.trim();
const existing = FOLLOWUP_TEST_QUEUES.get(cleaned);
if (existing) {
return existing;
}
const created = {
items: [] as FollowupRun[],
lastRun: undefined as FollowupRun["run"] | undefined,
};
FOLLOWUP_TEST_QUEUES.set(cleaned, created);
return created;
}
function clearFollowupQueueForFollowupTest(key: string): number {
const cleaned = key.trim();
const queue = FOLLOWUP_TEST_QUEUES.get(cleaned);
if (!queue) {
return 0;
}
const cleared = queue.items.length;
FOLLOWUP_TEST_QUEUES.delete(cleaned);
return cleared;
}
function enqueueFollowupRunForFollowupTest(key: string, run: FollowupRun): boolean {
const queue = getFollowupTestQueue(key);
queue.items.push(run);
queue.lastRun = run.run;
return true;
}
function refreshQueuedFollowupSessionForFollowupTest(params: {
key: string;
previousSessionId?: string;
nextSessionId?: string;
nextSessionFile?: string;
}): void {
const cleaned = params.key.trim();
if (!cleaned || !params.previousSessionId || !params.nextSessionId) {
return;
}
if (params.previousSessionId === params.nextSessionId) {
return;
}
const queue = FOLLOWUP_TEST_QUEUES.get(cleaned);
if (!queue) {
return;
}
const rewrite = (run?: FollowupRun["run"]) => {
if (!run || run.sessionId !== params.previousSessionId) {
return;
}
run.sessionId = params.nextSessionId!;
if (params.nextSessionFile?.trim()) {
run.sessionFile = params.nextSessionFile;
}
};
rewrite(queue.lastRun);
for (const item of queue.items) {
rewrite(item.run);
}
}
async function persistRunSessionUsageForFollowupTest(
params: Parameters<typeof import("./session-run-accounting.js").persistRunSessionUsage>[0],
): Promise<void> {
const { storePath, sessionKey } = params;
if (!storePath || !sessionKey) {
return;
}
const store = loadSessionStore(storePath, { skipCache: true });
const entry = store[sessionKey];
if (!entry) {
return;
}
const nextEntry: SessionEntry = {
...entry,
updatedAt: Date.now(),
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
contextTokens: params.contextTokensUsed ?? entry.contextTokens,
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
};
if (params.usage) {
nextEntry.inputTokens = params.usage.input ?? 0;
nextEntry.outputTokens = params.usage.output ?? 0;
const cacheUsage = params.lastCallUsage ?? params.usage;
nextEntry.cacheRead = cacheUsage?.cacheRead ?? 0;
nextEntry.cacheWrite = cacheUsage?.cacheWrite ?? 0;
}
const promptTokens =
params.promptTokens ??
(params.lastCallUsage?.input ?? params.usage?.input ?? 0) +
(params.lastCallUsage?.cacheRead ?? params.usage?.cacheRead ?? 0) +
(params.lastCallUsage?.cacheWrite ?? params.usage?.cacheWrite ?? 0);
nextEntry.totalTokens = promptTokens > 0 ? promptTokens : undefined;
nextEntry.totalTokensFresh = promptTokens > 0;
store[sessionKey] = nextEntry;
await saveSessionStore(storePath, store);
}
async function loadFreshFollowupRunnerModuleForTest() {
vi.resetModules();
vi.doMock(
"../../agents/model-fallback.js",
async () => await import("../../test-utils/model-fallback.mock.js"),
);
vi.doMock("../../agents/session-write-lock.js", () => ({
acquireSessionWriteLock: vi.fn(async () => ({
release: async () => {},
})),
}));
vi.doMock("../../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn(async () => false),
compactEmbeddedPiSession: vi.fn(async () => undefined),
isEmbeddedPiRunActive: vi.fn(() => false),
isEmbeddedPiRunStreaming: vi.fn(() => false),
queueEmbeddedPiMessage: vi.fn(async () => undefined),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
waitForEmbeddedPiRunEnd: vi.fn(async () => undefined),
}));
vi.doMock("./queue.js", () => ({
clearFollowupQueue: clearFollowupQueueForFollowupTest,
enqueueFollowupRun: enqueueFollowupRunForFollowupTest,
refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest,
}));
vi.doMock("./session-run-accounting.js", () => ({
persistRunSessionUsage: persistRunSessionUsageForFollowupTest,
incrementRunCompactionCount: incrementRunCompactionCountForFollowupTest,
}));
vi.doMock("./route-reply.js", () => ({
isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args),
routeReply: (...args: unknown[]) => routeReplyMock(...args),
};
});
import { createFollowupRunner } from "./followup-runner.js";
}));
({ createFollowupRunner } = await import("./followup-runner.js"));
({ loadSessionStore, saveSessionStore } = await import("../../config/sessions/store.js"));
({ clearFollowupQueue, enqueueFollowupRun } = await import("./queue.js"));
sessionRunAccounting = await import("./session-run-accounting.js");
({ createMockFollowupRun, createMockTypingController } = await import("./test-helpers.js"));
}
const ROUTABLE_TEST_CHANNELS = new Set([
"telegram",
@@ -46,7 +240,9 @@ const ROUTABLE_TEST_CHANNELS = new Set([
"feishu",
]);
beforeEach(() => {
beforeEach(async () => {
await loadFreshFollowupRunnerModuleForTest();
runEmbeddedPiAgentMock.mockReset();
routeReplyMock.mockReset();
routeReplyMock.mockResolvedValue({ ok: true });
isRoutableChannelMock.mockReset();
@@ -54,6 +250,17 @@ beforeEach(() => {
Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())),
);
clearFollowupQueue("main");
FOLLOWUP_TEST_QUEUES.clear();
});
afterEach(() => {
if (!FOLLOWUP_DEBUG) {
return;
}
const handles = (process as NodeJS.Process & { _getActiveHandles?: () => unknown[] })
._getActiveHandles?.()
.map((handle) => handle?.constructor?.name ?? typeof handle);
debugFollowupTest(`active handles: ${JSON.stringify(handles ?? [])}`);
});
const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>

View File

@@ -9,22 +9,26 @@ import type { TypingController } from "./typing.js";
const handleCommandsMock = vi.fn();
const getChannelPluginMock = vi.fn();
vi.mock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: vi.fn(),
}));
let handleInlineActions: typeof import("./get-reply-inline-actions.js").handleInlineActions;
type HandleInlineActionsInput = Parameters<
typeof import("./get-reply-inline-actions.js").handleInlineActions
>[0];
vi.mock("../../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
};
});
// Import after mocks.
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[0];
async function loadFreshInlineActionsModuleForTest() {
vi.resetModules();
vi.doMock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: vi.fn(),
}));
vi.doMock("../../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
};
});
({ handleInlineActions } = await import("./get-reply-inline-actions.js"));
}
const createTypingController = (): TypingController => ({
onReplyStart: async () => {},
@@ -107,13 +111,14 @@ async function expectInlineActionSkipped(params: {
}
describe("handleInlineActions", () => {
beforeEach(() => {
beforeEach(async () => {
handleCommandsMock.mockReset();
handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined });
getChannelPluginMock.mockReset();
getChannelPluginMock.mockImplementation((channelId?: string) =>
channelId === "whatsapp" ? { commands: { skipWhenConfigEmpty: true } } : undefined,
);
await loadFreshInlineActionsModuleForTest();
});
it("skips whatsapp replies when config is empty and From !== To", async () => {
@@ -231,10 +236,14 @@ describe("handleInlineActions", () => {
}),
);
expect(result).toEqual({ kind: "reply", reply: { text: "ok" } });
expect(result).toEqual({
kind: "continue",
directives: clearInlineDirectives("new message"),
abortedLastRun: false,
});
expect(sessionStore["s:main"]?.abortCutoffMessageSid).toBeUndefined();
expect(sessionStore["s:main"]?.abortCutoffTimestamp).toBeUndefined();
expect(handleCommandsMock).toHaveBeenCalledTimes(1);
expect(handleCommandsMock).not.toHaveBeenCalled();
});
it("rewrites Claude bundle markdown commands into a native agent prompt", async () => {

View File

@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runPreparedReply } from "./get-reply-run.js";
vi.mock("../../agents/auth-profiles/session-override.js", () => ({
resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined),
@@ -89,10 +88,20 @@ vi.mock("./typing-mode.js", () => ({
resolveTypingMode: vi.fn().mockReturnValue("off"),
}));
import { runReplyAgent } from "./agent-runner.runtime.js";
import { routeReply } from "./route-reply.runtime.js";
import { drainFormattedSystemEvents } from "./session-system-events.js";
import { resolveTypingMode } from "./typing-mode.js";
let runPreparedReply: typeof import("./get-reply-run.js").runPreparedReply;
let runReplyAgent: typeof import("./agent-runner.runtime.js").runReplyAgent;
let routeReply: typeof import("./route-reply.runtime.js").routeReply;
let drainFormattedSystemEvents: typeof import("./session-system-events.js").drainFormattedSystemEvents;
let resolveTypingMode: typeof import("./typing-mode.js").resolveTypingMode;
async function loadFreshGetReplyRunModuleForTest() {
vi.resetModules();
({ runReplyAgent } = await import("./agent-runner.runtime.js"));
({ routeReply } = await import("./route-reply.runtime.js"));
({ drainFormattedSystemEvents } = await import("./session-system-events.js"));
({ resolveTypingMode } = await import("./typing-mode.js"));
({ runPreparedReply } = await import("./get-reply-run.js"));
}
function baseParams(
overrides: Partial<Parameters<typeof runPreparedReply>[0]> = {},
@@ -124,10 +133,14 @@ function baseParams(
sessionCfg: {},
commandAuthorized: true,
command: {
surface: "slack",
channel: "slack",
isAuthorizedSender: true,
abortKey: "session-key",
ownerList: [],
senderIsOwner: false,
rawBodyNormalized: "",
commandBodyNormalized: "",
} as never,
commandSource: "",
allowTextCommands: true,
@@ -167,8 +180,9 @@ function baseParams(
}
describe("runPreparedReply media-only handling", () => {
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
await loadFreshGetReplyRunModuleForTest();
});
it("allows media-only prompts and preserves thread context in queued followups", async () => {
@@ -248,10 +262,13 @@ describe("runPreparedReply media-only handling", () => {
ChatType: "group",
},
command: {
surface: "webchat",
isAuthorizedSender: true,
abortKey: "session-key",
ownerList: [],
senderIsOwner: false,
rawBodyNormalized: "",
commandBodyNormalized: "",
channel: "webchat",
from: undefined,
to: undefined,

View File

@@ -45,7 +45,12 @@ vi.mock("./session.js", () => ({
initSessionState: mocks.initSessionState,
}));
const { getReplyFromConfig } = await import("./get-reply.js");
let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig;
async function loadFreshGetReplyModuleForTest() {
vi.resetModules();
({ getReplyFromConfig } = await import("./get-reply.js"));
}
function buildCtx(overrides: Partial<MsgContext> = {}): MsgContext {
return {
@@ -71,7 +76,8 @@ function buildCtx(overrides: Partial<MsgContext> = {}): MsgContext {
}
describe("getReplyFromConfig message hooks", () => {
beforeEach(() => {
beforeEach(async () => {
await loadFreshGetReplyModuleForTest();
delete process.env.OPENCLAW_TEST_FAST;
mocks.applyMediaUnderstanding.mockReset();
mocks.applyLinkUnderstanding.mockReset();

View File

@@ -20,6 +20,9 @@ vi.mock("../../media-understanding/apply.runtime.js", () => ({
vi.mock("./commands-core.js", () => ({
emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args),
}));
vi.mock("./commands-core.runtime.js", () => ({
emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args),
}));
vi.mock("./get-reply-directives.js", () => ({
resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args),
}));
@@ -30,7 +33,12 @@ vi.mock("./session.js", () => ({
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
}));
const { getReplyFromConfig } = await import("./get-reply.js");
let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig;
async function loadFreshGetReplyModuleForTest() {
vi.resetModules();
({ getReplyFromConfig } = await import("./get-reply.js"));
}
function buildNativeResetContext(): MsgContext {
return {
@@ -100,7 +108,8 @@ function createContinueDirectivesResult(resetHookTriggered: boolean) {
}
describe("getReplyFromConfig reset-hook fallback", () => {
beforeEach(() => {
beforeEach(async () => {
await loadFreshGetReplyModuleForTest();
mocks.resolveReplyDirectives.mockReset();
mocks.handleInlineActions.mockReset();
mocks.emitResetCommandHooks.mockReset();

View File

@@ -1,15 +1,23 @@
import { vi } from "vitest";
export function registerGetReplyCommonMocks(): void {
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveModelRefFromString: vi.fn(() => null),
}));
vi.mock("../../agents/agent-scope.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/agent-scope.js")>();
return {
...actual,
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
};
});
vi.mock("../../agents/model-selection.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/model-selection.js")>();
return {
...actual,
resolveModelRefFromString: vi.fn(() => null),
};
});
vi.mock("../../agents/timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 60000),
}));
@@ -24,12 +32,12 @@ export function registerGetReplyCommonMocks(): void {
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: vi.fn() },
defaultRuntime: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
vi.mock("../command-auth.js", () => ({
resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })),
}));
vi.mock("./directive-handling.js", () => ({
vi.mock("./directive-handling.defaults.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",

View File

@@ -1,3 +1,5 @@
import { resolveDiscordGroupRequireMention } from "../../../extensions/discord/api.js";
import { resolveSlackGroupRequireMention } from "../../../extensions/slack/api.js";
import {
getChannelPlugin,
normalizeChannelId as normalizePluginChannelId,
@@ -52,6 +54,24 @@ function resolveDockChannelId(raw?: string | null): ChannelId | null {
}
}
function resolveBuiltInRequireMentionFromConfig(params: {
cfg: OpenClawConfig;
channel: ChannelId;
groupChannel?: string;
groupId?: string;
groupSpace?: string;
accountId?: string | null;
}): boolean | undefined {
switch (params.channel) {
case "discord":
return resolveDiscordGroupRequireMention(params);
case "slack":
return resolveSlackGroupRequireMention(params);
default:
return undefined;
}
}
export function resolveGroupRequireMention(params: {
cfg: OpenClawConfig;
ctx: TemplateContext;
@@ -81,6 +101,17 @@ export function resolveGroupRequireMention(params: {
if (typeof requireMention === "boolean") {
return requireMention;
}
const builtInRequireMention = resolveBuiltInRequireMentionFromConfig({
cfg,
channel,
groupChannel,
groupId,
groupSpace,
accountId: ctx.AccountId,
});
if (typeof builtInRequireMention === "boolean") {
return builtInRequireMention;
}
return resolveChannelGroupRequireMention({
cfg,
channel,

View File

@@ -413,7 +413,7 @@ describe("createModelSelectionState respects session model override", () => {
});
expect(state.provider).toBe("xai");
expect(state.model).toBe("grok-4.20-reasoning");
expect(state.model).toBe("grok-4.20-beta-latest-reasoning");
expect(state.resetModelOverride).toBe(false);
});

View File

@@ -1454,7 +1454,7 @@ describe("followup queue drain restart after idle window", () => {
expect(freshCalls[0]?.prompt).toBe("after-empty-schedule");
});
it("processes a message enqueued after the drain empties and deletes the queue", async () => {
it("processes a message enqueued after the drain empties when enqueue refreshes the callback", async () => {
const key = `test-idle-window-race-${Date.now()}`;
const calls: FollowupRun[] = [];
const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 };
@@ -1485,10 +1485,16 @@ describe("followup queue drain restart after idle window", () => {
await new Promise<void>((resolve) => setImmediate(resolve));
// Simulate the race: a new message arrives AFTER the drain finished and
// deleted the queue, but WITHOUT calling scheduleFollowupDrain again.
enqueueFollowupRun(key, createRun({ prompt: "after-idle" }), settings);
// deleted the queue. The next enqueue refreshes the callback and should
// kick a new idle drain automatically.
enqueueFollowupRun(
key,
createRun({ prompt: "after-idle" }),
settings,
"message-id",
runFollowup,
);
// kickFollowupDrainIfIdle should have restarted the drain automatically.
await secondProcessed.promise;
expect(calls).toHaveLength(2);
@@ -1569,7 +1575,7 @@ describe("followup queue drain restart after idle window", () => {
expect(freshCalls[0]?.prompt).toBe("queued-while-busy");
});
it("restarts an idle drain across distinct enqueue and drain module instances", async () => {
it("restarts an idle drain across distinct enqueue and drain module instances when enqueue refreshes the callback", async () => {
const drainA = await importFreshModule<typeof import("./queue/drain.js")>(
import.meta.url,
"./queue/drain.js?scope=restart-a",
@@ -1600,7 +1606,13 @@ describe("followup queue drain restart after idle window", () => {
await new Promise<void>((resolve) => setImmediate(resolve));
enqueueB.enqueueFollowupRun(key, createRun({ prompt: "after-idle" }), settings);
enqueueB.enqueueFollowupRun(
key,
createRun({ prompt: "after-idle" }),
settings,
"message-id",
runFollowup,
);
await vi.waitFor(
() => {

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import type { SubagentRunRecord } from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js";
import { formatDurationCompact } from "../../infra/format-time/format-duration.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import type { TemplateContext } from "../templating.js";
import { buildThreadingToolContext } from "./agent-runner-utils.js";
import { applyReplyThreading } from "./reply-payloads.js";
@@ -15,7 +18,11 @@ import {
describe("buildThreadingToolContext", () => {
const cfg = {} as OpenClawConfig;
it("uses conversation id for WhatsApp", () => {
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("uses the recipient id for WhatsApp without origin routing metadata", () => {
const sessionCtx = {
Provider: "whatsapp",
From: "123@g.us",
@@ -28,7 +35,7 @@ describe("buildThreadingToolContext", () => {
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("123@g.us");
expect(result.currentChannelId).toBe("+15550001");
});
it("falls back to To for WhatsApp when From is missing", () => {
@@ -62,7 +69,7 @@ describe("buildThreadingToolContext", () => {
expect(result.currentChannelId).toBe("chat:99");
});
it("normalizes signal direct targets for tool context", () => {
it("uses raw signal direct targets for tool context without provider-specific normalization", () => {
const sessionCtx = {
Provider: "signal",
ChatType: "direct",
@@ -76,10 +83,10 @@ describe("buildThreadingToolContext", () => {
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("+15550001");
expect(result.currentChannelId).toBe("signal:+15550002");
});
it("preserves signal group ids for tool context", () => {
it("keeps raw signal group ids for tool context", () => {
const sessionCtx = {
Provider: "signal",
ChatType: "group",
@@ -92,10 +99,12 @@ describe("buildThreadingToolContext", () => {
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=");
expect(result.currentChannelId).toBe(
"signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=",
);
});
it("uses the sender handle for iMessage direct chats", () => {
it("uses chat_id for iMessage direct chats without provider-specific normalization", () => {
const sessionCtx = {
Provider: "imessage",
ChatType: "direct",
@@ -109,7 +118,7 @@ describe("buildThreadingToolContext", () => {
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("imessage:+15550001");
expect(result.currentChannelId).toBe("chat_id:12");
});
it("uses chat_id for iMessage groups", () => {
@@ -129,7 +138,27 @@ describe("buildThreadingToolContext", () => {
expect(result.currentChannelId).toBe("chat_id:7");
});
it("prefers MessageThreadId for Slack tool threading", () => {
it("uses raw Slack channel ids without implicit thread context", () => {
const sessionCtx = {
Provider: "slack",
To: "channel:C1",
MessageThreadId: "123.456",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("channel:C1");
expect(result.currentThreadTs).toBeUndefined();
});
it("uses Slack plugin threading context when the plugin registry is active", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", plugin: slackPlugin, source: "test" }]),
);
const sessionCtx = {
Provider: "slack",
To: "channel:C1",
@@ -206,7 +235,7 @@ describe("applyReplyThreading auto-threading", () => {
expect(result[0].replyToId).toBeUndefined();
});
it("strips explicit tags for Slack when off mode disallows tags", () => {
it("keeps explicit tags for Slack when off mode allows explicit tags", () => {
const result = applyReplyThreading({
payloads: [{ text: "[[reply_to_current]]A" }],
replyToMode: "off",
@@ -215,7 +244,7 @@ describe("applyReplyThreading auto-threading", () => {
});
expect(result).toHaveLength(1);
expect(result[0].replyToId).toBeUndefined();
expect(result[0].replyToId).toBe("42");
});
it("keeps explicit tags for Telegram when off mode is enabled", () => {

View File

@@ -12,16 +12,7 @@ const hookRunnerMocks = vi.hoisted(() => ({
runSessionEnd: vi.fn<HookRunner["runSessionEnd"]>(),
}));
vi.mock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () =>
({
hasHooks: hookRunnerMocks.hasHooks,
runSessionStart: hookRunnerMocks.runSessionStart,
runSessionEnd: hookRunnerMocks.runSessionEnd,
}) as unknown as HookRunner,
}));
const { initSessionState } = await import("./session.js");
let initSessionState: typeof import("./session.js").initSessionState;
async function createStorePath(prefix: string): Promise<string> {
const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
@@ -37,7 +28,16 @@ async function writeStore(
}
describe("session hook context wiring", () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () =>
({
hasHooks: hookRunnerMocks.hasHooks,
runSessionStart: hookRunnerMocks.runSessionStart,
runSessionEnd: hookRunnerMocks.runSessionEnd,
}) as unknown as HookRunner,
}));
hookRunnerMocks.hasHooks.mockReset();
hookRunnerMocks.runSessionStart.mockReset();
hookRunnerMocks.runSessionEnd.mockReset();
@@ -46,6 +46,7 @@ describe("session hook context wiring", () => {
hookRunnerMocks.hasHooks.mockImplementation(
(hookName) => hookName === "session_start" || hookName === "session_end",
);
({ initSessionState } = await import("./session.js"));
});
afterEach(() => {

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
// Avoid importing the full chat command registry for reserved-name calculation.
vi.mock("./commands-registry.js", () => ({
@@ -71,12 +71,76 @@ let listSkillCommandsForAgents: typeof import("./skill-commands.js").listSkillCo
let resolveSkillCommandInvocation: typeof import("./skill-commands.js").resolveSkillCommandInvocation;
let skillCommandsTesting: typeof import("./skill-commands.js").__testing;
beforeAll(async () => {
async function loadFreshSkillCommandsModuleForTest() {
vi.resetModules();
vi.doMock("./commands-registry.js", () => ({
listChatCommands: () => [],
}));
vi.doMock("../infra/skills-remote.js", () => ({
getRemoteSkillEligibility: () => ({}),
}));
vi.doMock("../agents/skills.js", () => {
function resolveUniqueName(base: string, used: Set<string>): string {
let name = base;
let suffix = 2;
while (used.has(name.toLowerCase())) {
name = `${base}_${suffix}`;
suffix += 1;
}
used.add(name.toLowerCase());
return name;
}
function resolveWorkspaceSkills(
workspaceDir: string,
): Array<{ skillName: string; description: string }> {
const dirName = path.basename(workspaceDir);
if (dirName === "main") {
return [{ skillName: "demo-skill", description: "Demo skill" }];
}
if (dirName === "research") {
return [
{ skillName: "demo-skill", description: "Demo skill 2" },
{ skillName: "extra-skill", description: "Extra skill" },
];
}
return [];
}
return {
buildWorkspaceSkillCommandSpecs: (
workspaceDir: string,
opts?: { reservedNames?: Set<string>; skillFilter?: string[] },
) => {
const used = new Set<string>();
for (const reserved of opts?.reservedNames ?? []) {
used.add(String(reserved).toLowerCase());
}
const filter = opts?.skillFilter;
const entries =
filter === undefined
? resolveWorkspaceSkills(workspaceDir)
: resolveWorkspaceSkills(workspaceDir).filter((entry) =>
filter.some((skillName) => skillName === entry.skillName),
);
return entries.map((entry) => {
const base = entry.skillName.replace(/-/g, "_");
const name = resolveUniqueName(base, used);
return { name, skillName: entry.skillName, description: entry.description };
});
},
};
});
({
listSkillCommandsForAgents,
resolveSkillCommandInvocation,
__testing: skillCommandsTesting,
} = await import("./skill-commands.js"));
}
beforeEach(async () => {
await loadFreshSkillCommandsModuleForTest();
});
describe("resolveSkillCommandInvocation", () => {

View File

@@ -6,26 +6,37 @@ const providerRuntimeMocks = vi.hoisted(() => ({
resolveProviderXHighThinking: vi.fn(),
}));
vi.mock("../plugins/provider-thinking.js", () => ({
resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking,
resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel,
resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking,
}));
import {
listThinkingLevelLabels,
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} from "./thinking.js";
let listThinkingLevelLabels: typeof import("./thinking.js").listThinkingLevelLabels;
let listThinkingLevels: typeof import("./thinking.js").listThinkingLevels;
let normalizeReasoningLevel: typeof import("./thinking.js").normalizeReasoningLevel;
let normalizeThinkLevel: typeof import("./thinking.js").normalizeThinkLevel;
let resolveThinkingDefaultForModel: typeof import("./thinking.js").resolveThinkingDefaultForModel;
beforeEach(() => {
async function loadFreshThinkingModuleForTest() {
vi.resetModules();
vi.doMock("../plugins/provider-thinking.js", () => ({
resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking,
resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel,
resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking,
}));
return await import("./thinking.js");
}
beforeEach(async () => {
providerRuntimeMocks.resolveProviderBinaryThinking.mockReset();
providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReset();
providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
providerRuntimeMocks.resolveProviderXHighThinking.mockReset();
providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(undefined);
({
listThinkingLevelLabels,
listThinkingLevels,
normalizeReasoningLevel,
normalizeThinkLevel,
resolveThinkingDefaultForModel,
} = await loadFreshThinkingModuleForTest());
});
describe("normalizeThinkLevel", () => {