diff --git a/CHANGELOG.md b/CHANGELOG.md index 2799f0d8c7a..73eb5a99673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Docs: https://docs.openclaw.ai - Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn. - CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev. - Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis. +- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda. ## 2026.3.8 diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts index 97314f5e13b..c3ff193896f 100644 --- a/extensions/mattermost/src/channel.test.ts +++ b/extensions/mattermost/src/channel.test.ts @@ -214,6 +214,57 @@ describe("mattermostPlugin", () => { ]); expect(result?.details).toEqual({}); }); + + it("maps replyTo to replyToId for send actions", async () => { + const cfg = createMattermostTestConfig(); + + await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "send", + params: { + to: "channel:CHAN1", + message: "hello", + replyTo: "post-root", + }, + cfg, + accountId: "default", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); + + it("falls back to trimmed replyTo when replyToId is blank", async () => { + const cfg = createMattermostTestConfig(); + + await mattermostPlugin.actions?.handleAction?.({ + channel: "mattermost", + action: "send", + params: { + to: "channel:CHAN1", + message: "hello", + replyToId: " ", + replyTo: " post-root ", + }, + cfg, + accountId: "default", + } as any); + + expect(sendMessageMattermostMock).toHaveBeenCalledWith( + "channel:CHAN1", + "hello", + expect.objectContaining({ + accountId: "default", + replyToId: "post-root", + }), + ); + }); }); describe("outbound", () => { diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8c0504c7a5c..4ff847ea30d 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -157,7 +157,9 @@ const mattermostMessageActions: ChannelMessageActionAdapter = { } const message = typeof params.message === "string" ? params.message : ""; - const replyToId = typeof params.replyToId === "string" ? params.replyToId : undefined; + // Match the shared runner semantics: trim empty reply IDs away before + // falling back from replyToId to replyTo on direct plugin calls. + const replyToId = readMattermostReplyToId(params); const resolvedAccountId = accountId || undefined; const mediaUrl = @@ -201,6 +203,18 @@ const meta = { quickstartAllowFrom: true, } as const; +function readMattermostReplyToId(params: Record): string | undefined { + const readNormalizedValue = (value: unknown) => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; + }; + + return readNormalizedValue(params.replyToId) ?? readNormalizedValue(params.replyTo); +} + function normalizeAllowEntry(entry: string): string { return entry .trim()