diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.context.test.ts similarity index 62% rename from src/infra/outbound/message-action-runner.test.ts rename to src/infra/outbound/message-action-runner.context.test.ts index 3858bae84..185ff2bf6 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.context.test.ts @@ -111,8 +111,9 @@ describe("runMessageAction context isolation", () => { setActivePluginRegistry(createTestRegistry([])); }); - it("allows send when target matches current channel", async () => { - const result = await runDrySend({ + it.each([ + { + name: "allows send when target matches current channel", cfg: slackConfig, actionParams: { channel: "slack", @@ -120,39 +121,27 @@ describe("runMessageAction context isolation", () => { message: "hi", }, toolContext: { currentChannelId: "C12345678" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("accepts legacy to parameter for send", async () => { - const result = await runDrySend({ + }, + { + name: "accepts legacy to parameter for send", cfg: slackConfig, actionParams: { channel: "slack", to: "#C12345678", message: "hi", }, - }); - - expect(result.kind).toBe("send"); - }); - - it("defaults to current channel when target is omitted", async () => { - const result = await runDrySend({ + }, + { + name: "defaults to current channel when target is omitted", cfg: slackConfig, actionParams: { channel: "slack", message: "hi", }, toolContext: { currentChannelId: "C12345678" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("allows media-only send when target matches current channel", async () => { - const result = await runDrySend({ + }, + { + name: "allows media-only send when target matches current channel", cfg: slackConfig, actionParams: { channel: "slack", @@ -160,6 +149,25 @@ describe("runMessageAction context isolation", () => { media: "https://example.com/note.ogg", }, toolContext: { currentChannelId: "C12345678" }, + }, + { + name: "allows send when poll booleans are explicitly false", + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "#C12345678", + message: "hi", + pollMulti: false, + pollAnonymous: false, + pollPublic: false, + }, + toolContext: { currentChannelId: "C12345678" }, + }, + ])("$name", async ({ cfg, actionParams, toolContext }) => { + const result = await runDrySend({ + cfg, + actionParams, + ...(toolContext ? { toolContext } : {}), }); expect(result.kind).toBe("send"); @@ -178,144 +186,111 @@ describe("runMessageAction context isolation", () => { ).rejects.toThrow(/message required/i); }); - it("rejects send actions that include poll creation params", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - message: "hi", - pollQuestion: "Ready?", - pollOption: ["Yes", "No"], - }, - toolContext: { currentChannelId: "C12345678" }, - }), - ).rejects.toThrow(/use action "poll" instead of "send"/i); - }); - - it("rejects send actions that include string-encoded poll params", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - message: "hi", - pollDurationSeconds: "60", - pollPublic: "true", - }, - toolContext: { currentChannelId: "C12345678" }, - }), - ).rejects.toThrow(/use action "poll" instead of "send"/i); - }); - - it("rejects send actions that include snake_case poll params", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "slack", - target: "#C12345678", - message: "hi", - poll_question: "Ready?", - poll_option: ["Yes", "No"], - poll_public: "true", - }, - toolContext: { currentChannelId: "C12345678" }, - }), - ).rejects.toThrow(/use action "poll" instead of "send"/i); - }); - - it("allows send when poll booleans are explicitly false", async () => { - const result = await runDrySend({ - cfg: slackConfig, + it.each([ + { + name: "structured poll params", actionParams: { channel: "slack", target: "#C12345678", message: "hi", - pollMulti: false, - pollAnonymous: false, - pollPublic: false, + pollQuestion: "Ready?", + pollOption: ["Yes", "No"], }, - toolContext: { currentChannelId: "C12345678" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("blocks send when target differs from current channel", async () => { - const result = await runDrySend({ - cfg: slackConfig, + }, + { + name: "string-encoded poll params", actionParams: { channel: "slack", - target: "channel:C99999999", + target: "#C12345678", message: "hi", + pollDurationSeconds: "60", + pollPublic: "true", }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }); - - expect(result.kind).toBe("send"); - }); - - it("blocks thread-reply when channelId differs from current channel", async () => { - const result = await runDryAction({ - cfg: slackConfig, - action: "thread-reply", + }, + { + name: "snake_case poll params", actionParams: { channel: "slack", - target: "C99999999", + target: "#C12345678", message: "hi", + poll_question: "Ready?", + poll_option: ["Yes", "No"], + poll_public: "true", }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }); - - expect(result.kind).toBe("action"); + }, + ])("rejects send actions that include $name", async ({ actionParams }) => { + await expect( + runDrySend({ + cfg: slackConfig, + actionParams, + toolContext: { currentChannelId: "C12345678" }, + }), + ).rejects.toThrow(/use action "poll" instead of "send"/i); }); it.each([ { - name: "whatsapp", + name: "send when target differs from current slack channel", + run: () => + runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "slack", + target: "channel:C99999999", + message: "hi", + }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + expectedKind: "send", + }, + { + name: "thread-reply when channelId differs from current slack channel", + run: () => + runDryAction({ + cfg: slackConfig, + action: "thread-reply", + actionParams: { + channel: "slack", + target: "C99999999", + message: "hi", + }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }), + expectedKind: "action", + }, + ])("blocks cross-context UI handoff for $name", async ({ run, expectedKind }) => { + const result = await run(); + expect(result.kind).toBe(expectedKind); + }); + + it.each([ + { + name: "whatsapp match", channel: "whatsapp", target: "123@g.us", currentChannelId: "123@g.us", }, { - name: "imessage", + name: "imessage match", channel: "imessage", target: "imessage:+15551234567", currentChannelId: "imessage:+15551234567", }, - ] as const)("allows $name send when target matches current context", async (testCase) => { - const result = await runDrySend({ - cfg: whatsappConfig, - actionParams: { - channel: testCase.channel, - target: testCase.target, - message: "hi", - }, - toolContext: { currentChannelId: testCase.currentChannelId }, - }); - - expect(result.kind).toBe("send"); - }); - - it.each([ { - name: "whatsapp", + name: "whatsapp mismatch", channel: "whatsapp", target: "456@g.us", currentChannelId: "123@g.us", currentChannelProvider: "whatsapp", }, { - name: "imessage", + name: "imessage mismatch", channel: "imessage", target: "imessage:+15551230000", currentChannelId: "imessage:+15551234567", currentChannelProvider: "imessage", }, - ] as const)("blocks $name send when target differs from current context", async (testCase) => { + ] as const)("$name", async (testCase) => { const result = await runDrySend({ cfg: whatsappConfig, actionParams: { @@ -325,106 +300,115 @@ describe("runMessageAction context isolation", () => { }, toolContext: { currentChannelId: testCase.currentChannelId, - currentChannelProvider: testCase.currentChannelProvider, + ...(testCase.currentChannelProvider + ? { currentChannelProvider: testCase.currentChannelProvider } + : {}), }, }); expect(result.kind).toBe("send"); }); - it("infers channel + target from tool context when missing", async () => { - const multiConfig = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", + it.each([ + { + name: "infers channel + target from tool context when missing", + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + }, + telegram: { + token: "tg-test", + }, }, - telegram: { - token: "tg-test", - }, - }, - } as OpenClawConfig; - - const result = await runDrySend({ - cfg: multiConfig, + } as OpenClawConfig, + action: "send" as const, actionParams: { message: "hi", }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }); - - expect(result.kind).toBe("send"); - expect(result.channel).toBe("slack"); - }); - - it("falls back to tool-context provider when channel param is an id", async () => { - const result = await runDrySend({ + expectedKind: "send", + expectedChannel: "slack", + }, + { + name: "falls back to tool-context provider when channel param is an id", cfg: slackConfig, + action: "send" as const, actionParams: { channel: "C12345678", target: "#C12345678", message: "hi", }, toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }); - - expect(result.kind).toBe("send"); - expect(result.channel).toBe("slack"); - }); - - it("falls back to tool-context provider for broadcast channel ids", async () => { - const result = await runDryAction({ + expectedKind: "send", + expectedChannel: "slack", + }, + { + name: "falls back to tool-context provider for broadcast channel ids", cfg: slackConfig, - action: "broadcast", + action: "broadcast" as const, actionParams: { targets: ["channel:C12345678"], channel: "C12345678", message: "hi", }, toolContext: { currentChannelProvider: "slack" }, + expectedKind: "broadcast", + expectedChannel: "slack", + }, + ])("$name", async ({ cfg, action, actionParams, toolContext, expectedKind, expectedChannel }) => { + const result = await runDryAction({ + cfg, + action, + actionParams, + toolContext, }); - expect(result.kind).toBe("broadcast"); - expect(result.channel).toBe("slack"); + expect(result.kind).toBe(expectedKind); + expect(result.channel).toBe(expectedChannel); }); - it("blocks cross-provider sends by default", async () => { - await expect( - runDrySend({ - cfg: slackConfig, - actionParams: { - channel: "telegram", - target: "@opsbot", - message: "hi", - }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, - }), - ).rejects.toThrow(/Cross-context messaging denied/); - }); - - it("blocks same-provider cross-context when disabled", async () => { - const cfg = { - ...slackConfig, - tools: { - message: { - crossContext: { - allowWithinProvider: false, + it.each([ + { + name: "blocks cross-provider sends by default", + cfg: slackConfig, + actionParams: { + channel: "telegram", + target: "@opsbot", + message: "hi", + }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + message: /Cross-context messaging denied/, + }, + { + name: "blocks same-provider cross-context when disabled", + cfg: { + ...slackConfig, + tools: { + message: { + crossContext: { + allowWithinProvider: false, + }, }, }, + } as OpenClawConfig, + actionParams: { + channel: "slack", + target: "channel:C99999999", + message: "hi", }, - } as OpenClawConfig; - + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + message: /Cross-context messaging denied/, + }, + ])("$name", async ({ cfg, actionParams, toolContext, message }) => { await expect( runDrySend({ cfg, - actionParams: { - channel: "slack", - target: "channel:C99999999", - message: "hi", - }, - toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + actionParams, + toolContext, }), - ).rejects.toThrow(/Cross-context messaging denied/); + ).rejects.toThrow(message); }); it.each([