Skip to content

fix(discord): anchor inbound message-map on thread root so directly-created threads keep threading - #319

Open
meganechan wants to merge 2 commits into
sandbox-quantum:mainfrom
meganechan:fix/discord-thread-anchor
Open

fix(discord): anchor inbound message-map on thread root so directly-created threads keep threading#319
meganechan wants to merge 2 commits into
sandbox-quantum:mainfrom
meganechan:fix/discord-thread-anchor

Conversation

@meganechan

Copy link
Copy Markdown

Fixes #318

What

_handle_inbound_message in core/switch_core/bridges/collaboration/bridge_core.py now anchors the recorded message-map correlation on msg.root_id or msg.message_ref instead of always msg.message_ref, and only records it when thread_root_id is None (i.e. the first bridged message of a thread).

Why

Discord's direct "Create Thread" flow mints a thread whose channel id is unrelated to any message id. The thread lookup (a few lines above the change) already keys on msg.root_id, but the record was keying on the message's own ref — so a directly-created thread's message-map entry could never be found by the next reply's lookup. Every reply after the first in such a thread posted top-level in Matrix, and agents received those events with no thread_id.

The sibling method _handle_inbound_command already anchors correctly (cmd.root_id or cmd.message_ref, line ~622) — this brings _handle_inbound_message in line with that existing precedent. Non-thread (top-level) messages are unaffected: msg.root_id is None for them, so the anchor is unchanged (msg.message_ref).

How verified

  • Root-caused against a live self-hosted v0.21.0 deployment: bridge_message_map held rows keyed {channel_id}:{message_id} for messages posted into a directly-created thread, while the lookup for the next reply computed {parent_channel_id}:{thread_channel_id} — a key that was never recorded. Full evidence + repro steps in Discord: directly-created threads lose threading after the first message (message-map keyed on message ref, not thread root) #318.
  • Added core/tests/switch_core/bridges/collaboration/test_bridge_inbound_thread_anchor.py:
    • test_directly_created_thread_second_reply_still_threads — simulates a thread whose id shares no id with any message (the Discord direct-create case); asserts the anchor recorded after the first reply is the thread id, and the second reply resolves and threads under it.
    • test_top_level_message_still_anchors_on_its_own_ref — regression guard that top-level messages are unaffected.
    • Verified the first test fails against the pre-fix code (git stash the fix, rerun) and passes after — confirms the test actually exercises the bug, not just the happy path.
  • uv run ruff check clean on both changed files.
  • Did not run the full just test suite (requires a real PostgreSQL instance per core/tests/ conventions) or just typecheck — happy to run these if a maintainer points me at CI, or CI will confirm on this PR.

Precedent referenced

_handle_inbound_command, same file, ~line 622:

thread_root_post = cmd.root_id or cmd.message_ref

🤖 Filed by eq3 (AI agent) on behalf of Tony (meganechan) — found while self-hosting Switch v0.21.0

…reated threads keep threading

Discord's direct "Create Thread" flow mints a thread channel id unrelated
to any message id. _handle_inbound_message recorded the message-map
correlation under the message's OWN ref, while the thread lookup keys on
msg.root_id (the thread id). The two only coincide when a thread is
created FROM an existing message (reply-in-thread), so a directly-created
thread's second message can never resolve its predecessor and every
reply after the first posts top-level in Matrix.

Anchor the recorded correlation on msg.root_id or msg.message_ref
instead, and only record when thread_root_id is None (first bridged
message of a thread anchors it; re-recording after that would just
shadow the resolved root). Mirrors the pattern _handle_inbound_command
already uses via cmd.root_id or cmd.message_ref.

Added test_bridge_inbound_thread_anchor.py covering the
directly-created-thread case (second reply must still thread) and the
top-level case (anchors on its own ref, unchanged).

Fixes sandbox-quantum#318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

All contributors have signed the CLA. ✅
Posted by the CLA Assistant Lite bot.

@meganechan meganechan closed this Aug 29, 2026
@meganechan

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

switch-cla-assistant Bot added a commit that referenced this pull request Aug 29, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNFAdqF3aeuhZb8beCmC2v
@meganechan

Copy link
Copy Markdown
Author

Ran the full local pre-PR verification (macOS, Python 3.13.11, uv 0.9.18) at HEAD (cc98ef6):

  • ruff format --check + ruff check — pass (366 files formatted, no lint issues; pushed a small reformat commit for the new test file to match)
  • mypy --config-file core/pyproject.toml core/switch_core/ connectors/ — pass, no issues found in 185 source files
  • pytest -c core/pyproject.toml core/tests/ — pass, 2013 passed, 5 deselected (integration-marked), 176.25s. Store tests ran against a real PostgreSQL via testcontainers, not mocks.

All green — closing the local-verification gap noted in the PR description.

— eq3 (AI agent) for @meganechan

@meganechan meganechan reopened this Aug 29, 2026
@amaudruz

amaudruz commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Thanks for this — the write-up in #318 is excellent, and the DB evidence made it quick to confirm. The diagnosis is right: the record keys on the message's own ref, the lookup keys on the thread root, and on Discord those only coincide when the thread was created from a message.

I'd like to fix it a bit differently, though — here's why.

The awkward constraint:

UniqueConstraint("bridge_id", "matrix_event_id"),
UniqueConstraint("bridge_id", "external_post_id"),

One Matrix event gets exactly one external post id. So each bridged message has a single key, and that key is doing two different jobs:

  1. message identity — which Discord message is this Matrix event? (the 👀 read marker via anchor_message_ref, plus edit/delete sync)
  2. thread anchor — which Matrix event does this external thread hang off?

On Mattermost and Slack those are the same thing, because a thread root really is a message in the channel. On Discord a thread is a channel, not a message, so they come apart. That's the real defect — one column doing two jobs.

Because there's only one key per event, any in-place fix has to pick one job over the other. This PR picks threading: with if thread_root_id is None, thread replies stop recording a mapping at all. Two problems with that:

  • It's shared code, so it also changes Slack, Mattermost, Telegram and Teams — none of which have the bug.
  • _external_post_for_matrix_event(event.anchor_event_id) (bridge_core.py:1600) then returns None for in-thread messages, so the 👀 marker silently stops landing on them, on every platform. Not covered by the tests here.

So rather than trade one for the other, I'd rather split them:

  • new bridge_thread_map(bridge_id, external_thread_id, matrix_thread_root_event_id)
  • threading reads and writes that
  • bridge_message_map goes back to being purely message ↔ message, unchanged for everyone

That fixes Discord with no trade-off and no behaviour change elsewhere. It needs a migration, so it's a bit more than this diff.

Would you be up for taking that on? Your test file carries over almost as-is — it's exactly the right case to assert. If you'd rather not, no problem at all, we'll pick it up from here and credit the issue.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discord: directly-created threads lose threading after the first message (message-map keyed on message ref, not thread root)

2 participants