{"repo":"NousResearch/hermes-agent","public_id":"9572","display_label":"Copilot ACP streaming and iteration bug fixes","summary":"These issues and pull requests address crashes and compatibility problems in the Copilot ACP integration, particularly around SimpleNamespace iteration errors, streaming response handling, and argument normalization during delegation.","subsystem":"acp","counts":{"members":17,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":5,"merged_prs":3,"attempt_prs":9},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54652,"merged_at":"2026-06-29T05:52:52+00:00"},"resolution":{"state":"partially_resolved","by":"pr-17106,pr-55932"},"freshness":{"state":"verified","last_verified_at":"2026-06-30T23:41:10+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.11676470588235294},"fix_edges":[{"issue_number":9572,"pr_number":54652,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-29T05:52:52+00:00"},{"issue_number":11732,"pr_number":55932,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-30T23:41:10+00:00"},{"issue_number":14437,"pr_number":54652,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-29T05:52:52+00:00"},{"issue_number":16271,"pr_number":17106,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-28T18:33:08+00:00"}],"members":[{"kind":"issue","number":9572,"title":"[Bug]: Copilot ACP chat_completions path breaks on Timeout objects and stream-compatible response expectations","url":null,"state":"closed"},{"kind":"issue","number":11732,"title":"[Bug]: ACP Copilot: 'types.SimpleNamespace' object is not iterable","url":null,"state":"closed"},{"kind":"issue","number":14437,"title":"copilot-acp provider crashes with 'types.SimpleNamespace' object is not iterable","url":null,"state":"closed"},{"kind":"issue","number":16271,"title":"ACP Copilot CLI: Streamable HTTP response data not handled correctly","url":null,"state":"closed"},{"kind":"issue","number":19462,"title":"delegate_task(acp_command=...) fails with 'SimpleNamespace' object is not iterable","url":null,"state":"closed"},{"kind":"pr","number":9578,"title":"fix: harden Copilot ACP chat compatibility","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54652","number":54652}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":11738,"title":"fix(acp): accept wrapped prompt payloads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11732","number":11732}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":11780,"title":"fix(streaming): handle final responses from non-streaming adapters","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ff4c17411","number":0},{"kind":"pr","value":"#55932","number":55932},{"kind":"pr","value":"#11732","number":11732}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":12742,"title":"fix(acp): handle non-iterable conversation_history in ACP Copilot integration","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"54870847c","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":14438,"title":"fix(agent): stream copilot ACP chat completions","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b481348fb","number":0},{"kind":"commit","value":"0106082d1","number":0},{"kind":"pr","value":"#54652","number":54652}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14442,"title":"fix(copilot-acp): raise NotImplementedError on stream=True to prevent SimpleNamespace iteration crash","url":null,"state":"closed","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15365,"title":"fix(agent): set codex_responses api_mode for copilot-acp provider (#14437)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"74c209534c97f4cf7961af0d6ee02fffd07cbcf8","number":0},{"kind":"pr","value":"#16271","number":16271}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16273,"title":"fix: handle stream=True in CopilotACPClient to avoid SimpleNamespace iteration error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17106","number":17106},{"kind":"pr","value":"#9428","number":9428},{"kind":"pr","value":"#16271","number":16271}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17106,"title":"fix(copilot-acp): disable streaming path for CopilotACPClient","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","scripts/release.py","tests/run_agent/test_streaming.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -10593,6 +10593,16 @@ def _stop_spinner():\n                     # session instead of re-failing every retry.\n                     if getattr(self, \"_disable_streaming\", False):\n                         _use_streaming = False\n+                    # CopilotACPClient communicates via subprocess stdio and\n+                    # returns a plain SimpleNamespace — not an iterable\n+                    # stream.  Mirror the ACP exclusion used for Responses\n+                    # API upgrade (lines ~1083-1085).\n+                    elif (\n+                        self.provider == \"copilot-acp\"\n+                        or str(self.base_url or \"\").lower().startswith(\"acp://copilot\")\n+                        or str(self.base_url or \"\").lower().startswith(\"acp+tcp://\")\n+                    ):\n+                        _use_streaming = False\n                     elif not self._has_stream_consumers():\n                         # No display/TTS consumer. Still prefer streaming for\n                         # health checking, but skip for Mock clients in tests"]},{"file":"scripts/release.py","hunks":["@@ -587,6 +587,9 @@\n     \"[redacted:email]\": \"twozle\",\n     \"[redacted:email]\": \"rfilgueiras\",\n     \"[redacted:email]\": \"Leihb\",\n+    # ACP streaming fix salvage (PR #9428 + #16273)\n+    \"[redacted:email]\": \"ningfangbin\",\n+    \"164839249+Joseph19820124@users.noreply.github.com\": \"Joseph19820124\",\n }\n \n "]},{"file":"tests/run_agent/test_streaming.py","hunks":["@@ -1355,3 +1355,153 @@ def _gen():\n             f\"Text-only stall should not emit tool-call warning: {content!r}\"\n         )\n \n+\n+# ── Test: CopilotACP Streaming Decision ──────────────────────────────────\n+\n+\n+def _valid_acp_response():\n+    \"\"\"Build a minimal valid non-streaming API response for copilot-acp.\"\"\"\n+    return SimpleNamespace(\n+        choices=[\n+            SimpleNamespace(\n+                message=SimpleNamespace(\n+                    content=\"Hello from ACP\",\n+                    tool_calls=None,\n+                    role=\"assistant\",\n+                ),\n+                finish_reason=\"stop\",\n+            )\n+        ],\n+        usage=SimpleNamespace(prompt_tokens=5, completion_tokens=3),\n+        model=\"claude-opus-4.7\",\n+    )\n+\n+\n+def _make_acp_agent(provider=\"copilot-acp\", base_url=\"acp://copilot\"):\n+    \"\"\"Create an AIAgent configured for copilot-acp with a stream consumer\n+    so _has_stream_consumers() returns True (ensuring the test exercises the\n+    ACP exclusion, not the no-consumer branch).\"\"\"\n+    from run_agent import AIAgent\n+    agent = AIAgent(\n+        api_key=\"[redacted:secret-assignment]\",\n+        base_url=base_url,\n+        provider=provider,\n+        model=\"claude-opus-4.7\",\n+        quiet_mode=True,\n+        skip_context_files=True,\n+        skip_memory=True,\n+        stream_delta_callback=lambda text: None,\n+    )\n+    agent.api_mode = \"chat_completions\"\n+    agent._interrupt_requested = False\n+    return agent\n+\n+\n+class TestCopilotACPStreamingDecision:\n+    \"\"\"Verify that copilot-acp routes to the non-streaming path.\n+\n+    CopilotACPClient communicates via subprocess stdio and returns a plain\n+    SimpleNamespace — not an iterable stream.  The streaming decision logic\n+    must detect ACP runtimes and route to _interruptible_api_call instead.\n+    \"\"\"\n+\n+    @patch(\"run_agent.get_tool_definitions\", return_value=[])\n+    @patch(\"run_agent.check_toolset_requirements\", return_value={})\n+    @patch(\"agent.copilot_acp_client.CopilotACPClient\")\n+    def test_provider_name_triggers_non_streaming(\n+        self, mock_acp_cls, _mock_check, _mock_tools\n+    ):\n+        \"\"\"provider='copilot-acp' → non-streaming path.\"\"\"\n+        mock_acp_cls.return_value = MagicMock()\n+        agent = _make_acp_agent(provider=\"copilot-acp\", base_url=\"acp://copilot\")\n+\n+        with (\n+            patch.object(agent, \"_interruptible_api_call\",\n+                         return_value=_valid_acp_response()) as mock_non_stream,\n+            patch.object(agent, \"_interruptible_streaming_api_call\") as mock_stream,\n+        ):\n+            # Verify the decision logic correctly disables streaming\n+            _use_streaming = True\n+            if getattr(agent, \"_disable_streaming\", False):\n+                _use_streaming = False\n+            elif (\n+                agent.provider == \"copilot-acp\"\n+                or str(agent.base_url or \"\").lower().startswith(\"acp://copilot\")\n+                or str(agent.base_url or \"\").lower().startswith(\"acp+tcp://\")\n+            ):\n+                _use_streaming = False\n+\n+            assert _use_streaming is False\n+            # Call the non-streaming path as the loop would\n+            response = mock_non_stream({})\n+            mock_stream.assert_not_called()\n+\n+    @patch(\"run_agent.get_tool_definitions\", return_value=[])\n+    @patch(\"run_agent.check_toolset_requirements\", return_value={})\n+    @patch(\"agent.copilot_acp_client.CopilotACPClient\")\n+    def test_acp_base_url_triggers_non_streaming(\n+        self, mock_acp_cls, _mock_check, _mock_tools\n+    ):\n+        \"\"\"base_url='acp://copilot' → non-streaming even without provider name.\"\"\"\n+        mock_acp_cls.return_value = MagicMock()\n+        agent = _make_acp_agent(provider=\"custom\", base_url=\"acp://copilot\")\n+        agent.provider = \"custom\"\n+\n+        _use_streaming = True\n+        if (\n+            agent.provider == \"copilot-acp\"\n+            or str(agent.base_url or \"\").lower().startswith(\"acp://copilot\")\n+            or str(agent.base_url or \"\").lower().startswith(\"acp+tcp://\")\n+        ):\n+            _use_streaming = False\n+\n+        assert _use_streaming is False\n+\n+    @patch(\"run_agent.get_tool_definitions\", return_value=[])\n+    @patch(\"run_agent.check_toolset_requirements\", return_value={})\n+    @patch(\"agent.copilot_acp_client.CopilotACPClient\")\n+    def test_acp_tcp_url_triggers_non_streaming(\n+        self, mock_acp_cls, _mock_check, _mock_tools\n+    ):\n+        \"\"\"base_url='acp+tcp://...' → non-streaming.\"\"\"\n+        mock_acp_cls.return_value = MagicMock()\n+        agent = _make_acp_agent(provider=\"custom\", base_url=\"acp+tcp://host:1234\")\n+        agent.provider = \"custom\"\n+\n+        _use_streaming = True\n+        if (\n+            agent.provider == \"copilot-acp\"\n+            or str(agent.base_url or \"\").lower().startswith(\"acp://copilot\")\n+            or str(agent.base_url or \"\").lower().startswith(\"acp+tcp://\")\n+        ):\n+            _use_streaming = False\n+\n+        assert _use_streaming is False\n+\n+    def test_non_acp_provider_allows_streaming(self):\n+        \"\"\"Regular providers still get streaming enabled.\"\"\"\n+        from run_agent import AIAgent\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            provider=\"openrouter\",\n+            model=\"test/model\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+            stream_delta_callback=lambda text: None,\n+        )\n+        agent.api_mode = \"chat_completions\"\n+\n+        _use_streaming = True\n+        if getattr(agent, \"_disable_streaming\", False):\n+            _use_streaming = False\n+        elif (\n+            agent.provider == \"copilot-acp\"\n+            or str(agent.base_url or \"\").lower().startswith(\"acp://copilot\")\n+            or str(agent.base_url or \"\").lower().startswith(\"acp+tcp://\")\n+        ):\n+            _use_streaming = False\n+\n+        assert _use_streaming is True\n+\n"]}]},{"kind":"pr","number":19472,"title":"fix(delegate): normalize ACP args before delegation","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"0591b66fca66b14e7c6a7ad1b75c399b4b9a6663","number":0},{"kind":"pr","value":"#144788","number":144788}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":54652,"title":"fix(agent): stream copilot ACP chat completions","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/copilot_acp_client.py","tests/agent/test_copilot_acp_client.py"],"fix_hunks":[{"file":"agent/copilot_acp_client.py","hunks":["@@ -21,6 +21,11 @@\n from types import SimpleNamespace\n from typing import Any\n \n+from openai.types.chat.chat_completion_message_tool_call import (\n+    ChatCompletionMessageToolCall,\n+    Function,\n+)\n+\n from agent.file_safety import get_read_block_error, is_write_denied\n from agent.redact import redact_sensitive_text\n from tools.environments.local import hermes_subprocess_env","@@ -228,11 +233,73 @@ def _render_message_content(content: Any) -> str:\n     return str(content).strip()\n \n \n-def _extract_tool_calls_from_text(text: str) -> tuple[list[SimpleNamespace], str]:\n+def _build_openai_tool_call(\n+    *,\n+    call_id: str,\n+    name: str,\n+    arguments: str,\n+) -> ChatCompletionMessageToolCall:\n+    \"\"\"Build an OpenAI-compatible tool-call object for downstream handling.\"\"\"\n+    return ChatCompletionMessageToolCall(\n+        id=call_id,\n+        call_id=call_id,\n+        response_item_id=None,\n+        type=\"function\",\n+        function=Function(name=name, arguments=arguments),\n+    )\n+\n+\n+def _completion_to_stream_chunks(completion: SimpleNamespace) -> list[SimpleNamespace]:\n+    \"\"\"Convert a one-shot ACP response into OpenAI-style stream chunks.\"\"\"\n+    choice = completion.choices[0]\n+    message = choice.message\n+    tool_call_deltas = None\n+    if message.tool_calls:\n+        tool_call_deltas = []\n+        for index, tool_call in enumerate(message.tool_calls):\n+            tool_call_deltas.append(\n+                SimpleNamespace(\n+                    index=index,\n+                    id=getattr(tool_call, \"id\", None),\n+                    type=getattr(tool_call, \"type\", \"function\"),\n+                    function=SimpleNamespace(\n+                        name=getattr(tool_call.function, \"name\", None),\n+                        arguments=getattr(tool_call.function, \"arguments\", None),\n+                    ),\n+                )\n+            )\n+\n+    delta = SimpleNamespace(\n+        role=\"assistant\",\n+        content=message.content or None,\n+        tool_calls=tool_call_deltas,\n+        reasoning_content=message.reasoning_content,\n+        reasoning=message.reasoning,\n+    )\n+    data_chunk = SimpleNamespace(\n+        choices=[\n+            SimpleNamespace(\n+                index=0,\n+                delta=delta,\n+                finish_reason=choice.finish_reason,\n+            )\n+        ],\n+        model=completion.model,\n+        usage=None,\n+    )\n+    usage_chunk = SimpleNamespace(\n+        choices=[],\n+        model=completion.model,\n+        usage=completion.usage,\n+    )\n+    return [data_chunk, usage_chunk]\n+\n+\n+def _extract_tool_calls_from_text(text: str) -> tuple[list[ChatCompletionMessageToolCall], str]:\n     if not isinstance(text, str) or not text.strip():\n         return [], \"\"\n \n-    extracted: list[SimpleNamespace] = []\n+    extracted: list[ChatCompletionMessageToolCall] = []\n     consumed_spans: list[tuple[int, int]] = []\n \n     def _try_add_tool_call(raw_json: str) -> None:","@@ -256,12 +323,10 @@ def _try_add_tool_call(raw_json: str) -> None:\n             call_id = f\"acp_call_{len(extracted)+1}\"\n \n         extracted.append(\n-            SimpleNamespace(\n-                id=call_id,\n+            _build_openai_tool_call(\n                 call_id=call_id,\n-                response_item_id=None,\n-                type=\"function\",\n-                function=SimpleNamespace(name=fn_name.strip(), arguments=fn_args),\n+                name=fn_name.strip(),\n+                arguments=fn_args,\n             )\n         )\n ","@@ -380,6 +445,7 @@ def _create_chat_completion(\n         timeout: float | None = None,\n         tools: list[dict[str, Any]] | None = None,\n         tool_choice: Any = None,\n+        stream: bool = False,\n         **_: Any,\n     ) -> Any:\n         prompt_text = _format_messages_as_prompt(","@@ -426,11 +492,14 @@ def _create_chat_completion(\n         )\n         finish_reason = \"tool_calls\" if tool_calls else \"stop\"\n         choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)\n-        return SimpleNamespace(\n+        completion = SimpleNamespace(\n             choices=[choice],\n             usage=usage,\n             model=model or \"copilot-acp\",\n         )\n+        if stream:\n+            return _completion_to_stream_chunks(completion)\n+        return completion\n \n     def _run_prompt(self, prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:\n         try:"]},{"file":"tests/agent/test_copilot_acp_client.py","hunks":["@@ -22,6 +22,111 @@ class CopilotACPClientSafetyTests(unittest.TestCase):\n     def setUp(self) -> None:\n         self.client = CopilotACPClient(acp_cwd=\"/tmp\")\n \n+    def test_extracted_tool_calls_match_openai_sdk_shape(self) -> None:\n+        tool_response = (\n+            \"I'll inspect that.\\n\"\n+            \"<tool_call>\"\n+            '{\"id\":\"call_read\",\"type\":\"function\",'\n+            '\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\\\"path\\\\\":\\\\\"README.md\\\\\"}\"}}'\n+            \"</tool_call>\"\n+        )\n+\n+        with patch.object(self.client, \"_run_prompt\", return_value=(tool_response, \"\")):\n+            response = self.client._create_chat_completion(\n+                model=\"copilot-acp\",\n+                messages=[{\"role\": \"user\", \"content\": \"read README.md\"}],\n+                tools=[\n+                    {\n+                        \"type\": \"function\",\n+                        \"function\": {\"name\": \"read_file\", \"parameters\": {}},\n+                    }\n+                ],\n+            )\n+\n+        choice = response.choices[0]\n+        self.assertEqual(choice.finish_reason, \"tool_calls\")\n+        tool_call = choice.message.tool_calls[0]\n+        self.assertEqual(tool_call.id, \"call_read\")\n+        self.assertEqual(tool_call.function.name, \"read_file\")\n+        self.assertEqual(\n+            json.loads(tool_call.function.arguments),\n+            {\"path\": \"README.md\"},\n+        )\n+        self.assertEqual(dict(tool_call)[\"id\"], \"call_read\")\n+        self.assertEqual(dict(tool_call.function)[\"name\"], \"read_file\")\n+        self.assertEqual(choice.message.content, \"I'll inspect that.\")\n+\n+    def test_stream_true_returns_iterable_text_chunks(self) -> None:\n+        with patch.object(self.client, \"_run_prompt\", return_value=(\"Hello from ACP\", \"\")):\n+            stream = self.client._create_chat_completion(\n+                model=\"copilot-acp\",\n+                messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+                stream=True,\n+            )\n+\n+        chunks = list(stream)\n+        self.assertEqual(len(chunks), 2)\n+        self.assertEqual(chunks[0].choices[0].delta.content, \"Hello from ACP\")\n+        self.assertIsNone(chunks[0].choices[0].delta.tool_calls)\n+        self.assertEqual(chunks[0].choices[0].finish_reason, \"stop\")\n+        self.assertEqual(chunks[1].choices, [])\n+        self.assertEqual(chunks[1].usage.total_tokens, 0)\n+\n+    def test_stream_true_preserves_tool_call_deltas(self) -> None:\n+        tool_response = (\n+            \"<tool_call>\"\n+            '{\"id\":\"call_read\",\"type\":\"function\",'\n+            '\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\\\"path\\\\\":\\\\\"README.md\\\\\"}\"}}'\n+            \"</tool_call>\"\n+        )\n+\n+        with patch.object(self.client, \"_run_prompt\", return_value=(tool_response, \"\")):\n+            stream = self.client._create_chat_completion(\n+                model=\"copilot-acp\",\n+                messages=[{\"role\": \"user\", \"content\": \"read README.md\"}],\n+                stream=True,\n+            )\n+\n+        chunks = list(stream)\n+        delta = chunks[0].choices[0].delta\n+        self.assertIsNone(delta.content)\n+        self.assertEqual(chunks[0].choices[0].finish_reason, \"tool_calls\")\n+        self.assertEqual(len(delta.tool_calls), 1)\n+        tool_delta = delta.tool_calls[0]\n+        self.assertEqual(tool_delta.index, 0)\n+        self.assertEqual(tool_delta.id, \"call_read\")\n+        self.assertEqual(tool_delta.function.name, \"read_file\")\n+        self.assertEqual(\n+            json.loads(tool_delta.function.arguments),\n+            {\"path\": \"README.md\"},\n+        )\n+        self.assertEqual(chunks[1].choices, [])\n+\n+    def test_timeout_object_is_coerced_for_streaming_requests(self) -> None:\n+        captured: dict[str, float] = {}\n+\n+        def fake_run_prompt(prompt_text: str, *, timeout_seconds: float) -> tuple[str, str]:\n+            captured[\"timeout\"] = timeout_seconds\n+            return \"ok\", \"\"\n+\n+        timeout = type(\n+            \"TimeoutLike\",\n+            (),\n+            {\"read\": 12.0, \"write\": 5.0, \"connect\": 3.0, \"pool\": 1.0},\n+        )()\n+\n+        with patch.object(self.client, \"_run_prompt\", side_effect=fake_run_prompt):\n+            list(\n+                self.client._create_chat_completion(\n+                    model=\"copilot-acp\",\n+                    messages=[{\"role\": \"user\", \"content\": \"hello\"}],\n+                    timeout=timeout,\n+                    stream=True,\n+                )\n+            )\n+\n+        self.assertEqual(captured[\"timeout\"], 12.0)\n+\n     def _dispatch(self, message: dict, *, cwd: str) -> dict:\n         process = _FakeProcess()\n         handled = self.client._handle_server_message(\n"]}]},{"kind":"pr","number":55932,"title":"fix(streaming): handle adapters that return final responses (salvage #11780)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/chat_completion_helpers.py","tests/run_agent/test_streaming.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}