{"repo":"NousResearch/hermes-agent","public_id":"2221","display_label":"Message role alternation bugs and normalization fixes","summary":"Multiple issues and fixes addressing violations of strict user/assistant message alternation, including cron job outputs injected with wrong roles, consecutive assistant message merging, and a feature request for broader chat history normalization support for strict backends.","subsystem":"cron","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2313,"merged_at":"2026-03-21T14:18:36+00:00"},"resolution":{"state":"resolved","by":"pr-2313"},"freshness":{"state":"stale","last_verified_at":"2026-03-21T14:18:36+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":2221,"pr_number":2313,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-21T14:18:36+00:00"}],"members":[{"kind":"issue","number":2221,"title":"bug(gateway): cron job outputs injected as assistant role, breaking message alternation","url":null,"state":"closed"},{"kind":"pr","number":2223,"title":"fix(gateway): use user role for cron mirror messages to maintain message alternation","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":2264,"title":"fix: bug(gateway): cron job outputs injected as assistant role, breaki","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2313","number":2313}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":2313,"title":"fix(cron): stop injecting cron outputs into gateway session history","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -159,15 +159,25 @@ def _deliver_result(job: dict, content: str) -> None:\n         logger.warning(\"Job '%s': platform '%s' not configured/enabled\", job[\"id\"], platform_name)\n         return\n \n+    # Wrap the content so the user knows this is a cron delivery and that\n+    # the interactive agent has no visibility into it.\n+    task_name = job.get(\"name\", job[\"id\"])\n+    wrapped = (\n+        f\"Cronjob Response: {task_name}\\n\"\n+        f\"-------------\\n\\n\"\n+        f\"{content}\\n\\n\"\n+        f\"Note: The agent cannot see this message, and therefore cannot respond to it.\"\n+    )\n+\n     # Run the async send in a fresh event loop (safe from any thread)\n     try:\n-        result = asyncio.run(_send_to_platform(platform, pconfig, chat_id, content, thread_id=thread_id))\n+        result = asyncio.run(_send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id))\n     except RuntimeError:\n         # asyncio.run() fails if there's already a running loop in this thread;\n         # spin up a new thread to avoid that.\n         import concurrent.futures\n         with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:\n-            future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, content, thread_id=thread_id))\n+            future = pool.submit(asyncio.run, _send_to_platform(platform, pconfig, chat_id, wrapped, thread_id=thread_id))\n             result = future.result(timeout=30)\n     except Exception as e:\n         logger.error(\"Job '%s': delivery to %s:%s failed: %s\", job[\"id\"], platform_name, chat_id, e)","@@ -177,12 +187,6 @@ def _deliver_result(job: dict, content: str) -> None:\n         logger.error(\"Job '%s': delivery error: %s\", job[\"id\"], result[\"error\"])\n     else:\n         logger.info(\"Job '%s': delivered to %s:%s\", job[\"id\"], platform_name, chat_id)\n-        # Mirror the delivered content into the target's gateway session\n-        try:\n-            from gateway.mirror import mirror_to_session\n-            mirror_to_session(platform_name, chat_id, content, source_label=\"cron\", thread_id=thread_id)\n-        except Exception as e:\n-            logger.warning(\"Job '%s': mirror_to_session failed: %s\", job[\"id\"], e)\n \n \n def _build_job_prompt(job: dict) -> str:"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -95,11 +95,58 @@ def test_bare_platform_falls_back_to_home_channel(self, monkeypatch):\n         }\n \n \n-class TestDeliverResultMirrorLogging:\n-    \"\"\"Verify that mirror_to_session failures are logged, not silently swallowed.\"\"\"\n+class TestDeliverResultWrapping:\n+    \"\"\"Verify that cron deliveries are wrapped with header/footer and no longer mirrored.\"\"\"\n \n-    def test_mirror_failure_is_logged(self, caplog):\n-        \"\"\"When mirror_to_session raises, a warning should be logged.\"\"\"\n+    def test_delivery_wraps_content_with_header_and_footer(self):\n+        \"\"\"Delivered content should include task name header and agent-invisible note.\"\"\"\n+        from gateway.config import Platform\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})) as send_mock:\n+            job = {\n+                \"id\": \"test-job\",\n+                \"name\": \"daily-report\",\n+                \"deliver\": \"origin\",\n+                \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+            }\n+            _deliver_result(job, \"Here is today's summary.\")\n+\n+        send_mock.assert_called_once()\n+        sent_content = send_mock.call_args.kwargs.get(\"content\") or send_mock.call_args[0][-1]\n+        assert \"Cronjob Response: daily-report\" in sent_content\n+        assert \"-------------\" in sent_content\n+        assert \"Here is today's summary.\" in sent_content\n+        assert \"The agent cannot see this message\" in sent_content\n+\n+    def test_delivery_uses_job_id_when_no_name(self):\n+        \"\"\"When a job has no name, the wrapper should fall back to job id.\"\"\"\n+        from gateway.config import Platform\n+\n+        pconfig = MagicMock()\n+        pconfig.enabled = True\n+        mock_cfg = MagicMock()\n+        mock_cfg.platforms = {Platform.TELEGRAM: pconfig}\n+\n+        with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n+             patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})) as send_mock:\n+            job = {\n+                \"id\": \"abc-123\",\n+                \"deliver\": \"origin\",\n+                \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n+            }\n+            _deliver_result(job, \"Output.\")\n+\n+        sent_content = send_mock.call_args.kwargs.get(\"content\") or send_mock.call_args[0][-1]\n+        assert \"Cronjob Response: abc-123\" in sent_content\n+\n+    def test_no_mirror_to_session_call(self):\n+        \"\"\"Cron deliveries should NOT mirror into the gateway session.\"\"\"\n         from gateway.config import Platform\n \n         pconfig = MagicMock()","@@ -109,20 +156,18 @@ def test_mirror_failure_is_logged(self, caplog):\n \n         with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n              patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})), \\\n-             patch(\"gateway.mirror.mirror_to_session\", side_effect=ConnectionError(\"network down\")):\n+             patch(\"gateway.mirror.mirror_to_session\") as mirror_mock:\n             job = {\n                 \"id\": \"test-job\",\n                 \"deliver\": \"origin\",\n                 \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"123\"},\n             }\n-            with caplog.at_level(logging.WARNING, logger=\"cron.scheduler\"):\n-                _deliver_result(job, \"Hello!\")\n+            _deliver_result(job, \"Hello!\")\n \n-        assert any(\"mirror_to_session failed\" in r.message for r in caplog.records), \\\n-            f\"Expected 'mirror_to_session failed' warning in logs, got: {[r.message for r in caplog.records]}\"\n+        mirror_mock.assert_not_called()\n \n     def test_origin_delivery_preserves_thread_id(self):\n-        \"\"\"Origin delivery should forward thread_id to send/mirror helpers.\"\"\"\n+        \"\"\"Origin delivery should forward thread_id to the send helper.\"\"\"\n         from gateway.config import Platform\n \n         pconfig = MagicMock()","@@ -132,6 +177,7 @@ def test_origin_delivery_preserves_thread_id(self):\n \n         job = {\n             \"id\": \"test-job\",\n+            \"name\": \"topic-job\",\n             \"deliver\": \"origin\",\n             \"origin\": {\n                 \"platform\": \"telegram\",","@@ -141,19 +187,11 @@ def test_origin_delivery_preserves_thread_id(self):\n         }\n \n         with patch(\"gateway.config.load_gateway_config\", return_value=mock_cfg), \\\n-             patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})) as send_mock, \\\n-             patch(\"gateway.mirror.mirror_to_session\") as mirror_mock:\n+             patch(\"tools.send_message_tool._send_to_platform\", new=AsyncMock(return_value={\"success\": True})) as send_mock:\n             _deliver_result(job, \"hello\")\n \n         send_mock.assert_called_once()\n         assert send_mock.call_args.kwargs[\"thread_id\"] == \"17585\"\n-        mirror_mock.assert_called_once_with(\n-            \"telegram\",\n-            \"-1001\",\n-            \"hello\",\n-            source_label=\"cron\",\n-            thread_id=\"17585\",\n-        )\n \n \n class TestRunJobSessionPersistence:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}