diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 96b3f940797..f93ff221b41 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -46,6 +46,19 @@ 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 }; +} + describe("gateway send mirroring", () => { beforeEach(() => { vi.clearAllMocks(); @@ -54,19 +67,11 @@ describe("gateway send mirroring", () => { it("accepts media-only sends without message", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-media", channel: "slack" }]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - mediaUrl: "https://example.com/a.png", - channel: "slack", - idempotencyKey: "idem-media-only", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + const { respond } = await runSend({ + to: "channel:C1", + mediaUrl: "https://example.com/a.png", + channel: "slack", + idempotencyKey: "idem-media-only", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( @@ -83,19 +88,11 @@ describe("gateway send mirroring", () => { }); it("rejects empty sends when neither text nor media is present", async () => { - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: " ", - channel: "slack", - idempotencyKey: "idem-empty", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + const { respond } = await runSend({ + to: "channel:C1", + message: " ", + channel: "slack", + idempotencyKey: "idem-empty", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); @@ -109,19 +106,11 @@ describe("gateway send mirroring", () => { }); it("returns actionable guidance when channel is internal webchat", async () => { - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "x", - message: "hi", - channel: "webchat", - idempotencyKey: "idem-webchat", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + const { respond } = await runSend({ + to: "x", + message: "hi", + channel: "webchat", + idempotencyKey: "idem-webchat", }); expect(mocks.deliverOutboundPayloads).not.toHaveBeenCalled(); @@ -144,20 +133,12 @@ describe("gateway send mirroring", () => { it("does not mirror when delivery returns no results", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: "hi", - channel: "slack", - idempotencyKey: "idem-1", - sessionKey: "agent:main:main", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + await runSend({ + to: "channel:C1", + message: "hi", + channel: "slack", + idempotencyKey: "idem-1", + sessionKey: "agent:main:main", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( @@ -172,21 +153,13 @@ describe("gateway send mirroring", () => { it("mirrors media filenames when delivery succeeds", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m1", channel: "slack" }]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: "caption", - mediaUrl: "https://example.com/files/report.pdf?sig=1", - channel: "slack", - idempotencyKey: "idem-2", - sessionKey: "agent:main:main", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + 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( @@ -203,20 +176,12 @@ describe("gateway send mirroring", () => { it("mirrors MEDIA tags as attachments", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m2", channel: "slack" }]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: "Here\nMEDIA:https://example.com/image.png", - channel: "slack", - idempotencyKey: "idem-3", - sessionKey: "agent:main:main", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + 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( @@ -233,20 +198,12 @@ describe("gateway send mirroring", () => { it("lowercases provided session keys for mirroring", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-lower", channel: "slack" }]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: "hi", - channel: "slack", - idempotencyKey: "idem-lower", - sessionKey: "agent:main:slack:channel:C123", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + await runSend({ + to: "channel:C1", + message: "hi", + channel: "slack", + idempotencyKey: "idem-lower", + sessionKey: "agent:main:slack:channel:C123", }); expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( @@ -261,19 +218,11 @@ describe("gateway send mirroring", () => { it("derives a target session key when none is provided", async () => { mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m3", channel: "slack" }]); - const respond = vi.fn(); - await sendHandlers.send({ - params: { - to: "channel:C1", - message: "hello", - channel: "slack", - idempotencyKey: "idem-4", - }, - respond, - context: makeContext(), - req: { type: "req", id: "1", method: "send" }, - client: null, - isWebchatConnect: () => false, + await runSend({ + to: "channel:C1", + message: "hello", + channel: "slack", + idempotencyKey: "idem-4", }); expect(mocks.recordSessionMetaFromInbound).toHaveBeenCalled(); diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index bc4d68c2d9b..418e833abda 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -49,6 +49,37 @@ const telegramConfig = { }, } as OpenClawConfig; +async function runThreadingAction(params: { + cfg: OpenClawConfig; + actionParams: Record; + toolContext?: Record; +}) { + await runMessageAction({ + cfg: params.cfg, + action: "send", + params: params.actionParams as never, + toolContext: params.toolContext as never, + agentId: "main", + }); + return mocks.executeSendAction.mock.calls[0]?.[0] as { + threadId?: string; + replyToId?: string; + ctx?: { agentId?: string; mirror?: { sessionKey?: string }; params?: Record }; + }; +} + +function mockHandledSendAction() { + mocks.executeSendAction.mockResolvedValue({ + handledBy: "plugin", + payload: {}, + }); +} + +const defaultTelegramToolContext = { + currentChannelId: "telegram:123", + currentThreadTs: "42", +} as const; + describe("runMessageAction threading auto-injection", () => { beforeEach(async () => { const { createPluginRuntime } = await import("../../plugins/runtime/index.js"); @@ -80,15 +111,11 @@ describe("runMessageAction threading auto-injection", () => { }); it("uses toolContext thread when auto-threading is active", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: slackConfig, - action: "send", - params: { + actionParams: { channel: "slack", target: "channel:C123", message: "hi", @@ -98,24 +125,18 @@ describe("runMessageAction threading auto-injection", () => { currentThreadTs: "111.222", replyToMode: "all", }, - agentId: "main", }); - const call = mocks.executeSendAction.mock.calls[0]?.[0]; expect(call?.ctx?.agentId).toBe("main"); expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222"); }); it("matches auto-threading when channel ids differ in case", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: slackConfig, - action: "send", - params: { + actionParams: { channel: "slack", target: "channel:c123", message: "hi", @@ -125,152 +146,92 @@ describe("runMessageAction threading auto-injection", () => { currentThreadTs: "333.444", replyToMode: "all", }, - agentId: "main", }); - const call = mocks.executeSendAction.mock.calls[0]?.[0]; expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444"); }); it("auto-injects telegram threadId from toolContext when omitted", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: telegramConfig, - action: "send", - params: { + actionParams: { channel: "telegram", target: "telegram:123", message: "hi", }, - toolContext: { - currentChannelId: "telegram:123", - currentThreadTs: "42", - }, - agentId: "main", + toolContext: defaultTelegramToolContext, }); - const call = mocks.executeSendAction.mock.calls[0]?.[0] as { - threadId?: string; - ctx?: { params?: Record }; - }; expect(call?.threadId).toBe("42"); expect(call?.ctx?.params?.threadId).toBe("42"); }); it("skips telegram auto-threading when target chat differs", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: telegramConfig, - action: "send", - params: { + actionParams: { channel: "telegram", target: "telegram:999", message: "hi", }, - toolContext: { - currentChannelId: "telegram:123", - currentThreadTs: "42", - }, - agentId: "main", + toolContext: defaultTelegramToolContext, }); - const call = mocks.executeSendAction.mock.calls[0]?.[0] as { - ctx?: { params?: Record }; - }; expect(call?.ctx?.params?.threadId).toBeUndefined(); }); it("matches telegram target with internal prefix variations", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: telegramConfig, - action: "send", - params: { + actionParams: { channel: "telegram", target: "telegram:group:123", message: "hi", }, - toolContext: { - currentChannelId: "telegram:123", - currentThreadTs: "42", - }, - agentId: "main", + toolContext: defaultTelegramToolContext, }); - const call = mocks.executeSendAction.mock.calls[0]?.[0] as { - ctx?: { params?: Record }; - }; expect(call?.ctx?.params?.threadId).toBe("42"); }); it("uses explicit telegram threadId when provided", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: telegramConfig, - action: "send", - params: { + actionParams: { channel: "telegram", target: "telegram:123", message: "hi", threadId: "999", }, - toolContext: { - currentChannelId: "telegram:123", - currentThreadTs: "42", - }, - agentId: "main", + toolContext: defaultTelegramToolContext, }); - const call = mocks.executeSendAction.mock.calls[0]?.[0] as { - threadId?: string; - ctx?: { params?: Record }; - }; expect(call?.threadId).toBe("999"); expect(call?.ctx?.params?.threadId).toBe("999"); }); it("threads explicit replyTo through executeSendAction", async () => { - mocks.executeSendAction.mockResolvedValue({ - handledBy: "plugin", - payload: {}, - }); + mockHandledSendAction(); - await runMessageAction({ + const call = await runThreadingAction({ cfg: telegramConfig, - action: "send", - params: { + actionParams: { channel: "telegram", target: "telegram:123", message: "hi", replyTo: "777", }, - toolContext: { - currentChannelId: "telegram:123", - currentThreadTs: "42", - }, - agentId: "main", + toolContext: defaultTelegramToolContext, }); - const call = mocks.executeSendAction.mock.calls[0]?.[0] as { - replyToId?: string; - ctx?: { params?: Record }; - }; expect(call?.replyToId).toBe("777"); expect(call?.ctx?.params?.replyTo).toBe("777"); });