From 13bfd9da8350af4c4d32974782867a03bcfabdf5 Mon Sep 17 00:00:00 2001 From: Marcus Castro Date: Fri, 13 Feb 2026 00:55:20 -0300 Subject: [PATCH] fix: thread replyToId and threadId through message tool send action (#14948) * fix: thread replyToId and threadId through message tool send action * fix: omit replyToId/threadId from gateway send params * fix: add threading seam regression coverage (#14948) (thanks @mcaxtr) --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../message-action-runner.threading.test.ts | 34 ++++++++ src/infra/outbound/message-action-runner.ts | 2 + src/infra/outbound/message.test.ts | 82 +++++++++++++++++++ src/infra/outbound/message.ts | 4 + src/infra/outbound/outbound-send-service.ts | 4 + 6 files changed, 127 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acfeafd754c..4a354580de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug. - Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr. - Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad. +- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. ## 2026.2.12 diff --git a/src/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index 946f0db9615..c1b0122ec81 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -153,8 +153,10 @@ describe("runMessageAction threading auto-injection", () => { }); 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"); }); @@ -235,8 +237,40 @@ describe("runMessageAction threading auto-injection", () => { }); 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: {}, + }); + + await runMessageAction({ + cfg: telegramConfig, + action: "send", + params: { + channel: "telegram", + target: "telegram:123", + message: "hi", + replyTo: "777", + }, + toolContext: { + currentChannelId: "telegram:123", + currentThreadTs: "42", + }, + agentId: "main", + }); + + 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"); + }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 16d5029ec28..bf9c33265da 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -891,6 +891,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { }); }); +describe("sendMessage replyToId threading", () => { + beforeEach(async () => { + callGatewayMock.mockReset(); + vi.resetModules(); + await setRegistry(emptyRegistry); + }); + + afterEach(async () => { + await setRegistry(emptyRegistry); + }); + + it("passes replyToId through to the outbound adapter", async () => { + const { sendMessage } = await loadMessage(); + const capturedCtx: Record[] = []; + const plugin = createMattermostLikePlugin({ + onSendText: (ctx) => { + capturedCtx.push(ctx); + }, + }); + await setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }])); + + await sendMessage({ + cfg: {}, + to: "channel:town-square", + content: "thread reply", + channel: "mattermost", + replyToId: "post123", + }); + + expect(capturedCtx).toHaveLength(1); + expect(capturedCtx[0]?.replyToId).toBe("post123"); + }); + + it("passes threadId through to the outbound adapter", async () => { + const { sendMessage } = await loadMessage(); + const capturedCtx: Record[] = []; + const plugin = createMattermostLikePlugin({ + onSendText: (ctx) => { + capturedCtx.push(ctx); + }, + }); + await setRegistry(createTestRegistry([{ pluginId: "mattermost", source: "test", plugin }])); + + await sendMessage({ + cfg: {}, + to: "channel:town-square", + content: "topic reply", + channel: "mattermost", + threadId: "topic456", + }); + + expect(capturedCtx).toHaveLength(1); + expect(capturedCtx[0]?.threadId).toBe("topic456"); + }); +}); + describe("sendPoll channel normalization", () => { beforeEach(async () => { callGatewayMock.mockReset(); @@ -151,6 +207,32 @@ const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboun : {}), }); +const createMattermostLikePlugin = (opts: { + onSendText: (ctx: Record) => void; +}): ChannelPlugin => ({ + id: "mattermost", + meta: { + id: "mattermost", + label: "Mattermost", + selectionLabel: "Mattermost", + docsPath: "/channels/mattermost", + blurb: "Mattermost test stub.", + }, + capabilities: { chatTypes: ["direct", "channel"] }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + outbound: { + deliveryMode: "direct", + sendText: async (ctx) => { + opts.onSendText(ctx as unknown as Record); + return { channel: "mattermost", messageId: "m1" }; + }, + sendMedia: async () => ({ channel: "mattermost", messageId: "m2" }), + }, +}); + const createMSTeamsPlugin = (params: { aliases?: string[]; outbound: ChannelOutboundAdapter; diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 1efcf601deb..1f4390a4ac6 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -36,6 +36,8 @@ type MessageSendParams = { mediaUrls?: string[]; gifPlayback?: boolean; accountId?: string; + replyToId?: string; + threadId?: string | number; dryRun?: boolean; bestEffort?: boolean; deps?: OutboundSendDeps; @@ -165,6 +167,8 @@ export async function sendMessage(params: MessageSendParams): Promise