{"repo":"NousResearch/hermes-agent","public_id":"30970","display_label":"Discord backfill bugs and notification configurability","summary":"Issues and fixes around Discord thread history backfill being disrupted by bot/status messages losing context, plus work to make background review and memory notifications configurable and consistently honored across CLI, gateway, and TUI.","subsystem":"gateway","counts":{"members":11,"open_issues":4,"open_issues_distinct":4,"open_prs":3,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49081},"resolution":{"state":"partially_resolved","by":"pr-49081"},"freshness":{"state":"verified","last_verified_at":"2026-06-19T14:29:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.14181818181818184},"fix_edges":[{"issue_number":43843,"pr_number":49081,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":30970,"title":"Gateway should allow suppressing background review notifications","url":null,"state":"open"},{"kind":"issue","number":42079,"title":"Discord thread history backfill loses context between bot messages","url":null,"state":"open"},{"kind":"issue","number":43843,"title":"[Bug]: Delayed harness status messages (self-improvement review, heartbeats, etc.) partition Discord channel-history backfill and hide real replies","url":null,"state":"closed"},{"kind":"issue","number":46908,"title":"[Feature]: add config gate to suppress background-review self-improvement notifications (mirror display.tool_progress)","url":null,"state":"open"},{"kind":"issue","number":64736,"title":"display.memory_notifications is ignored by the classic CLI (works on gateway and TUI)","url":null,"state":"open"},{"kind":"pr","number":30971,"title":"feat(gateway): make background review notifications configurable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28727","number":28727}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42075,"title":"fix(discord): don't break at bot messages in thread backfill","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"5a6e48d5de","number":0},{"kind":"commit","value":"c91823f21d","number":0},{"kind":"commit","value":"f43eabee5f","number":0},{"kind":"pr","value":"#90857","number":90857}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43844,"title":"fix(gateway): don't let delayed Discord status messages partition history backfill","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"caaa916289","number":0},{"kind":"pr","value":"#49081","number":49081}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":49081,"title":"fix(gateway): don't let delayed Discord status messages partition history backfill","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","plugins/platforms/discord/adapter.py","tests/gateway/test_discord_free_response.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -195,6 +195,19 @@ def _gateway_platform_value(platform: Any) -> str:\n     return str(getattr(platform, \"value\", platform) or \"\").strip().lower()\n \n \n+def _non_conversational_metadata(\n+    metadata: Optional[Dict[str, Any]] = None,\n+    *,\n+    platform: Any = None,\n+) -> Optional[Dict[str, Any]]:\n+    \"\"\"Mark Discord lifecycle/status sends without changing other platforms.\"\"\"\n+    if _gateway_platform_value(platform) != \"discord\":\n+        return metadata\n+    merged = dict(metadata or {})\n+    merged[\"non_conversational\"] = True\n+    return merged\n+\n+\n def _is_transient_network_error(exc: BaseException) -> bool:\n     \"\"\"Return True for transient network errors safe to log + swallow.\n ","@@ -11746,7 +11759,11 @@ async def _flush_buffer() -> None:\n             chunks = [clean[i:i + max_chunk] for i in range(0, len(clean), max_chunk)]\n             for chunk in chunks:\n                 try:\n-                    await adapter.send(chat_id, f\"```\\n{chunk}\\n```\", metadata=metadata)\n+                    await adapter.send(\n+                        chat_id,\n+                        f\"```\\n{chunk}\\n```\",\n+                        metadata=_non_conversational_metadata(metadata, platform=platform),\n+                    )\n                 except Exception as e:\n                     logger.debug(\"Update stream send failed: %s\", e)\n ","@@ -11769,12 +11786,16 @@ async def _flush_buffer() -> None:\n                     exit_code_raw = exit_code_path.read_text().strip() or \"1\"\n                     exit_code = int(exit_code_raw)\n                     if exit_code == 0:\n-                        await adapter.send(chat_id, \"✅ Hermes update finished.\", metadata=metadata)\n+                        await adapter.send(\n+                            chat_id,\n+                            \"✅ Hermes update finished.\",\n+                            metadata=_non_conversational_metadata(metadata, platform=platform),\n+                        )\n                     else:\n                         await adapter.send(\n                             chat_id,\n                             \"❌ Hermes update failed (exit code {}).\".format(exit_code),\n-                            metadata=metadata,\n+                            metadata=_non_conversational_metadata(metadata, platform=platform),\n                         )\n                     logger.info(\"Update finished (exit=%s), notified %s\", exit_code, session_key)\n                 except Exception as e:","@@ -11825,7 +11846,7 @@ async def _flush_buffer() -> None:\n                                     prompt=prompt_text,\n                                     default=default,\n                                     session_key=session_key,\n-                                    metadata=metadata,\n+                                    metadata=_non_conversational_metadata(metadata, platform=platform),\n                                 )\n                                 sent_buttons = True\n                             except Exception as btn_err:","@@ -11839,7 +11860,7 @@ async def _flush_buffer() -> None:\n                                 f\"{prompt_text}{default_hint}\\n\\n\"\n                                 f\"Reply `{_p}approve` (yes) or `{_p}deny` (no), \"\n                                 f\"or type your answer directly.\",\n-                                metadata=metadata,\n+                                metadata=_non_conversational_metadata(metadata, platform=platform),\n                             )\n                         # Keep the prompt marker on disk until the user\n                         # answers. If the gateway restarts mid-prompt, the","@@ -11863,7 +11884,7 @@ async def _flush_buffer() -> None:\n                 await adapter.send(\n                     chat_id,\n                     \"❌ Hermes update timed out after 30 minutes.\",\n-                    metadata=metadata,\n+                    metadata=_non_conversational_metadata(metadata, platform=platform),\n                 )\n             except Exception:\n                 pass","@@ -11969,7 +11990,11 @@ async def _send_update_notification(self) -> bool:\n                     msg = \"✅ Hermes update finished successfully.\"\n                 else:\n                     msg = \"❌ Hermes update failed. Check the gateway logs or run `hermes update` manually for details.\"\n-                await adapter.send(chat_id, msg, metadata=metadata)\n+                await adapter.send(\n+                    chat_id,\n+                    msg,\n+                    metadata=_non_conversational_metadata(metadata, platform=platform),\n+                )\n                 logger.info(\n                     \"Sent post-update notification to %s:%s (exit=%s)\",\n                     platform_str,","@@ -12032,7 +12057,7 @@ async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[\n             result = await adapter.send(\n                 str(chat_id),\n                 \"♻ Gateway restarted successfully. Your session continues.\",\n-                metadata=metadata,\n+                metadata=_non_conversational_metadata(metadata, platform=platform),\n             )\n             # adapter.send() catches provider errors (e.g. \"Chat not found\")\n             # and returns SendResult(success=False) rather than raising, so","@@ -12099,9 +12124,21 @@ async def _send_home_channel_startup_notifications(\n                     adapter=adapter,\n                 )\n                 if metadata:\n-                    result = await adapter.send(str(home.chat_id), message, metadata=metadata)\n+                    result = await adapter.send(\n+                        str(home.chat_id),\n+                        message,\n+                        metadata=_non_conversational_metadata(metadata, platform=platform),\n+                    )\n                 else:\n-                    result = await adapter.send(str(home.chat_id), message)\n+                    _startup_meta = _non_conversational_metadata(platform=platform)\n+                    if _startup_meta:\n+                        result = await adapter.send(\n+                            str(home.chat_id),\n+                            message,\n+                            metadata=_startup_meta,\n+                        )\n+                    else:\n+                        result = await adapter.send(str(home.chat_id), message)\n                 if result is not None and getattr(result, \"success\", True) is False:\n                     logger.warning(\n                         \"Home-channel startup notification failed for %s:%s: %s\",","@@ -12742,7 +12779,11 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n                     if adapter and chat_id:\n                         try:\n                             send_meta = {\"thread_id\": thread_id} if thread_id else None\n-                            await adapter.send(chat_id, message_text, metadata=send_meta)\n+                            await adapter.send(\n+                                chat_id,\n+                                message_text,\n+                                metadata=_non_conversational_metadata(send_meta, platform=platform_name),\n+                            )\n                         except Exception as e:\n                             logger.error(\"Watcher delivery error: %s\", e)\n                 break","@@ -12763,7 +12804,11 @@ async def _run_process_watcher(self, watcher: dict) -> None:\n                 if adapter and chat_id:\n                     try:\n                         send_meta = {\"thread_id\": thread_id} if thread_id else None\n-                        await adapter.send(chat_id, message_text, metadata=send_meta)\n+                        await adapter.send(\n+                            chat_id,\n+                            message_text,\n+                            metadata=_non_conversational_metadata(send_meta, platform=platform_name),\n+                        )\n                     except Exception as e:\n                         logger.error(\"Watcher delivery error: %s\", e)\n ","@@ -14144,6 +14189,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non\n             if _progress_thread_id == source.thread_id\n             else {\"thread_id\": _progress_thread_id}\n         ) if _progress_thread_id else None\n+        _progress_metadata = _non_conversational_metadata(_progress_metadata, platform=source.platform)\n         _progress_reply_to = (\n             event_message_id\n             if source.platform in (Platform.FEISHU, Platform.MATTERMOST) and source.thread_id and event_message_id","@@ -14906,7 +14952,7 @@ def _deliver_bg_review_message(message: str) -> None:\n                     _status_adapter.send(\n                         _status_chat_id,\n                         message,\n-                        metadata=_status_thread_metadata,\n+                        metadata=_non_conversational_metadata(_status_thread_metadata, platform=source.platform),\n                     ),\n                     _loop_for_step,\n                     logger=logger,","@@ -15748,7 +15794,7 @@ async def _notify_long_running():\n                         _notify_res = await _notify_adapter.send(\n                             source.chat_id,\n                             _heartbeat_text,\n-                            metadata=_status_thread_metadata,\n+                            metadata=_non_conversational_metadata(_status_thread_metadata, platform=source.platform),\n                         )\n                         if getattr(_notify_res, \"success\", False) and getattr(\n                             _notify_res, \"message_id\", None"]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -14,6 +14,7 @@\n import json\n import logging\n import os\n+import re\n import struct\n import subprocess\n import tempfile","@@ -29,6 +30,7 @@\n _DISCORD_COMMAND_SYNC_POLICIES = {\"safe\", \"bulk\", \"off\"}\n _DISCORD_COMMAND_SYNC_STATE_SUBDIR = \"gateway\"\n _DISCORD_COMMAND_SYNC_STATE_FILENAME = \"discord_command_sync_state.json\"\n+_DISCORD_NONCONVERSATIONAL_STATE_FILENAME = \"discord_nonconversational_messages.json\"\n _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5\n _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0\n # Discord enforces a hard cap of 100 global application (slash) commands per","@@ -37,6 +39,37 @@\n # every slash command — not just the overflow ones. We keep the desired set\n # at or below this limit at registration time.\n _DISCORD_MAX_APP_COMMANDS = 100\n+_DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({\n+    \"non_conversational\",\n+    \"non_conversational_history\",\n+})\n+# Upgrade-bridge fallback only. The primary mechanism is the persisted\n+# non-conversational message-ID set populated from explicitly marked sends\n+# (metadata[\"non_conversational\"]). These regexes exist solely to recognize\n+# status bumps emitted by an older gateway version that pre-dates the marking,\n+# so they don't partition history after an upgrade. New emitters should set the\n+# metadata flag, not rely on a regex here.\n+_DISCORD_NONCONVERSATIONAL_HISTORY_MESSAGE_PATTERNS = (\n+    re.compile(r\"^\\s*💾\\s*Self-improvement review:\\s+\\S[\\s\\S]*$\", re.IGNORECASE),\n+    # Legacy/background-review test doubles used this shorter form before the\n+    # self-improvement prefix became the stable emitter contract.\n+    re.compile(\n+        r\"^\\s*💾\\s+Skill\\s+['\\\"].+?['\\\"]\\s+(?:created|updated|improved|patched)\\.?\\s*$\",\n+        re.IGNORECASE,\n+    ),\n+    re.compile(r\"^\\s*⏳\\s+Working\\s+—\\s+\\d+\\s+min(?:\\s|$)\", re.IGNORECASE),\n+    re.compile(\n+        r\"^\\s*\\[Background process\\s+\\S+\\s+\"\n+        r\"(?:finished with exit code|is still running~)[\\s\\S]*\\]\\s*$\",\n+        re.IGNORECASE,\n+    ),\n+    re.compile(\n+        r\"^\\s*(?:✅|❌)\\s+Hermes update\\s+\"\n+        r\"(?:finished|failed|timed out)[\\s\\S]*$\",\n+        re.IGNORECASE,\n+    ),\n+    re.compile(r\"^\\s*♻️?\\s+Gateway\\s+(?:restarted successfully|online\\b)[\\s\\S]*$\", re.IGNORECASE),\n+)\n \n try:\n     import discord","@@ -55,7 +88,6 @@\n sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))\n \n from gateway.config import Platform, PlatformConfig\n-import re\n \n from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker\n from utils import atomic_json_write","@@ -132,6 +164,73 @@ def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[s\n     return None\n \n \n+class _DiscordNonConversationalMessageTracker:\n+    \"\"\"Persistent bounded set of Discord message IDs that are status noise.\"\"\"\n+\n+    _MAX_TRACKED = 2000\n+\n+    def __init__(self, max_tracked: int = _MAX_TRACKED):\n+        self._max_tracked = max_tracked\n+        self._ids: dict[str, None] = dict.fromkeys(self._load())\n+\n+    def _state_path(self) -> _Path:\n+        from hermes_constants import get_hermes_home\n+\n+        return (\n+            get_hermes_home()\n+            / _DISCORD_COMMAND_SYNC_STATE_SUBDIR\n+            / _DISCORD_NONCONVERSATIONAL_STATE_FILENAME\n+        )\n+\n+    def _load(self) -> list[str]:\n+        path = self._state_path()\n+        if not path.exists():\n+            return []\n+        try:\n+            data = json.loads(path.read_text(encoding=\"utf-8\"))\n+            if isinstance(data, list):\n+                return [str(message_id) for message_id in data if str(message_id).strip()]\n+        except Exception:\n+            logger.debug(\"[%s] Failed to load non-conversational Discord IDs\", \"Discord\")\n+        return []\n+\n+    def _save(self) -> None:\n+        ids = list(self._ids)\n+        if len(ids) > self._max_tracked:\n+            ids = ids[-self._max_tracked:]\n+            self._ids = dict.fromkeys(ids)\n+        try:\n+            atomic_json_write(self._state_path(), ids, indent=None)\n+        except Exception:\n+            logger.debug(\"[%s] Failed to save non-conversational Discord IDs\", \"Discord\", exc_info=True)\n+\n+    def mark_many(self, message_ids: List[str]) -> None:\n+        changed = False\n+        for message_id in message_ids:\n+            key = str(message_id or \"\").strip()\n+            if key and key not in self._ids:\n+                self._ids[key] = None\n+                changed = True\n+        if changed:\n+            self._save()\n+\n+    def __contains__(self, message_id: str) -> bool:\n+        return str(message_id or \"\") in self._ids\n+\n+\n+def _metadata_marks_nonconversational(metadata: Optional[Dict[str, Any]]) -> bool:\n+    \"\"\"Return True when an outbound send was explicitly marked as status-only.\"\"\"\n+    if not isinstance(metadata, dict):\n+        return False\n+    return any(bool(metadata.get(key)) for key in _DISCORD_NONCONVERSATIONAL_METADATA_KEYS)\n+\n+\n+def _looks_like_nonconversational_history_message(content: str) -> bool:\n+    \"\"\"Fallback recognizer for legacy status bumps missing persisted IDs.\"\"\"\n+    text = content or \"\"\n+    return any(pattern.match(text) for pattern in _DISCORD_NONCONVERSATIONAL_HISTORY_MESSAGE_PATTERNS)\n+\n+\n def _clean_discord_id(entry: str) -> str:\n     \"\"\"Strip common prefixes from a Discord user ID or username entry.\n ","@@ -681,6 +780,9 @@ def __init__(self, config: PlatformConfig):\n         # history backfill to skip the full scan on hot paths.  Falls back to\n         # scanning channel.history() on cache miss (cold start / restart).\n         self._last_self_message_id: Dict[str, str] = {}\n+        # Persistent set of bot-authored lifecycle/status message IDs that\n+        # should not act as conversational history boundaries after restart.\n+        self._nonconversational_messages = _DiscordNonConversationalMessageTracker()\n \n     def _handle_bot_task_done(self, task: asyncio.Task) -> None:\n         \"\"\"Surface post-startup discord.py task exits to the gateway supervisor.","@@ -1577,6 +1679,7 @@ async def send(\n             thread_id = None\n             if metadata and metadata.get(\"thread_id\"):\n                 thread_id = metadata[\"thread_id\"]\n+            nonconversational = _metadata_marks_nonconversational(metadata)\n \n             if thread_id:\n                 # Fetch the thread directly — threads are addressed by their own ID.","@@ -1654,7 +1757,10 @@ async def send(\n             # backfill — avoids a full channel.history() scan on hot paths.\n             if message_ids:\n                 _target_id = thread_id or chat_id\n-                self._last_self_message_id[_target_id] = message_ids[-1]\n+                if nonconversational:\n+                    self._nonconversational_messages.mark_many(message_ids)\n+                elif not _looks_like_nonconversational_history_message(content):\n+                    self._last_self_message_id[_target_id] = message_ids[-1]\n \n             return SendResult(\n                 success=True,","@@ -4203,23 +4309,29 @@ async def _fetch_channel_context(\n                 after=_after_obj,\n                 oldest_first=False,\n             ):\n+                # Skip system messages (pins, joins, thread renames, etc.)\n+                if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:\n+                    continue\n+\n+                content = getattr(msg, \"clean_content\", msg.content) or \"\"\n+                if (\n+                    str(getattr(msg, \"id\", \"\")) in self._nonconversational_messages\n+                    or _looks_like_nonconversational_history_message(content)\n+                ):\n+                    continue\n+\n                 # Stop at our own message — this is the partition point.\n                 # Everything before this is already in the session transcript.\n                 # (Redundant when _after_obj is set, but needed for cold start.)\n                 if msg.author == self._client.user:\n                     break\n \n-                # Skip system messages (pins, joins, thread renames, etc.)\n-                if msg.type not in {discord.MessageType.default, discord.MessageType.reply}:\n-                    continue\n-\n                 # Respect DISCORD_ALLOW_BOTS for other bots.\n                 # For history context, \"mentions\" is treated as \"all\" — we are\n                 # deciding what context to show, not whether to respond.\n                 if getattr(msg.author, \"bot\", False) and not include_other_bots:\n                     continue\n \n-                content = getattr(msg, \"clean_content\", msg.content) or \"\"\n                 if not content and msg.attachments:\n                     content = \"(attachment)\"\n                 if not content:","@@ -4693,6 +4805,8 @@ async def send_update_prompt(\n             )\n             msg = await channel.send(embed=embed, view=view)\n             view._message = msg  # store for on_timeout expiration editing\n+            if _metadata_marks_nonconversational(metadata):\n+                self._nonconversational_messages.mark_many([str(msg.id)])\n             return SendResult(success=True, message_id=str(msg.id))\n         except Exception as e:\n             return SendResult(success=False, error=str(e))"]},{"file":"tests/gateway/test_discord_free_response.py","hunks":["@@ -666,6 +666,70 @@ async def test_fetch_channel_context_stops_at_self_message_and_reverses_to_chron\n     )\n \n \n[redacted:email]\n+async def test_fetch_channel_context_skips_self_improvement_boundary_message(adapter, monkeypatch):\n+    \"\"\"Delayed harness status bumps must not hide messages after the real reply.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"all\")\n+    adapter.config.extra[\"history_backfill_limit\"] = 10\n+\n+    codex = SimpleNamespace(id=55, display_name=\"Codex\", name=\"Codex\", bot=True)\n+    human = SimpleNamespace(id=56, display_name=\"Alice\", name=\"Alice\", bot=False)\n+\n+    channel = FakeHistoryChannel(\n+        [\n+            make_history_message(\n+                author=adapter._client.user,\n+                content=\"arbitrary lifecycle text from a metadata-marked send\",\n+                msg_id=9,\n+            ),\n+            make_history_message(\n+                author=adapter._client.user,\n+                content=\"[Background process bg-123 finished with exit code 0~ Here's the final output:\\nok]\",\n+                msg_id=8,\n+            ),\n+            make_history_message(\n+                author=codex,\n+                content=\"♻ Gateway restarted successfully. Your session continues.\",\n+                msg_id=7,\n+            ),\n+            make_history_message(\n+                author=codex,\n+                content=\"💾 Self-improvement review: Memory updated\",\n+                msg_id=6,\n+            ),\n+            make_history_message(author=human, content=\"question after reply\", msg_id=5),\n+            make_history_message(\n+                author=adapter._client.user,\n+                content=\"💾 Self-improvement review: Skill 'hermes-gateway-display-config' patched\",\n+                msg_id=4,\n+            ),\n+            make_history_message(author=codex, content=\"Codex final answer\", msg_id=3),\n+            make_history_message(author=human, content=\"prompt before reply\", msg_id=2),\n+            make_history_message(author=adapter._client.user, content=\"our prior response\", msg_id=1),\n+        ],\n+        channel_id=123,\n+    )\n+    adapter._nonconversational_messages.mark_many([\"9\"])\n+\n+    result = await adapter._fetch_channel_context(channel, before=make_message(channel=channel, content=\"trigger\"))\n+\n+    assert result == (\n+        \"[Recent channel messages]\\n\"\n+        \"[Alice] prompt before reply\\n\"\n+        \"[Codex [bot]] Codex final answer\\n\"\n+        \"[Alice] question after reply\"\n+    )\n+\n+\n+def test_nonconversational_fallback_requires_self_improvement_emoji():\n+    assert discord_platform._looks_like_nonconversational_history_message(\n+        \"💾 Self-improvement review: Memory updated\"\n+    )\n+    assert not discord_platform._looks_like_nonconversational_history_message(\n+        \"Self-improvement review: this is a normal assistant heading\"\n+    )\n+\n+\n @pytest.mark.asyncio\n async def test_fetch_channel_context_skips_other_bots_when_allow_bots_none(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"none\")","@@ -801,6 +865,33 @@ def history(self, *, limit, before, after=None, oldest_first=None):\n     assert recorded_after[\"value\"] is None\n \n \n[redacted:email]\n+async def test_discord_send_does_not_cache_nonconversational_status_as_history_boundary(adapter):\n+    \"\"\"Automated status notifications should not move the backfill boundary.\"\"\"\n+\n+    class SendingChannel(FakeTextChannel):\n+        async def send(self, content, reference=None):\n+            return SimpleNamespace(id=222)\n+\n+    channel = SendingChannel(channel_id=777)\n+    adapter._client = SimpleNamespace(\n+        user=adapter._client.user,\n+        get_channel=lambda channel_id: channel if channel_id == 777 else None,\n+        fetch_channel=AsyncMock(return_value=channel),\n+    )\n+    adapter._last_self_message_id[\"777\"] = \"111\"\n+\n+    result = await adapter.send(\n+        \"777\",\n+        \"arbitrary lifecycle text from gateway\",\n+        metadata={\"non_conversational\": True},\n+    )\n+\n+    assert result.success is True\n+    assert adapter._last_self_message_id[\"777\"] == \"111\"\n+    assert \"222\" in adapter._nonconversational_messages\n+\n+\n @pytest.mark.asyncio\n async def test_discord_shared_channel_backfill_prepends_context(adapter, monkeypatch):\n     monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")","@@ -925,5 +1016,3 @@ async def test_discord_auto_thread_skips_backfill(adapter, monkeypatch):\n \n     adapter._auto_create_thread.assert_awaited_once()\n     adapter._fetch_channel_context.assert_not_awaited()\n-\n-\n"]}]},{"kind":"pr","number":64737,"title":"fix: honor display.memory_notifications in the classic CLI","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"3204a24eefdc","number":0},{"kind":"pr","value":"#64736","number":64736},{"kind":"pr","value":"#64737","number":64737},{"kind":"pr","value":"#64737","number":64737},{"kind":"pr","value":"#64737","number":64737},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#64736","number":64736},{"kind":"pr","value":"#64737","number":64737}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":90857,"title":"fix(discord): rebase thread backfill continue onto current main","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}