From 09cbce43e0da7c49d4b60f56658cfabd343ac55a Mon Sep 17 00:00:00 2001 From: yuvalfis Date: Thu, 6 Aug 2026 09:49:30 +0300 Subject: [PATCH] fix(buzz): preserve stable thread roots --- plugins/platforms/buzz/adapter.py | 27 +++++++++++-- tests/gateway/test_buzz_adapter.py | 65 ++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index 432b8ce3ac..745d2b61ba 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -782,7 +782,7 @@ class BuzzAdapter(BasePlatformAdapter): if not content: return SendResult(success=False, error="Empty message") args = ["messages", "send", "--channel", str(chat_id), "--content", "-"] - reply_target = reply_to or (metadata or {}).get("thread_id") + reply_target = (metadata or {}).get("thread_id") or reply_to if reply_target: args += ["--reply-to", str(reply_target)] code, out, err = await self._run_cli(args, input_text=content) @@ -929,8 +929,9 @@ class BuzzAdapter(BasePlatformAdapter): "--file", str(local), "--content", "-", ] - if reply_to: - args += ["--reply-to", str(reply_to)] + reply_target = (metadata or {}).get("thread_id") or reply_to + if reply_target: + args += ["--reply-to", str(reply_target)] code, out, err = await self._run_cli(args, input_text=caption or "") if code != 0: return SendResult(success=False, error=_cli_error_message(err, code), retryable=code == 2) @@ -1310,6 +1311,23 @@ class BuzzAdapter(BasePlatformAdapter): # open with "@Chip" even though no mention is required there, so the # strip applies to both chat types. dispatch_text = self._strip_mention(content) + tags = event.get("tags") + thread_id = ( + next( + ( + str(tag[1]) + for tag in tags + if isinstance(tag, (list, tuple)) + and len(tag) > 3 + and tag[0] == "e" + and tag[1] + and tag[3] == "root" + ), + None, + ) + if isinstance(tags, list) + else None + ) await self._dispatch_message( text=dispatch_text, @@ -1319,6 +1337,7 @@ class BuzzAdapter(BasePlatformAdapter): user_name=await self._resolve_user_name(pubkey), message_id=event_id, created_at=created_at, + thread_id=thread_id, ) # ── DM classification (issue #68871) ────────────────────────────────── @@ -1482,6 +1501,7 @@ class BuzzAdapter(BasePlatformAdapter): user_name: str, message_id: str, created_at: int, + thread_id: Optional[str] = None, ) -> None: """Build a MessageEvent and hand it to the base class handler.""" if not self._message_handler: @@ -1493,6 +1513,7 @@ class BuzzAdapter(BasePlatformAdapter): chat_type=chat_type, user_id=user_id, user_name=user_name, + thread_id=thread_id, ) event = MessageEvent( diff --git a/tests/gateway/test_buzz_adapter.py b/tests/gateway/test_buzz_adapter.py index 7da16bf268..660fa41823 100644 --- a/tests/gateway/test_buzz_adapter.py +++ b/tests/gateway/test_buzz_adapter.py @@ -828,6 +828,34 @@ class TestDmClassification: assert a._may_reclassify_as_dm(CHANNEL) is False +class TestThreadRoots: + + @pytest.mark.asyncio + async def test_inbound_root_e_tag_propagates_to_session_source(self): + adapter = _make_adapter() + adapter._channel_state[CHANNEL] = { + "chat_type": "group", + "last_ts": 0, + "seen": {}, + } + dispatched = [] + + async def capture(event): + dispatched.append(event) + + adapter._message_handler = AsyncMock() + adapter.handle_message = capture + event = _tagged_event("latest-child", CHANNEL, content="@Chip follow-up") + event["tags"] += [ + ["e", "stable-root", "", "root"], + ["e", "latest-parent", "", "reply"], + ] + + await adapter._handle_event(CHANNEL, adapter._channel_state[CHANNEL], event) + + assert dispatched[0].source.thread_id == "stable-root" + + # ── Sending ─────────────────────────────────────────────────────────────── @@ -871,6 +899,24 @@ class TestBuzzAdapterSend: args, _stdin = cli.calls[0] assert args[args.index("--reply-to") + 1] == "buzz-event-123" + @pytest.mark.asyncio + async def test_send_prefers_stable_thread_root_over_latest_reply(self): + adapter = _make_adapter() + cli = _ScriptedCli() + cli.script("messages", "send", {"accepted": True, "event_id": "evt124"}) + adapter._run_cli = cli + + await adapter.send( + CHANNEL, + "threaded reply", + reply_to="latest-child", + metadata={"thread_id": "stable-root"}, + ) + + args, _stdin = cli.calls[0] + assert args[args.index("--reply-to") + 1] == "stable-root" + + @pytest.mark.asyncio async def test_send_image_local_file_uses_file_flag(self, tmp_path): img = tmp_path / "shot.png" @@ -884,6 +930,25 @@ class TestBuzzAdapterSend: args, _stdin = cli.calls[0] assert args[args.index("--file") + 1] == str(img) + @pytest.mark.asyncio + async def test_send_image_local_file_prefers_stable_thread_root(self, tmp_path): + img = tmp_path / "shot.png" + img.write_bytes(b"\x89PNG fake") + adapter = _make_adapter() + cli = _ScriptedCli() + cli.script("messages", "send", {"accepted": True, "event_id": "evt127"}) + adapter._run_cli = cli + + await adapter.send_image( + CHANNEL, + str(img), + reply_to="latest-child", + metadata={"thread_id": "stable-root"}, + ) + + args, _stdin = cli.calls[0] + assert args[args.index("--reply-to") + 1] == "stable-root" + # ── Lifecycle ─────────────────────────────────────────────────────────────