fix(mattermost): prevent duplicate messages when block streaming + threading are active (#41362)

* fix(mattermost): prevent duplicate messages when block streaming + threading are active

Remove replyToId from createBlockReplyPayloadKey so identical content is
deduplicated regardless of threading target. Add explicit threading dock
to the Mattermost plugin with resolveReplyToMode reading from config
(default "all"), and add replyToMode to the Mattermost config schema.

Fixes #41219

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(mattermost): address PR review — per-account replyToMode and test clarity

Read replyToMode from the merged per-account config via
resolveMattermostAccount so account-level overrides are honored in
multi-account setups. Add replyToMode to MattermostAccountConfig type.
Rename misleading test to clarify it exercises shouldDropFinalPayloads
short-circuit, not payload key dedup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Replies: keep block-pipeline reply targets distinct

* Tests: cover block reply target-aware dedupe

* Update CHANGELOG.md

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Mathias Nagler
2026-03-12 08:15:17 +01:00
committed by GitHub
parent 241e8cc553
commit e8a162d3d8
10 changed files with 182 additions and 6 deletions

View File

@@ -270,6 +270,16 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
threading: {
resolveReplyToMode: ({ cfg, accountId }) => {
const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" });
const mode = account.config.replyToMode;
if (mode === "off" || mode === "first") {
return mode;
}
return "all";
},
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {

View File

@@ -43,6 +43,7 @@ const MattermostAccountSchemaBase = z
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
actions: z
.object({

View File

@@ -109,6 +109,29 @@ describe("mattermost mention gating", () => {
});
});
describe("resolveMattermostReplyRootId with block streaming payloads", () => {
it("uses threadRootId for block-streamed payloads with replyToId", () => {
// When block streaming sends a payload with replyToId from the threading
// mode, the deliver callback should still use the existing threadRootId.
expect(
resolveMattermostReplyRootId({
threadRootId: "thread-root-1",
replyToId: "streamed-reply-id",
}),
).toBe("thread-root-1");
});
it("falls back to payload replyToId when no threadRootId in block streaming", () => {
// Top-level channel message: no threadRootId, payload carries the
// inbound post id as replyToId from the "all" threading mode.
expect(
resolveMattermostReplyRootId({
replyToId: "inbound-post-for-threading",
}),
).toBe("inbound-post-for-threading");
});
});
describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(

View File

@@ -52,6 +52,8 @@ export type MattermostAccountConfig = {
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Control reply threading (off|first|all). Default: "all". */
replyToMode?: "off" | "first" | "all";
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Action toggles for this account. */