import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { sendHandlers } from "./send.js"; import type { GatewayRequestContext } from "./types.js"; const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(), appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })), recordSessionMetaFromInbound: vi.fn(async () => ({ ok: true })), resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), resolveMessageChannelSelection: vi.fn(), sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), getChannelPlugin: vi.fn(), loadOpenClawPlugins: vi.fn(), })); vi.mock("../../config/config.js", async () => { const actual = await vi.importActual("../../config/config.js"); return { ...actual, loadConfig: () => ({}), }; }); vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: mocks.getChannelPlugin, normalizeChannelId: (value: string) => (value === "webchat" ? null : value), })); const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace"; function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string { if (typeof params.sessionKey === "string") { const match = params.sessionKey.match(/^agent:([^:]+)/i); if (match?.[1]) { return match[1]; } } return "main"; } function passthroughPluginAutoEnable(config: unknown) { return { config, changes: [] as unknown[] }; } vi.mock("../../agents/agent-scope.js", () => ({ resolveSessionAgentId: ({ sessionKey, }: { sessionKey?: string; config?: unknown; agentId?: string; }) => resolveAgentIdFromSessionKeyForTests({ sessionKey }), resolveDefaultAgentId: () => "main", resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE, })); vi.mock("../../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config), })); vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins: mocks.loadOpenClawPlugins, })); vi.mock("../../infra/outbound/targets.js", () => ({ resolveOutboundTarget: mocks.resolveOutboundTarget, })); vi.mock("../../infra/outbound/channel-selection.js", () => ({ resolveMessageChannelSelection: mocks.resolveMessageChannelSelection, })); vi.mock("../../infra/outbound/deliver.js", () => ({ deliverOutboundPayloads: mocks.deliverOutboundPayloads, })); vi.mock("../../config/sessions.js", async () => { const actual = await vi.importActual( "../../config/sessions.js", ); return { ...actual, appendAssistantMessageToSessionTranscript: mocks.appendAssistantMessageToSessionTranscript, recordSessionMetaFromInbound: mocks.recordSessionMetaFromInbound, }; }); const makeContext = (): GatewayRequestContext => ({ dedupe: new Map(), }) as unknown as GatewayRequestContext; async function runSend(params: Record) { const respond = vi.fn(); await sendHandlers.send({ params: params as never, respond, context: makeContext(), req: { type: "req", id: "1", method: "send" }, client: null, isWebchatConnect: () => false, }); return { respond }; } async function runPoll(params: Record) { const respond = vi.fn(); await sendHandlers.poll({ params: params as never, respond, context: makeContext(), req: { type: "req", id: "1", method: "poll" }, client: null, isWebchatConnect: () => false, }); return { respond }; } function expectDeliverySessionMirror(params: { agentId: string; sessionKey: string }) { expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ session: expect.objectContaining({ agentId: params.agentId, key: params.sessionKey, }), mirror: expect.objectContaining({ sessionKey: params.sessionKey, agentId: params.agentId, }), }), ); } function mockDeliverySuccess(messageId: string) { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId, channel: "slack" }]); } describe("gateway send mirroring", () => { let registrySeq = 0; beforeEach(() => { vi.clearAllMocks(); registrySeq += 1; setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`); mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" }); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "slack", configured: ["slack"], }); mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" }); mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } }); }); it("accepts media-only sends without message", async () => { mockDeliverySuccess("m-media"); const { respond } = await runSend({ to: "channel:C1", mediaUrl: "https://example.com/a.png", channel: "slack", idempotencyKey: "idem-media-only", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ payloads: [{ text: "", mediaUrl: "https://example.com/a.png", mediaUrls: undefined }], }), ); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ messageId: "m-media" }), undefined, expect.objectContaining({ channel: "slack" }), ); }); it("rejects empty sends when neither text nor media is present", async () => { const { respond } = await runSend({ to: "channel:C1", message: " ", channel: "slack", idempotencyKey: "idem-empty", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("text or media is required"), }), ); }); it("returns actionable guidance when channel is internal webchat", async () => { const { respond } = await runSend({ to: "x", message: "hi", channel: "webchat", idempotencyKey: "idem-webchat", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("unsupported channel: webchat"), }), ); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("Use `chat.send`"), }), ); }); it("auto-picks the single configured channel for send", async () => { mockDeliverySuccess("m-single-send"); const { respond } = await runSend({ to: "x", message: "hi", idempotencyKey: "idem-missing-channel", }); expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled(); expect(mocks.deliverOutboundPayloads).toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ messageId: "m-single-send" }), undefined, expect.objectContaining({ channel: "slack" }), ); }); it("returns invalid request when send channel selection is ambiguous", async () => { mocks.resolveMessageChannelSelection.mockRejectedValueOnce( new Error("Channel is required when multiple channels are configured: telegram, slack"), ); const { respond } = await runSend({ to: "x", message: "hi", idempotencyKey: "idem-missing-channel-ambiguous", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("Channel is required"), }), ); }); it("auto-picks the single configured channel for poll", async () => { const { respond } = await runPoll({ to: "x", question: "Q?", options: ["A", "B"], idempotencyKey: "idem-poll-missing-channel", }); expect(mocks.resolveMessageChannelSelection).toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith(true, expect.any(Object), undefined, { channel: "slack", }); }); it("returns invalid request when poll channel selection is ambiguous", async () => { mocks.resolveMessageChannelSelection.mockRejectedValueOnce( new Error("Channel is required when multiple channels are configured: telegram, slack"), ); const { respond } = await runPoll({ to: "x", question: "Q?", options: ["A", "B"], idempotencyKey: "idem-poll-missing-channel-ambiguous", }); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("Channel is required"), }), ); }); it("does not mirror when delivery returns no results", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([]); await runSend({ to: "channel:C1", message: "hi", channel: "slack", idempotencyKey: "idem-1", sessionKey: "agent:main:main", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ mirror: expect.objectContaining({ sessionKey: "agent:main:main", }), }), ); }); it("mirrors media filenames when delivery succeeds", async () => { mockDeliverySuccess("m1"); await runSend({ to: "channel:C1", message: "caption", mediaUrl: "https://example.com/files/report.pdf?sig=1", channel: "slack", idempotencyKey: "idem-2", sessionKey: "agent:main:main", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ mirror: expect.objectContaining({ sessionKey: "agent:main:main", text: "caption", mediaUrls: ["https://example.com/files/report.pdf?sig=1"], idempotencyKey: "idem-2", }), }), ); }); it("mirrors MEDIA tags as attachments", async () => { mockDeliverySuccess("m2"); await runSend({ to: "channel:C1", message: "Here\nMEDIA:https://example.com/image.png", channel: "slack", idempotencyKey: "idem-3", sessionKey: "agent:main:main", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ mirror: expect.objectContaining({ sessionKey: "agent:main:main", text: "Here", mediaUrls: ["https://example.com/image.png"], }), }), ); }); it("lowercases provided session keys for mirroring", async () => { mockDeliverySuccess("m-lower"); await runSend({ to: "channel:C1", message: "hi", channel: "slack", idempotencyKey: "idem-lower", sessionKey: "agent:main:slack:channel:C123", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ mirror: expect.objectContaining({ sessionKey: "agent:main:slack:channel:c123", }), }), ); }); it("derives a target session key when none is provided", async () => { mockDeliverySuccess("m3"); await runSend({ to: "channel:C1", message: "hello", channel: "slack", idempotencyKey: "idem-4", }); expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled(); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ mirror: expect.objectContaining({ sessionKey: "agent:main:slack:channel:resolved", agentId: "main", }), }), ); }); it("uses explicit agentId for delivery when sessionKey is not provided", async () => { mockDeliverySuccess("m-agent"); await runSend({ to: "channel:C1", message: "hello", channel: "slack", agentId: "work", idempotencyKey: "idem-agent-explicit", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ session: expect.objectContaining({ agentId: "work", key: "agent:work:slack:channel:resolved", }), mirror: expect.objectContaining({ sessionKey: "agent:work:slack:channel:resolved", agentId: "work", }), }), ); }); it("uses sessionKey agentId when explicit agentId is omitted", async () => { mockDeliverySuccess("m-session-agent"); await runSend({ to: "channel:C1", message: "hello", channel: "slack", sessionKey: "agent:work:slack:channel:c1", idempotencyKey: "idem-session-agent", }); expectDeliverySessionMirror({ agentId: "work", sessionKey: "agent:work:slack:channel:c1", }); }); it("prefers explicit agentId over sessionKey agent for delivery and mirror", async () => { mockDeliverySuccess("m-agent-precedence"); await runSend({ to: "channel:C1", message: "hello", channel: "slack", agentId: "work", sessionKey: "agent:main:slack:channel:c1", idempotencyKey: "idem-agent-precedence", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ session: expect.objectContaining({ agentId: "work", key: "agent:main:slack:channel:c1", }), mirror: expect.objectContaining({ sessionKey: "agent:main:slack:channel:c1", agentId: "work", }), }), ); }); it("ignores blank explicit agentId and falls back to sessionKey agent", async () => { mockDeliverySuccess("m-agent-blank"); await runSend({ to: "channel:C1", message: "hello", channel: "slack", agentId: " ", sessionKey: "agent:work:slack:channel:c1", idempotencyKey: "idem-agent-blank", }); expectDeliverySessionMirror({ agentId: "work", sessionKey: "agent:work:slack:channel:c1", }); }); it("forwards threadId to outbound delivery when provided", async () => { mockDeliverySuccess("m-thread"); await runSend({ to: "channel:C1", message: "hi", channel: "slack", threadId: "1710000000.9999", idempotencyKey: "idem-thread", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ threadId: "1710000000.9999", }), ); }); it("returns invalid request when outbound target resolution fails", async () => { vi.mocked(resolveOutboundTarget).mockReturnValue({ ok: false, error: new Error("target not found"), }); const { respond } = await runSend({ to: "channel:C1", message: "hi", channel: "slack", idempotencyKey: "idem-target-fail", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("target not found"), }), expect.objectContaining({ channel: "slack", }), ); }); it("recovers cold plugin resolution for telegram threaded sends", async () => { mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" }); mocks.deliverOutboundPayloads.mockResolvedValue([ { messageId: "m-telegram", channel: "telegram" }, ]); const telegramPlugin = { outbound: { sendPoll: mocks.sendPoll } }; mocks.getChannelPlugin .mockReturnValueOnce(undefined) .mockReturnValueOnce(telegramPlugin) .mockReturnValue(telegramPlugin); const { respond } = await runSend({ to: "123", message: "forum completion", channel: "telegram", threadId: "42", idempotencyKey: "idem-cold-telegram-thread", }); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "123", threadId: "42", }), ); expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ messageId: "m-telegram" }), undefined, expect.objectContaining({ channel: "telegram" }), ); }); });