{"repo":"NousResearch/hermes-agent","public_id":"48912","display_label":"Native image session-key handoff issue and patches","summary":"A bug causes native image attachments to be silently dropped during long-lived or threaded sessions due to session-key mismatches in the gateway. Several patches address how pending native image paths are keyed and isolated by session key.","subsystem":"gateway","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":57648,"merged_at":"2026-07-03T10:21:09+00:00"},"resolution":{"state":"resolved","by":"pr-57648"},"freshness":{"state":"verified","last_verified_at":"2026-07-03T10:21:09+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":48912,"pr_number":57648,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-03T10:21:09+00:00"}],"members":[{"kind":"issue","number":48912,"title":"Native image_input_mode silently drops user images on long-lived/threaded sessions (session-key handoff mismatch); text mode works","url":null,"state":"closed"},{"kind":"pr","number":48919,"title":"fix(gateway): preserve queued native image attachments","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57648","number":57648},{"kind":"pr","value":"#48922","number":48922}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":48922,"title":"fix(gateway): keep native images on resolved session key","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57648","number":57648}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57648,"title":"fix(gateway): key native image handoff by the same resolved session key (#48912)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_native_image_buffer_isolation.py","tests/gateway/test_queued_native_image_session_key.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -9962,6 +9962,7 @@ async def _prepare_inbound_message_text(\n         event: MessageEvent,\n         source: SessionSource,\n         history: List[Dict[str, Any]],\n+        session_key: Optional[str] = None,\n     ) -> Optional[str]:\n         \"\"\"Prepare inbound event text for the agent.\n ","@@ -9980,10 +9981,10 @@ async def _prepare_inbound_message_text(\n         message_text = event.text or \"\"\n         _group_sessions_per_user = getattr(self.config, \"group_sessions_per_user\", True)\n         _thread_sessions_per_user = getattr(self.config, \"thread_sessions_per_user\", False)\n-        # Use the same helper every other call site uses so the write key here\n-        # matches the consume key at the run_conversation site — even if the\n-        # session store overrides build_session_key's default behavior.\n-        session_key = self._session_key_for_source(source)\n+        # Prefer the already resolved session key from the caller so this write\n+        # key matches the consume key at the run_conversation site. Fall back\n+        # to deriving it here for tests and legacy standalone callers.\n+        session_key = session_key or self._session_key_for_source(source)\n         # Reset only this session's per-call buffer; other sessions may be\n         # concurrently preparing multimodal turns on the same runner.\n         self._consume_pending_native_image_paths(session_key)","@@ -10997,6 +10998,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             event=event,\n             source=source,\n             history=history,\n+            session_key=session_key,\n         )\n         if message_text is None:\n             return","@@ -19061,6 +19063,7 @@ def _stream_confirmed_final_delivery(\n                 next_message = pending\n                 next_message_id = None\n                 next_channel_prompt = None\n+                next_session_key = session_key\n                 if pending_event is not None:\n                     next_source = getattr(pending_event, \"source\", None) or source\n                     if self._is_goal_continuation_event(pending_event) and not self._goal_still_active_for_session(session_id):","@@ -19069,10 +19072,24 @@ def _stream_confirmed_final_delivery(\n                             session_key or \"?\",\n                         )\n                         return result\n+                    # Resolve the follow-up's session key BEFORE preparing the\n+                    # inbound text: _prepare_inbound_message_text buffers native\n+                    # image paths under the key it is given, and the recursive\n+                    # _run_agent below consumes them under next_session_key.\n+                    # The write and consume keys must match or the images drop.\n+                    try:\n+                        next_session_key = self._session_key_for_source(next_source)\n+                    except Exception:\n+                        logger.debug(\n+                            \"Queued follow-up session-key resolution failed; reusing %s\",\n+                            session_key or \"?\",\n+                            exc_info=True,\n+                        )\n                     next_message = await self._prepare_inbound_message_text(\n                         event=pending_event,\n                         source=next_source,\n                         history=updated_history,\n+                        session_key=next_session_key,\n                     )\n                     if next_message is None:\n                         return result","@@ -19114,7 +19131,7 @@ def _stream_confirmed_final_delivery(\n                     history=updated_history,\n                     source=next_source,\n                     session_id=session_id,\n-                    session_key=session_key,\n+                    session_key=next_session_key,\n                     run_generation=run_generation,\n                     _interrupt_depth=_interrupt_depth + 1,\n                     event_message_id=next_message_id,"]},{"file":"tests/gateway/test_native_image_buffer_isolation.py","hunks":["@@ -77,3 +77,20 @@ async def test_native_image_buffer_not_cleared_by_other_sessions_without_images(\n \n     assert runner._consume_pending_native_image_paths(build_session_key(source_a)) == [\"/tmp/a.png\"]\n     assert runner._consume_pending_native_image_paths(build_session_key(source_b)) == []\n+\n+\n[redacted:email]\n+async def test_native_image_buffer_uses_resolved_session_key_when_provided():\n+    runner = _make_runner()\n+    source = _source(\"chat-a\")\n+    runner._session_key_for_source = lambda _source: \"source-derived-key\"\n+\n+    await runner._prepare_inbound_message_text(\n+        event=_image_event(source, \"/tmp/a.png\"),\n+        source=source,\n+        history=[],\n+        session_key=\"canonical-session-key\",\n+    )\n+\n+    assert runner._consume_pending_native_image_paths(\"source-derived-key\") == []\n+    assert runner._consume_pending_native_image_paths(\"canonical-session-key\") == [\"/tmp/a.png\"]"]},{"file":"tests/gateway/test_queued_native_image_session_key.py","hunks":["@@ -0,0 +1,151 @@\n+import base64\n+import importlib\n+import sys\n+import types\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult\n+from gateway.session import SessionSource\n+\n+\n+_ONE_BY_ONE_PNG = base64.b64decode(\n+    \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6L2ioAAAAASUVORK5CYII=\"\n+)\n+\n+\n+class CaptureAdapter(BasePlatformAdapter):\n+    def __init__(self):\n+        super().__init__(PlatformConfig(enabled=True, token=\"***\"), Platform.TELEGRAM)\n+        self.sent = []\n+        self.typing = []\n+\n+    async def connect(self) -> bool:\n+        return True\n+\n+    async def disconnect(self) -> None:\n+        return None\n+\n+    async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult:\n+        self.sent.append(\n+            {\n+                \"chat_id\": chat_id,\n+                \"content\": content,\n+                \"reply_to\": reply_to,\n+                \"metadata\": metadata,\n+            }\n+        )\n+        return SendResult(success=True, message_id=\"sent-1\")\n+\n+    async def send_typing(self, chat_id, metadata=None) -> None:\n+        self.typing.append({\"chat_id\": chat_id, \"metadata\": metadata})\n+\n+    async def stop_typing(self, chat_id) -> None:\n+        return None\n+\n+    async def get_chat_info(self, chat_id: str):\n+        return {\"id\": chat_id}\n+\n+\n+class CaptureQueuedNativeImageAgent:\n+    calls = []\n+\n+    def __init__(self, **kwargs):\n+        self.tools = []\n+        self.tool_progress_callback = kwargs.get(\"tool_progress_callback\")\n+\n+    def run_conversation(self, message, conversation_history=None, task_id=None):\n+        type(self).calls.append(message)\n+        return {\n+            \"final_response\": f\"done-{len(type(self).calls)}\",\n+            \"messages\": [],\n+            \"api_calls\": 1,\n+        }\n+\n+\n+def _make_runner(adapter):\n+    gateway_run = importlib.import_module(\"gateway.run\")\n+    runner = object.__new__(gateway_run.GatewayRunner)\n+    runner.adapters = {adapter.platform: adapter}\n+    runner._voice_mode = {}\n+    runner._prefill_messages = []\n+    runner._ephemeral_system_prompt = \"\"\n+    runner._reasoning_config = None\n+    runner._provider_routing = {}\n+    runner._fallback_model = None\n+    runner._session_db = None\n+    runner._running_agents = {}\n+    runner._session_run_generation = {}\n+    runner.hooks = SimpleNamespace(loaded_hooks=False)\n+    runner.config = SimpleNamespace(\n+        thread_sessions_per_user=False,\n+        group_sessions_per_user=False,\n+        stt_enabled=False,\n+    )\n+    runner._model = \"openai/gpt-4.1-mini\"\n+    runner._base_url = None\n+    runner._decide_image_input_mode = lambda: \"native\"\n+    return runner\n+\n+\n[redacted:email]\n+async def test_queued_followup_uses_pending_event_session_key_for_native_images(monkeypatch, tmp_path):\n+    CaptureQueuedNativeImageAgent.calls = []\n+\n+    fake_dotenv = types.ModuleType(\"dotenv\")\n+    fake_dotenv.load_dotenv = lambda *args, **kwargs: None\n+    monkeypatch.setitem(sys.modules, \"dotenv\", fake_dotenv)\n+\n+    fake_run_agent = types.ModuleType(\"run_agent\")\n+    fake_run_agent.AIAgent = CaptureQueuedNativeImageAgent\n+    monkeypatch.setitem(sys.modules, \"run_agent\", fake_run_agent)\n+\n+    gateway_run = importlib.import_module(\"gateway.run\")\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.setattr(gateway_run, \"_resolve_runtime_agent_kwargs\", lambda: {\"api_key\": \"***\"})\n+\n+    adapter = CaptureAdapter()\n+    runner = _make_runner(adapter)\n+\n+    image_path = tmp_path / \"queued-image.png\"\n+    image_path.write_bytes(_ONE_BY_ONE_PNG)\n+\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_type=\"group\",\n+    )\n+    pending_source = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"-1001\",\n+        chat_type=\"group\",\n+        thread_id=\"17585\",\n+    )\n+\n+    adapter._pending_messages[\"agent:main:telegram:group:-1001\"] = MessageEvent(\n+        text=\"describe this\",\n+        message_type=MessageType.PHOTO,\n+        source=pending_source,\n+        media_urls=[str(image_path)],\n+        media_types=[\"image/png\"],\n+        message_id=\"queued-1\",\n+    )\n+\n+    result = await runner._run_agent(\n+        message=\"hello\",\n+        context_prompt=\"\",\n+        history=[],\n+        source=source,\n+        session_id=\"sess-native-image-followup\",\n+        session_key=\"agent:main:telegram:group:-1001\",\n+    )\n+\n+    assert result[\"final_response\"] == \"done-2\"\n+    assert len(CaptureQueuedNativeImageAgent.calls) == 2\n+    queued_message = CaptureQueuedNativeImageAgent.calls[1]\n+    assert isinstance(queued_message, list)\n+    assert queued_message[0][\"type\"] == \"text\"\n+    assert queued_message[0][\"text\"].startswith(\"describe this\")\n+    assert any(part.get(\"type\") == \"image_url\" for part in queued_message)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}