mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
test: dedupe auto-reply web and signal flows
This commit is contained in:
@@ -2,30 +2,30 @@ import { beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installTriggerHandlingE2eTestHooks,
|
||||
loadGetReplyFromConfig,
|
||||
makeCfg,
|
||||
mockRunEmbeddedPiAgentOk,
|
||||
withTempHome,
|
||||
} from "./reply.triggers.trigger-handling.test-harness.js";
|
||||
|
||||
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await import("./reply.js"));
|
||||
getReplyFromConfig = await loadGetReplyFromConfig();
|
||||
});
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
|
||||
function getLastExtraSystemPrompt() {
|
||||
return getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
}
|
||||
|
||||
describe("group intro prompts", () => {
|
||||
const groupParticipationNote =
|
||||
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly.";
|
||||
|
||||
it("labels Discord groups using the surface metadata", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockRunEmbeddedPiAgentOk();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
@@ -42,8 +42,7 @@ describe("group intro prompts", () => {
|
||||
);
|
||||
|
||||
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
|
||||
const extraSystemPrompt =
|
||||
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
const extraSystemPrompt = getLastExtraSystemPrompt();
|
||||
expect(extraSystemPrompt).toContain('"channel": "discord"');
|
||||
expect(extraSystemPrompt).toContain(
|
||||
`You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`,
|
||||
@@ -55,13 +54,7 @@ describe("group intro prompts", () => {
|
||||
});
|
||||
it("keeps WhatsApp labeling for WhatsApp group chats", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockRunEmbeddedPiAgentOk();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
@@ -77,8 +70,7 @@ describe("group intro prompts", () => {
|
||||
);
|
||||
|
||||
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
|
||||
const extraSystemPrompt =
|
||||
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
const extraSystemPrompt = getLastExtraSystemPrompt();
|
||||
expect(extraSystemPrompt).toContain('"channel": "whatsapp"');
|
||||
expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
@@ -91,13 +83,7 @@ describe("group intro prompts", () => {
|
||||
});
|
||||
it("labels Telegram groups using their own surface", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockRunEmbeddedPiAgentOk();
|
||||
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
@@ -113,8 +99,7 @@ describe("group intro prompts", () => {
|
||||
);
|
||||
|
||||
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
|
||||
const extraSystemPrompt =
|
||||
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
|
||||
const extraSystemPrompt = getLastExtraSystemPrompt();
|
||||
expect(extraSystemPrompt).toContain('"channel": "telegram"');
|
||||
expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`);
|
||||
expect(extraSystemPrompt).toContain(
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createBlockReplyCollector,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installTriggerHandlingE2eTestHooks,
|
||||
loadGetReplyFromConfig,
|
||||
makeCfg,
|
||||
mockRunEmbeddedPiAgentOk,
|
||||
withTempHome,
|
||||
@@ -10,11 +11,40 @@ import {
|
||||
|
||||
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await import("./reply.js"));
|
||||
getReplyFromConfig = await loadGetReplyFromConfig();
|
||||
});
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
|
||||
async function expectUnauthorizedCommandDropped(home: string, body: "/status" | "/whoami") {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const baseCfg = makeCfg(home);
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: body,
|
||||
From: "+2001",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2001",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("handles inline /commands and strips it before the agent", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
@@ -69,63 +99,13 @@ describe("trigger handling", () => {
|
||||
|
||||
it("drops /status for unauthorized senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const baseCfg = makeCfg(home);
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/status",
|
||||
From: "+2001",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2001",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
await expectUnauthorizedCommandDropped(home, "/status");
|
||||
});
|
||||
});
|
||||
|
||||
it("drops /whoami for unauthorized senders", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
|
||||
const baseCfg = makeCfg(home);
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
whatsapp: {
|
||||
allowFrom: ["+1000"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/whoami",
|
||||
From: "+2001",
|
||||
To: "+2000",
|
||||
Provider: "whatsapp",
|
||||
SenderE164: "+2001",
|
||||
},
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
|
||||
expect(res).toBeUndefined();
|
||||
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
|
||||
await expectUnauthorizedCommandDropped(home, "/whoami");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,13 +6,15 @@ import {
|
||||
getCompactEmbeddedPiSessionMock,
|
||||
getRunEmbeddedPiAgentMock,
|
||||
installTriggerHandlingE2eTestHooks,
|
||||
loadGetReplyFromConfig,
|
||||
makeCfg,
|
||||
mockRunEmbeddedPiAgentOk,
|
||||
withTempHome,
|
||||
} from "./reply.triggers.trigger-handling.test-harness.js";
|
||||
|
||||
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await import("./reply.js"));
|
||||
getReplyFromConfig = await loadGetReplyFromConfig();
|
||||
});
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
@@ -92,13 +94,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockRunEmbeddedPiAgentOk();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
@@ -127,13 +123,7 @@ describe("trigger handling", () => {
|
||||
});
|
||||
it("does not emit directive acks for heartbeats with /think", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getRunEmbeddedPiAgentMock().mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId: "s", provider: "p", model: "m" },
|
||||
},
|
||||
});
|
||||
mockRunEmbeddedPiAgentOk();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
|
||||
@@ -42,6 +42,39 @@ vi.mock("../../agents/subagent-registry.js", () => ({
|
||||
}));
|
||||
|
||||
describe("abort detection", () => {
|
||||
async function writeSessionStore(
|
||||
storePath: string,
|
||||
sessionIdsByKey: Record<string, string>,
|
||||
nowMs = Date.now(),
|
||||
) {
|
||||
const storeEntries = Object.fromEntries(
|
||||
Object.entries(sessionIdsByKey).map(([key, sessionId]) => [
|
||||
key,
|
||||
{ sessionId, updatedAt: nowMs },
|
||||
]),
|
||||
);
|
||||
await fs.writeFile(storePath, JSON.stringify(storeEntries, null, 2));
|
||||
}
|
||||
|
||||
async function createAbortConfig(params?: {
|
||||
commandsTextEnabled?: boolean;
|
||||
sessionIdsByKey?: Record<string, string>;
|
||||
nowMs?: number;
|
||||
}) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = {
|
||||
session: { store: storePath },
|
||||
...(typeof params?.commandsTextEnabled === "boolean"
|
||||
? { commands: { text: params.commandsTextEnabled } }
|
||||
: {}),
|
||||
} as OpenClawConfig;
|
||||
if (params?.sessionIdsByKey) {
|
||||
await writeSessionStore(storePath, params.sessionIdsByKey, params.nowMs);
|
||||
}
|
||||
return { root, storePath, cfg };
|
||||
}
|
||||
|
||||
async function runStopCommand(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
@@ -142,9 +175,7 @@ describe("abort detection", () => {
|
||||
});
|
||||
|
||||
it("fast-aborts even when text commands are disabled", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig;
|
||||
const { cfg } = await createAbortConfig({ commandsTextEnabled: false });
|
||||
|
||||
const result = await runStopCommand({
|
||||
cfg,
|
||||
@@ -157,24 +188,11 @@ describe("abort detection", () => {
|
||||
});
|
||||
|
||||
it("fast-abort clears queued followups and session lane", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const sessionKey = "telegram:123";
|
||||
const sessionId = "session-123";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { root, cfg } = await createAbortConfig({
|
||||
sessionIdsByKey: { [sessionKey]: sessionId },
|
||||
});
|
||||
const followupRun: FollowupRun = {
|
||||
prompt: "queued",
|
||||
enqueuedAt: Date.now(),
|
||||
@@ -215,30 +233,16 @@ describe("abort detection", () => {
|
||||
});
|
||||
|
||||
it("fast-abort stops active subagent runs for requester session", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const sessionKey = "telegram:parent";
|
||||
const childKey = "agent:main:subagent:child-1";
|
||||
const sessionId = "session-parent";
|
||||
const childSessionId = "session-child";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[childKey]: {
|
||||
sessionId: childSessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { cfg } = await createAbortConfig({
|
||||
sessionIdsByKey: {
|
||||
[sessionKey]: sessionId,
|
||||
[childKey]: childSessionId,
|
||||
},
|
||||
});
|
||||
|
||||
subagentRegistryMocks.listSubagentRunsForRequester.mockReturnValueOnce([
|
||||
{
|
||||
@@ -264,36 +268,19 @@ describe("abort detection", () => {
|
||||
});
|
||||
|
||||
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const sessionKey = "telegram:parent";
|
||||
const depth1Key = "agent:main:subagent:child-1";
|
||||
const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1";
|
||||
const sessionId = "session-parent";
|
||||
const depth1SessionId = "session-child";
|
||||
const depth2SessionId = "session-grandchild";
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[depth1Key]: {
|
||||
sessionId: depth1SessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
[depth2Key]: {
|
||||
sessionId: depth2SessionId,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { cfg } = await createAbortConfig({
|
||||
sessionIdsByKey: {
|
||||
[sessionKey]: sessionId,
|
||||
[depth1Key]: depth1SessionId,
|
||||
[depth2Key]: depth2SessionId,
|
||||
},
|
||||
});
|
||||
|
||||
// First call: main session lists depth-1 children
|
||||
// Second call (cascade): depth-1 session lists depth-2 children
|
||||
@@ -339,34 +326,18 @@ describe("abort detection", () => {
|
||||
it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
|
||||
subagentRegistryMocks.listSubagentRunsForRequester.mockClear();
|
||||
subagentRegistryMocks.markSubagentRunTerminated.mockClear();
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as OpenClawConfig;
|
||||
const sessionKey = "telegram:parent";
|
||||
const depth1Key = "agent:main:subagent:child-ended";
|
||||
const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active";
|
||||
const now = Date.now();
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "session-parent",
|
||||
updatedAt: now,
|
||||
},
|
||||
[depth1Key]: {
|
||||
sessionId: "session-child-ended",
|
||||
updatedAt: now,
|
||||
},
|
||||
[depth2Key]: {
|
||||
sessionId: "session-grandchild-active",
|
||||
updatedAt: now,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const { cfg } = await createAbortConfig({
|
||||
nowMs: now,
|
||||
sessionIdsByKey: {
|
||||
[sessionKey]: "session-parent",
|
||||
[depth1Key]: "session-child-ended",
|
||||
[depth2Key]: "session-grandchild-active",
|
||||
},
|
||||
});
|
||||
|
||||
// main -> ended depth-1 parent
|
||||
// depth-1 parent -> active depth-2 child
|
||||
|
||||
@@ -175,6 +175,22 @@ function formatEntryList(entries: string[], resolved?: Map<string, string>): str
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function extractConfigAllowlist(account: {
|
||||
config?: {
|
||||
allowFrom?: Array<string | number>;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
dmPolicy?: string;
|
||||
groupPolicy?: string;
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
dmAllowFrom: (account.config?.allowFrom ?? []).map(String),
|
||||
groupAllowFrom: (account.config?.groupAllowFrom ?? []).map(String),
|
||||
dmPolicy: account.config?.dmPolicy,
|
||||
groupPolicy: account.config?.groupPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAccountTarget(
|
||||
parsed: Record<string, unknown>,
|
||||
channelId: ChannelId,
|
||||
@@ -363,10 +379,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
|
||||
if (channelId === "telegram") {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
const groups = account.config.groups ?? {};
|
||||
for (const [groupId, groupCfg] of Object.entries(groups)) {
|
||||
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||
@@ -389,16 +402,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
|
||||
groupPolicy = account.groupPolicy;
|
||||
} else if (channelId === "signal") {
|
||||
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
} else if (channelId === "imessage") {
|
||||
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
|
||||
} else if (channelId === "slack") {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
resetSubagentRegistryForTests,
|
||||
} from "../../agents/subagent-registry.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const callGatewayMock = vi.fn();
|
||||
@@ -29,18 +30,7 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
|
||||
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({}),
|
||||
}));
|
||||
installSubagentsCommandCoreMocks();
|
||||
|
||||
const { handleSubagentsCommand } = await import("./commands-subagents.js");
|
||||
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
|
||||
@@ -59,6 +49,25 @@ type FakeBinding = {
|
||||
boundAt: number;
|
||||
};
|
||||
|
||||
function createFakeBinding(
|
||||
overrides: Pick<FakeBinding, "threadId" | "targetKind" | "targetSessionKey" | "agentId"> &
|
||||
Partial<FakeBinding>,
|
||||
): FakeBinding {
|
||||
return {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectAgentListContainsThreadBinding(text: string, label: string, threadId: string): void {
|
||||
expect(text).toContain("agents:");
|
||||
expect(text).toContain(label);
|
||||
expect(text).toContain(`thread:${threadId}`);
|
||||
}
|
||||
|
||||
function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
|
||||
const byThread = new Map<string, FakeBinding>(
|
||||
initialBindings.map((binding) => [binding.threadId, binding]),
|
||||
@@ -222,39 +231,27 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
});
|
||||
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
createFakeBinding({
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
label: "child-1",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
}),
|
||||
createFakeBinding({
|
||||
threadId: "thread-2",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:main:main",
|
||||
agentId: "codex-acp",
|
||||
label: "main-session",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
}),
|
||||
createFakeBinding({
|
||||
threadId: "thread-3",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex-acp:session-2",
|
||||
agentId: "codex-acp",
|
||||
label: "codex-acp",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
@@ -284,17 +281,13 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
});
|
||||
|
||||
const fake = createFakeThreadBindingManager([
|
||||
{
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
createFakeBinding({
|
||||
threadId: "thread-persistent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:persistent-1",
|
||||
agentId: "main",
|
||||
label: "persistent-1",
|
||||
boundBy: "user-1",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
|
||||
|
||||
@@ -302,9 +295,7 @@ describe("/focus, /unfocus, /agents", () => {
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
const text = result?.reply?.text ?? "";
|
||||
|
||||
expect(text).toContain("agents:");
|
||||
expect(text).toContain("persistent-1");
|
||||
expect(text).toContain("thread:thread-persistent-1");
|
||||
expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
|
||||
});
|
||||
|
||||
it("/focus is discord-only", async () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
|
||||
import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const spawnSubagentDirectMock = vi.fn();
|
||||
@@ -18,18 +19,7 @@ vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
|
||||
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({}),
|
||||
}));
|
||||
installSubagentsCommandCoreMocks();
|
||||
|
||||
// Dynamic import to ensure mocks are installed first.
|
||||
const { handleSubagentsCommand } = await import("./commands-subagents.js");
|
||||
@@ -64,6 +54,41 @@ describe("/subagents spawn command", () => {
|
||||
hoisted.callGatewayMock.mockClear();
|
||||
});
|
||||
|
||||
async function runSpawnWithFlag(
|
||||
flagSegment: string,
|
||||
result: SpawnSubagentResult = acceptedResult(),
|
||||
) {
|
||||
spawnSubagentDirectMock.mockResolvedValue(result);
|
||||
const params = buildCommandTestParams(
|
||||
`/subagents spawn beta do the thing ${flagSegment}`,
|
||||
baseCfg,
|
||||
);
|
||||
const commandResult = await handleSubagentsCommand(params, true);
|
||||
expect(commandResult).not.toBeNull();
|
||||
expect(commandResult?.reply?.text).toContain("Spawned subagent beta");
|
||||
const [spawnParams] = spawnSubagentDirectMock.mock.calls[0];
|
||||
return spawnParams as { model?: string; thinking?: string; task?: string };
|
||||
}
|
||||
|
||||
async function runSuccessfulSpawn(params?: {
|
||||
commandText?: string;
|
||||
context?: Record<string, unknown>;
|
||||
mutateParams?: (commandParams: ReturnType<typeof buildCommandTestParams>) => void;
|
||||
}) {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const commandParams = buildCommandTestParams(
|
||||
params?.commandText ?? "/subagents spawn beta do the thing",
|
||||
baseCfg,
|
||||
params?.context,
|
||||
);
|
||||
params?.mutateParams?.(commandParams);
|
||||
const result = await handleSubagentsCommand(commandParams, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
return { spawnParams, spawnCtx, commandParams, commandResult: result };
|
||||
}
|
||||
|
||||
it("shows usage when agentId is missing", async () => {
|
||||
const params = buildCommandTestParams("/subagents spawn", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
@@ -82,16 +107,10 @@ describe("/subagents spawn command", () => {
|
||||
});
|
||||
|
||||
it("spawns subagent and confirms reply text and child session key", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
expect(result?.reply?.text).toContain("agent:beta:subagent:test-uuid");
|
||||
expect(result?.reply?.text).toContain("run-spaw");
|
||||
|
||||
const { spawnParams, spawnCtx, commandResult } = await runSuccessfulSpawn();
|
||||
expect(commandResult?.reply?.text).toContain("agent:beta:subagent:test-uuid");
|
||||
expect(commandResult?.reply?.text).toContain("run-spaw");
|
||||
expect(spawnSubagentDirectMock).toHaveBeenCalledOnce();
|
||||
const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
expect(spawnParams.task).toBe("do the thing");
|
||||
expect(spawnParams.agentId).toBe("beta");
|
||||
expect(spawnParams.mode).toBe("run");
|
||||
@@ -101,50 +120,32 @@ describe("/subagents spawn command", () => {
|
||||
});
|
||||
|
||||
it("spawns with --model flag and passes model to spawnSubagentDirect", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult({ modelApplied: true }));
|
||||
const params = buildCommandTestParams(
|
||||
"/subagents spawn beta do the thing --model openai/gpt-4o",
|
||||
baseCfg,
|
||||
const spawnParams = await runSpawnWithFlag(
|
||||
"--model openai/gpt-4o",
|
||||
acceptedResult({ modelApplied: true }),
|
||||
);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
|
||||
const [spawnParams] = spawnSubagentDirectMock.mock.calls[0];
|
||||
expect(spawnParams.model).toBe("openai/gpt-4o");
|
||||
expect(spawnParams.task).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("spawns with --thinking flag and passes thinking to spawnSubagentDirect", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams(
|
||||
"/subagents spawn beta do the thing --thinking high",
|
||||
baseCfg,
|
||||
);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
|
||||
const [spawnParams] = spawnSubagentDirectMock.mock.calls[0];
|
||||
const spawnParams = await runSpawnWithFlag("--thinking high");
|
||||
expect(spawnParams.thinking).toBe("high");
|
||||
expect(spawnParams.task).toBe("do the thing");
|
||||
});
|
||||
|
||||
it("passes group context from session entry to spawnSubagentDirect", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg);
|
||||
params.sessionEntry = {
|
||||
sessionId: "session-main",
|
||||
updatedAt: Date.now(),
|
||||
groupId: "group-1",
|
||||
groupChannel: "#group-channel",
|
||||
space: "workspace-1",
|
||||
};
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
|
||||
const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
const { spawnCtx } = await runSuccessfulSpawn({
|
||||
mutateParams: (commandParams) => {
|
||||
commandParams.sessionEntry = {
|
||||
sessionId: "session-main",
|
||||
updatedAt: Date.now(),
|
||||
groupId: "group-1",
|
||||
groupChannel: "#group-channel",
|
||||
space: "workspace-1",
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(spawnCtx).toMatchObject({
|
||||
agentGroupId: "group-1",
|
||||
agentGroupChannel: "#group-channel",
|
||||
@@ -153,38 +154,32 @@ describe("/subagents spawn command", () => {
|
||||
});
|
||||
|
||||
it("prefers CommandTargetSessionKey for native /subagents spawn", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, {
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: "agent:main:main",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:12345",
|
||||
const { spawnCtx } = await runSuccessfulSpawn({
|
||||
context: {
|
||||
CommandSource: "native",
|
||||
CommandTargetSessionKey: "agent:main:main",
|
||||
OriginatingChannel: "discord",
|
||||
OriginatingTo: "channel:12345",
|
||||
},
|
||||
mutateParams: (commandParams) => {
|
||||
commandParams.sessionKey = "agent:main:slack:slash:u1";
|
||||
},
|
||||
});
|
||||
params.sessionKey = "agent:main:slack:slash:u1";
|
||||
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
expect(spawnCtx.agentSessionKey).toBe("agent:main:main");
|
||||
expect(spawnCtx.agentChannel).toBe("discord");
|
||||
expect(spawnCtx.agentTo).toBe("channel:12345");
|
||||
});
|
||||
|
||||
it("falls back to OriginatingTo for agentTo when command.to is missing", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, {
|
||||
OriginatingTo: "channel:manual",
|
||||
To: "channel:fallback-from-to",
|
||||
const { spawnCtx } = await runSuccessfulSpawn({
|
||||
context: {
|
||||
OriginatingTo: "channel:manual",
|
||||
To: "channel:fallback-from-to",
|
||||
},
|
||||
mutateParams: (commandParams) => {
|
||||
commandParams.command.to = undefined;
|
||||
},
|
||||
});
|
||||
params.command.to = undefined;
|
||||
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
|
||||
const [, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
|
||||
expect(spawnCtx).toMatchObject({ agentTo: "channel:manual" });
|
||||
});
|
||||
it("returns forbidden for unauthorized cross-agent spawn", async () => {
|
||||
@@ -199,11 +194,8 @@ describe("/subagents spawn command", () => {
|
||||
});
|
||||
|
||||
it("allows cross-agent spawn when in allowlist", async () => {
|
||||
spawnSubagentDirectMock.mockResolvedValue(acceptedResult());
|
||||
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg);
|
||||
const result = await handleSubagentsCommand(params, true);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.reply?.text).toContain("Spawned subagent beta");
|
||||
await runSuccessfulSpawn();
|
||||
expect(spawnSubagentDirectMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores unauthorized sender (silent, no reply)", async () => {
|
||||
|
||||
16
src/auto-reply/reply/commands-subagents.test-mocks.ts
Normal file
16
src/auto-reply/reply/commands-subagents.test-mocks.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function installSubagentsCommandCoreMocks() {
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
// Prevent transitive import chain from reaching discord/monitor which needs https-proxy-agent.
|
||||
vi.mock("../../discord/monitor/gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({}),
|
||||
}));
|
||||
}
|
||||
@@ -53,6 +53,15 @@ async function createCompactionSessionFixture(entry: SessionEntry) {
|
||||
}
|
||||
|
||||
describe("history helpers", () => {
|
||||
function createHistoryMapWithTwoEntries() {
|
||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||
historyMap.set("group", [
|
||||
{ sender: "A", body: "one" },
|
||||
{ sender: "B", body: "two" },
|
||||
]);
|
||||
return historyMap;
|
||||
}
|
||||
|
||||
it("returns current message when history is empty", () => {
|
||||
const result = buildHistoryContext({
|
||||
historyText: " ",
|
||||
@@ -104,11 +113,7 @@ describe("history helpers", () => {
|
||||
});
|
||||
|
||||
it("builds context from map and appends entry", () => {
|
||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||
historyMap.set("group", [
|
||||
{ sender: "A", body: "one" },
|
||||
{ sender: "B", body: "two" },
|
||||
]);
|
||||
const historyMap = createHistoryMapWithTwoEntries();
|
||||
|
||||
const result = buildHistoryContextFromMap({
|
||||
historyMap,
|
||||
@@ -127,11 +132,7 @@ describe("history helpers", () => {
|
||||
});
|
||||
|
||||
it("builds context from pending map without appending", () => {
|
||||
const historyMap = new Map<string, { sender: string; body: string }[]>();
|
||||
historyMap.set("group", [
|
||||
{ sender: "A", body: "one" },
|
||||
{ sender: "B", body: "two" },
|
||||
]);
|
||||
const historyMap = createHistoryMapWithTwoEntries();
|
||||
|
||||
const result = buildPendingHistoryContextFromMap({
|
||||
historyMap,
|
||||
|
||||
@@ -17,6 +17,23 @@ type CarouselColumn = messagingApi.CarouselColumn;
|
||||
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
|
||||
type ImageCarouselColumn = messagingApi.ImageCarouselColumn;
|
||||
|
||||
type TemplatePayloadAction = {
|
||||
type?: "uri" | "postback" | "message";
|
||||
uri?: string;
|
||||
data?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function buildTemplatePayloadAction(action: TemplatePayloadAction): Action {
|
||||
if (action.type === "uri" && action.uri) {
|
||||
return uriAction(action.label, action.uri);
|
||||
}
|
||||
if (action.type === "postback" && action.data) {
|
||||
return postbackAction(action.label, action.data, action.label);
|
||||
}
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a confirm template (yes/no style dialog)
|
||||
*/
|
||||
@@ -293,16 +310,9 @@ export function buildTemplateMessageFromPayload(
|
||||
}
|
||||
|
||||
case "buttons": {
|
||||
const actions: Action[] = payload.actions.slice(0, 4).map((action) => {
|
||||
if (action.type === "uri" && action.uri) {
|
||||
return uriAction(action.label, action.uri);
|
||||
}
|
||||
if (action.type === "postback" && action.data) {
|
||||
return postbackAction(action.label, action.data, action.label);
|
||||
}
|
||||
// Default to message action
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
});
|
||||
const actions: Action[] = payload.actions
|
||||
.slice(0, 4)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createButtonTemplate(payload.title, payload.text, actions, {
|
||||
thumbnailImageUrl: payload.thumbnailImageUrl,
|
||||
@@ -312,15 +322,9 @@ export function buildTemplateMessageFromPayload(
|
||||
|
||||
case "carousel": {
|
||||
const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
|
||||
const colActions: Action[] = col.actions.slice(0, 3).map((action) => {
|
||||
if (action.type === "uri" && action.uri) {
|
||||
return uriAction(action.label, action.uri);
|
||||
}
|
||||
if (action.type === "postback" && action.data) {
|
||||
return postbackAction(action.label, action.data, action.label);
|
||||
}
|
||||
return messageAction(action.label, action.data ?? action.label);
|
||||
});
|
||||
const colActions: Action[] = col.actions
|
||||
.slice(0, 3)
|
||||
.map((action) => buildTemplatePayloadAction(action));
|
||||
|
||||
return createCarouselColumn({
|
||||
title: col.title,
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToSignalTextChunks } from "./format.js";
|
||||
|
||||
function expectChunkStyleRangesInBounds(chunks: ReturnType<typeof markdownToSignalTextChunks>) {
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
expect(style.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("splitSignalFormattedText", () => {
|
||||
// We test the internal chunking behavior via markdownToSignalTextChunks with
|
||||
// pre-rendered SignalFormattedText. The helper is not exported, so we test
|
||||
@@ -145,13 +155,7 @@ describe("splitSignalFormattedText", () => {
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// Verify all style ranges are valid within their respective chunks
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
expect(style.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Collect all styles across chunks
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
@@ -330,13 +334,7 @@ describe("markdownToSignalTextChunks", () => {
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
|
||||
// All style ranges should be valid within their chunks
|
||||
for (const chunk of chunks) {
|
||||
for (const style of chunk.styles) {
|
||||
expect(style.start).toBeGreaterThanOrEqual(0);
|
||||
expect(style.start + style.length).toBeLessThanOrEqual(chunk.text.length);
|
||||
expect(style.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
expectChunkStyleRangesInBounds(chunks);
|
||||
|
||||
// Verify styles exist somewhere
|
||||
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { monitorWebChannel } from "./auto-reply.js";
|
||||
@@ -63,21 +64,106 @@ describe("web auto-reply", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("honors mediaMaxMb from config", async () => {
|
||||
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } }));
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
},
|
||||
sendMedia,
|
||||
});
|
||||
async function withMediaCap<T>(mediaMaxMb: number, run: () => Promise<T>): Promise<T> {
|
||||
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb } } }));
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
resetLoadConfigMock();
|
||||
}
|
||||
}
|
||||
|
||||
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||
headers: { get: () => mime },
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
async function expectCompressedImageWithinCap(params: {
|
||||
mediaUrl: string;
|
||||
mime: string;
|
||||
image: Buffer;
|
||||
messageId: string;
|
||||
mediaMaxMb?: number;
|
||||
}) {
|
||||
await withMediaCap(params.mediaMaxMb ?? 1, async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
resolverValue: { text: "hi", mediaUrl: params.mediaUrl },
|
||||
sendMedia,
|
||||
});
|
||||
const fetchMock = mockFetchMediaBuffer(params.image, params.mime);
|
||||
|
||||
await dispatch(params.messageId);
|
||||
|
||||
const payload = getSingleImagePayload(sendMedia);
|
||||
expect(payload.image.length).toBeLessThanOrEqual((params.mediaMaxMb ?? 1) * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
}
|
||||
|
||||
it("compresses common formats to jpeg under the cap", { timeout: 45_000 }, async () => {
|
||||
const formats = [
|
||||
{
|
||||
name: "png",
|
||||
mime: "image/png",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "jpeg",
|
||||
mime: "image/jpeg",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.jpeg({ quality: 90 })
|
||||
.toBuffer(),
|
||||
},
|
||||
{
|
||||
name: "webp",
|
||||
mime: "image/webp",
|
||||
make: (buf: Buffer, opts: { width: number; height: number }) =>
|
||||
sharp(buf, {
|
||||
raw: { width: opts.width, height: opts.height, channels: 3 },
|
||||
})
|
||||
.webp({ quality: 100 })
|
||||
.toBuffer(),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const width = 1150;
|
||||
const height = 1150;
|
||||
const sharedRaw = crypto.randomBytes(width * height * 3);
|
||||
|
||||
for (const fmt of formats) {
|
||||
const big = await fmt.make(sharedRaw, { width, height });
|
||||
expect(big.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
await expectCompressedImageWithinCap({
|
||||
mediaUrl: `https://example.com/big.${fmt.name}`,
|
||||
mime: fmt.mime,
|
||||
image: big,
|
||||
messageId: `msg-${fmt.name}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("honors mediaMaxMb from config", async () => {
|
||||
const bigPng = await sharp({
|
||||
create: {
|
||||
width: 900,
|
||||
height: 900,
|
||||
width: 1200,
|
||||
height: 1200,
|
||||
channels: 3,
|
||||
background: { r: 0, g: 0, b: 255 },
|
||||
},
|
||||
@@ -85,25 +171,12 @@ describe("web auto-reply", () => {
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
bigPng.buffer.slice(bigPng.byteOffset, bigPng.byteOffset + bigPng.byteLength),
|
||||
headers: { get: () => "image/png" },
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
|
||||
await dispatch("msg-big");
|
||||
|
||||
const payload = getSingleImagePayload(sendMedia);
|
||||
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
|
||||
expect(payload.mimetype).toBe("image/jpeg");
|
||||
expect(reply).not.toHaveBeenCalled();
|
||||
|
||||
fetchMock.mockRestore();
|
||||
resetLoadConfigMock();
|
||||
await expectCompressedImageWithinCap({
|
||||
mediaUrl: "https://example.com/big.png",
|
||||
mime: "image/png",
|
||||
image: bigPng,
|
||||
messageId: "msg1",
|
||||
});
|
||||
});
|
||||
it("falls back to text when media is unsupported", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user