fix(mattermost): pass payload.replyToId as root_id for threaded replies (#27744)

Merged via squash.

Prepared head SHA: e029079872
Co-authored-by: hnykda <2741256+hnykda@users.noreply.github.com>
Co-authored-by: mukhtharcm <56378562+mukhtharcm@users.noreply.github.com>
Reviewed-by: @mukhtharcm
This commit is contained in:
Daniel Hnyk
2026-03-08 09:43:13 +01:00
committed by GitHub
parent 4db634964b
commit 9425209602
4 changed files with 84 additions and 2 deletions

View File

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

View File

@@ -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();
});
});

View File

@@ -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,
}),
});
}
}

View File

@@ -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:<id>]] 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 = {