fix(mattermost): route thread replies correctly when replyToMode is off#57565
fix(mattermost): route thread replies correctly when replyToMode is off#57565dave wants to merge 12 commits intoopenclaw:mainfrom
Conversation
Greptile SummaryThis PR correctly separates two previously conflated concerns in the Mattermost integration: session routing (which session key to use, controlled by Key changes:
Confidence Score: 5/5Safe to merge — the fix is well-scoped, all three affected call sites are updated consistently, and new tests lock in the corrected behaviour at the exact regression seam. All remaining findings are P2: the typing-indicator inconsistency is cosmetic and pre-existing behaviour is not made worse, and the redundant fallback is harmless. No P0 or P1 issues found. Session routing is provably unchanged. No files require special attention beyond the optional P2 typing-indicator cleanup in
|
| Filename | Overview |
|---|---|
| extensions/mattermost/src/mattermost/monitor.ts | Adds messageThreadId to resolveMattermostThreadSessionContext return value and updates all three deliver call sites and ctxPayload construction sites to use it instead of effectiveReplyToId for routing; fixes thread delivery with replyToMode: "off". Typing indicator call sites still use effectiveReplyToId (minor inconsistency). |
| extensions/mattermost/src/mattermost/monitor.test.ts | Adds messageThreadId assertions to all existing resolveMattermostThreadSessionContext test cases and adds a new dedicated test for the replyToMode: "off" + threaded message scenario; coverage is comprehensive. |
| extensions/mattermost/src/channel.ts | Adds buildToolContext to the Mattermost threading config, populating currentThreadTs from context.MessageThreadId so agent tools get correct thread visibility regardless of replyToMode. |
Comments Outside Diff (1)
-
extensions/mattermost/src/mattermost/monitor.ts, line 517 (link)Typing indicators still target channel root with
replyToMode: "off"All three
sendTypingIndicatorcall sites (lines 517, 711, 1436) still passeffectiveReplyToId, which isundefinedwhenreplyToMode: "off"and the inbound message is in a thread. Since this PR correctly routes the reply to the thread viamessageThreadId, the two are now out of sync: the typing indicator appears at the channel root while the response arrives inside the thread.Before this PR both were at channel root (consistently wrong). After this PR the reply lands in the right place, but the typing indicator still targets channel root — a subtle but user-visible inconsistency.
Consider passing
messageThreadId(orthreadContext.messageThreadId) in all three call sites to keep the typing indicator co-located with the eventual reply:Same change is needed at line 711 (
params.messageThreadId) and line 1436 (messageThreadId).Prompt To Fix With AI
This is a comment left during a code review. Path: extensions/mattermost/src/mattermost/monitor.ts Line: 517 Comment: **Typing indicators still target channel root with `replyToMode: "off"`** All three `sendTypingIndicator` call sites (lines 517, 711, 1436) still pass `effectiveReplyToId`, which is `undefined` when `replyToMode: "off"` and the inbound message is in a thread. Since this PR correctly routes the *reply* to the thread via `messageThreadId`, the two are now out of sync: the typing indicator appears at the channel root while the response arrives inside the thread. Before this PR both were at channel root (consistently wrong). After this PR the reply lands in the right place, but the typing indicator still targets channel root — a subtle but user-visible inconsistency. Consider passing `messageThreadId` (or `threadContext.messageThreadId`) in all three call sites to keep the typing indicator co-located with the eventual reply: Same change is needed at line 711 (`params.messageThreadId`) and line 1436 (`messageThreadId`). How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 517
Comment:
**Typing indicators still target channel root with `replyToMode: "off"`**
All three `sendTypingIndicator` call sites (lines 517, 711, 1436) still pass `effectiveReplyToId`, which is `undefined` when `replyToMode: "off"` and the inbound message is in a thread. Since this PR correctly routes the *reply* to the thread via `messageThreadId`, the two are now out of sync: the typing indicator appears at the channel root while the response arrives inside the thread.
Before this PR both were at channel root (consistently wrong). After this PR the reply lands in the right place, but the typing indicator still targets channel root — a subtle but user-visible inconsistency.
Consider passing `messageThreadId` (or `threadContext.messageThreadId`) in all three call sites to keep the typing indicator co-located with the eventual reply:
```suggestion
start: () => sendTypingIndicator(opts.channelId, threadContext.messageThreadId),
```
Same change is needed at line 711 (`params.messageThreadId`) and line 1436 (`messageThreadId`).
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 681
Comment:
**Redundant fallback in `MessageThreadId` assignment**
`messageThreadId` is already computed in `resolveMattermostThreadSessionContext` as `threadRootId?.trim() || effectiveReplyToId`, so it already incorporates `effectiveReplyToId` when `threadRootId` is absent. The `?? params.effectiveReplyToId` fallback here can never add new information — if `params.messageThreadId` is `undefined` it means both `threadRootId` and `effectiveReplyToId` were `undefined`.
The two other `MessageThreadId` assignments (lines 493 and 1386) use the value directly without a fallback. Keeping this consistent avoids confusion about whether the fallback ever fires:
```suggestion
MessageThreadId: params.messageThreadId,
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(mattermost): route thread replies co..." | Re-trigger Greptile
795b3a7 to
472530b
Compare
|
Greptile suggestions done ✅ |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 472530b794
ℹ️ 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: 413f157aea
ℹ️ 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".
16d7192 to
bd5528b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bd5528be65
ℹ️ 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".
f0445d5 to
236d330
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 236d330c14
ℹ️ 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".
a1650a9 to
fd377f0
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd377f0b6f
ℹ️ 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".
| replyToId: resolveMattermostReplyRootId({ | ||
| threadRootId: threadContext.effectiveReplyToId, | ||
| threadRootId: threadContext.messageThreadId, | ||
| replyToId: payload.replyToId, |
There was a problem hiding this comment.
Guard threaded delivery on unknown interaction channel types
This delivery path now prefers threadContext.messageThreadId, but interaction handlers still infer kind only from resolveChannelInfo; when that lookup fails, mapMattermostChannelTypeToChatType(undefined) falls back to "channel" and messageThreadId is populated from post.root_id even for DM interactions. Because resolveChannelInfo caches null lookups for 5 minutes, a transient lookup failure can make DM button/model-picker replies with replyToMode: "off" get posted into a thread instead of the main DM window, violating the direct-message non-threaded contract. Fresh evidence in this commit is that delivery switched from effectiveReplyToId to messageThreadId at these interaction call sites without a matching channel-type-known guard.
Useful? React with 👍 / 👎.
|
Thanks for the continued review. The interaction-handler DM threading edge case (failed We're going to fix this properly in a separate PR by adding Will leave this PR as-is and rebase once the |
|
Quick update on scope: this was first observed with plugin command responses going to the main channel instead of the originating thread. We've now tested generic agent replies on unpatched 2026.3.28 and confirmed they're broken in exactly the same way — replies land in the main channel regardless of whether it's a command response or a normal agent reply. All three deliver call sites are shared, so the impact is the same. Updated the PR description to make agent replies the primary framing. |
…solvable Previously, mapMattermostChannelTypeToChatType returned "channel" for null/undefined input, masking failed channel lookups as valid routing state. All dispatch paths (handlePost, dispatchButtonClick, handleReactionEvent, resolveSessionKey) now early-exit on "unknown" instead of silently routing to the wrong session. monitor-auth's unknown-channel deny path now correctly sets kind/chatType to "unknown". SlashInvocationAuth updated to discriminated union; resolveSlackReplyToMode guards against "unknown" indexing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…anch Addresses Greptile review comment: the local channel-lookup-failure path in authorizeSlashInvocation was still returning kind: "channel", inconsistent with the discriminated union refactor and monitor-auth's unknown-channel deny. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses contributor review suggestion: add a comment explaining why we return the base session key early when kind is 'unknown', and why this is safe (handlePost drops the post before the session key is used for routing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eturning coerced key Addresses Codex P1 review: returning a coerced 'channel' session key caused the system event to be enqueued into the wrong session before dispatchButtonClick's guard ever ran. Throwing instead causes the caller's try/catch in interactions.ts to skip enqueueSystemEvent entirely, truly failing closed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…onKey The throw for unknown kind was placed after resolveAgentRoute, doing unnecessary work. Move it earlier to also eliminate the kind coercion in the peer object. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With replyToMode "off", resolveMattermostEffectiveReplyToId returns undefined to prevent thread session forking. However, this also broke two delivery behaviours that depended on effectiveReplyToId being set: 1. MessageThreadId was always undefined in the inbound context, so tools and bootstrap hooks had no way to know which thread a message came from. 2. Agent and plugin-command replies went to the main channel instead of the originating thread, because all three deliver call sites passed effectiveReplyToId (undefined) as the threadRootId to resolveMattermostReplyRootId. Fix: - resolveMattermostThreadSessionContext now computes messageThreadId separately as post.root_id ?? effectiveReplyToId, reflecting the actual thread root regardless of replyToMode. - MessageThreadId in the inbound ctxPayload is now set from messageThreadId at all three dispatch sites (interaction, model-picker/sendMessage, main handler). - All three resolveMattermostReplyRootId deliver call sites now pass messageThreadId as threadRootId instead of effectiveReplyToId, so replies are delivered to the correct thread. - Added buildToolContext to the Mattermost threading config so that currentThreadTs is populated from MessageThreadId, giving agent tools visibility into the active thread. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sageThreadId - All three sendTypingIndicator call sites now pass messageThreadId instead of effectiveReplyToId, keeping typing indicators co-located with the reply when replyToMode is "off" and the message is inside a thread. - Removed redundant ?? effectiveReplyToId fallback in the sendMessage MessageThreadId assignment; messageThreadId already incorporates effectiveReplyToId by construction, matching the other two call sites. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n messageThreadId is set for routing resolveThreadFlag returned true whenever messageThreadId was non-null, which misclassified sessions as thread-scoped when the session key was explicitly a group/channel session. Channels like Mattermost with replyToMode: "off" set messageThreadId for delivery routing (so replies go to the correct thread) while intentionally keeping all messages in a single shared channel session. The session key (e.g. containing ":channel:") is the authoritative signal for session type. messageThreadId is a delivery detail that should not override it. When the session key is explicitly a group/channel session, messageThreadId is now ignored for classification purposes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ct messages Direct messages are never threaded by contract — resolveMattermostReplyToMode hardcodes replyToMode: "off" for DMs. A DM can carry post.root_id (Mattermost supports DM threads) but replies should always go to the main DM window, not into the thread. Before this fix, messageThreadId was computed from threadRootId unconditionally, so a DM that was a reply in a thread would cause the bot to respond into that thread instead of the main DM. For non-direct chat types, the behaviour is unchanged: messageThreadId still comes from threadRootId when set. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When resolveChannelInfo returns null (network/lookup error), mapMattermostChannelTypeToChatType(undefined) silently falls back to "channel", bypassing the DM guard in resolveMattermostReplyToMode. In that scenario, setting threadRootId from post.root_id could cause a DM to be treated as a threaded channel session. Guard all three threadRootId assignments: resolveSessionKey, button-click dispatchButtonClick, and the main handler. For the main handler, also accept a raw channel_type from the websocket event payload as a signal that the channel kind is known. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sionKey and dispatchButtonClick The channelInfo != null guard was added to prevent DM threading when channel lookup fails and kind defaults to "channel". However, when channelInfo is null, both the `to` address and session key are already misrouted (kind defaults to "channel" instead of "direct"), so the threadRootId guard only protects against a secondary symptom of a more fundamental failure. The cost is too high: resolveChannelInfo caches null for 5 minutes on any network error, so a single transient failure would de-thread all interaction/session-key replies in real channel threads for the entire cache window. The main handler already has a more precise guard using the websocket event's channel_type as a fallback (channelTypeKnown = channelInfo != null || payload.data?.channel_type != null). The interaction and resolveSessionKey callbacks have no equivalent event-level fallback, so the null guard there is strictly lossy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The early-exit guard for kind === "unknown" in handlePost (from the unknown-chat-type PR) means we never reach the threading logic with an unresolved channel type. The channelTypeKnown conditional was the old workaround for this exact scenario and is now unreachable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fd377f0 to
02de778
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 02de7789bc
ℹ️ 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".
|
Codex review: needs changes before merge. Summary Reproducibility: yes. A source-level reproduction exists on current main: channel/group posts with Next step before merge Security Review detailsBest possible solution: Land a fresh current-main implementation that keeps Do we have a high-confidence way to reproduce the issue? Yes. A source-level reproduction exists on current main: channel/group posts with Is this the best way to solve the issue? Yes conceptually, but not as a direct merge. Separating session routing from delivery routing is the narrow maintainable fix; the current branch should be replaced or rebased onto main while resolving #57970 and current Mattermost drift. Acceptance criteria:
What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 9fff2b779159. |
|
Quick update from me, it kind of felt like I was playing whack-a-mole with these bugs and going round and round in circles. I'm not really familiar with any of this code, so it might be better if someone who knows the system a little bit better took over this. I don't actually have any more time to devote to it, unfortunately, so I'm going to have to leave this hanging. Sorry. |
With
replyToMode: "off",resolveMattermostEffectiveReplyToIdreturnsundefinedto prevent thread session forking. However, this also broke two delivery behaviours
that depended on
effectiveReplyToIdbeing set:MessageThreadIdwas alwaysundefinedin the inbound context, so tools andplugin hooks had no way to know which thread a message came from.
Agent replies went to the main channel instead of the originating thread, because
all three deliver call sites passed
effectiveReplyToId(undefined) as thethreadRootIdtoresolveMattermostReplyRootId. Plugin command responses sharethe same deliver path and are affected by the same bug.
Fix:
resolveMattermostThreadSessionContextnow computesmessageThreadIdseparatelyas
post.root_id ?? effectiveReplyToId, reflecting the actual thread rootregardless of
replyToMode.MessageThreadIdin the inboundctxPayloadis now set frommessageThreadIdat all three dispatch sites (interaction, model-picker/sendMessage, main handler).
resolveMattermostReplyRootIddeliver call sites now passmessageThreadIdasthreadRootIdinstead ofeffectiveReplyToId, so repliesare delivered to the correct thread.
sendTypingIndicatorcall sites now passmessageThreadIdso typingindicators appear in the same thread as the eventual reply.
buildToolContextto the Mattermost threading config so thatcurrentThreadTsis populated fromMessageThreadId, giving agent toolsvisibility into the active thread.
resolveThreadFlaginsrc/config/sessions/reset.tsto not classify asession as thread-scoped when
messageThreadIdis set but the session keyexplicitly identifies a group/channel session.
messageThreadIdis a deliverydetail; the session key is authoritative for session type.
channelTypeKnownworkaround fromhandlePost— now dead codebecause the
kind === "unknown"early-exit guard from fix(mattermost): return 'unknown' ChatType when channel type is not resolvable #57970 prevents reachingthe threading logic with an unresolved channel type.
Summary
replyToMode: "off", any message posted inside a Mattermostthread caused
effectiveReplyToIdto beundefined. All three deliver call sitesused
effectiveReplyToIdas the thread root for outgoing replies, so agent repliesposted to the main channel instead of the originating thread. Plugin command
responses share the same deliver path and are affected identically. Additionally,
ctx.MessageThreadIdwas alwaysundefined, so tools had no visibility into theactive thread.
replyToMode: "off"(the typical setup for botsthat should not fork per-thread sessions) could not use Mattermost threads at all
— every agent reply landed in the main channel instead of the thread it came from.
resolveMattermostThreadSessionContextnow returns a separatemessageThreadIdfield (post.root_id ?? effectiveReplyToId) that carries theactual thread root independent of
replyToMode. All three deliver call sites,all three typing indicator call sites, and all three
ctxPayloadconstructionsites now use
messageThreadIdinstead ofeffectiveReplyToId.buildToolContextadded to the Mattermost threading config socurrentThreadTsis populated.
resolveThreadFlagupdated to treatmessageThreadIdas adelivery detail rather than a session-type signal when the session key is
explicitly a group/channel session. Removed the
channelTypeKnownworkaround(dead code after fix(mattermost): return 'unknown' ChatType when channel type is not resolvable #57970).
effectiveReplyToIdandresolveThreadSessionKeysare unchanged, soreplyToMode: "off"still preventsthread session forking exactly as intended.
Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
fix/mattermost-unknown-chat-type)Root Cause / Regression History (if applicable)
effectiveReplyToIdwas serving two distinct purposes — sessionrouting (which session key to use) and delivery routing (where to post the reply).
Commit
aaba1ae653correctly set it toundefinedfor session routing whenreplyToMode: "off", but inadvertently broke delivery routing as a side effect.resolveMattermostThreadSessionContextonly asserted on
effectiveReplyToIdandsessionKey. No test covered theend-to-end delivery path (deliver callback →
resolveMattermostReplyRootId→actual post target) for the
replyToMode: "off"+ threaded message scenario.replyToModesupport was added in 2026.3.12 (171d2df9e0,PR feat(mattermost): add replyToMode support (off | first | all) #29587). The regression was introduced in 2026.3.22 by
aaba1ae653.Regression Test Plan (if applicable)
extensions/mattermost/src/mattermost/monitor.test.ts,resolveMattermostThreadSessionContextdescribe blockreplyToMode: "off"and a non-zerothreadRootIdis present,messageThreadIdequalsthreadRootIdandeffectiveReplyToIdis stillundefined."still reports messageThreadId when replyToMode is off"in this PR; also updatedthe existing
"keeps threaded messages top-level when replyToMode is off"assertion to include
messageThreadId: "root-456".User-visible / Behavior Changes
With
replyToMode: "off", agent replies now go to the originating thread insteadof the main channel. Typing indicators appear in the same thread. Plugin command
responses are also fixed as they share the same delivery path. No change in
behaviour for
replyToMode: "all"or"first".Diagram (if applicable)
Security Impact (required)
Repro + Verification
Environment
replyToMode: "off"channels.mattermost.replyToMode: "off"(or per-account equivalent)Steps
replyToMode: "off"Expected
Actual (before fix)
Human Verification (required)
replyToMode: "off", agent replies land inthe main channel instead of the originating thread. Confirmed fixed with this PR.
replyToMode: "off"unaffected;replyToMode: "all"behaviour unchanged.Compatibility / Migration
Risks and Mitigations
messageThreadIdused whereeffectiveReplyToIdwas previouslyundefinedfor
replyToMode: "all"/"first"top-level posts — could change delivery target.messageThreadId = threadRootId ?? effectiveReplyToId, so fornon-
"off"modes the value is identical to the oldeffectiveReplyToId. Nobehaviour change for those modes.
MessageThreadIdcould misclassify channel sessions as threadsessions in
resolveThreadFlag, changing reset policy for users with separatesession.resetByType.threadconfig.src/config/sessions/reset.ts—resolveThreadFlagnowdefers to the session key when it explicitly identifies a group/channel session,
treating
messageThreadIdas a delivery detail rather than a session-type signal.