fix(mattermost): carry thread context to non-inbound reply paths#44283
Conversation
Greptile SummaryThis PR fixes Mattermost thread routing for non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) by closing three gaps: extracting
Confidence Score: 5/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: CHANGELOG.md
Line: 190
Comment:
**CHANGELOG entry describes the guard, not the primary fix**
The entry focuses on the defensive aspect ("keep cleared route threads cleared … instead of reviving stale `origin.threadId` metadata") but omits the user-visible primary change: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread instead of posting to the channel root.
A developer scanning the changelog to understand why their TUI replies were landing in the channel root would not find this entry. Consider rewording to lead with the positive fix, e.g.:
```suggestion
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: 498de0f |
2a3a6e2 to
e557f20
Compare
CI status22/23 checks pass. The 1 remaining failure ( The
This same failure is present on the last merged PR (#44274) before this one: https://github.com/openclaw/openclaw/actions/runs/23017167737/job/66843452718 The 6 skipped checks (macOS, iOS, Android, docs, release, labels) are expected for a code-only PR. All checks relevant to the changes in this PR pass:
|
|
@mukhtharcm — pinging you since you merged #29587 today. This PR is a direct follow-up: it fixes the cases that With Happy to iterate if you have questions or suggestions. |
|
@greptile-apps please re review |
c80b26b to
e2deb91
Compare
e2deb91 to
ed9247e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed9247ec58
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3279044b14
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
Addressed the Codex review ("Drop lastThreadId fallback"). Verified in Latest commit drops the |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 408a33d894
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
@greptile-apps pls re review |
|
I agree with this. The current CHANGELOG wording leads with the defensive guard, but the primary user-visible fix is that non-inbound reply paths now route back into the originating Mattermost thread instead of the channel root. Suggested wording:
|
Manual test results — 2026-03-13Tested on prod (v3.12) with Test setup
Results
Test 2 confirms the core fix: a The |
498de0f to
16ceeee
Compare
Fixes a bug where replies triggered from TUI/WebUI or by agent-initiated sends (tool callbacks, subagent responses, message tool) land in the Mattermost channel root instead of the originating thread. Root cause: three gaps in the outbound routing path for turns not directly triggered by an inbound Mattermost message: 1. dispatch-from-config.ts: sendPayloadAsync passed ctx.MessageThreadId, which is undefined for webchat/TUI turns. Now falls back to the session entry's deliveryContext.threadId (the lastThreadId stored when the session was first created from an inbound Mattermost message). 2. route-reply.ts: threadId was only forwarded as replyToId for Slack. Mattermost uses the same root_id mechanic, so the same fallback now applies to Mattermost too. 3. channel.ts (Mattermost outbound): sendText/sendMedia only consumed replyToId, ignoring threadId. Added threadId as a defense-in-depth fallback for any path that sets threadId but not replyToId. All three gaps in a single PR. Tests added for each fix path. Fixes openclaw#39759
lastThreadId is normalised from origin.threadId by loadSessionStore, so using it as a fallback here would re-acquire the same stale thread that deliveryContext.threadId was intentionally cleared from. Only deliveryContext.threadId is safe to use as the restored route thread. Also strengthens the regression test to include the normalised lastThreadId value that the real store produces, so the guard is verified against the actual production data shape. Addresses review feedback from chatgpt-codex-connector.
Do not recover route thread ids from the normalised session store in non-inbound reply paths. Store normalisation can fold origin.threadId back into lastThreadId/deliveryContext, which resurrects stale thread routing after delivery was intentionally cleared. Instead, restore thread context only from: - ctx.MessageThreadId (active inbound turn), or - the active thread-scoped session key (:thread: / :topic:) Also updates dispatch tests to verify that stale origin/store thread metadata cannot override a non-thread session key, while a thread-scoped session key still restores the correct route thread.
16ceeee to
2846a6c
Compare
|
Merged via squash.
Thanks @teconomix! |
Problem
When
replyToMode: "all"is active, replies to Mattermost threads only land in the correct thread when triggered by a direct inbound Mattermost message. Any other reply path — TUI/WebUI turns, tool call callbacks, subagent responses, or explicitmessagetool calls — ignores the thread context and posts to the channel root instead.Reported in #39759.
Root Cause
Three gaps in the outbound routing path for non-inbound-triggered deliveries:
Gap 1 —
dispatch-from-config.ts:sendPayloadAsyncpassedctx.MessageThreadIdtorouteReply. For TUI/WebUI turns the current context is a webchat surface, soctx.MessageThreadIdisundefined. The session entry'sdeliveryContext.threadId(set when the session was originally created from an inbound Mattermost message) was never consulted.Gap 2 —
route-reply.ts:The threadId→replyToId fallback was Slack-only:
Mattermost uses the same
root_idmechanic as Slack'sthread_tsbut was excluded.Gap 3 —
extensions/mattermost/src/channel.ts:sendText/sendMediadestructured onlyreplyToId, silently ignoringthreadIdeven when it was present in the outbound context.Fix
All three gaps addressed in a single change:
dispatch-from-config.ts: fall back todeliveryContextFromSession(sessionStoreEntry.entry)?.threadIdwhenctx.MessageThreadIdis absent. This uses the session store's persisted delivery context, which already stores the thread root ID from the original inbound message.route-reply.ts: extend the threadId→replyToId fallback to include Mattermost alongside Slack.extensions/mattermost/src/channel.ts: acceptthreadIdinsendText/sendMediaand use it as a defense-in-depth fallback whenreplyToIdis not set.How to test
channels.mattermost.replyToMode: "all"Tests
route-reply.test.ts:uses threadId as replyToId for Mattermost when replyToId is missingdispatch-from-config.test.ts:falls back to session deliveryContext threadId when current ctx has no MessageThreadIdchannel.test.ts:uses threadId as fallback when replyToId is absentpnpm checkandpnpm buildclean locally.