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 {
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig,
makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome,
} from "./reply.triggers.trigger-handling.test-harness.js";
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js"));
getReplyFromConfig = await loadGetReplyFromConfig();
});
installTriggerHandlingE2eTestHooks();
function getLastExtraSystemPrompt() {
return getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
}
describe("group intro prompts", () => {
const groupParticipationNote =
"Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available. Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly.";
it("labels Discord groups using the surface metadata", async () => {
await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockRunEmbeddedPiAgentOk();
await getReplyFromConfig(
{
@@ -42,8 +42,7 @@ describe("group intro prompts", () => {
);
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
const extraSystemPrompt = getLastExtraSystemPrompt();
expect(extraSystemPrompt).toContain('"channel": "discord"');
expect(extraSystemPrompt).toContain(
`You are in the Discord group chat "Release Squad". Participants: Alice, Bob.`,
@@ -55,13 +54,7 @@ describe("group intro prompts", () => {
});
it("keeps WhatsApp labeling for WhatsApp group chats", async () => {
await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockRunEmbeddedPiAgentOk();
await getReplyFromConfig(
{
@@ -77,8 +70,7 @@ describe("group intro prompts", () => {
);
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
const extraSystemPrompt = getLastExtraSystemPrompt();
expect(extraSystemPrompt).toContain('"channel": "whatsapp"');
expect(extraSystemPrompt).toContain(`You are in the WhatsApp group chat "Ops".`);
expect(extraSystemPrompt).toContain(
@@ -91,13 +83,7 @@ describe("group intro prompts", () => {
});
it("labels Telegram groups using their own surface", async () => {
await withTempHome(async (home) => {
getRunEmbeddedPiAgentMock().mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
mockRunEmbeddedPiAgentOk();
await getReplyFromConfig(
{
@@ -113,8 +99,7 @@ describe("group intro prompts", () => {
);
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
const extraSystemPrompt =
getRunEmbeddedPiAgentMock().mock.calls.at(-1)?.[0]?.extraSystemPrompt ?? "";
const extraSystemPrompt = getLastExtraSystemPrompt();
expect(extraSystemPrompt).toContain('"channel": "telegram"');
expect(extraSystemPrompt).toContain(`You are in the Telegram group chat "Dev Chat".`);
expect(extraSystemPrompt).toContain(

View File

@@ -3,6 +3,7 @@ import {
createBlockReplyCollector,
getRunEmbeddedPiAgentMock,
installTriggerHandlingE2eTestHooks,
loadGetReplyFromConfig,
makeCfg,
mockRunEmbeddedPiAgentOk,
withTempHome,
@@ -10,11 +11,40 @@ import {
let getReplyFromConfig: typeof import("./reply.js").getReplyFromConfig;
beforeAll(async () => {
({ getReplyFromConfig } = await import("./reply.js"));
getReplyFromConfig = await loadGetReplyFromConfig();
});
installTriggerHandlingE2eTestHooks();
async function expectUnauthorizedCommandDropped(home: string, body: "/status" | "/whoami") {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: body,
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
expect(res).toBeUndefined();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
}
describe("trigger handling", () => {
it("handles inline /commands and strips it before the agent", async () => {
await withTempHome(async (home) => {
@@ -69,63 +99,13 @@ describe("trigger handling", () => {
it("drops /status for unauthorized senders", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: "/status",
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
expect(res).toBeUndefined();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
await expectUnauthorizedCommandDropped(home, "/status");
});
});
it("drops /whoami for unauthorized senders", async () => {
await withTempHome(async (home) => {
const runEmbeddedPiAgentMock = getRunEmbeddedPiAgentMock();
const baseCfg = makeCfg(home);
const cfg = {
...baseCfg,
channels: {
...baseCfg.channels,
whatsapp: {
allowFrom: ["+1000"],
},
},
};
const res = await getReplyFromConfig(
{
Body: "/whoami",
From: "+2001",
To: "+2000",
Provider: "whatsapp",
SenderE164: "+2001",
},
{},
cfg,
);
expect(res).toBeUndefined();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
await expectUnauthorizedCommandDropped(home, "/whoami");
});
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import sharp from "sharp";
import { describe, expect, it, vi } from "vitest";
import { monitorWebChannel } from "./auto-reply.js";
@@ -63,21 +64,106 @@ describe("web auto-reply", () => {
};
}
it("honors mediaMaxMb from config", async () => {
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb: 1 } } }));
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: {
text: "hi",
mediaUrl: "https://example.com/big.png",
},
sendMedia,
});
async function withMediaCap<T>(mediaMaxMb: number, run: () => Promise<T>): Promise<T> {
setLoadConfigMock(() => ({ agents: { defaults: { mediaMaxMb } } }));
try {
return await run();
} finally {
resetLoadConfigMock();
}
}
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
arrayBuffer: async () =>
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
headers: { get: () => mime },
status: 200,
} as unknown as Response);
}
async function expectCompressedImageWithinCap(params: {
mediaUrl: string;
mime: string;
image: Buffer;
messageId: string;
mediaMaxMb?: number;
}) {
await withMediaCap(params.mediaMaxMb ?? 1, async () => {
const sendMedia = vi.fn();
const { reply, dispatch } = await setupSingleInboundMessage({
resolverValue: { text: "hi", mediaUrl: params.mediaUrl },
sendMedia,
});
const fetchMock = mockFetchMediaBuffer(params.image, params.mime);
await dispatch(params.messageId);
const payload = getSingleImagePayload(sendMedia);
expect(payload.image.length).toBeLessThanOrEqual((params.mediaMaxMb ?? 1) * 1024 * 1024);
expect(payload.mimetype).toBe("image/jpeg");
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
});
}
it("compresses common formats to jpeg under the cap", { timeout: 45_000 }, async () => {
const formats = [
{
name: "png",
mime: "image/png",
make: (buf: Buffer, opts: { width: number; height: number }) =>
sharp(buf, {
raw: { width: opts.width, height: opts.height, channels: 3 },
})
.png({ compressionLevel: 0 })
.toBuffer(),
},
{
name: "jpeg",
mime: "image/jpeg",
make: (buf: Buffer, opts: { width: number; height: number }) =>
sharp(buf, {
raw: { width: opts.width, height: opts.height, channels: 3 },
})
.jpeg({ quality: 90 })
.toBuffer(),
},
{
name: "webp",
mime: "image/webp",
make: (buf: Buffer, opts: { width: number; height: number }) =>
sharp(buf, {
raw: { width: opts.width, height: opts.height, channels: 3 },
})
.webp({ quality: 100 })
.toBuffer(),
},
] as const;
const width = 1150;
const height = 1150;
const sharedRaw = crypto.randomBytes(width * height * 3);
for (const fmt of formats) {
const big = await fmt.make(sharedRaw, { width, height });
expect(big.length).toBeGreaterThan(1 * 1024 * 1024);
await expectCompressedImageWithinCap({
mediaUrl: `https://example.com/big.${fmt.name}`,
mime: fmt.mime,
image: big,
messageId: `msg-${fmt.name}`,
});
}
});
it("honors mediaMaxMb from config", async () => {
const bigPng = await sharp({
create: {
width: 900,
height: 900,
width: 1200,
height: 1200,
channels: 3,
background: { r: 0, g: 0, b: 255 },
},
@@ -85,25 +171,12 @@ describe("web auto-reply", () => {
.png({ compressionLevel: 0 })
.toBuffer();
expect(bigPng.length).toBeGreaterThan(1 * 1024 * 1024);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
body: true,
arrayBuffer: async () =>
bigPng.buffer.slice(bigPng.byteOffset, bigPng.byteOffset + bigPng.byteLength),
headers: { get: () => "image/png" },
status: 200,
} as unknown as Response);
await dispatch("msg-big");
const payload = getSingleImagePayload(sendMedia);
expect(payload.image.length).toBeLessThanOrEqual(1 * 1024 * 1024);
expect(payload.mimetype).toBe("image/jpeg");
expect(reply).not.toHaveBeenCalled();
fetchMock.mockRestore();
resetLoadConfigMock();
await expectCompressedImageWithinCap({
mediaUrl: "https://example.com/big.png",
mime: "image/png",
image: bigPng,
messageId: "msg1",
});
});
it("falls back to text when media is unsupported", async () => {
const sendMedia = vi.fn();