test: dedupe auto-reply web and signal flows
This commit is contained in:
parent
ad1072842e
commit
24ea941e28
@ -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(
|
||||||
|
|||||||
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
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", () => {
|
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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user