Matrix threads in the transport (M2.1, PR A)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m47s

Transport.post(text, thread=...) returns the event ID, which can root a
thread; a threaded post carries m.thread with the reply fallback for
clients without threads. Inbound messages carry their event ID and thread
root: a message in a thread gives its root, a plain reply joins the
thread of the message it replies to when known, and the quoted lines of a
rich reply are no longer part of the text. The in-memory transport plays
threads for tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-02 23:40:24 +00:00
1 parent 5077ed0067
commit 6dff36f458
4 files changed
+266 -12

No files matched your search

+122
View File
@@ -237,3 +237,125 @@ def test_a_mention_later_in_the_message_is_left_alone() -> None:
assert received == [
InboundMessage(sender="@don:test", text="approve, cc @alice:test")
]
# --- threads (L5): a work item's conversation is a thread --------------------
def threaded_transport() -> tuple[MatrixTransport, list[dict], list[InboundMessage]]:
sent: list[dict] = []
ids = iter(f"$sent{n}" for n in range(1, 100))
def handler(request: httpx.Request) -> httpx.Response:
sent.append(json.loads(request.content))
return httpx.Response(200, json={"event_id": next(ids)})
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
received: list[InboundMessage] = []
transport.on_message(received.append)
return transport, sent, received
def event(event_id: str, body: str, relates: dict | None = None) -> dict:
content: dict = {"msgtype": "m.text", "body": body}
if relates is not None:
content["m.relates_to"] = relates
return {
"type": "m.room.message",
"sender": "@don:test",
"event_id": event_id,
"content": content,
}
def deliver(transport: MatrixTransport, *events: dict) -> None:
transport._deliver(sync_body(list(events), "b"))
def test_post_returns_the_event_id_and_threads_a_reply() -> None:
transport, sent, _ = threaded_transport()
root = transport.post("queued sandbox#14")
transport.post("As I understand it: ...", thread=root)
assert root == "$sent1"
assert "m.relates_to" not in sent[0]
assert sent[1]["m.relates_to"] == {
"rel_type": "m.thread",
"event_id": "$sent1",
# Clients without threads show it as a reply to the root.
"is_falling_back": True,
"m.in_reply_to": {"event_id": "$sent1"},
}
def test_a_message_in_a_thread_carries_its_root() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$top", "status"),
event("$in", "go", {"rel_type": "m.thread", "event_id": "$root"}),
)
assert [(m.text, m.event_id, m.thread) for m in received] == [
("status", "$top", None),
("go", "$in", "$root"),
]
def test_a_plain_reply_to_a_threaded_message_joins_its_thread() -> None:
# Clients without thread support send an ordinary reply, quoting.
transport, _, received = threaded_transport()
root = transport.post("queued sandbox#14")
in_thread = transport.post("As I understand it: ...", thread=root)
deliver(
transport,
event(
"$r1",
"> <@nisse-dev:test> As I understand it: ...\n\nyes, go",
{"m.in_reply_to": {"event_id": in_thread}},
),
event(
"$r2",
"> <@nisse-dev:test> queued sandbox#14\n\nthanks",
{"m.in_reply_to": {"event_id": root}},
),
)
assert [(m.text, m.thread) for m in received] == [
("yes, go", root), # the quote is not what was said
("thanks", root), # a reply to the root joins its thread
]
def test_a_reply_to_an_unknown_message_stays_at_top_level() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$r", "> <@x:test> old\n\nhm", {"m.in_reply_to": {"event_id": "$old"}}),
)
assert [(m.text, m.thread) for m in received] == [("hm", None)]
def test_a_reply_to_the_operators_own_threaded_message_joins_the_thread() -> None:
transport, _, received = threaded_transport()
deliver(
transport,
event("$t", "first", {"rel_type": "m.thread", "event_id": "$root"}),
event(
"$r",
"> <@don:test> first\n\nand also",
{"m.in_reply_to": {"event_id": "$t"}},
),
)
assert [m.thread for m in received] == ["$root", "$root"]
def test_in_memory_transport_plays_threads() -> None:
from nisse_dev.transport import InMemoryTransport
transport = InMemoryTransport()
seen: list[InboundMessage] = []
transport.on_message(seen.append)
root = transport.post("queued #14")
transport.post("restated", thread=root)
reply = transport.receive("@don:test", "go", thread=root)
assert transport.outbound[-1].thread == root
assert (seen[0].thread, seen[0].event_id) == (root, reply)