diff --git a/CHANGELOG.md b/CHANGELOG.md index 2902617108f..cc52f4a8273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai ### Fixes - 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. +- 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. ## 2026.3.7 diff --git a/extensions/mattermost/src/mattermost/monitor.test.ts b/extensions/mattermost/src/mattermost/monitor.test.ts index ab122948ebc..1bd871714c4 100644 --- a/extensions/mattermost/src/mattermost/monitor.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { resolveMattermostAccount } from "./accounts.js"; import { evaluateMattermostMentionGate, + resolveMattermostReplyRootId, type MattermostMentionGateInput, type MattermostRequireMentionResolverInput, } from "./monitor.js"; @@ -107,3 +108,26 @@ describe("mattermost mention gating", () => { expect(decision.dropReason).toBe("missing-mention"); }); }); + +describe("resolveMattermostReplyRootId", () => { + it("uses replyToId for top-level replies", () => { + expect( + resolveMattermostReplyRootId({ + replyToId: "inbound-post-123", + }), + ).toBe("inbound-post-123"); + }); + + it("keeps the thread root when replying inside an existing thread", () => { + expect( + resolveMattermostReplyRootId({ + threadRootId: "thread-root-456", + replyToId: "child-post-789", + }), + ).toBe("thread-root-456"); + }); + + it("falls back to undefined when neither reply target is available", () => { + expect(resolveMattermostReplyRootId({})).toBeUndefined(); + }); +}); diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index a7b692c2c74..d6f4bd9543c 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -271,6 +271,17 @@ export function evaluateMattermostMentionGate( dropReason: null, }; } + +export function resolveMattermostReplyRootId(params: { + threadRootId?: string; + replyToId?: string; +}): string | undefined { + const threadRootId = params.threadRootId?.trim(); + if (threadRootId) { + return threadRootId; + } + return params.replyToId?.trim() || undefined; +} type MattermostMediaInfo = { path: string; contentType?: string; @@ -1651,7 +1662,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} } await sendMessageMattermost(to, chunk, { accountId: account.accountId, - replyToId: threadRootId, + replyToId: resolveMattermostReplyRootId({ + threadRootId, + replyToId: payload.replyToId, + }), }); } } else { @@ -1662,7 +1676,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} await sendMessageMattermost(to, caption, { accountId: account.accountId, mediaUrl, - replyToId: threadRootId, + replyToId: resolveMattermostReplyRootId({ + threadRootId, + replyToId: payload.replyToId, + }), }); } } diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 6d8a3d53232..6e039333c58 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -230,6 +230,46 @@ describe("applyReplyThreading auto-threading", () => { expect(result[0].replyToId).toBe("42"); expect(result[0].replyToTag).toBe(true); }); + + it("resolves [[reply_to_current]] to currentMessageId when replyToMode is 'all'", () => { + // Mattermost-style scenario: agent responds with [[reply_to_current]] and replyToMode + // is "all". The tag should resolve to the inbound message id. + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]] some reply text" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-abc123"); + expect(result[0].replyToTag).toBe(true); + expect(result[0].text).toBe("some reply text"); + }); + + it("resolves [[reply_to:]] to explicit id when replyToMode is 'all'", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to:mm-post-xyz789]] threaded reply" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-xyz789"); + expect(result[0].text).toBe("threaded reply"); + }); + + it("sets replyToId via implicit threading when replyToMode is 'all'", () => { + // Even without explicit tags, replyToMode "all" should set replyToId + // to currentMessageId for threading. + const result = applyReplyThreading({ + payloads: [{ text: "hello" }], + replyToMode: "all", + currentMessageId: "mm-post-abc123", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("mm-post-abc123"); + }); }); const baseRun: SubagentRunRecord = {