Skip to content

fix(mattermost): inherit thread context in message tool send action#52120

Open
teconomix wants to merge 1 commit intoopenclaw:mainfrom
teconomix:fix/mattermost-message-tool-thread-v2
Open

fix(mattermost): inherit thread context in message tool send action#52120
teconomix wants to merge 1 commit intoopenclaw:mainfrom
teconomix:fix/mattermost-message-tool-thread-v2

Conversation

@teconomix
Copy link
Copy Markdown
Contributor

@teconomix teconomix commented Mar 22, 2026

Problem

When the agent uses the message tool to send a proactive message (without an explicit replyTo) from a Mattermost thread-scoped session, the message lands at the channel root instead of the current thread.

Root Cause

Two issues:

  1. The Mattermost plugin did not implement threading.buildToolContext, so the message tool never received currentThreadTs or replyToMode from the active session. The generic fallback only supplied currentChannelId — thread context was never propagated.

  2. context.To is channel:<id> format from monitor.ts, but there is a potential mismatch if any intermediate layer adds the mattermost: prefix. The fix strips any such prefix defensively before storing as currentChannelId.

Fix

1. Add threading.buildToolContext to the Mattermost plugin

Supplies the message tool with:

  • currentChannelId — platform prefix stripped defensively (.replace(/^mattermost:/i, "")) for correct comparison
  • currentThreadTs — from MessageThreadId or ReplyToId
  • replyToMode — resolved from account config, with off→all promotion when already inside a thread (mirrors extensions/slack/src/threading-tool-context.ts)
  • hasRepliedRef — passed through

2. Inherit thread context in handleAction send path

replyToId falls back to toolContext.currentThreadTs with guards:

  • Both to and currentChannelId normalized via normalizeMattermostMessagingTarget before comparison
  • replyToMode gate: all always inherits; first inherits only with explicit hasRepliedRef present and false (absent ref fails-closed); off + existing thread promoted to all
  • hasRepliedRef.value flipped to true after any threaded send (implicit or explicit) under replyToMode=first

Explicit replyTo always takes precedence.

Tests

9 unit test cases in channel.test.ts covering all guard branches and edge cases.

How to test

  1. Configure Mattermost with replyToMode: "all"
  2. Start a conversation in a channel thread
  3. Have the agent call message(action="send", channel="mattermost", message="...") without specifying replyTo or target (letting routing decide implicitly)
  4. Before: message appears at channel root
  5. After: message appears in the current thread

A good reproduction is the cron/heartbeat wake case: set a cron job that wakes a thread-scoped session and has the agent send a message — before this fix the reply would land as a DM or channel root.

Note on .github/workflows/preflight.yml

This file is fork-specific CI scaffolding that lives in the teconomix/openclaw fork. It is not included in this PR's diff (the PR diff covers only channel.ts and channel.test.ts).

Rebased on current main (2026-03-25)

Previously based on a stale merge base. Rebased cleanly onto upstream/main as a single commit.

Closes #45082

@openclaw-barnacle openclaw-barnacle Bot added channel: mattermost Channel integration: mattermost size: M labels Mar 22, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 183d6f5062

ℹ️ 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".

Comment on lines +371 to +372
const effectiveReplyToMode =
configuredReplyToMode === "off" && threadTs != null ? "all" : configuredReplyToMode;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Promote existing threaded replyToMode=first sessions to all

When the inbound Mattermost message is already inside a thread, extensions/mattermost/src/mattermost/monitor.ts always keeps normal replies on that thread by returning the existing threadRootId regardless of replyToMode. This new buildToolContext path preserves "first" instead, so once hasRepliedRef becomes true, later message send tool calls stop inheriting the active thread and post back to the channel root. That regresses any threaded channel/group conversation configured with replyToMode: first whenever the agent emits a follow-up tool send after its first reply.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in commit 3c183d1: for replyToMode=first, hasRepliedRef is now explicitly flipped to true after the first implicit thread send. Subsequent sends in the same turn see hasRepliedRef.value===true and fall through to channel root, matching how monitor.ts handles inbound replies (it keeps them on the thread for the reply path, but tool sends are separately gated by the ref).

Comment on lines +207 to +211
const replyToModeAllowsThread =
toolContext?.replyToMode === "all" ||
(toolContext?.replyToMode === "first" &&
toolContext.hasRepliedRef !== undefined &&
toolContext.hasRepliedRef.value !== true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume hasRepliedRef when the first tool send inherits a thread

For replyToMode="first", this branch only reads toolContext.hasRepliedRef and never flips it after auto-injecting sessionThreadTs. As a result, if the agent issues multiple Mattermost message send actions in one turn before any non-tool reply path updates the ref, every send will be threaded instead of just the first one. The Slack action runtime avoids this by marking the ref as soon as it consumes the first threaded send.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3c183d1: handleAction now sets toolContext.hasRepliedRef.value = true after using sessionThreadTs as the implicit replyToId when replyToMode=first. Test updated to assert hasRepliedRef.value is true after the first implicit send.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 22, 2026

Greptile Summary

This PR fixes a bug where the Mattermost plugin's message tool, when invoked without an explicit replyTo, would post at channel root instead of staying inside the active thread. It adds threading.buildToolContext (previously unimplemented for Mattermost) and wires thread-context inheritance into handleAction's send path, mirroring the approach used by the Slack extension.

Key changes:

  • buildToolContext now populates currentChannelId, currentThreadTs, replyToMode, and hasRepliedRef from the live session context, with a defensive mattermost: prefix strip to match the channel:<id> format expected by normalizeMattermostMessagingTarget.
  • handleAction computes an implicitReplyToId from toolContext.currentThreadTs when the send target is the same channel and replyToMode permits threading (all, or first with hasRepliedRef.value === false).
  • hasRepliedRef.value is flipped to true after any threaded send (implicit or explicit) under replyToMode=first, preventing "first" from behaving like "all" — the bug flagged in prior review threads is now addressed.
  • 9 unit tests cover all guard branches (same-channel check, different-channel, replyToMode=all/first/off, hasRepliedRef gating, explicit-replyTo precedence, hasRepliedRef flip on explicit send).

The implementation intentionally diverges from Slack's buildSlackThreadingToolContext on one point: Slack unconditionally promotes any configured mode to "all" when MessageThreadId is present, while Mattermost only promotes "off"→"all" and preserves "first" so that hasRepliedRef can continue to gate subsequent sends. This is consistent with the code comment and the test asserting the behavior.

Confidence Score: 5/5

  • This PR is safe to merge — prior P1 concerns (hasRepliedRef flip, mattermost: prefix stripping) have been addressed in commits 3c183d1 and 3312a42, and the 9 unit tests cover all guard branches cleanly.
  • No new P0 or P1 issues found. All previously flagged bugs are fixed. The normalize/compare symmetry is correct (both sides of the isSameChannel check go through normalizeMattermostMessagingTarget), the fail-closed replyToMode=first + absent hasRepliedRef behavior is correct, and the intentional divergence from Slack's promotion logic is clearly documented and tested.
  • No files require special attention.

Reviews (3): Last reviewed commit: "fix(mattermost): inherit thread context ..." | Re-trigger Greptile

Comment thread extensions/mattermost/src/channel.ts Outdated
Comment on lines +207 to +214
const replyToModeAllowsThread =
toolContext?.replyToMode === "all" ||
(toolContext?.replyToMode === "first" &&
toolContext.hasRepliedRef !== undefined &&
toolContext.hasRepliedRef.value !== true);
const replyToId =
readMattermostReplyToId(params) ??
(isSameChannel && replyToModeAllowsThread ? sessionThreadTs : undefined);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 replyToMode=first: hasRepliedRef.value is never set to true

When replyToMode === "first" and hasRepliedRef.value === false, the code correctly inherits the thread context — but it never flips hasRepliedRef.value to true after doing so. This means every subsequent call through this path also sees hasRepliedRef.value === false and keeps inheriting the thread, making first behave identically to all for all tool-send calls.

The Slack equivalent (in extensions/slack/src/action-runtime.ts:113-116) performs the mutation before returning the thread ID:

if (context.replyToMode === "first" && context.hasRepliedRef && !context.hasRepliedRef.value) {
  context.hasRepliedRef.value = true;  // ← mutation present in Slack
  return context.currentThreadTs;
}

The fix is to mirror that pattern here — set hasRepliedRef.value = true at the point the inherited replyToId is resolved for the first-mode branch:

    const replyToModeAllowsThread = (() => {
      if (toolContext?.replyToMode === "all") return true;
      if (
        toolContext?.replyToMode === "first" &&
        toolContext.hasRepliedRef !== undefined &&
        toolContext.hasRepliedRef.value !== true
      ) {
        toolContext.hasRepliedRef.value = true;
        return true;
      }
      return false;
    })();
    const replyToId =
      readMattermostReplyToId(params) ??
      (isSameChannel && replyToModeAllowsThread ? sessionThreadTs : undefined);

There is a corresponding test gap: none of the 8 new tests assert that handleAction mutates hasRepliedRef.value to true after the first thread-inheriting send. A test that calls handleAction twice with replyToMode=first and hasRepliedRef = { value: false } and then checks both the second call's replyToId and the resulting hasRepliedRef.value would catch this regression.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/channel.ts
Line: 207-214

Comment:
**`replyToMode=first`: `hasRepliedRef.value` is never set to `true`**

When `replyToMode === "first"` and `hasRepliedRef.value === false`, the code correctly inherits the thread context — but it never flips `hasRepliedRef.value` to `true` after doing so. This means every subsequent call through this path also sees `hasRepliedRef.value === false` and keeps inheriting the thread, making `first` behave identically to `all` for all tool-send calls.

The Slack equivalent (in `extensions/slack/src/action-runtime.ts:113-116`) performs the mutation before returning the thread ID:
```typescript
if (context.replyToMode === "first" && context.hasRepliedRef && !context.hasRepliedRef.value) {
  context.hasRepliedRef.value = true;  // ← mutation present in Slack
  return context.currentThreadTs;
}
```

The fix is to mirror that pattern here — set `hasRepliedRef.value = true` at the point the inherited `replyToId` is resolved for the `first`-mode branch:

```typescript
    const replyToModeAllowsThread = (() => {
      if (toolContext?.replyToMode === "all") return true;
      if (
        toolContext?.replyToMode === "first" &&
        toolContext.hasRepliedRef !== undefined &&
        toolContext.hasRepliedRef.value !== true
      ) {
        toolContext.hasRepliedRef.value = true;
        return true;
      }
      return false;
    })();
    const replyToId =
      readMattermostReplyToId(params) ??
      (isSameChannel && replyToModeAllowsThread ? sessionThreadTs : undefined);
```

There is a corresponding test gap: none of the 8 new tests assert that `handleAction` mutates `hasRepliedRef.value` to `true` after the first thread-inheriting send. A test that calls `handleAction` twice with `replyToMode=first` and `hasRepliedRef = { value: false }` and then checks both the second call's `replyToId` **and** the resulting `hasRepliedRef.value` would catch this regression.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3c183d1: added the missing flip — if implicitReplyToId is used and replyToMode=first and hasRepliedRef is present, we set hasRepliedRef.value = true. Test asserts this. Mirrors the Slack action runtime behavior.

Comment thread .github/workflows/preflight.yml Outdated
Comment on lines +1 to +6
name: Preflight (fork)

# Runs on any branch push to teconomix/openclaw (except main).
# Scoped to checks relevant to our Mattermost patches — avoids pre-existing
# failures in unrelated extensions (tlon, urbit, etc.) that are not our concern.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fork-specific CI workflow included in upstream PR

The file header and comments make clear this workflow is intended exclusively for the contributor's fork (teconomix/openclaw):

"Runs on any branch push to teconomix/openclaw (except main)."
"Scoped to checks relevant to our Mattermost patches — avoids pre-existing failures in unrelated extensions (tlon, urbit, etc.) that are not our concern."

If merged into openclaw/openclaw, this file adds a redundant CI job scoped to a now-irrelevant fork context and may conflict with or duplicate existing upstream CI. The trigger (branches-ignore: main) also means it silently stops running on the very branch it would be merged into.

This file should either be removed from the PR entirely, or replaced with a targeted upstream CI addition (e.g. a job that runs Mattermost extension tests on all PR branches) using the upstream conventions.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/preflight.yml
Line: 1-6

Comment:
**Fork-specific CI workflow included in upstream PR**

The file header and comments make clear this workflow is intended exclusively for the contributor's fork (`teconomix/openclaw`):

> "Runs on any branch push to teconomix/openclaw (except main)."
> "Scoped to checks relevant to our Mattermost patches — avoids pre-existing failures in unrelated extensions (tlon, urbit, etc.) that are not our concern."

If merged into `openclaw/openclaw`, this file adds a redundant CI job scoped to a now-irrelevant fork context and may conflict with or duplicate existing upstream CI. The trigger (`branches-ignore: main`) also means it silently stops running on the very branch it would be merged into.

This file should either be removed from the PR entirely, or replaced with a targeted upstream CI addition (e.g. a job that runs Mattermost extension tests on all PR branches) using the upstream conventions.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. The preflight.yml is fork-specific and is only on the branch because GitHub requires the workflow file to exist on the pushed branch for push triggers to fire. It is not included in the actual code change — the upstream PR diff shows only extensions/mattermost/src/channel.ts and channel.test.ts. If merged upstream, the file would need to be removed or scoped to the upstream repo, which is out of scope here.

? context.MessageThreadId.trim()
: typeof context.ReplyToId === "string" && context.ReplyToId.trim()
? context.ReplyToId.trim()
: undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 context.To prefix mismatch makes isSameChannel always false

context.To is constructed in monitor.ts as "mattermost:channel:${channelId}" (or "mattermost:group:${channelId}"), but normalizeMattermostMessagingTarget interprets the mattermost: prefix as a user prefix, not a channel prefix:

normalizeMattermostMessagingTarget("mattermost:channel:CHAN1")
  → id = "channel:CHAN1"
  → returns "user:channel:CHAN1"   ← wrong

Meanwhile, the tool's to param uses "channel:CHAN1" format:

normalizeMattermostMessagingTarget("channel:CHAN1") → "channel:CHAN1"

So normalizedTo ("channel:CHAN1") !== normalizedCurrentChannelId ("user:channel:CHAN1") for every channel-send, making isSameChannel permanently false. Thread context is never inherited in production, even though the tests pass (they bypass buildToolContext by supplying currentChannelId: "channel:CHAN1" directly).

The fix is to strip the "mattermost:" platform prefix in buildToolContext before storing it as currentChannelId, mirroring how Slack's buildSlackThreadingToolContext strips "channel:" from context.To:

Suggested change
: undefined;
currentChannelId:
context.To?.trim().replace(/^mattermost:/i, "") || undefined,

This turns "mattermost:channel:CHAN1""channel:CHAN1" and "mattermost:group:CHAN1""group:CHAN1", which will then normalize identically to what params.to normalizes to in handleAction.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/channel.ts
Line: 374

Comment:
**`context.To` prefix mismatch makes `isSameChannel` always false**

`context.To` is constructed in `monitor.ts` as `"mattermost:channel:${channelId}"` (or `"mattermost:group:${channelId}"`), but `normalizeMattermostMessagingTarget` interprets the `mattermost:` prefix as a *user* prefix, not a channel prefix:

```
normalizeMattermostMessagingTarget("mattermost:channel:CHAN1")
  → id = "channel:CHAN1"
  → returns "user:channel:CHAN1"   ← wrong
```

Meanwhile, the tool's `to` param uses `"channel:CHAN1"` format:
```
normalizeMattermostMessagingTarget("channel:CHAN1") → "channel:CHAN1"
```

So `normalizedTo ("channel:CHAN1") !== normalizedCurrentChannelId ("user:channel:CHAN1")` for every channel-send, making `isSameChannel` permanently `false`. Thread context is **never inherited in production**, even though the tests pass (they bypass `buildToolContext` by supplying `currentChannelId: "channel:CHAN1"` directly).

The fix is to strip the `"mattermost:"` platform prefix in `buildToolContext` before storing it as `currentChannelId`, mirroring how Slack's `buildSlackThreadingToolContext` strips `"channel:"` from `context.To`:

```suggestion
        currentChannelId:
          context.To?.trim().replace(/^mattermost:/i, "") || undefined,
```

This turns `"mattermost:channel:CHAN1"``"channel:CHAN1"` and `"mattermost:group:CHAN1"``"group:CHAN1"`, which will then normalize identically to what `params.to` normalizes to in `handleAction`.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3312a42: strip the mattermost: platform prefix in buildToolContext before storing as currentChannelId. context.To uses mattermost:channel:<id> format from monitor.ts, but normalizeMattermostMessagingTarget treats mattermost: as a user prefix — so currentChannelId became user:channel:<id> instead of channel:<id>, making isSameChannel always false. Now using .replace(/^mattermost:/i, "") before storing, matching the channel:<id> / group:<id> format used by params.to in handleAction.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e91f03c550

ℹ️ 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".

Comment on lines +212 to +214
const implicitReplyToId =
isSameChannel && replyToModeAllowsThread ? sessionThreadTs : undefined;
const replyToId = readMattermostReplyToId(params) ?? implicitReplyToId;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Surface implicit Mattermost thread ids to outbound routing

This new fallback only chooses replyToId inside extensions/mattermost/src/channel.ts, after src/infra/outbound/message-action-runner.ts has already called resolveOutboundSessionRoute(...) and chosen the mirror/session key. For a threaded Mattermost tool send without an explicit replyTo, the post now lands in the thread, but extensions/mattermost/src/session-route.ts still sees replyToId ?? threadId as empty and records the send under the base channel session. That will drift the mirrored transcript and any later thread-scoped follow-up/session lookups away from the message’s actual thread.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged as out of scope for this PR. The session routing / outbound mirror drift is a valid follow-up but would require changes to resolveOutboundSessionRoute (extensions/mattermost/src/session-route.ts) and/or resolveAutoThreadId in the threading adapter — a separate concern from the message tool fallback this PR addresses. Will track as a follow-up issue.

Comment on lines +217 to +222
if (
implicitReplyToId !== undefined &&
toolContext?.replyToMode === "first" &&
toolContext.hasRepliedRef !== undefined
) {
toolContext.hasRepliedRef.value = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Consume reply-to-first state for explicit threaded tool sends

Fresh evidence: this update still flips hasRepliedRef only when implicitReplyToId is used. In a replyToMode="first" session, if the first tool send already carries replyTo/replyToId explicitly (for example via [[reply_to_current]]), hasRepliedRef stays false, so the next Mattermost message send in the same turn will auto-inherit toolContext.currentThreadTs and post a second threaded reply. That makes first behave like all whenever the first tool message uses an explicit reply target.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit e7174a9: the hasRepliedRef flip now triggers on any non-null replyToId (explicit or implicit) when replyToMode=first, not just when the implicit fallback is used. An explicit replyTo/replyToId param now correctly consumes the first-reply slot.

@teconomix
Copy link
Copy Markdown
Contributor Author

Rebased this cleanly onto current upstream/main (2026-03-25). The branch was 1125 commits behind and had a merge commit in the history. The new single commit 97e3bb6 incorporates all previous fixes:

  • buildToolContext with mattermost: prefix strip on currentChannelId
  • Thread context inheritance in handleAction with same-channel guard
  • replyToMode gate (all/first/off)
  • hasRepliedRef flipped after both implicit and explicit threaded sends under replyToMode=first
  • off→all promotion when already inside a thread

All 9 unit tests pass. Local pnpm check and pnpm run build:docker green. CI running on the new commit.

Add buildToolContext to supply currentThreadTs/replyToMode/hasRepliedRef to the
message tool, and use those in handleAction to inherit the active thread when
sending without an explicit replyTo.

Two fixes in one:

1. buildToolContext implementation for the Mattermost plugin. Populates:
   - currentChannelId: from context.To (platform prefix stripped defensively)
   - currentThreadTs: from MessageThreadId or ReplyToId
   - replyToMode: resolved from account config; off promoted to all when inside
     an existing thread (mirrors Slack threading-tool-context.ts)
   - hasRepliedRef: passed through

2. handleAction send path: falls back to toolContext.currentThreadTs when no
   explicit replyTo/replyToId param is provided, with guards:
   - Same channel check: normalized to/currentChannelId must match
   - replyToMode gate: all=always inherit, first=once (hasRepliedRef gates),
     off=never
   - hasRepliedRef flipped after any threaded send (implicit or explicit)
     under replyToMode=first

9 unit tests cover all guard branches and edge cases.

Closes openclaw#45082
@teconomix teconomix force-pushed the fix/mattermost-message-tool-thread-v2 branch from 97e3bb6 to a56237a Compare March 31, 2026 04:51
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a56237af19

ℹ️ 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".

Comment on lines +495 to +496
const effectiveReplyToMode =
configuredReplyToMode === "off" && threadTs != null ? "all" : configuredReplyToMode;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive off-mode promotion from actual thread presence

effectiveReplyToMode is promoted from off to all only when threadTs is present, but threadTs here is sourced from context.MessageThreadId/context.ReplyToId. In Mattermost inbound handling, those fields are populated from effectiveReplyToId, which is explicitly undefined for replyToMode="off" even when the inbound post is in a thread (resolveMattermostEffectiveReplyToId in extensions/mattermost/src/mattermost/monitor.ts). That makes this promotion path unreachable for real off-mode threaded sessions, so tool sends still fall back to channel root instead of inheriting the active thread.

Useful? React with 👍 / 👎.

Comment on lines +208 to +212
replyToId !== undefined &&
toolContext?.replyToMode === "first" &&
toolContext.hasRepliedRef !== undefined
) {
toolContext.hasRepliedRef.value = true;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate first-reply consumption to the active channel

This flips hasRepliedRef for any non-empty replyToId in replyToMode="first", even when the send target is not the session’s current channel. If the first tool send is an explicit threaded message to a different channel/DM, it consumes the current session’s first-reply slot, and later sends back in the active thread no longer inherit currentThreadTs. The state transition should be tied to same-channel sends (like the implicit-thread path) to avoid cross-channel interference.

Useful? React with 👍 / 👎.

@teconomix teconomix closed this Apr 1, 2026
@teconomix
Copy link
Copy Markdown
Contributor Author

teconomix commented Apr 1, 2026

abandoning — we have migrated from Mattermost to Matrix and are no longer maintaining Mattermost patches. Thanks for the reviews. maybe someone wants to take it from here.

@clawsweeper
Copy link
Copy Markdown
Contributor

clawsweeper Bot commented Apr 28, 2026

Codex review: needs changes before merge.

Summary
The PR adds Mattermost buildToolContext support, Mattermost handleAction reply fallback logic, and unit tests so message-tool sends without explicit replyTo inherit active Mattermost thread context.

Reproducibility: yes. The linked report gives concrete Mattermost replyToMode: "all" thread-scoped message-tool steps, and current main source shows Mattermost tool sends only use explicit replyTo fields while the plugin lacks the hook needed to supply the active thread root.

Next step before merge
The source PR is abandoned and has review blockers, but the underlying bundled Mattermost bug is valid, narrow, and has a clear replacement-PR path.

Security
Cleared: Security review cleared: the PR diff is limited to Mattermost plugin runtime logic and unit tests, with no workflow, dependency, lockfile, secret-handling, package-publishing, or external code execution changes.

Review findings

  • [P2] Route Mattermost auto-threading before dispatch — extensions/mattermost/src/channel.ts:204
  • [P2] Keep active first-mode threads on the root — extensions/mattermost/src/channel.ts:492-493
  • [P2] Build off-mode context from the actual root — extensions/mattermost/src/channel.ts:481-493
Review details

Best possible solution:

Adapt the useful Mattermost-owned fix to current main by adding a Mattermost tool-context builder plus pre-dispatch auto-thread resolver that supplies the actual active thread root before shared reply injection, mirror routing, and plugin dispatch.

Do we have a high-confidence way to reproduce the issue?

Yes. The linked report gives concrete Mattermost replyToMode: "all" thread-scoped message-tool steps, and current main source shows Mattermost tool sends only use explicit replyTo fields while the plugin lacks the hook needed to supply the active thread root.

Is this the best way to solve the issue?

No. The direction is right, but this patch resolves Mattermost thread inheritance inside plugin handleAction after shared reply injection and routing have already run, and its context builder still mishandles active first and off thread cases.

Full review comments:

  • [P2] Route Mattermost auto-threading before dispatch — extensions/mattermost/src/channel.ts:204
    Adding currentChannelId and replyToMode lets the shared runner set params.replyTo from currentMessageId before Mattermost handleAction runs. That explicit value masks this fallback, so sends from an existing Mattermost thread can use the latest message id as the root instead of the thread root; wire Mattermost through resolveAutoThreadId or an equivalent pre-dispatch hook.
    Confidence: 0.78
  • [P2] Keep active first-mode threads on the root — extensions/mattermost/src/channel.ts:492-493
    When the active Mattermost session is already inside a thread, preserving replyToMode="first" makes later tool sends stop inheriting currentThreadTs after hasRepliedRef is true and post at the channel root. Current Mattermost thread handling keeps first follow-ups on the active root, so the tool context should promote active threaded sessions to keep using the root.
    Confidence: 0.82
  • [P2] Build off-mode context from the actual root — extensions/mattermost/src/channel.ts:481-493
    This promotes off to all only when MessageThreadId or ReplyToId is present, but current Mattermost derives those fields from effectiveReplyToId, which is undefined for replyToMode="off" even when the inbound post has a thread root. Use the actual Mattermost thread root/session context instead of the already-filtered reply id.
    Confidence: 0.7

Overall correctness: patch is incorrect
Overall confidence: 0.84

Acceptance criteria:

  • pnpm test extensions/mattermost/src/channel.test.ts extensions/mattermost/src/mattermost/monitor.test.ts
  • pnpm test src/infra/outbound/message-action-runner.threading.test.ts src/auto-reply/reply/agent-runner-utils.test.ts
  • pnpm exec oxfmt --check --threads=1 extensions/mattermost/src/channel.ts extensions/mattermost/src/channel.test.ts extensions/mattermost/src/mattermost/monitor.ts extensions/mattermost/src/mattermost/monitor.test.ts src/infra/outbound/message-action-threading.ts src/infra/outbound/message-action-runner.threading.test.ts src/auto-reply/reply/agent-runner-utils.ts

What I checked:

Likely related people:

  • teconomix: Prior merged Mattermost threading work added replyToMode support and carried Mattermost thread context to non-inbound reply paths; this PR also builds on that area. (role: introduced behavior and domain contributor; confidence: high; commits: 171d2df9e0fe, 0c926a2c5e82; files: extensions/mattermost/src/mattermost/monitor.ts, extensions/mattermost/src/channel.ts)
  • Gustavo Madeira Santana: Recent history shows work moving Mattermost outbound session routing behind the plugin boundary and removing channel-specific message-action fallbacks, both central to the correct fix shape. (role: adjacent shared routing owner; confidence: medium; commits: d6c13d9dc01e, 03f18ec043de, a14ad01d66f8; files: extensions/mattermost/src/session-route.ts, src/infra/outbound/message-action-runner.ts, src/auto-reply/reply/agent-runner-utils.ts)
  • Peter Steinberger: Recent current-main commits touched shared message routing context and plugin-owned channel boundary enforcement, which are the seams this fix should use. (role: recent shared outbound maintainer; confidence: medium; commits: 2e3ef1b9e180, 8bfa06e992be; files: src/infra/outbound/message-action-runner.ts, src/infra/outbound/message-action-threading.ts, extensions/mattermost/src/channel.ts)
  • Vincent Koc: Recent Mattermost and message-action capability work suggests familiarity with the bundled Mattermost plugin runtime and test surface. (role: recent Mattermost maintainer; confidence: medium; commits: a7ac3c666c4e, fad06f7c2126, 92bea9704e17; files: extensions/mattermost/src/channel.ts, extensions/mattermost/src/mattermost/monitor.ts)

Remaining risk / open question:

Codex review notes: model gpt-5.5, reasoning high; reviewed against a92e2b13b8b8.

@admin-prodesign
Copy link
Copy Markdown

Adding a real-world reproduction in support of this fix.

We hit this today in Mattermost with replyToMode: "all":

  • User mentioned the bot inside a Mattermost thread.
  • Inbound runtime context correctly included the thread root (reply_to_id / topic_id).
  • The assistant then used message(action="send") from the thread-scoped session without an explicit replyTo.
  • The sent Mattermost post landed at the channel root (root_id: "") instead of under the current thread.
  • This repeated across multiple thread mentions, so it was not a one-off Mattermost/API race.

The PR’s approach matches the observed failure mode: final assistant replies preserve the thread, but the Mattermost message-tool send path does not inherit thread context unless it is explicitly passed. In practice this is a pretty sharp footgun for agents, because using the first-class message tool from a thread-scoped session should not silently escape to the channel root.

A local hot patch that falls back from explicit replyToId to current Mattermost thread context fixes the immediate behavior for us; this PR looks like the cleaner upstream version because it adds the missing Mattermost tool-context plumbing and regression tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: mattermost Channel integration: mattermost size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(mattermost): proactive message tool sends ignore thread context

2 participants