Skip to content

feat(mattermost): add 'auto' reply mode — flat DMs, threaded channels - #33149

Closed
ugoenyioha wants to merge 1 commit into
NousResearch:mainfrom
ugoenyioha:feat/mattermost-auto-reply
Closed

feat(mattermost): add 'auto' reply mode — flat DMs, threaded channels#33149
ugoenyioha wants to merge 1 commit into
NousResearch:mainfrom
ugoenyioha:feat/mattermost-auto-reply

Conversation

@ugoenyioha

@ugoenyioha ugoenyioha commented May 27, 2026

Copy link
Copy Markdown

Summary

MATTERMOST_REPLY_MODE supports only thread (always nest) and off (always flat). thread keeps a busy channel legible but looks wrong in a DM, where a one-on-one conversation ends up nested inside itself; off gives up thread tidiness everywhere. auto decides per conversation: thread in channels, stay flat in DMs and group DMs.

Reimplemented against current main per review — the previous revision patched inline call sites that no longer exist, since thread-root selection is now centralised in _thread_root_for_send().

Root cause of the trap this had to avoid: get_chat_info() returns {"type": "channel"} when its API lookup fails, and "channel" is the threadable answer. Routing an auto decision through it means a transient DM lookup failure silently starts threading inside a DM.

Changes

  • plugins/platforms/mattermost/adapter.py:
    • _auto_mode_should_thread() (new) resolves the channel type from channels/<id> directly, not via get_chat_info(), and treats a failed lookup, a missing type, or an unrecognised code as unknown → flat. Successful lookups are cached (a channel's type doesn't change); failures are deliberately not cached, so a later send can still learn the real type.
    • _thread_root_for_send() takes chat_id (already in scope at all four call sites) and consults the new resolver in auto; metadata roots (metadata["thread_id"] / ["root_id"]) keep working for auto exactly as for thread.
    • _mode_threads_raw_type() (new) shares the same decision on the inbound path, where the websocket event already carries the channel type so no lookup is needed. thread behaviour there is unchanged (it already excluded DMs).
    • _AUTO_THREAD_CHANNEL_TYPES = {"O","P"} keys on the raw Mattermost code rather than _CHANNEL_TYPE_MAP, which folds P (private channel) into "group" alongside real group DMs — a private channel is a channel and should thread.
  • plugins/platforms/mattermost/plugin.yaml: description and prompt now list thread|off|auto.
  • website/docs/user-guide/messaging/mattermost.md: reply-mode table documents auto, including the flat-on-unknown behaviour.
  • tests/gateway/test_mattermost.py: 12 new tests.

Validation

Conversation raw type thread off auto
public channel O thread flat thread
private channel P thread flat thread
group DM G thread flat flat
DM D flat flat flat
lookup failed / unknown code thread flat flat
Check Result
pytest tests/gateway/test_mattermost.py -q 75/75 pass (12 new)
pytest tests/gateway/test_send_multiple_images.py -q 24/24 pass (covers the multi-image call site)

New cases: channels vs DMs vs group DMs; failed lookup stays flat; unrecognised code stays flat; failed lookup is not cached; successful type cached and looked up once across sends; metadata roots honoured in a channel and ignored in a DM; no-root short circuit skips the lookup; inbound matrix for all three modes across all four codes.

Overlap with #47079 — flagging rather than competing

#47079 by @sidorovanthon also adds reply_mode "auto", opened after this one. Two open PRs for one feature is the worst outcome, so rather than leave that implicit — the two differ in coverage and a maintainer may well prefer parts of each:

this PR #47079
decision point centralised _thread_root_for_send() + inbound resolver adapter call sites
failed / unknown channel type explicitly flat, and not cached worth confirming
private channel (P) vs group DM (G) distinguished via raw code folded by _CHANNEL_TYPE_MAP
gateway session keying untouched also updates hermes_cli/gateway.py
docs mattermost.md mattermost.md + environment-variables.md

Happy to close this in favour of #47079, or to fold the unknown-type handling and the P/G distinction into it — whichever the platforms maintainer prefers. Flagging @sidorovanthon.

Notes

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins comp/gateway Gateway runner, session dispatch, delivery labels May 27, 2026

@teknium1 teknium1 left a comment

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.

Thanks for addressing the DM/threading conflict. The requested auto behavior is still absent from current main, but this patch needs a rework before it can be salvaged.

Problems

  • At plugins/platforms/mattermost/adapter.py:264, an unknown type is treated as threadable. However get_chat_info() returns "channel" when its API lookup fails (:317-321), so a transient DM lookup failure can still send root_id.
  • Current main centralizes thread-root selection, metadata roots, and final-notify fallback in _thread_root_for_send() / _post_preserving_thread() (plugins/platforms/mattermost/adapter.py:163-215); the old inline-call-site patch no longer covers that structure.
  • The PR changes no tests or user-facing mode declarations. plugin.yaml:34-36 and website/docs/user-guide/messaging/mattermost.md:202-212 still describe only thread|off.

Suggested changes

  • Implement auto selection in the centralized resolver, preserve metadata-root handling, default unknown lookup results to flat, and add auto-mode tests plus documentation/config prompt updates.

Automated hermes-sweeper review.

Comment thread plugins/platforms/mattermost/adapter.py Outdated
if not ch_type:
info = await self.get_chat_info(chat_id)
ch_type = info["type"]
return ch_type != "dm"

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.

get_chat_info() returns type: "channel" when its API lookup fails (lines 317-321), so a transient lookup failure in a DM reaches this branch and sends root_id anyway. Treat unknown/failed lookup as flat, or seed the channel-type cache from the inbound WebSocket event.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
`thread` keeps busy channels legible but looks strange in a DM, where a
one-on-one conversation ends up nested inside itself; `off` avoids that but
gives up thread tidiness everywhere. `auto` picks per conversation: thread in
channels, stay flat in DMs and group DMs.

Reimplemented against the current centralized structure, per review of
NousResearch#33149. The previous revision patched the inline call sites, which no longer
exist -- thread-root selection now lives in `_thread_root_for_send()`, so the
mode decision belongs there and metadata roots keep working for `auto` the
same way they do for `thread`.

An indeterminate channel type always falls back to flat. Two specific traps:

- The lookup does NOT go through `get_chat_info()`. That helper reports
  "channel" when its API call fails, and "channel" is the threadable answer,
  so a transient DM lookup failure would have silently started threading
  inside a DM. `_auto_mode_should_thread()` reads the raw type and treats a
  failure, a missing type, or an unrecognised code as unknown → flat.
- The decision keys on the RAW Mattermost type code, not `_CHANNEL_TYPE_MAP`,
  which folds `P` (private channel) into "group" alongside real group DMs. A
  private channel is a channel and should thread; going through the map would
  have forced it flat.

Failed lookups are deliberately not cached, so a later send can still learn
the real type once the API recovers. Successful ones are, since a channel's
type does not change.

On the inbound path the same decision is shared via `_mode_threads_raw_type()`,
where the websocket event already carries the channel type and no lookup is
needed. `thread` mode behaviour there is unchanged (it already excluded DMs).

Also updates the user-facing declarations the review flagged as missing:
`plugin.yaml` prompt/description and the reply-mode table in the Mattermost
guide, including the flat-on-unknown behaviour.

Tests: 12 new cases covering channels vs DMs vs group DMs, failed lookup,
unrecognised code, cache hit and cache-miss-on-failure, metadata roots in
both channel and DM, no-root short circuit, and the inbound matrix for all
three modes.
@ugoenyioha
ugoenyioha force-pushed the feat/mattermost-auto-reply branch from 102bd77 to b2c0cc5 Compare July 26, 2026 18:31
@ugoenyioha

Copy link
Copy Markdown
Author

Reworked against current main — all three problems addressed.

  • Unknown types / masked failures: the lookup no longer goes through get_chat_info(), precisely because it reports "channel" on failure and that's the threadable answer. _auto_mode_should_thread() reads the raw type and treats failure, missing type, or an unrecognised code as unknown → flat. Failed lookups aren't cached, so a later send can still learn the real type.
  • Centralized structure: the decision now lives in _thread_root_for_send() (which takes chat_id — already in scope at all four call sites), so metadata roots keep working for auto as they do for thread. The inbound path shares it via _mode_threads_raw_type(); thread behaviour there is unchanged.
  • Tests and declarations: 12 new tests, plus plugin.yaml and the reply-mode table in the Mattermost guide.

One thing I found beyond the review: the decision keys on the raw type code rather than _CHANNEL_TYPE_MAP, because that map folds P (private channel) into "group" next to real group DMs. Going through the map would have forced private channels flat, which isn't the intent — they're channels.

@ugoenyioha

Copy link
Copy Markdown
Author

Closing in favour of #47079, which is the better design. Recording why, since the AI triage note above concluded "not a duplicate" and that reading deserves a response.

The triage comparison was diff-shaped: it saw distinct code in each PR and inferred complementary value. But the distinctive parts of this PR — fail-closed channel classification, private-channel handling — exist only because my rule needs to know the channel type. #47079's rule doesn't, so those parts protect against a problem its design never has.

Concretely, the two auto modes answer different questions:

#47079's is better on the points that matter:

  • No I/O. It reads metadata["thread_id"], already in hand. Mine calls channels/<id> to classify, which forces a cache, a cache policy, and failure handling. That complexity is self-inflicted.
  • It respects explicit intent. If a user deliberately replies inside a thread in a DM, feat(mattermost): add reply_mode "auto" for thread-aware replies #47079 mirrors that; mine forces the reply flat and breaks the thread they chose.
  • It fixes session fragmentation. Under thread, every top-level message roots a new thread — and for gateways that key sessions by thread root, a new session and lost context. Mine inherits that from thread; feat(mattermost): add reply_mode "auto" for thread-aware replies #47079 removes it.
  • It still gets flat DMs, which is the thing this PR was built for — as a consequence of the simpler rule rather than a lookup.
  • Wider surface. It updates the setup wizard (hermes_cli/gateway.py) and website/docs/reference/environment-variables.md. Mine updated plugin.yaml and the Mattermost guide only, so it would have shipped a wizard advertising two modes while plugin.yaml advertised three.

The honest case for keeping both is narrow: mine is really "thread minus the DM awkwardness", which serves someone already on thread who wants DM relief, whereas #47079 serves someone on off who wants thread continuity. That's a real but small niche, and it doesn't justify two modes competing for one name or a second implementation to maintain. If that niche ever comes up, it should be a differently-named mode built on #47079's foundation, not this branch.

One durable finding worth keeping regardless — posting it on #47079 too, since it will outlive this PR: get_chat_info() returns {"type": "channel"} when its API lookup fails, and "channel" is the threadable answer. Any future code that branches on channel type and reads it through that helper will silently thread inside a DM on a transient API failure. Worth a fail-closed wrapper if channel-type logic is ever added.

Thanks @sidorovanthon — no changes needed on your side.

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

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants