test: dedupe auto-reply web and signal flows

This commit is contained in:
Peter Steinberger 2026-02-22 17:11:26 +00:00
parent ad1072842e
commit 24ea941e28
12 changed files with 399 additions and 391 deletions

View File

@ -2,30 +2,30 @@ import { beforeAll, describe, expect, it } from "vitest";
import { import {
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig,
makeCfg, makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome, withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js"; } from "./reply.triggers.trigger-handling.test-harness.js";
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => { beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js")); getReplyFromConfig = await loadGetReplyFromConfig();
}); });
installTriggerHandlingE2eTestHooks(); installTriggerHandlingE2eTestHooks();
function getLastExtraSystemPrompt() {
return getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
}
describe("group intro prompts", () => { describe("group intro prompts", () => {
const groupParticipationNote = 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."; "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 () => { it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({ mockRunEmbeddedPiAgentOk();
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig( await getReplyFromConfig(
{ {
@ -42,8 +42,7 @@ describe("group intro prompts", () => {
); );
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt = const extraSystemPrompt = getLastExtraSystemPrompt();
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "discord"'); expect(extraSystemPrompt).toContain('"channel": "discord"');
expect(extraSystemPrompt).toContain( expect(extraSystemPrompt).toContain(
`You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`, `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 () => { it("keeps WhatsApp labeling for WhatsApp group chats", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({ mockRunEmbeddedPiAgentOk();
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig( await getReplyFromConfig(
{ {
@ -77,8 +70,7 @@ describe("group intro prompts", () => {
); );
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt = const extraSystemPrompt = getLastExtraSystemPrompt();
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "whatsapp"'); expect(extraSystemPrompt).toContain('"channel": "whatsapp"');
expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`); expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`);
expect(extraSystemPrompt).toContain( expect(extraSystemPrompt).toContain(
@ -91,13 +83,7 @@ describe("group intro prompts", () => {
}); });
it("labels Telegram groups using their own surface", async () => { it("labels Telegram groups using their own surface", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({ mockRunEmbeddedPiAgentOk();
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
await getReplyFromConfig( await getReplyFromConfig(
{ {
@ -113,8 +99,7 @@ describe("group intro prompts", () => {
); );
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce(); expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt = const extraSystemPrompt = getLastExtraSystemPrompt();
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain('"channel": "telegram"'); expect(extraSystemPrompt).toContain('"channel": "telegram"');
expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`); expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`);
expect(extraSystemPrompt).toContain( expect(extraSystemPrompt).toContain(

View File

@ -3,6 +3,7 @@ import {
createBlockReplyCollector, createBlockReplyCollector,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig,
makeCfg, makeCfg,
mockRunEmbeddedPiAgentOk, mockRunEmbeddedPiAgentOk,
withTempHome, withTempHome,
@ -10,11 +11,40 @@ import {
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => { beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js")); getReplyFromConfig = await loadGetReplyFromConfig();
}); });
installTriggerHandlingE2eTestHooks(); 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", () => { describe("trigger handling", () => {
it("handles inline /commands and strips it before the agent", async () => { it("handles inline /commands and strips it before the agent", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
@ -69,63 +99,13 @@ describe("trigger handling", () => {
it("drops /status for unauthorized senders", async () => { it("drops /status for unauthorized senders", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); await expectUnauthorizedCommandDropped(home, "/status");
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();
}); });
}); });
it("drops /whoami for unauthorized senders", async () => { it("drops /whoami for unauthorized senders", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock(); await expectUnauthorizedCommandDropped(home, "/whoami");
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();
}); });
}); });
}); });

View File

@ -6,13 +6,15 @@ import {
getCompactEmbeddedPiSessionMock, getCompactEmbeddedPiSessionMock,
getRunEmbeddedPiAgentMock, getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks, installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig,
makeCfg, makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome, withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js"; } from "./reply.triggers.trigger-handling.test-harness.js";
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig; let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => { beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js")); getReplyFromConfig = await loadGetReplyFromConfig();
}); });
installTriggerHandlingE2eTestHooks(); installTriggerHandlingE2eTestHooks();
@ -92,13 +94,7 @@ describe("trigger handling", () => {
}); });
it("ignores think directives that only appear in the context wrapper", async () => { it("ignores think directives that only appear in the context wrapper", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({ mockRunEmbeddedPiAgentOk();
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
@ -127,13 +123,7 @@ describe("trigger handling", () => {
}); });
it("does not emit directive acks for heartbeats with /think", async () => { it("does not emit directive acks for heartbeats with /think", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({ mockRunEmbeddedPiAgentOk();
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {

View File

@ -42,6 +42,39 @@ vi.mock("../../agents/subagent-registry.js", () => ({
})); }));
describe("abort detection", () => { 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: { async function runStopCommand(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
sessionKey: string; sessionKey: string;
@ -142,9 +175,7 @@ describe("abort detection", () => {
}); });
it("fast-aborts even when text commands are disabled", async () => { it("fast-aborts even when text commands are disabled", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-abort-")); const { cfg } = await createAbortConfig({ commandsTextEnabled: false });
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath }, commands: { text: false } } as OpenClawConfig;
const result = await runStopCommand({ const result = await runStopCommand({
cfg, cfg,
@ -157,24 +188,11 @@ describe("abort detection", () => {
}); });
it("fast-abort clears queued followups and session lane", async () => { 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 sessionKey = "telegram:123";
const sessionId = "session-123"; const sessionId = "session-123";
await fs.writeFile( const { root, cfg } = await createAbortConfig({
storePath, sessionIdsByKey: { [sessionKey]: sessionId },
JSON.stringify( });
{
[sessionKey]: {
sessionId,
updatedAt: Date.now(),
},
},
null,
2,
),
);
const followupRun: FollowupRun = { const followupRun: FollowupRun = {
prompt: "queued", prompt: "queued",
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
@ -215,30 +233,16 @@ describe("abort detection", () => {
}); });
it("fast-abort stops active subagent runs for requester session", async () => { 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 sessionKey = "telegram:parent";
const childKey = "agent:main:subagent:child-1"; const childKey = "agent:main:subagent:child-1";
const sessionId = "session-parent"; const sessionId = "session-parent";
const childSessionId = "session-child"; const childSessionId = "session-child";
await fs.writeFile( const { cfg } = await createAbortConfig({
storePath, sessionIdsByKey: {
JSON.stringify( [sessionKey]: sessionId,
{ [childKey]: childSessionId,
[sessionKey]: { },
sessionId, });
updatedAt: Date.now(),
},
[childKey]: {
sessionId: childSessionId,
updatedAt: Date.now(),
},
},
null,
2,
),
);
subagentRegistryMocks.listSubagentRunsForRequester.mockReturnValueOnce([ subagentRegistryMocks.listSubagentRunsForRequester.mockReturnValueOnce([
{ {
@ -264,36 +268,19 @@ describe("abort detection", () => {
}); });
it("cascade stop kills depth-2 children when stopping depth-1 agent", async () => { 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 sessionKey = "telegram:parent";
const depth1Key = "agent:main:subagent:child-1"; const depth1Key = "agent:main:subagent:child-1";
const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1"; const depth2Key = "agent:main:subagent:child-1:subagent:grandchild-1";
const sessionId = "session-parent"; const sessionId = "session-parent";
const depth1SessionId = "session-child"; const depth1SessionId = "session-child";
const depth2SessionId = "session-grandchild"; const depth2SessionId = "session-grandchild";
await fs.writeFile( const { cfg } = await createAbortConfig({
storePath, sessionIdsByKey: {
JSON.stringify( [sessionKey]: sessionId,
{ [depth1Key]: depth1SessionId,
[sessionKey]: { [depth2Key]: depth2SessionId,
sessionId, },
updatedAt: Date.now(), });
},
[depth1Key]: {
sessionId: depth1SessionId,
updatedAt: Date.now(),
},
[depth2Key]: {
sessionId: depth2SessionId,
updatedAt: Date.now(),
},
},
null,
2,
),
);
// First call: main session lists depth-1 children // First call: main session lists depth-1 children
// Second call (cascade): depth-1 session lists depth-2 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 () => { it("cascade stop traverses ended depth-1 parents to stop active depth-2 children", async () => {
subagentRegistryMocks.listSubagentRunsForRequester.mockClear(); subagentRegistryMocks.listSubagentRunsForRequester.mockClear();
subagentRegistryMocks.markSubagentRunTerminated.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 sessionKey = "telegram:parent";
const depth1Key = "agent:main:subagent:child-ended"; const depth1Key = "agent:main:subagent:child-ended";
const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active"; const depth2Key = "agent:main:subagent:child-ended:subagent:grandchild-active";
const now = Date.now(); const now = Date.now();
await fs.writeFile( const { cfg } = await createAbortConfig({
storePath, nowMs: now,
JSON.stringify( sessionIdsByKey: {
{ [sessionKey]: "session-parent",
[sessionKey]: { [depth1Key]: "session-child-ended",
sessionId: "session-parent", [depth2Key]: "session-grandchild-active",
updatedAt: now, },
}, });
[depth1Key]: {
sessionId: "session-child-ended",
updatedAt: now,
},
[depth2Key]: {
sessionId: "session-grandchild-active",
updatedAt: now,
},
},
null,
2,
),
);
// main -> ended depth-1 parent // main -> ended depth-1 parent
// depth-1 parent -> active depth-2 child // depth-1 parent -> active depth-2 child

View File

@ -175,6 +175,22 @@ function formatEntryList(entries: string[], resolved?: Map<string, string>): str
.join(", "); .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( function resolveAccountTarget(
parsed: Record<string, unknown>, parsed: Record<string, unknown>,
channelId: ChannelId, channelId: ChannelId,
@ -363,10 +379,7 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
if (channelId === "telegram") { if (channelId === "telegram") {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId }); const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String); ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
const groups = account.config.groups ?? {}; const groups = account.config.groups ?? {};
for (const [groupId, groupCfg] of Object.entries(groups)) { for (const [groupId, groupCfg] of Object.entries(groups)) {
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
@ -389,16 +402,10 @@ export const handleAllowlistCommand: CommandHandler = async (params, allowTextCo
groupPolicy = account.groupPolicy; groupPolicy = account.groupPolicy;
} else if (channelId === "signal") { } else if (channelId === "signal") {
const account = resolveSignalAccount({ cfg: params.cfg, accountId }); const account = resolveSignalAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String); ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "imessage") { } else if (channelId === "imessage") {
const account = resolveIMessageAccount({ cfg: params.cfg, accountId }); const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? []).map(String); ({ dmAllowFrom, groupAllowFrom, dmPolicy, groupPolicy } = extractConfigAllowlist(account));
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
dmPolicy = account.config.dmPolicy;
groupPolicy = account.config.groupPolicy;
} else if (channelId === "slack") { } else if (channelId === "slack") {
const account = resolveSlackAccount({ cfg: params.cfg, accountId }); const account = resolveSlackAccount({ cfg: params.cfg, accountId });
dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String); dmAllowFrom = (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String);

View File

@ -4,6 +4,7 @@ import {
resetSubagentRegistryForTests, resetSubagentRegistryForTests,
} from "../../agents/subagent-registry.js"; } from "../../agents/subagent-registry.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js";
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const callGatewayMock = vi.fn(); const callGatewayMock = vi.fn();
@ -29,18 +30,7 @@ vi.mock("../../discord/monitor/thread-bindings.js", async (importOriginal) => {
}; };
}); });
vi.mock("../../config/config.js", async (importOriginal) => { installSubagentsCommandCoreMocks();
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: () => ({}),
}));
const { handleSubagentsCommand } = await import("./commands-subagents.js"); const { handleSubagentsCommand } = await import("./commands-subagents.js");
const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js"); const { buildCommandTestParams } = await import("./commands-spawn.test-harness.js");
@ -59,6 +49,25 @@ type FakeBinding = {
boundAt: number; 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[] = []) { function createFakeThreadBindingManager(initialBindings: FakeBinding[] = []) {
const byThread = new Map<string, FakeBinding>( const byThread = new Map<string, FakeBinding>(
initialBindings.map((binding) => [binding.threadId, binding]), initialBindings.map((binding) => [binding.threadId, binding]),
@ -222,39 +231,27 @@ describe("/focus, /unfocus, /agents", () => {
}); });
const fake = createFakeThreadBindingManager([ const fake = createFakeThreadBindingManager([
{ createFakeBinding({
accountId: "default",
channelId: "parent-1",
threadId: "thread-1", threadId: "thread-1",
targetKind: "subagent", targetKind: "subagent",
targetSessionKey: "agent:main:subagent:child-1", targetSessionKey: "agent:main:subagent:child-1",
agentId: "main", agentId: "main",
label: "child-1", label: "child-1",
boundBy: "user-1", }),
boundAt: Date.now(), createFakeBinding({
},
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-2", threadId: "thread-2",
targetKind: "acp", targetKind: "acp",
targetSessionKey: "agent:main:main", targetSessionKey: "agent:main:main",
agentId: "codex-acp", agentId: "codex-acp",
label: "main-session", label: "main-session",
boundBy: "user-1", }),
boundAt: Date.now(), createFakeBinding({
},
{
accountId: "default",
channelId: "parent-1",
threadId: "thread-3", threadId: "thread-3",
targetKind: "acp", targetKind: "acp",
targetSessionKey: "agent:codex-acp:session-2", targetSessionKey: "agent:codex-acp:session-2",
agentId: "codex-acp", agentId: "codex-acp",
label: "codex-acp", label: "codex-acp",
boundBy: "user-1", }),
boundAt: Date.now(),
},
]); ]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
@ -284,17 +281,13 @@ describe("/focus, /unfocus, /agents", () => {
}); });
const fake = createFakeThreadBindingManager([ const fake = createFakeThreadBindingManager([
{ createFakeBinding({
accountId: "default",
channelId: "parent-1",
threadId: "thread-persistent-1", threadId: "thread-persistent-1",
targetKind: "subagent", targetKind: "subagent",
targetSessionKey: "agent:main:subagent:persistent-1", targetSessionKey: "agent:main:subagent:persistent-1",
agentId: "main", agentId: "main",
label: "persistent-1", label: "persistent-1",
boundBy: "user-1", }),
boundAt: Date.now(),
},
]); ]);
hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager); hoisted.getThreadBindingManagerMock.mockReturnValue(fake.manager);
@ -302,9 +295,7 @@ describe("/focus, /unfocus, /agents", () => {
const result = await handleSubagentsCommand(params, true); const result = await handleSubagentsCommand(params, true);
const text = result?.reply?.text ?? ""; const text = result?.reply?.text ?? "";
expect(text).toContain("agents:"); expectAgentListContainsThreadBinding(text, "persistent-1", "thread-persistent-1");
expect(text).toContain("persistent-1");
expect(text).toContain("thread:thread-persistent-1");
}); });
it("/focus is discord-only", async () => { it("/focus is discord-only", async () => {

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js"; import { resetSubagentRegistryForTests } from "../../agents/subagent-registry.js";
import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js"; import type { SpawnSubagentResult } from "../../agents/subagent-spawn.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js";
const hoisted = vi.hoisted(() => { const hoisted = vi.hoisted(() => {
const spawnSubagentDirectMock = vi.fn(); const spawnSubagentDirectMock = vi.fn();
@ -18,18 +19,7 @@ vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), callGateway: (opts: unknown) => hoisted.callGatewayMock(opts),
})); }));
vi.mock("../../config/config.js", async (importOriginal) => { installSubagentsCommandCoreMocks();
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: () => ({}),
}));
// Dynamic import to ensure mocks are installed first. // Dynamic import to ensure mocks are installed first.
const { handleSubagentsCommand } = await import("./commands-subagents.js"); const { handleSubagentsCommand } = await import("./commands-subagents.js");
@ -64,6 +54,41 @@ describe("/subagents spawn command", () => {
hoisted.callGatewayMock.mockClear(); 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 () => { it("shows usage when agentId is missing", async () => {
const params = buildCommandTestParams("/subagents spawn", baseCfg); const params = buildCommandTestParams("/subagents spawn", baseCfg);
const result = await handleSubagentsCommand(params, true); 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 () => { it("spawns subagent and confirms reply text and child session key", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); const { spawnParams, spawnCtx, commandResult } = await runSuccessfulSpawn();
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); expect(commandResult?.reply?.text).toContain("agent:beta:subagent:test-uuid");
const result = await handleSubagentsCommand(params, true); expect(commandResult?.reply?.text).toContain("run-spaw");
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");
expect(spawnSubagentDirectMock).toHaveBeenCalledOnce(); expect(spawnSubagentDirectMock).toHaveBeenCalledOnce();
const [spawnParams, spawnCtx] = spawnSubagentDirectMock.mock.calls[0];
expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.task).toBe("do the thing");
expect(spawnParams.agentId).toBe("beta"); expect(spawnParams.agentId).toBe("beta");
expect(spawnParams.mode).toBe("run"); expect(spawnParams.mode).toBe("run");
@ -101,50 +120,32 @@ describe("/subagents spawn command", () => {
}); });
it("spawns with --model flag and passes model to spawnSubagentDirect", async () => { it("spawns with --model flag and passes model to spawnSubagentDirect", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult({ modelApplied: true })); const spawnParams = await runSpawnWithFlag(
const params = buildCommandTestParams( "--model openai/gpt-4o",
"/subagents spawn beta do the thing --model openai/gpt-4o", acceptedResult({ modelApplied: true }),
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];
expect(spawnParams.model).toBe("openai/gpt-4o"); expect(spawnParams.model).toBe("openai/gpt-4o");
expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.task).toBe("do the thing");
}); });
it("spawns with --thinking flag and passes thinking to spawnSubagentDirect", async () => { it("spawns with --thinking flag and passes thinking to spawnSubagentDirect", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); const spawnParams = await runSpawnWithFlag("--thinking high");
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];
expect(spawnParams.thinking).toBe("high"); expect(spawnParams.thinking).toBe("high");
expect(spawnParams.task).toBe("do the thing"); expect(spawnParams.task).toBe("do the thing");
}); });
it("passes group context from session entry to spawnSubagentDirect", async () => { it("passes group context from session entry to spawnSubagentDirect", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); const { spawnCtx } = await runSuccessfulSpawn({
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); mutateParams: (commandParams) => {
params.sessionEntry = { commandParams.sessionEntry = {
sessionId: "session-main", sessionId: "session-main",
updatedAt: Date.now(), updatedAt: Date.now(),
groupId: "group-1", groupId: "group-1",
groupChannel: "#group-channel", groupChannel: "#group-channel",
space: "workspace-1", 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];
expect(spawnCtx).toMatchObject({ expect(spawnCtx).toMatchObject({
agentGroupId: "group-1", agentGroupId: "group-1",
agentGroupChannel: "#group-channel", agentGroupChannel: "#group-channel",
@ -153,38 +154,32 @@ describe("/subagents spawn command", () => {
}); });
it("prefers CommandTargetSessionKey for native /subagents spawn", async () => { it("prefers CommandTargetSessionKey for native /subagents spawn", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); const { spawnCtx } = await runSuccessfulSpawn({
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, { context: {
CommandSource: "native", CommandSource: "native",
CommandTargetSessionKey: "agent:main:main", CommandTargetSessionKey: "agent:main:main",
OriginatingChannel: "discord", OriginatingChannel: "discord",
OriginatingTo: "channel:12345", 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.agentSessionKey).toBe("agent:main:main");
expect(spawnCtx.agentChannel).toBe("discord"); expect(spawnCtx.agentChannel).toBe("discord");
expect(spawnCtx.agentTo).toBe("channel:12345"); expect(spawnCtx.agentTo).toBe("channel:12345");
}); });
it("falls back to OriginatingTo for agentTo when command.to is missing", async () => { it("falls back to OriginatingTo for agentTo when command.to is missing", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); const { spawnCtx } = await runSuccessfulSpawn({
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg, { context: {
OriginatingTo: "channel:manual", OriginatingTo: "channel:manual",
To: "channel:fallback-from-to", 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" }); expect(spawnCtx).toMatchObject({ agentTo: "channel:manual" });
}); });
it("returns forbidden for unauthorized cross-agent spawn", async () => { 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 () => { it("allows cross-agent spawn when in allowlist", async () => {
spawnSubagentDirectMock.mockResolvedValue(acceptedResult()); await runSuccessfulSpawn();
const params = buildCommandTestParams("/subagents spawn beta do the thing", baseCfg); expect(spawnSubagentDirectMock).toHaveBeenCalledOnce();
const result = await handleSubagentsCommand(params, true);
expect(result).not.toBeNull();
expect(result?.reply?.text).toContain("Spawned subagent beta");
}); });
it("ignores unauthorized sender (silent, no reply)", async () => { it("ignores unauthorized sender (silent, no reply)", async () => {

View 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: () => ({}),
}));
}

View File

@ -53,6 +53,15 @@ async function createCompactionSessionFixture(entry: SessionEntry) {
} }
describe("history helpers", () => { 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", () => { it("returns current message when history is empty", () => {
const result = buildHistoryContext({ const result = buildHistoryContext({
historyText: " ", historyText: " ",
@ -104,11 +113,7 @@ describe("history helpers", () => {
}); });
it("builds context from map and appends entry", () => { it("builds context from map and appends entry", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>(); const historyMap = createHistoryMapWithTwoEntries();
historyMap.set("group", [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
]);
const result = buildHistoryContextFromMap({ const result = buildHistoryContextFromMap({
historyMap, historyMap,
@ -127,11 +132,7 @@ describe("history helpers", () => {
}); });
it("builds context from pending map without appending", () => { it("builds context from pending map without appending", () => {
const historyMap = new Map<string, { sender: string; body: string }[]>(); const historyMap = createHistoryMapWithTwoEntries();
historyMap.set("group", [
{ sender: "A", body: "one" },
{ sender: "B", body: "two" },
]);
const result = buildPendingHistoryContextFromMap({ const result = buildPendingHistoryContextFromMap({
historyMap, historyMap,

View File

@ -17,6 +17,23 @@ type CarouselColumn = messagingApi.CarouselColumn;
type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate; type ImageCarouselTemplate = messagingApi.ImageCarouselTemplate;
type ImageCarouselColumn = messagingApi.ImageCarouselColumn; 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) * Create a confirm template (yes/no style dialog)
*/ */
@ -293,16 +310,9 @@ export function buildTemplateMessageFromPayload(
} }
case "buttons": { case "buttons": {
const actions: Action[] = payload.actions.slice(0, 4).map((action) => { const actions: Action[] = payload.actions
if (action.type === "uri" && action.uri) { .slice(0, 4)
return uriAction(action.label, action.uri); .map((action) => buildTemplatePayloadAction(action));
}
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);
});
return createButtonTemplate(payload.title, payload.text, actions, { return createButtonTemplate(payload.title, payload.text, actions, {
thumbnailImageUrl: payload.thumbnailImageUrl, thumbnailImageUrl: payload.thumbnailImageUrl,
@ -312,15 +322,9 @@ export function buildTemplateMessageFromPayload(
case "carousel": { case "carousel": {
const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => { const columns: CarouselColumn[] = payload.columns.slice(0, 10).map((col) => {
const colActions: Action[] = col.actions.slice(0, 3).map((action) => { const colActions: Action[] = col.actions
if (action.type === "uri" && action.uri) { .slice(0, 3)
return uriAction(action.label, action.uri); .map((action) => buildTemplatePayloadAction(action));
}
if (action.type === "postback" && action.data) {
return postbackAction(action.label, action.data, action.label);
}
return messageAction(action.label, action.data ?? action.label);
});
return createCarouselColumn({ return createCarouselColumn({
title: col.title, title: col.title,

View File

@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { markdownToSignalTextChunks } from "./format.js"; 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", () => { describe("splitSignalFormattedText", () => {
// We test the internal chunking behavior via markdownToSignalTextChunks with // We test the internal chunking behavior via markdownToSignalTextChunks with
// pre-rendered SignalFormattedText. The helper is not exported, so we test // pre-rendered SignalFormattedText. The helper is not exported, so we test
@ -145,13 +155,7 @@ describe("splitSignalFormattedText", () => {
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);
// Verify all style ranges are valid within their respective chunks // Verify all style ranges are valid within their respective chunks
for (const chunk of chunks) { expectChunkStyleRangesInBounds(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);
}
}
// Collect all styles across chunks // Collect all styles across chunks
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style)); const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));
@ -330,13 +334,7 @@ describe("markdownToSignalTextChunks", () => {
expect(chunks.length).toBeGreaterThan(1); expect(chunks.length).toBeGreaterThan(1);
// All style ranges should be valid within their chunks // All style ranges should be valid within their chunks
for (const chunk of chunks) { expectChunkStyleRangesInBounds(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);
}
}
// Verify styles exist somewhere // Verify styles exist somewhere
const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style)); const allStyles = chunks.flatMap((c) => c.styles.map((s) => s.style));

View File

@ -1,3 +1,4 @@
import crypto from "node:crypto";
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { monitorWebChannel } from "./auto-reply.js"; import { monitorWebChannel } from "./auto-reply.js";
@ -63,21 +64,106 @@ describe("web auto-reply", () => {
}; };
} }
it("honors mediaMaxMb from config", async () => { async function withMediaCap<T>(mediaMaxMb: number, run: () => Promise<T>): Promise<T> {
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } })); setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb } } }));
const sendMedia = vi.fn(); try {
const { reply, dispatch } = await setupSingleInboundMessage({ return await run();
resolverValue: { } finally {
text: "hi", resetLoadConfigMock();
mediaUrl: "https://example.com/big.png", }
}, }
sendMedia,
});
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({ const bigPng = await sharp({
create: { create: {
width: 900, width: 1200,
height: 900, height: 1200,
channels: 3, channels: 3,
background: { r: 0, g: 0, b: 255 }, background: { r: 0, g: 0, b: 255 },
}, },
@ -85,25 +171,12 @@ describe("web auto-reply", () => {
.png({ compressionLevel: 0 }) .png({ compressionLevel: 0 })
.toBuffer(); .toBuffer();
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024); expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
await expectCompressedImageWithinCap({
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ mediaUrl: "https://example.com/big.png",
ok: true, mime: "image/png",
body: true, image: bigPng,
arrayBuffer: async () => messageId: "msg1",
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();
}); });
it("falls back to text when media is unsupported", async () => { it("falls back to text when media is unsupported", async () => {
const sendMedia = vi.fn(); const sendMedia = vi.fn();