mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-27 09:52:25 +00:00
test: continue vitest threads migration
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]]"),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user