Skip to content

Commit 9425209

Browse files
hnykdamukhtharcm
andauthored
fix(mattermost): pass payload.replyToId as root_id for threaded replies (openclaw#27744)
Merged via squash. Prepared head SHA: e029079 Co-authored-by: hnykda <[email protected]> Co-authored-by: mukhtharcm <[email protected]> Reviewed-by: @mukhtharcm
1 parent 4db6349 commit 9425209

File tree

4 files changed

+84
-2
lines changed

4 files changed

+84
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
### Fixes
1010

1111
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
12+
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
1213

1314
## 2026.3.7
1415

extensions/mattermost/src/mattermost/monitor.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
33
import { resolveMattermostAccount } from "./accounts.js";
44
import {
55
evaluateMattermostMentionGate,
6+
resolveMattermostReplyRootId,
67
type MattermostMentionGateInput,
78
type MattermostRequireMentionResolverInput,
89
} from "./monitor.js";
@@ -107,3 +108,26 @@ describe("mattermost mention gating", () => {
107108
expect(decision.dropReason).toBe("missing-mention");
108109
});
109110
});
111+
112+
describe("resolveMattermostReplyRootId", () => {
113+
it("uses replyToId for top-level replies", () => {
114+
expect(
115+
resolveMattermostReplyRootId({
116+
replyToId: "inbound-post-123",
117+
}),
118+
).toBe("inbound-post-123");
119+
});
120+
121+
it("keeps the thread root when replying inside an existing thread", () => {
122+
expect(
123+
resolveMattermostReplyRootId({
124+
threadRootId: "thread-root-456",
125+
replyToId: "child-post-789",
126+
}),
127+
).toBe("thread-root-456");
128+
});
129+
130+
it("falls back to undefined when neither reply target is available", () => {
131+
expect(resolveMattermostReplyRootId({})).toBeUndefined();
132+
});
133+
});

extensions/mattermost/src/mattermost/monitor.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,17 @@ export function evaluateMattermostMentionGate(
271271
dropReason: null,
272272
};
273273
}
274+
275+
export function resolveMattermostReplyRootId(params: {
276+
threadRootId?: string;
277+
replyToId?: string;
278+
}): string | undefined {
279+
const threadRootId = params.threadRootId?.trim();
280+
if (threadRootId) {
281+
return threadRootId;
282+
}
283+
return params.replyToId?.trim() || undefined;
284+
}
274285
type MattermostMediaInfo = {
275286
path: string;
276287
contentType?: string;
@@ -1651,7 +1662,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
16511662
}
16521663
await sendMessageMattermost(to, chunk, {
16531664
accountId: account.accountId,
1654-
replyToId: threadRootId,
1665+
replyToId: resolveMattermostReplyRootId({
1666+
threadRootId,
1667+
replyToId: payload.replyToId,
1668+
}),
16551669
});
16561670
}
16571671
} else {
@@ -1662,7 +1676,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
16621676
await sendMessageMattermost(to, caption, {
16631677
accountId: account.accountId,
16641678
mediaUrl,
1665-
replyToId: threadRootId,
1679+
replyToId: resolveMattermostReplyRootId({
1680+
threadRootId,
1681+
replyToId: payload.replyToId,
1682+
}),
16661683
});
16671684
}
16681685
}

src/auto-reply/reply/reply-plumbing.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,46 @@ describe("applyReplyThreading auto-threading", () => {
230230
expect(result[0].replyToId).toBe("42");
231231
expect(result[0].replyToTag).toBe(true);
232232
});
233+
234+
it("resolves [[reply_to_current]] to currentMessageId when replyToMode is 'all'", () => {
235+
// Mattermost-style scenario: agent responds with [[reply_to_current]] and replyToMode
236+
// is "all". The tag should resolve to the inbound message id.
237+
const result = applyReplyThreading({
238+
payloads: [{ text: "[[reply_to_current]] some reply text" }],
239+
replyToMode: "all",
240+
currentMessageId: "mm-post-abc123",
241+
});
242+
243+
expect(result).toHaveLength(1);
244+
expect(result[0].replyToId).toBe("mm-post-abc123");
245+
expect(result[0].replyToTag).toBe(true);
246+
expect(result[0].text).toBe("some reply text");
247+
});
248+
249+
it("resolves [[reply_to:<id>]] to explicit id when replyToMode is 'all'", () => {
250+
const result = applyReplyThreading({
251+
payloads: [{ text: "[[reply_to:mm-post-xyz789]] threaded reply" }],
252+
replyToMode: "all",
253+
currentMessageId: "mm-post-abc123",
254+
});
255+
256+
expect(result).toHaveLength(1);
257+
expect(result[0].replyToId).toBe("mm-post-xyz789");
258+
expect(result[0].text).toBe("threaded reply");
259+
});
260+
261+
it("sets replyToId via implicit threading when replyToMode is 'all'", () => {
262+
// Even without explicit tags, replyToMode "all" should set replyToId
263+
// to currentMessageId for threading.
264+
const result = applyReplyThreading({
265+
payloads: [{ text: "hello" }],
266+
replyToMode: "all",
267+
currentMessageId: "mm-post-abc123",
268+
});
269+
270+
expect(result).toHaveLength(1);
271+
expect(result[0].replyToId).toBe("mm-post-abc123");
272+
});
233273
});
234274

235275
const baseRun: SubagentRunRecord = {

0 commit comments

Comments
 (0)