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 <steipete@gmail.com>
This commit is contained in:
Marcus Castro
2026-02-13 00:55:20 -03:00
committed by GitHub
parent 8c920b9a18
commit 13bfd9da83
6 changed files with 127 additions and 0 deletions

View File

@@ -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

View File

@@ -153,8 +153,10 @@ describe("runMessageAction threading auto-injection", () => {
});
const call = mocks.executeSendAction.mock.calls[0]?.[0] as {
threadId?: string;
ctx?: { params?: Record<string, unknown> };
};
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<string, unknown> };
};
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<string, unknown> };
};
expect(call?.replyToId).toBe("777");
expect(call?.ctx?.params?.replyTo).toBe("777");
});
});

View File

@@ -891,6 +891,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
gifPlayback,
bestEffort: bestEffort ?? undefined,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId ?? undefined,
});
return {

View File

@@ -80,6 +80,62 @@ describe("sendMessage channel normalization", () => {
});
});
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<string, unknown>[] = [];
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<string, unknown>[] = [];
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<string, unknown>) => 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<string, unknown>);
return { channel: "mattermost", messageId: "m1" };
},
sendMedia: async () => ({ channel: "mattermost", messageId: "m2" }),
},
});
const createMSTeamsPlugin = (params: {
aliases?: string[];
outbound: ChannelOutboundAdapter;

View File

@@ -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<MessageSen
to: resolvedTarget.to,
accountId: params.accountId,
payloads: normalizedPayloads,
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
deps: params.deps,
bestEffort: params.bestEffort,

View File

@@ -68,6 +68,8 @@ export async function executeSendAction(params: {
mediaUrls?: string[];
gifPlayback?: boolean;
bestEffort?: boolean;
replyToId?: string;
threadId?: string | number;
}): Promise<{
handledBy: "plugin" | "core";
payload: unknown;
@@ -117,6 +119,8 @@ export async function executeSendAction(params: {
mediaUrls: params.mediaUrls,
channel: params.ctx.channel || undefined,
accountId: params.ctx.accountId ?? undefined,
replyToId: params.replyToId,
threadId: params.threadId,
gifPlayback: params.gifPlayback,
dryRun: params.ctx.dryRun,
bestEffort: params.bestEffort ?? undefined,