{"repo":"NousResearch/hermes-agent","public_id":"12285","display_label":"ACP session load history replay fixes","summary":"Multiple fixes and follow-ups addressing a bug where conversation history was not being replayed when loading or resuming an ACP session. Work spans text history, reasoning/thoughts, and tool/context rendering during session load.","subsystem":null,"counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":4,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17874},"resolution":{"state":"resolved","by":"pr-26943"},"freshness":{"state":"verified","last_verified_at":"2026-05-16T14:41:35+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":12285,"pr_number":17874,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12285,"pr_number":19139,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":12285,"pr_number":26943,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12285,"pr_number":26957,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":12285,"title":"ACP: session/load does not replay conversation history per spec","url":null,"state":"closed"},{"kind":"pr","number":14691,"title":"fix(acp): replay conversation history via session_update on load_session","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f3a4af9cf","number":0},{"kind":"pr","value":"#26943","number":26943},{"kind":"pr","value":"#19139","number":19139}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16531,"title":"fix(acp): replay text history on session load","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f3a4af9cf","number":0},{"kind":"pr","value":"#26943","number":26943},{"kind":"pr","value":"#17874","number":17874},{"kind":"pr","value":"#19139","number":19139},{"kind":"pr","value":"#26943","number":26943},{"kind":"pr","value":"#26943","number":26943}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17874,"title":"fix(acp): replay persisted session history on load/resume (salvage of #17652)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","tests/acp/test_server.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -13,6 +13,7 @@\n import acp\n from acp.schema import (\n     AgentCapabilities,\n+    AgentMessageChunk,\n     AuthenticateResponse,\n     AvailableCommand,\n     AvailableCommandsUpdate,","@@ -45,6 +46,7 @@\n     TextContentBlock,\n     UnstructuredCommandInput,\n     Usage,\n+    UserMessageChunk,\n )\n \n # AuthMethodAgent was renamed from AuthMethod in agent-client-protocol 0.9.0","@@ -377,6 +379,78 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateRespo\n \n     # ---- Session management -------------------------------------------------\n \n+    @staticmethod\n+    def _history_message_text(message: dict[str, Any]) -> str:\n+        \"\"\"Extract displayable text from a persisted OpenAI-style message.\"\"\"\n+        content = message.get(\"content\")\n+        if isinstance(content, str):\n+            return content.strip()\n+        if isinstance(content, list):\n+            parts: list[str] = []\n+            for item in content:\n+                if isinstance(item, dict):\n+                    text = item.get(\"text\")\n+                    if isinstance(text, str):\n+                        parts.append(text)\n+                    elif item.get(\"type\") == \"text\" and isinstance(item.get(\"content\"), str):\n+                        parts.append(item[\"content\"])\n+                elif isinstance(item, str):\n+                    parts.append(item)\n+            return \"\\n\".join(part.strip() for part in parts if part and part.strip()).strip()\n+        return \"\"\n+\n+    @staticmethod\n+    def _history_message_update(\n+        *,\n+        role: str,\n+        text: str,\n+    ) -> UserMessageChunk | AgentMessageChunk | None:\n+        \"\"\"Build an ACP history replay update for a user/assistant message.\"\"\"\n+        block = TextContentBlock(type=\"text\", text=text)\n+        if role == \"user\":\n+            return UserMessageChunk(\n+                session_update=\"user_message_chunk\",\n+                content=block,\n+            )\n+        if role == \"assistant\":\n+            return AgentMessageChunk(\n+                session_update=\"agent_message_chunk\",\n+                content=block,\n+            )\n+        return None\n+\n+    async def _replay_session_history(self, state: SessionState) -> None:\n+        \"\"\"Send persisted user/assistant history to clients during session/load.\n+\n+        Zed's ACP history UI calls ``session/load`` after the user picks an item\n+        from the Agents sidebar. The agent must then replay the full conversation\n+        as ``user_message_chunk`` / ``agent_message_chunk`` notifications; merely\n+        restoring server-side state makes Hermes remember context, but leaves the\n+        editor looking like a clean thread.\n+        \"\"\"\n+        if not self._conn or not state.history:\n+            return\n+\n+        for message in state.history:\n+            role = str(message.get(\"role\") or \"\")\n+            if role not in {\"user\", \"assistant\"}:\n+                continue\n+            text = self._history_message_text(message)\n+            if not text:\n+                continue\n+            update = self._history_message_update(role=role, text=text)\n+            if update is None:\n+                continue\n+            try:\n+                await self._conn.session_update(session_id=state.session_id, update=update)\n+            except Exception:\n+                logger.warning(\n+                    \"Failed to replay ACP history for session %s\",\n+                    state.session_id,\n+                    exc_info=True,\n+                )\n+                return\n+\n     async def new_session(\n         self,\n         cwd: str,","@@ -405,6 +479,7 @@ async def load_session(\n             return None\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Loaded session %s\", session_id)\n+        await self._replay_session_history(state)\n         self._schedule_available_commands_update(session_id)\n         return LoadSessionResponse(models=self._build_model_state(state))\n ","@@ -421,6 +496,7 @@ async def resume_session(\n             state = self.session_manager.create_session(cwd=cwd)\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Resumed session %s\", state.session_id)\n+        await self._replay_session_history(state)\n         self._schedule_available_commands_update(state.session_id)\n         return ResumeSessionResponse(models=self._build_model_state(state))\n "]},{"file":"tests/acp/test_server.py","hunks":["@@ -11,6 +11,7 @@\n from acp.agent.router import build_agent_router\n from acp.schema import (\n     AgentCapabilities,\n+    AgentMessageChunk,\n     AuthenticateResponse,\n     AvailableCommandsUpdate,\n     Implementation,","@@ -27,6 +28,7 @@\n     SessionInfo,\n     TextContentBlock,\n     Usage,\n+    UserMessageChunk,\n )\n from acp_adapter.server import HermesACPAgent, HERMES_VERSION\n from acp_adapter.session import SessionManager","@@ -224,6 +226,58 @@ async def test_load_session_not_found_returns_none(self, agent):\n         resp = await agent.load_session(cwd=\"/tmp\", session_id=\"bogus\")\n         assert resp is None\n \n+    @pytest.mark.asyncio\n+    async def test_load_session_replays_persisted_history_to_client(self, agent):\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [\n+            {\"role\": \"system\", \"content\": \"hidden system\"},\n+            {\"role\": \"user\", \"content\": \"what controls the / slash commands?\"},\n+            {\"role\": \"assistant\", \"content\": \"HermesACPAgent._ADVERTISED_COMMANDS controls them.\"},\n+            {\"role\": \"tool\", \"content\": \"tool output should not replay\"},\n+        ]\n+\n+        mock_conn.session_update.reset_mock()\n+        resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+\n+        assert isinstance(resp, LoadSessionResponse)\n+        calls = mock_conn.session_update.await_args_list\n+        replay_calls = [\n+            call for call in calls\n+            if getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            in {\"user_message_chunk\", \"agent_message_chunk\"}\n+        ]\n+        assert len(replay_calls) == 2\n+        assert isinstance(replay_calls[0].kwargs[\"update\"], UserMessageChunk)\n+        assert replay_calls[0].kwargs[\"update\"].content.text == \"what controls the / slash commands?\"\n+        assert isinstance(replay_calls[1].kwargs[\"update\"], AgentMessageChunk)\n+        assert replay_calls[1].kwargs[\"update\"].content.text.startswith(\"HermesACPAgent\")\n+\n+    @pytest.mark.asyncio\n+    async def test_resume_session_replays_persisted_history_to_client(self, agent):\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [{\"role\": \"user\", \"content\": \"So tell me the current state\"}]\n+\n+        mock_conn.session_update.reset_mock()\n+        resp = await agent.resume_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+\n+        assert isinstance(resp, ResumeSessionResponse)\n+        updates = [call.kwargs[\"update\"] for call in mock_conn.session_update.await_args_list]\n+        assert any(\n+            isinstance(update, UserMessageChunk)\n+            and update.content.text == \"So tell me the current state\"\n+            for update in updates\n+        )\n+\n     @pytest.mark.asyncio\n     async def test_resume_session_creates_new_if_missing(self, agent):\n         resume_resp = await agent.resume_session(cwd=\"/tmp\", session_id=\"nonexistent\")\n"]}]},{"kind":"pr","number":19139,"title":"fix(acp): route Zed thoughts to reasoning + polish tool/context rendering","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","acp_adapter/tools.py","tests/acp/test_mcp_e2e.py","tests/acp/test_server.py","tests/acp/test_tools.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -4,6 +4,7 @@\n \n import asyncio\n import contextvars\n+import json\n import logging\n import os\n from collections import defaultdict, deque","@@ -47,6 +48,7 @@\n     TextContentBlock,\n     UnstructuredCommandInput,\n     Usage,\n+    UsageUpdate,\n     UserMessageChunk,\n )\n ","@@ -65,6 +67,7 @@\n )\n from acp_adapter.permissions import make_approval_callback\n from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets\n+from acp_adapter.tools import build_tool_complete, build_tool_start\n \n logger = logging.getLogger(__name__)\n ","@@ -315,6 +318,66 @@ def _resolve_model_selection(raw_model: str, current_provider: str) -> tuple[str\n \n         return target_provider, new_model\n \n+    @staticmethod\n+    def _build_usage_update(state: SessionState) -> UsageUpdate | None:\n+        \"\"\"Build ACP native context-usage data for clients like Zed.\n+\n+        Zed's circular context indicator is driven by ACP ``usage_update``\n+        session updates: ``size`` is the model context window and ``used`` is\n+        the current request pressure.  Hermes estimates ``used`` from the same\n+        buckets it sends to providers: system prompt, conversation history, and\n+        tool schemas.\n+        \"\"\"\n+        agent = state.agent\n+        compressor = getattr(agent, \"context_compressor\", None)\n+        size = int(getattr(compressor, \"context_length\", 0) or 0)\n+        if size <= 0:\n+            return None\n+\n+        try:\n+            from agent.model_metadata import estimate_request_tokens_rough\n+\n+            used = estimate_request_tokens_rough(\n+                state.history,\n+                system_prompt=getattr(agent, \"_cached_system_prompt\", \"\") or \"\",\n+                tools=getattr(agent, \"tools\", None) or None,\n+            )\n+        except Exception:\n+            logger.debug(\"Could not estimate ACP native context usage\", exc_info=True)\n+            used = int(getattr(compressor, \"last_prompt_tokens\", 0) or 0)\n+\n+        return UsageUpdate(\n+            session_update=\"usage_update\",\n+            size=max(size, 0),\n+            used=max(used, 0),\n+        )\n+\n+    async def _send_usage_update(self, state: SessionState) -> None:\n+        \"\"\"Send ACP native context usage to the connected client.\"\"\"\n+        if not self._conn:\n+            return\n+        update = self._build_usage_update(state)\n+        if update is None:\n+            return\n+        try:\n+            await self._conn.session_update(\n+                session_id=state.session_id,\n+                update=update,\n+            )\n+        except Exception:\n+            logger.warning(\n+                \"Failed to send ACP usage update for session %s\",\n+                state.session_id,\n+                exc_info=True,\n+            )\n+\n+    def _schedule_usage_update(self, state: SessionState) -> None:\n+        \"\"\"Schedule native context indicator refresh after ACP responses.\"\"\"\n+        if not self._conn:\n+            return\n+        loop = asyncio.get_running_loop()\n+        loop.call_soon(asyncio.create_task, self._send_usage_update(state))\n+\n     async def _register_session_mcp_servers(\n         self,\n         state: SessionState,","@@ -485,37 +548,99 @@ def _history_message_update(\n             )\n         return None\n \n+    @staticmethod\n+    def _history_tool_call_name_args(tool_call: dict[str, Any]) -> tuple[str, dict[str, Any]]:\n+        \"\"\"Extract function name/arguments from an OpenAI-style tool_call.\"\"\"\n+        function = tool_call.get(\"function\") if isinstance(tool_call.get(\"function\"), dict) else {}\n+        name = str(function.get(\"name\") or tool_call.get(\"name\") or \"unknown_tool\")\n+        raw_args = function.get(\"arguments\") or tool_call.get(\"arguments\") or tool_call.get(\"args\") or {}\n+        if isinstance(raw_args, str):\n+            try:\n+                parsed = json.loads(raw_args)\n+            except Exception:\n+                parsed = {\"raw\": raw_args}\n+            raw_args = parsed\n+        if not isinstance(raw_args, dict):\n+            raw_args = {}\n+        return name, raw_args\n+\n+    @staticmethod\n+    def _history_tool_call_id(tool_call: dict[str, Any]) -> str:\n+        \"\"\"Return the stable provider tool call id for ACP history replay.\"\"\"\n+        return str(\n+            tool_call.get(\"id\")\n+            or tool_call.get(\"call_id\")\n+            or tool_call.get(\"tool_call_id\")\n+            or \"\"\n+        ).strip()\n+\n     async def _replay_session_history(self, state: SessionState) -> None:\n         \"\"\"Send persisted user/assistant history to clients during session/load.\n \n         Zed's ACP history UI calls ``session/load`` after the user picks an item\n         from the Agents sidebar. The agent must then replay the full conversation\n-        as ``user_message_chunk`` / ``agent_message_chunk`` notifications; merely\n-        restoring server-side state makes Hermes remember context, but leaves the\n-        editor looking like a clean thread.\n+        as user/assistant chunks plus reconstructed tool-call start/completion\n+        notifications; merely restoring server-side state makes Hermes remember\n+        context, but leaves the editor looking like a clean thread.\n         \"\"\"\n         if not self._conn or not state.history:\n             return\n \n-        for message in state.history:\n-            role = str(message.get(\"role\") or \"\")\n-            if role not in {\"user\", \"assistant\"}:\n-                continue\n-            text = self._history_message_text(message)\n-            if not text:\n-                continue\n-            update = self._history_message_update(role=role, text=text)\n-            if update is None:\n-                continue\n+        active_tool_calls: dict[str, tuple[str, dict[str, Any]]] = {}\n+\n+        async def _send(update: Any) -> bool:\n             try:\n                 await self._conn.session_update(session_id=state.session_id, update=update)\n+                return True\n             except Exception:\n                 logger.warning(\n                     \"Failed to replay ACP history for session %s\",\n                     state.session_id,\n                     exc_info=True,\n                 )\n-                return\n+                return False\n+\n+        for message in state.history:\n+            role = str(message.get(\"role\") or \"\")\n+\n+            if role in {\"user\", \"assistant\"}:\n+                text = self._history_message_text(message)\n+                if text:\n+                    update = self._history_message_update(role=role, text=text)\n+                    if update is not None and not await _send(update):\n+                        return\n+\n+            if role == \"assistant\" and isinstance(message.get(\"tool_calls\"), list):\n+                for tool_call in message[\"tool_calls\"]:\n+                    if not isinstance(tool_call, dict):\n+                        continue\n+                    tool_call_id = self._history_tool_call_id(tool_call)\n+                    if not tool_call_id:\n+                        continue\n+                    tool_name, args = self._history_tool_call_name_args(tool_call)\n+                    active_tool_calls[tool_call_id] = (tool_name, args)\n+                    if not await _send(build_tool_start(tool_call_id, tool_name, args)):\n+                        return\n+                continue\n+\n+            if role == \"tool\":\n+                tool_call_id = str(message.get(\"tool_call_id\") or \"\").strip()\n+                tool_name = str(message.get(\"tool_name\") or \"\").strip()\n+                function_args: dict[str, Any] | None = None\n+                if tool_call_id in active_tool_calls:\n+                    tool_name, function_args = active_tool_calls.pop(tool_call_id)\n+                if not tool_call_id or not tool_name:\n+                    continue\n+                result = message.get(\"content\")\n+                if not await _send(\n+                    build_tool_complete(\n+                        tool_call_id,\n+                        tool_name,\n+                        result=result if isinstance(result, str) else None,\n+                        function_args=function_args,\n+                    )\n+                ):\n+                    return\n \n     async def new_session(\n         self,","@@ -527,11 +652,24 @@ async def new_session(\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"New session %s (cwd=%s)\", state.session_id, cwd)\n         self._schedule_available_commands_update(state.session_id)\n+        self._schedule_usage_update(state)\n         return NewSessionResponse(\n             session_id=state.session_id,\n             models=self._build_model_state(state),\n         )\n \n+    def _schedule_history_replay(self, state: SessionState) -> None:\n+        \"\"\"Replay persisted history after session/load or session/resume returns.\n+\n+        Zed only attaches streamed transcript/tool updates once the load/resume\n+        response has completed. Sending replay notifications while the request is\n+        still in-flight can make the server look correct in logs while the editor\n+        drops or fails to attach the tool-call history.\n+        \"\"\"\n+        loop = asyncio.get_running_loop()\n+        replay_coro = self._replay_session_history(state)\n+        loop.call_soon(asyncio.create_task, replay_coro)\n+\n     async def load_session(\n         self,\n         cwd: str,","@@ -545,8 +683,9 @@ async def load_session(\n             return None\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Loaded session %s\", session_id)\n-        await self._replay_session_history(state)\n+        self._schedule_history_replay(state)\n         self._schedule_available_commands_update(session_id)\n+        self._schedule_usage_update(state)\n         return LoadSessionResponse(models=self._build_model_state(state))\n \n     async def resume_session(","@@ -562,8 +701,9 @@ async def resume_session(\n             state = self.session_manager.create_session(cwd=cwd)\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Resumed session %s\", state.session_id)\n-        await self._replay_session_history(state)\n+        self._schedule_history_replay(state)\n         self._schedule_available_commands_update(state.session_id)\n+        self._schedule_usage_update(state)\n         return ResumeSessionResponse(models=self._build_model_state(state))\n \n     async def cancel(self, session_id: str, **kwargs: Any) -> None:","@@ -712,6 +852,7 @@ async def prompt(\n                 if self._conn:\n                     update = acp.update_agent_message_text(response_text)\n                     await self._conn.session_update(session_id, update)\n+                    await self._send_usage_update(state)\n                 return PromptResponse(stop_reason=\"end_turn\")\n \n         # If Zed sends another regular prompt while the same ACP session is","@@ -744,24 +885,37 @@ async def prompt(\n         tool_call_meta: dict[str, dict[str, Any]] = {}\n         previous_approval_cb = None\n \n+        streamed_message = False\n+\n         if conn:\n             tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)\n-            thinking_cb = make_thinking_cb(conn, session_id, loop)\n+            reasoning_cb = make_thinking_cb(conn, session_id, loop)\n             step_cb = make_step_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)\n             message_cb = make_message_cb(conn, session_id, loop)\n+\n+            def stream_delta_cb(text: str) -> None:\n+                nonlocal streamed_message\n+                if text:\n+                    streamed_message = True\n+                message_cb(text)\n+\n             approval_cb = make_approval_callback(conn.request_permission, loop, session_id)\n         else:\n             tool_progress_cb = None\n-            thinking_cb = None\n+            reasoning_cb = None\n             step_cb = None\n-            message_cb = None\n+            stream_delta_cb = None\n             approval_cb = None\n \n         agent = state.agent\n         agent.tool_progress_callback = tool_progress_cb\n-        agent.thinking_callback = thinking_cb\n+        # ACP thought panes should not receive Hermes' local kawaii waiting/status\n+        # updates. Route provider/model reasoning deltas instead; if the provider\n+        # emits no reasoning, Zed should not get a fake \"thinking\" accordion.\n+        agent.thinking_callback = None\n+        agent.reasoning_callback = reasoning_cb\n         agent.step_callback = step_cb\n-        agent.message_callback = message_cb\n+        agent.stream_delta_callback = stream_delta_cb\n \n         # Approval callback is per-thread (thread-local, GHSA-qg5c-hvr5-hjgr).\n         # Set it INSIDE _run_agent so the TLS write happens in the executor","@@ -867,7 +1021,7 @@ def _run_agent() -> dict:\n                 )\n             except Exception:\n                 logger.debug(\"Failed to auto-title ACP session %s\", session_id, exc_info=True)\n-        if final_response and conn:\n+        if final_response and conn and not streamed_message:\n             update = acp.update_agent_message_text(final_response)\n             await conn.session_update(session_id, update)\n ","@@ -903,6 +1057,8 @@ def _run_agent() -> dict:\n                 cached_read_tokens=[redacted:secret-assignment]\"cache_read_tokens\"),\n             )\n \n+        await self._send_usage_update(state)\n+\n         stop_reason = \"cancelled\" if state.cancel_event and state.cancel_event.is_set() else \"end_turn\"\n         return PromptResponse(stop_reason=stop_reason, usage=usage)\n ","@@ -1035,22 +1191,84 @@ def _cmd_tools(self, args: str, state: SessionState) -> str:\n             return f\"Could not list tools: {e}\"\n \n     def _cmd_context(self, args: str, state: SessionState) -> str:\n+        \"\"\"Show ACP session context pressure and compression guidance.\"\"\"\n         n_messages = len(state.history)\n-        if n_messages == 0:\n-            return \"Conversation is empty (no messages yet).\"\n-        # Count by role\n+\n+        # Count by role.\n         roles: dict[str, int] = {}\n         for msg in state.history:\n             role = msg.get(\"role\", \"unknown\")\n             roles[role] = roles.get(role, 0) + 1\n+\n+        agent = state.agent\n+        model = state.model or getattr(agent, \"model\", \"\")\n+        provider = getattr(agent, \"provider\", None) or \"auto\"\n+        compressor = getattr(agent, \"context_compressor\", None)\n+        context_length = int(getattr(compressor, \"context_length\", 0) or 0)\n+        threshold_tokens = [redacted:secret-assignment] \"threshold_tokens\", 0) or 0)\n+\n+        try:\n+            from agent.model_metadata import estimate_request_tokens_rough\n+\n+            system_prompt = getattr(agent, \"_cached_system_prompt\", \"\") or \"\"\n+            tools = getattr(agent, \"tools\", None) or None\n+            approx_tokens = [redacted:secret-assignment]\n+                state.history,\n+                system_prompt=system_prompt,\n+                tools=tools,\n+            )\n+        except Exception:\n+            logger.debug(\"Could not estimate ACP context usage\", exc_info=True)\n+            approx_tokens = 0\n+\n+        if threshold_tokens <= 0 and context_length > 0:\n+            threshold_tokens = [redacted:secret-assignment] * 0.80)\n+\n         lines = [\n-            f\"Conversation: {n_messages} messages\",\n+            f\"Conversation: {n_messages} messages\"\n+            if n_messages\n+            else \"Conversation is empty (no messages yet).\",\n             f\"  user: {roles.get('user', 0)}, assistant: {roles.get('assistant', 0)}, \"\n             f\"tool: {roles.get('tool', 0)}, system: {roles.get('system', 0)}\",\n         ]\n-        model = state.model or getattr(state.agent, \"model\", \"\")\n         if model:\n             lines.append(f\"Model: {model}\")\n+        lines.append(f\"Provider: {provider}\")\n+\n+        if approx_tokens > 0:\n+            if context_length > 0:\n+                usage_pct = (approx_tokens / context_length) * 100\n+                lines.append(\n+                    f\"Context usage: ~{approx_tokens:,} / {context_length:,} tokens ({usage_pct:.1f}%)\"\n+                )\n+            else:\n+                lines.append(f\"Context usage: ~{approx_tokens:,} tokens\")\n+\n+        if threshold_tokens > 0:\n+            if approx_tokens > 0:\n+                threshold_pct = (threshold_tokens / context_length) * 100 if context_length > 0 else 0\n+                remaining = max(threshold_tokens - approx_tokens, 0)\n+                if approx_tokens >= threshold_tokens:\n+                    lines.append(\n+                        f\"Compression: due now (threshold ~{threshold_tokens:,}\"\n+                        + (f\", {threshold_pct:.0f}%\" if threshold_pct else \"\")\n+                        + \"). Run /compact.\"\n+                    )\n+                else:\n+                    lines.append(\n+                        f\"Compression: ~{remaining:,} tokens until threshold \"\n+                        f\"(~{threshold_tokens:,}\"\n+                        + (f\", {threshold_pct:.0f}%\" if threshold_pct else \"\")\n+                        + \").\"\n+                    )\n+            else:\n+                lines.append(f\"Compression threshold: ~{threshold_tokens:,} tokens\")\n+\n+        if getattr(agent, \"compression_enabled\", True) is False:\n+            lines.append(\"Compression is disabled for this agent.\")\n+        else:\n+            lines.append(\"Tip: run /compact to compress manually before the threshold.\")\n+\n         return \"\\n\".join(lines)\n \n     def _cmd_reset(self, args: str, state: SessionState) -> str:"]},{"file":"acp_adapter/tools.py","hunks":["@@ -28,6 +28,11 @@\n     \"terminal\": \"execute\",\n     \"process\": \"execute\",\n     \"execute_code\": \"execute\",\n+    # Session/meta tools\n+    \"todo\": \"other\",\n+    \"skill_view\": \"read\",\n+    \"skills_list\": \"read\",\n+    \"skill_manage\": \"edit\",\n     # Web / fetch\n     \"web_search\": \"fetch\",\n     \"web_extract\": \"fetch\",","@@ -51,6 +56,28 @@\n }\n \n \n+_POLISHED_TOOLS = {\n+    # Core operator loop\n+    \"todo\", \"memory\", \"session_search\", \"delegate_task\",\n+    # Files / execution\n+    \"read_file\", \"write_file\", \"patch\", \"search_files\", \"terminal\", \"process\", \"execute_code\",\n+    # Skills / web / browser / media\n+    \"skill_view\", \"skills_list\", \"skill_manage\", \"web_search\", \"web_extract\",\n+    \"browser_navigate\", \"browser_click\", \"browser_type\", \"browser_press\", \"browser_scroll\",\n+    \"browser_back\", \"browser_snapshot\", \"browser_console\", \"browser_get_images\", \"browser_vision\",\n+    \"vision_analyze\", \"image_generate\", \"text_to_speech\",\n+    # Schedulers / platform integrations\n+    \"cronjob\", \"send_message\", \"clarify\", \"discord\", \"discord_admin\",\n+    \"ha_list_entities\", \"ha_get_state\", \"ha_list_services\", \"ha_call_service\",\n+    \"feishu_doc_read\", \"feishu_drive_list_comments\", \"feishu_drive_list_comment_replies\",\n+    \"feishu_drive_reply_comment\", \"feishu_drive_add_comment\",\n+    \"kanban_create\", \"kanban_show\", \"kanban_comment\", \"kanban_complete\",\n+    \"kanban_block\", \"kanban_link\", \"kanban_heartbeat\",\n+    \"yb_query_group_info\", \"yb_query_group_members\", \"yb_search_sticker\",\n+    \"yb_send_dm\", \"yb_send_sticker\", \"mixture_of_agents\",\n+}\n+\n+\n def get_tool_kind(tool_name: str) -> ToolKind:\n     \"\"\"Return the ACP ToolKind for a hermes tool, defaulting to 'other'.\"\"\"\n     return TOOL_KIND_MAP.get(tool_name, \"other\")","@@ -85,18 +112,645 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str:\n         if urls:\n             return f\"extract: {urls[0]}\" + (f\" (+{len(urls)-1})\" if len(urls) > 1 else \"\")\n         return \"web extract\"\n+    if tool_name == \"process\":\n+        action = str(args.get(\"action\") or \"\").strip() or \"manage\"\n+        sid = str(args.get(\"session_id\") or \"\").strip()\n+        return f\"process {action}: {sid}\" if sid else f\"process {action}\"\n     if tool_name == \"delegate_task\":\n+        tasks = args.get(\"tasks\")\n+        if isinstance(tasks, list) and tasks:\n+            return f\"delegate batch ({len(tasks)} tasks)\"\n         goal = args.get(\"goal\", \"\")\n         if goal and len(goal) > 60:\n             goal = goal[:57] + \"...\"\n         return f\"delegate: {goal}\" if goal else \"delegate task\"\n+    if tool_name == \"session_search\":\n+        query = str(args.get(\"query\") or \"\").strip()\n+        return f\"session search: {query}\" if query else \"recent sessions\"\n+    if tool_name == \"memory\":\n+        action = str(args.get(\"action\") or \"manage\").strip() or \"manage\"\n+        target = str(args.get(\"target\") or \"memory\").strip() or \"memory\"\n+        return f\"memory {action}: {target}\"\n     if tool_name == \"execute_code\":\n-        return \"execute code\"\n+        code = str(args.get(\"code\") or \"\").strip()\n+        first_line = next((line.strip() for line in code.splitlines() if line.strip()), \"\")\n+        if first_line:\n+            if len(first_line) > 70:\n+                first_line = first_line[:67] + \"...\"\n+            return f\"python: {first_line}\"\n+        return \"python code\"\n+    if tool_name == \"todo\":\n+        items = args.get(\"todos\")\n+        if isinstance(items, list):\n+            return f\"todo ({len(items)} item{'s' if len(items) != 1 else ''})\"\n+        return \"todo\"\n+    if tool_name == \"skill_view\":\n+        name = str(args.get(\"name\") or \"?\").strip() or \"?\"\n+        file_path = str(args.get(\"file_path\") or \"\").strip()\n+        suffix = f\"/{file_path}\" if file_path else \"\"\n+        return f\"skill view ({name}{suffix})\"\n+    if tool_name == \"skills_list\":\n+        category = str(args.get(\"category\") or \"\").strip()\n+        return f\"skills list ({category})\" if category else \"skills list\"\n+    if tool_name == \"skill_manage\":\n+        action = str(args.get(\"action\") or \"manage\").strip() or \"manage\"\n+        name = str(args.get(\"name\") or \"?\").strip() or \"?\"\n+        file_path = str(args.get(\"file_path\") or \"\").strip()\n+        target = f\"{name}/{file_path}\" if file_path else name\n+        if len(target) > 64:\n+            target = target[:61] + \"...\"\n+        return f\"skill {action}: {target}\"\n+    if tool_name == \"browser_navigate\":\n+        return f\"navigate: {args.get('url', '?')}\"\n+    if tool_name == \"browser_snapshot\":\n+        return \"browser snapshot\"\n+    if tool_name == \"browser_vision\":\n+        return f\"browser vision: {str(args.get('question', '?'))[:50]}\"\n+    if tool_name == \"browser_get_images\":\n+        return \"browser images\"\n     if tool_name == \"vision_analyze\":\n-        return f\"analyze image: {args.get('question', '?')[:50]}\"\n+        return f\"analyze image: {str(args.get('question', '?'))[:50]}\"\n+    if tool_name == \"image_generate\":\n+        prompt = str(args.get(\"prompt\") or args.get(\"description\") or \"\").strip()\n+        return f\"generate image: {prompt[:50]}\" if prompt else \"generate image\"\n+    if tool_name == \"cronjob\":\n+        action = str(args.get(\"action\") or \"manage\").strip() or \"manage\"\n+        job_id = str(args.get(\"job_id\") or args.get(\"id\") or \"\").strip()\n+        return f\"cron {action}: {job_id}\" if job_id else f\"cron {action}\"\n     return tool_name\n \n \n+def _text(content: str) -> Any:\n+    return acp.tool_content(acp.text_block(content))\n+\n+\n+def _json_loads_maybe(value: Optional[str]) -> Any:\n+    if not isinstance(value, str):\n+        return value\n+    try:\n+        return json.loads(value)\n+    except Exception:\n+        pass\n+\n+    # Some Hermes tools append a human hint after a JSON payload, e.g.\n+    # ``{...}\\n\\n[Hint: Results truncated...]``. Keep the structured rendering path\n+    # by decoding the first JSON value instead of falling back to raw text.\n+    try:\n+        decoded, _ = json.JSONDecoder().raw_decode(value.lstrip())\n+        return decoded\n+    except Exception:\n+        return None\n+\n+\n+def _truncate_text(text: str, limit: int = 5000) -> str:\n+    if len(text) <= limit:\n+        return text\n+    return text[: max(0, limit - 100)] + f\"\\n... ({len(text)} chars total, truncated)\"\n+\n+\n+def _fenced_text(text: str, language: str = \"\") -> str:\n+    \"\"\"Return a Markdown fence that cannot be broken by backticks in text.\"\"\"\n+    longest = max((len(run) for run in text.split(\"`\")[1::2]), default=0)\n+    fence = \"`\" * max(3, longest + 1)\n+    return f\"{fence}{language}\\n{text}\\n{fence}\"\n+\n+\n+def _format_todo_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict) or not isinstance(data.get(\"todos\"), list):\n+        return None\n+    summary = data.get(\"summary\") if isinstance(data.get(\"summary\"), dict) else {}\n+    icon = {\n+        \"completed\": \"✅\",\n+        \"in_progress\": \"🔄\",\n+        \"pending\": \"⏳\",\n+        \"cancelled\": \"✗\",\n+    }\n+    lines = [\"**Todo list**\", \"\"]\n+    for item in data[\"todos\"]:\n+        if not isinstance(item, dict):\n+            continue\n+        status = str(item.get(\"status\") or \"pending\")\n+        content = str(item.get(\"content\") or item.get(\"id\") or \"\").strip()\n+        if content:\n+            lines.append(f\"- {icon.get(status, '•')} {content}\")\n+    if summary:\n+        cancelled = summary.get(\"cancelled\", 0)\n+        lines.extend([\n+            \"\",\n+            \"**Progress:** \"\n+            f\"{summary.get('completed', 0)} completed, \"\n+            f\"{summary.get('in_progress', 0)} in progress, \"\n+            f\"{summary.get('pending', 0)} pending\"\n+            + (f\", {cancelled} cancelled\" if cancelled else \"\"),\n+        ])\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_read_file_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    if data.get(\"error\") and not data.get(\"content\"):\n+        return f\"Read failed: {data.get('error')}\"\n+    content = data.get(\"content\")\n+    if not isinstance(content, str):\n+        return None\n+    path = str((args or {}).get(\"path\") or data.get(\"path\") or \"file\").strip()\n+    offset = (args or {}).get(\"offset\")\n+    limit = (args or {}).get(\"limit\")\n+    range_bits = []\n+    if offset:\n+        range_bits.append(f\"from line {offset}\")\n+    if limit:\n+        range_bits.append(f\"limit {limit}\")\n+    suffix = f\" ({', '.join(range_bits)})\" if range_bits else \"\"\n+    header = f\"Read {path}{suffix}\"\n+    if data.get(\"total_lines\") is not None:\n+        header += f\" — {data.get('total_lines')} total lines\"\n+    # Hermes read_file output is line-numbered with `|`. If we send it as raw\n+    # Markdown, Zed can interpret pipes as tables and collapse the layout.\n+    # Fence the payload so file lines stay readable and literal.\n+    return _truncate_text(f\"{header}\\n\\n{_fenced_text(content)}\")\n+\n+\n+def _format_search_files_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    matches = data.get(\"matches\")\n+    if not isinstance(matches, list):\n+        return None\n+\n+    total = data.get(\"total_count\", len(matches))\n+    shown = min(len(matches), 12)\n+    truncated = bool(data.get(\"truncated\")) or len(matches) > shown\n+    lines = [\n+        \"Search results\",\n+        f\"Found {total} match{'es' if total != 1 else ''}; showing {shown}.\",\n+        \"\",\n+    ]\n+\n+    for match in matches[:shown]:\n+        if not isinstance(match, dict):\n+            lines.append(f\"- {match}\")\n+            continue\n+\n+        path = str(match.get(\"path\") or match.get(\"file\") or match.get(\"filename\") or \"?\")\n+        line = match.get(\"line\") or match.get(\"line_number\")\n+        content = str(match.get(\"content\") or match.get(\"text\") or \"\").strip()\n+        loc = f\"{path}:{line}\" if line else path\n+        lines.append(f\"- {loc}\")\n+        if content:\n+            snippet = _truncate_text(\" \".join(content.split()), 300)\n+            lines.append(f\"  {snippet}\")\n+\n+    if truncated:\n+        lines.extend([\n+            \"\",\n+            \"Results truncated. Narrow the search, add file_glob, or use offset to page.\",\n+        ])\n+    return _truncate_text(\"\\n\".join(lines), limit=7000)\n+\n+\n+def _format_execute_code_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return result if isinstance(result, str) and result.strip() else None\n+    output = str(data.get(\"output\") or \"\")\n+    error = str(data.get(\"error\") or \"\")\n+    exit_code = data.get(\"exit_code\")\n+    parts = [f\"Exit code: {exit_code}\" if exit_code is not None else \"Execution complete\"]\n+    if output:\n+        parts.extend([\"\", \"Output:\", output])\n+    if error:\n+        parts.extend([\"\", \"Error:\", error])\n+    return _truncate_text(\"\\n\".join(parts))\n+\n+\n+def _extract_markdown_headings(content: str, limit: int = 8) -> list[str]:\n+    headings: list[str] = []\n+    for line in content.splitlines():\n+        stripped = line.strip()\n+        if stripped.startswith(\"#\"):\n+            heading = stripped.lstrip(\"#\").strip()\n+            if heading:\n+                headings.append(heading)\n+        if len(headings) >= limit:\n+            break\n+    return headings\n+\n+\n+def _format_skill_view_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    if data.get(\"success\") is False:\n+        return f\"Skill view failed: {data.get('error', 'unknown error')}\"\n+    name = str(data.get(\"name\") or \"skill\")\n+    file_path = str(data.get(\"file\") or data.get(\"path\") or \"SKILL.md\")\n+    description = str(data.get(\"description\") or \"\").strip()\n+    content = str(data.get(\"content\") or \"\")\n+    linked = data.get(\"linked_files\") if isinstance(data.get(\"linked_files\"), dict) else None\n+\n+    lines = [\"**Skill loaded**\", \"\", f\"- **Name:** `{name}`\", f\"- **File:** `{file_path}`\"]\n+    if description:\n+        lines.append(f\"- **Description:** {description}\")\n+    if content:\n+        lines.append(f\"- **Content:** {len(content):,} chars loaded into agent context\")\n+    if linked:\n+        linked_count = sum(len(v) for v in linked.values() if isinstance(v, list))\n+        lines.append(f\"- **Linked files:** {linked_count}\")\n+\n+    headings = _extract_markdown_headings(content)\n+    if headings:\n+        lines.extend([\"\", \"**Sections**\"])\n+        lines.extend(f\"- {heading}\" for heading in headings)\n+\n+    lines.extend([\n+        \"\",\n+        \"_Full skill content is available to the agent but hidden here to keep ACP readable._\",\n+    ])\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_skill_manage_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+\n+    action = str((args or {}).get(\"action\") or \"manage\").strip() or \"manage\"\n+    name = str((args or {}).get(\"name\") or data.get(\"name\") or \"skill\").strip() or \"skill\"\n+    file_path = str((args or {}).get(\"file_path\") or data.get(\"file_path\") or \"SKILL.md\").strip() or \"SKILL.md\"\n+    success = data.get(\"success\")\n+    status = \"✅ Skill updated\" if success is not False else \"✗ Skill update failed\"\n+\n+    lines = [f\"**{status}**\", \"\", f\"- **Action:** `{action}`\", f\"- **Skill:** `{name}`\"]\n+    if action not in {\"delete\"}:\n+        lines.append(f\"- **File:** `{file_path}`\")\n+\n+    message = str(data.get(\"message\") or data.get(\"error\") or \"\").strip()\n+    if message:\n+        lines.append(f\"- **Result:** {message}\")\n+\n+    replacements = data.get(\"replacements\") or data.get(\"replacement_count\")\n+    if replacements is not None:\n+        lines.append(f\"- **Replacements:** {replacements}\")\n+\n+    path = str(data.get(\"path\") or \"\").strip()\n+    if path:\n+        lines.append(f\"- **Path:** `{path}`\")\n+\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_web_search_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    web = data.get(\"data\", {}).get(\"web\") if isinstance(data.get(\"data\"), dict) else data.get(\"web\")\n+    if not isinstance(web, list):\n+        return None\n+    lines = [f\"Web results: {len(web)}\"]\n+    for item in web[:10]:\n+        if not isinstance(item, dict):\n+            continue\n+        title = str(item.get(\"title\") or item.get(\"url\") or \"result\").strip()\n+        url = str(item.get(\"url\") or \"\").strip()\n+        desc = str(item.get(\"description\") or \"\").strip()\n+        lines.append(f\"• {title}\" + (f\" — {url}\" if url else \"\"))\n+        if desc:\n+            lines.append(f\"  {desc}\")\n+    return _truncate_text(\"\\n\".join(lines))\n+\n+\n+def _format_web_extract_result(result: Optional[str]) -> Optional[str]:\n+    \"\"\"Return only web_extract errors for ACP; success stays compact via title.\"\"\"\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    if data.get(\"success\") is False and data.get(\"error\"):\n+        return f\"Web extract failed: {data.get('error')}\"\n+    results = data.get(\"results\")\n+    if not isinstance(results, list):\n+        return None\n+\n+    failures: list[str] = []\n+    for item in results[:10]:\n+        if not isinstance(item, dict):\n+            continue\n+        error = str(item.get(\"error\") or \"\").strip()\n+        if not error or error in {\"None\", \"null\"}:\n+            continue\n+        url = str(item.get(\"url\") or \"\").strip()\n+        title = str(item.get(\"title\") or url or \"Untitled\").strip()\n+        failures.append(\n+            f\"- {title}\" + (f\" — {url}\" if url and url != title else \"\") + f\"\\n  Error: {_truncate_text(error, limit=500)}\"\n+        )\n+\n+    if not failures:\n+        return None\n+    lines = [f\"Web extract failed for {len(failures)} URL{'s' if len(failures) != 1 else ''}\"]\n+    lines.extend(failures)\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_process_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return result if isinstance(result, str) and result.strip() else None\n+    if data.get(\"success\") is False and data.get(\"error\"):\n+        return f\"Process error: {data.get('error')}\"\n+    action = str((args or {}).get(\"action\") or \"process\").strip() or \"process\"\n+    if isinstance(data.get(\"processes\"), list):\n+        processes = data[\"processes\"]\n+        lines = [f\"Processes: {len(processes)}\"]\n+        for proc in processes[:20]:\n+            if not isinstance(proc, dict):\n+                lines.append(f\"- {proc}\")\n+                continue\n+            sid = str(proc.get(\"session_id\") or proc.get(\"id\") or \"?\")\n+            status = str(proc.get(\"status\") or (\"exited\" if proc.get(\"exited\") else \"running\"))\n+            cmd = str(proc.get(\"command\") or \"\").strip()\n+            pid = proc.get(\"pid\")\n+            code = proc.get(\"exit_code\")\n+            bits = [status]\n+            if pid is not None:\n+                bits.append(f\"pid {pid}\")\n+            if code is not None:\n+                bits.append(f\"exit {code}\")\n+            lines.append(f\"- `{sid}` — {', '.join(bits)}\" + (f\" — {cmd[:120]}\" if cmd else \"\"))\n+        if len(processes) > 20:\n+            lines.append(f\"... {len(processes) - 20} more process(es)\")\n+        return \"\\n\".join(lines)\n+\n+    status = str(data.get(\"status\") or data.get(\"state\") or action).strip()\n+    sid = str(data.get(\"session_id\") or (args or {}).get(\"session_id\") or \"\").strip()\n+    lines = [f\"Process {action}: {status}\" + (f\" (`{sid}`)\" if sid else \"\")]\n+    for key, label in ((\"command\", \"Command\"), (\"pid\", \"PID\"), (\"exit_code\", \"Exit code\"), (\"returncode\", \"Exit code\"), (\"lines\", \"Lines\")):\n+        if data.get(key) is not None:\n+            lines.append(f\"- **{label}:** {data.get(key)}\")\n+    output = data.get(\"output\") or data.get(\"new_output\") or data.get(\"log\") or data.get(\"stdout\")\n+    error = data.get(\"error\") or data.get(\"stderr\")\n+    if output:\n+        lines.extend([\"\", \"Output:\", _truncate_text(str(output), limit=5000)])\n+    if error:\n+        lines.extend([\"\", \"Error:\", _truncate_text(str(error), limit=2000)])\n+    msg = data.get(\"message\")\n+    if msg and not output and not error:\n+        lines.append(str(msg))\n+    return _truncate_text(\"\\n\".join(lines), limit=7000)\n+\n+\n+def _format_delegate_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    if data.get(\"error\") and not isinstance(data.get(\"results\"), list):\n+        return f\"Delegation failed: {data.get('error')}\"\n+    results = data.get(\"results\")\n+    if not isinstance(results, list):\n+        return None\n+    total = data.get(\"total_duration_seconds\")\n+    lines = [f\"Delegation results: {len(results)} task{'s' if len(results) != 1 else ''}\" + (f\" in {total}s\" if total is not None else \"\")]\n+    icon = {\"completed\": \"✅\", \"failed\": \"✗\", \"error\": \"✗\", \"timeout\": \"⏱\", \"interrupted\": \"⚠\"}\n+    for item in results:\n+        if not isinstance(item, dict):\n+            lines.append(f\"- {item}\")\n+            continue\n+        idx = item.get(\"task_index\")\n+        status = str(item.get(\"status\") or \"unknown\")\n+        model = item.get(\"model\")\n+        dur = item.get(\"duration_seconds\")\n+        role = item.get(\"_child_role\")\n+        header = f\"{icon.get(status, '•')} Task {idx + 1 if isinstance(idx, int) else '?'}: {status}\"\n+        bits = []\n+        if model:\n+            bits.append(str(model))\n+        if role:\n+            bits.append(f\"role={role}\")\n+        if dur is not None:\n+            bits.append(f\"{dur}s\")\n+        if bits:\n+            header += \" (\" + \", \".join(bits) + \")\"\n+        lines.extend([\"\", header])\n+        summary = str(item.get(\"summary\") or \"\").strip()\n+        error = str(item.get(\"error\") or \"\").strip()\n+        if summary:\n+            lines.append(_truncate_text(summary, limit=1200))\n+        if error:\n+            lines.append(\"Error: \" + _truncate_text(error, limit=800))\n+        trace = item.get(\"tool_trace\")\n+        if isinstance(trace, list) and trace:\n+            names = [str(t.get(\"tool\") or \"?\") for t in trace if isinstance(t, dict)]\n+            if names:\n+                lines.append(\"Tools: \" + \", \".join(names[:12]) + (f\" (+{len(names)-12})\" if len(names) > 12 else \"\"))\n+    return _truncate_text(\"\\n\".join(lines), limit=8000)\n+\n+\n+def _format_session_search_result(result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    if data.get(\"success\") is False:\n+        return f\"Session search failed: {data.get('error', 'unknown error')}\"\n+    results = data.get(\"results\")\n+    if not isinstance(results, list):\n+        return None\n+    mode = data.get(\"mode\") or \"search\"\n+    query = data.get(\"query\")\n+    lines = [\"Recent sessions\" if mode == \"recent\" else f\"Session search results\" + (f\" for `{query}`\" if query else \"\")]\n+    if not results:\n+        lines.append(str(data.get(\"message\") or \"No matching sessions found.\"))\n+        return \"\\n\".join(lines)\n+    for item in results:\n+        if not isinstance(item, dict):\n+            continue\n+        sid = str(item.get(\"session_id\") or \"?\")\n+        title = str(item.get(\"title\") or item.get(\"when\") or \"Untitled session\").strip()\n+        when = str(item.get(\"last_active\") or item.get(\"started_at\") or item.get(\"when\") or \"\").strip()\n+        count = item.get(\"message_count\")\n+        source = str(item.get(\"source\") or \"\").strip()\n+        meta = \", \".join(str(x) for x in [when, source, f\"{count} msgs\" if count is not None else \"\"] if x)\n+        lines.append(f\"- **{title}** (`{sid}`)\" + (f\" — {meta}\" if meta else \"\"))\n+        summary = str(item.get(\"summary\") or item.get(\"preview\") or \"\").strip()\n+        if summary:\n+            lines.append(\"  \" + _truncate_text(\" \".join(summary.split()), limit=500))\n+    return _truncate_text(\"\\n\".join(lines), limit=7000)\n+\n+\n+def _format_memory_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return None\n+    action = str((args or {}).get(\"action\") or \"memory\").strip() or \"memory\"\n+    target = str(data.get(\"target\") or (args or {}).get(\"target\") or \"memory\")\n+    if data.get(\"success\") is False:\n+        lines = [f\"✗ Memory {action} failed ({target})\", str(data.get(\"error\") or \"unknown error\")]\n+        matches = data.get(\"matches\")\n+        if isinstance(matches, list) and matches:\n+            lines.append(\"Matches:\")\n+            lines.extend(f\"- {_truncate_text(str(m), 160)}\" for m in matches[:5])\n+        return \"\\n\".join(lines)\n+    lines = [f\"✅ Memory {action} saved ({target})\"]\n+    if data.get(\"message\"):\n+        lines.append(str(data.get(\"message\")))\n+    if data.get(\"entry_count\") is not None:\n+        lines.append(f\"Entries: {data.get('entry_count')}\")\n+    if data.get(\"usage\"):\n+        lines.append(f\"Usage: {data.get('usage')}\")\n+    # Avoid dumping all memory entries into ACP UI; show only the explicit new value preview.\n+    preview = str((args or {}).get(\"content\") or (args or {}).get(\"old_text\") or \"\").strip()\n+    if preview:\n+        lines.append(\"Preview: \" + _truncate_text(preview, limit=300))\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_edit_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    path = str((args or {}).get(\"path\") or \"file\").strip()\n+    if isinstance(data, dict):\n+        if data.get(\"success\") is False or data.get(\"error\"):\n+            return f\"{tool_name} failed for {path}: {data.get('error', 'unknown error')}\"\n+        message = str(data.get(\"message\") or \"\").strip()\n+        replacements = data.get(\"replacements\") or data.get(\"replacement_count\")\n+        lines = [f\"✅ {tool_name} completed\" + (f\" for `{path}`\" if path else \"\")]\n+        if message:\n+            lines.append(message)\n+        if replacements is not None:\n+            lines.append(f\"Replacements: {replacements}\")\n+        if data.get(\"files_modified\"):\n+            files = data.get(\"files_modified\")\n+            if isinstance(files, list):\n+                lines.append(\"Files: \" + \", \".join(f\"`{f}`\" for f in files[:8]))\n+        return \"\\n\".join(lines)\n+    if isinstance(result, str) and result.strip():\n+        return _truncate_text(result, limit=3000)\n+    return f\"✅ {tool_name} completed\" + (f\" for `{path}`\" if path else \"\")\n+\n+\n+def _format_browser_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return result if isinstance(result, str) and result.strip() else None\n+    if data.get(\"success\") is False or data.get(\"error\"):\n+        return f\"{tool_name} failed: {data.get('error', 'unknown error')}\"\n+    if tool_name == \"browser_get_images\":\n+        images = data.get(\"images\") or data.get(\"data\")\n+        if isinstance(images, list):\n+            lines = [f\"Images found: {len(images)}\"]\n+            for img in images[:12]:\n+                if isinstance(img, dict):\n+                    alt = str(img.get(\"alt\") or \"\").strip()\n+                    url = str(img.get(\"url\") or img.get(\"src\") or \"\").strip()\n+                    lines.append(f\"- {alt or 'image'}\" + (f\" — {url}\" if url else \"\"))\n+            return _truncate_text(\"\\n\".join(lines), limit=5000)\n+    title = str(data.get(\"title\") or data.get(\"url\") or data.get(\"status\") or tool_name)\n+    text = str(data.get(\"text\") or data.get(\"content\") or data.get(\"snapshot\") or data.get(\"analysis\") or data.get(\"message\") or \"\").strip()\n+    lines = [title]\n+    if data.get(\"url\") and data.get(\"url\") != title:\n+        lines.append(str(data.get(\"url\")))\n+    if text:\n+        lines.extend([\"\", _truncate_text(text, limit=5000)])\n+    return _truncate_text(\"\\n\".join(lines), limit=7000)\n+\n+\n+def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, dict):\n+        return result if isinstance(result, str) and result.strip() else None\n+    if data.get(\"success\") is False or data.get(\"error\"):\n+        return f\"{tool_name} failed: {data.get('error', 'unknown error')}\"\n+    lines = [f\"✅ {tool_name} completed\"]\n+    for key in (\"file_path\", \"path\", \"url\", \"image_url\", \"job_id\", \"id\", \"status\", \"message\", \"next_run\"):\n+        if data.get(key):\n+            lines.append(f\"- **{key}:** {data.get(key)}\")\n+    return \"\\n\".join(lines)\n+\n+\n+def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]:\n+    data = _json_loads_maybe(result)\n+    if not isinstance(data, (dict, list)):\n+        return result if isinstance(result, str) and result.strip() else None\n+    if isinstance(data, list):\n+        lines = [f\"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}\"]\n+        for item in data[:12]:\n+            lines.append(f\"- {_truncate_text(str(item), limit=240)}\")\n+        return _truncate_text(\"\\n\".join(lines), limit=5000)\n+\n+    if data.get(\"success\") is False or data.get(\"error\"):\n+        return f\"{tool_name} failed: {data.get('error', 'unknown error')}\"\n+\n+    lines = [f\"✅ {tool_name} completed\" if data.get(\"success\") is True else f\"{tool_name} result\"]\n+    priority_keys = (\n+        \"message\", \"status\", \"id\", \"task_id\", \"issue_id\", \"title\", \"name\", \"entity_id\",\n+        \"state\", \"service\", \"url\", \"path\", \"file_path\", \"count\", \"total\", \"next_run\",\n+    )\n+    seen = set()\n+    for key in priority_keys:\n+        value = data.get(key)\n+        if value in (None, \"\", [], {}):\n+            continue\n+        seen.add(key)\n+        lines.append(f\"- **{key}:** {_truncate_text(str(value), limit=500)}\")\n+\n+    for key, value in data.items():\n+        if key in seen or key in {\"success\", \"raw\", \"content\", \"entries\"}:\n+            continue\n+        if value in (None, \"\", [], {}):\n+            continue\n+        if isinstance(value, (dict, list)):\n+            preview = json.dumps(value, ensure_ascii=False, default=str)\n+        else:\n+            preview = str(value)\n+        lines.append(f\"- **{key}:** {_truncate_text(preview, limit=500)}\")\n+        if len(lines) >= 14:\n+            break\n+\n+    content = data.get(\"content\")\n+    if isinstance(content, str) and content.strip():\n+        lines.extend([\"\", _truncate_text(content.strip(), limit=1500)])\n+    return _truncate_text(\"\\n\".join(lines), limit=7000)\n+\n+\n+def _build_polished_completion_content(\n+    tool_name: str,\n+    result: Optional[str],\n+    function_args: Optional[Dict[str, Any]],\n+) -> Optional[List[Any]]:\n+    formatter = {\n+        \"todo\": lambda: _format_todo_result(result),\n+        \"read_file\": lambda: _format_read_file_result(result, function_args),\n+        \"write_file\": lambda: _format_edit_result(tool_name, result, function_args),\n+        \"patch\": lambda: _format_edit_result(tool_name, result, function_args),\n+        \"search_files\": lambda: _format_search_files_result(result),\n+        \"execute_code\": lambda: _format_execute_code_result(result),\n+        \"process\": lambda: _format_process_result(result, function_args),\n+        \"delegate_task\": lambda: _format_delegate_result(result),\n+        \"session_search\": lambda: _format_session_search_result(result),\n+        \"memory\": lambda: _format_memory_result(result, function_args),\n+        \"skill_view\": lambda: _format_skill_view_result(result),\n+        \"skill_manage\": lambda: _format_skill_manage_result(result, function_args),\n+        \"web_search\": lambda: _format_web_search_result(result),\n+        \"web_extract\": lambda: _format_web_extract_result(result),\n+        \"browser_navigate\": lambda: _format_browser_result(tool_name, result, function_args),\n+        \"browser_snapshot\": lambda: _format_browser_result(tool_name, result, function_args),\n+        \"browser_vision\": lambda: _format_browser_result(tool_name, result, function_args),\n+        \"browser_get_images\": lambda: _format_browser_result(tool_name, result, function_args),\n+        \"vision_analyze\": lambda: _format_media_or_cron_result(tool_name, result),\n+        \"image_generate\": lambda: _format_media_or_cron_result(tool_name, result),\n+        \"cronjob\": lambda: _format_media_or_cron_result(tool_name, result),\n+    }.get(tool_name)\n+    if formatter is None and tool_name in _POLISHED_TOOLS:\n+        formatter = lambda: _format_generic_structured_result(tool_name, result)\n+    if formatter is None:\n+        return None\n+    text = formatter()\n+    if not text:\n+        return None\n+    return [_text(text)]\n+\n+\n def _build_patch_mode_content(patch_text: str) -> List[Any]:\n     \"\"\"Parse V4A patch mode input into ACP diff blocks when possible.\"\"\"\n     if not patch_text:","@@ -258,7 +912,11 @@ def _build_tool_complete_content(\n         except Exception:\n             pass\n \n-    return [acp.tool_content(acp.text_block(display_result))]\n+    polished_content = _build_polished_completion_content(tool_name, result, function_args)\n+    if polished_content:\n+        return polished_content\n+\n+    return [_text(display_result)]\n \n \n # ---------------------------------------------------------------------------","@@ -288,7 +946,6 @@ def build_tool_start(\n             content = _build_patch_mode_content(patch_text)\n         return acp.start_tool_call(\n             tool_call_id, title, kind=kind, content=content, locations=locations,\n-            raw_input=arguments,\n         )\n \n     if tool_name == \"write_file\":","@@ -297,32 +954,172 @@ def build_tool_start(\n         content = [acp.tool_diff_content(path=path, new_text=file_content)]\n         return acp.start_tool_call(\n             tool_call_id, title, kind=kind, content=content, locations=locations,\n-            raw_input=arguments,\n         )\n \n     if tool_name == \"terminal\":\n         command = arguments.get(\"command\", \"\")\n-        content = [acp.tool_content(acp.text_block(f\"$ {command}\"))]\n+        content = [_text(f\"$ {command}\")]\n         return acp.start_tool_call(\n             tool_call_id, title, kind=kind, content=content, locations=locations,\n-            raw_input=arguments,\n         )\n \n     if tool_name == \"read_file\":\n-        path = arguments.get(\"path\", \"\")\n-        content = [acp.tool_content(acp.text_block(f\"Reading {path}\"))]\n+        # The title and location already identify the file. Sending a synthetic\n+        # \"Reading ...\" content block makes Zed render an unhelpful Output\n+        # section before the real file contents arrive on completion.\n         return acp.start_tool_call(\n-            tool_call_id, title, kind=kind, content=content, locations=locations,\n-            raw_input=arguments,\n+            tool_call_id, title, kind=kind, content=None, locations=locations,\n         )\n \n     if tool_name == \"search_files\":\n         pattern = arguments.get(\"pattern\", \"\")\n         target = arguments.get(\"target\", \"content\")\n-        content = [acp.tool_content(acp.text_block(f\"Searching for '{pattern}' ({target})\"))]\n+        search_path = arguments.get(\"path\")\n+        where = f\" in {search_path}\" if search_path else \"\"\n+        content = [_text(f\"Searching for '{pattern}' ({target}){where}\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"todo\":\n+        items = arguments.get(\"todos\")\n+        if isinstance(items, list):\n+            preview_lines = [\"Updating todo list\", \"\"]\n+            for item in items[:8]:\n+                if isinstance(item, dict):\n+                    preview_lines.append(f\"- {item.get('status', 'pending')}: {item.get('content', item.get('id', ''))}\")\n+            if len(items) > 8:\n+                preview_lines.append(f\"... {len(items) - 8} more\")\n+            content = [_text(\"\\n\".join(preview_lines))]\n+        else:\n+            content = [_text(\"Reading todo list\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"skill_view\":\n+        name = str(arguments.get(\"name\") or \"?\").strip() or \"?\"\n+        file_path = str(arguments.get(\"file_path\") or \"SKILL.md\").strip() or \"SKILL.md\"\n+        content = [_text(f\"Loading skill '{name}' ({file_path})\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"skill_manage\":\n+        action = str(arguments.get(\"action\") or \"manage\").strip() or \"manage\"\n+        name = str(arguments.get(\"name\") or \"?\").strip() or \"?\"\n+        file_path = str(arguments.get(\"file_path\") or \"SKILL.md\").strip() or \"SKILL.md\"\n+        path = f\"skills/{name}/{file_path}\" if file_path else f\"skills/{name}\"\n+\n+        if action == \"patch\":\n+            old = str(arguments.get(\"old_string\") or \"\")\n+            new = str(arguments.get(\"new_string\") or \"\")\n+            content = [acp.tool_diff_content(path=path, old_text=old or None, new_text=new)]\n+        elif action in {\"edit\", \"create\"}:\n+            content = [\n+                acp.tool_diff_content(\n+                    path=path,\n+                    new_text=str(arguments.get(\"content\") or \"\"),\n+                )\n+            ]\n+        elif action == \"write_file\":\n+            target = str(arguments.get(\"file_path\") or \"file\")\n+            content = [\n+                acp.tool_diff_content(\n+                    path=f\"skills/{name}/{target}\",\n+                    new_text=str(arguments.get(\"file_content\") or \"\"),\n+                )\n+            ]\n+        elif action in {\"delete\", \"remove_file\"}:\n+            target = str(arguments.get(\"file_path\") or file_path or name)\n+            content = [_text(f\"Removing {target} from skill '{name}'\")]\n+        else:\n+            content = [_text(f\"Running skill_manage action '{action}' on skill '{name}' ({file_path})\")]\n+\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"execute_code\":\n+        code = str(arguments.get(\"code\") or \"\").strip()\n+        preview = code[:1200] + (f\"\\n... ({len(code)} chars total, truncated)\" if len(code) > 1200 else \"\")\n+        content = [_text(f\"Running Python helper script:\\n\\n```python\\n{preview}\\n```\" if preview else \"Running Python helper script\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"web_search\":\n+        query = str(arguments.get(\"query\") or \"\").strip()\n+        content = [_text(f\"Searching the web for: {query}\" if query else \"Searching the web\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"web_extract\":\n+        # The title identifies the URL(s). Avoid a duplicate content block so\n+        # Zed renders this like read_file: compact start, concise completion.\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=None, locations=locations,\n+        )\n+\n+    if tool_name == \"process\":\n+        action = str(arguments.get(\"action\") or \"\").strip() or \"manage\"\n+        sid = str(arguments.get(\"session_id\") or \"\").strip()\n+        data_preview = str(arguments.get(\"data\") or \"\").strip()\n+        text = f\"Process action: {action}\" + (f\"\\nSession: {sid}\" if sid else \"\")\n+        if data_preview:\n+            text += \"\\nInput: \" + _truncate_text(data_preview, limit=500)\n+        content = [_text(text)]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"delegate_task\":\n+        tasks = arguments.get(\"tasks\")\n+        if isinstance(tasks, list) and tasks:\n+            lines = [f\"Delegating {len(tasks)} tasks\", \"\"]\n+            for i, task in enumerate(tasks[:8], 1):\n+                if isinstance(task, dict):\n+                    goal = str(task.get(\"goal\") or \"\").strip()\n+                    role = str(task.get(\"role\") or \"\").strip()\n+                    lines.append(f\"{i}. \" + _truncate_text(goal, limit=160) + (f\" ({role})\" if role else \"\"))\n+            if len(tasks) > 8:\n+                lines.append(f\"... {len(tasks) - 8} more\")\n+            content = [_text(\"\\n\".join(lines))]\n+        else:\n+            goal = str(arguments.get(\"goal\") or \"\").strip()\n+            content = [_text(\"Delegating task\" + (f\":\\n{_truncate_text(goal, limit=800)}\" if goal else \"\"))]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"session_search\":\n+        query = str(arguments.get(\"query\") or \"\").strip()\n+        content = [_text(f\"Searching past sessions for: {query}\" if query else \"Loading recent sessions\")]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name == \"memory\":\n+        action = str(arguments.get(\"action\") or \"manage\").strip() or \"manage\"\n+        target = str(arguments.get(\"target\") or \"memory\").strip() or \"memory\"\n+        preview = str(arguments.get(\"content\") or arguments.get(\"old_text\") or \"\").strip()\n+        text = f\"Memory {action} ({target})\"\n+        if preview:\n+            text += \"\\nPreview: \" + _truncate_text(preview, limit=500)\n+        content = [_text(text)]\n+        return acp.start_tool_call(\n+            tool_call_id, title, kind=kind, content=content, locations=locations,\n+        )\n+\n+    if tool_name in _POLISHED_TOOLS:\n+        try:\n+            args_text = json.dumps(arguments, indent=2, default=str)\n+        except (TypeError, ValueError):\n+            args_text = str(arguments)\n+        content = [_text(_truncate_text(args_text, limit=1200))]\n         return acp.start_tool_call(\n             tool_call_id, title, kind=kind, content=content, locations=locations,\n-            raw_input=arguments,\n         )\n \n     # Generic fallback","@@ -334,7 +1131,7 @@ def build_tool_start(\n     content = [acp.tool_content(acp.text_block(args_text))]\n     return acp.start_tool_call(\n         tool_call_id, title, kind=kind, content=content, locations=locations,\n-        raw_input=arguments,\n+        raw_input=None if tool_name in _POLISHED_TOOLS else arguments,\n     )\n \n ","@@ -347,18 +1144,22 @@ def build_tool_complete(\n ) -> ToolCallProgress:\n     \"\"\"Create a ToolCallUpdate (progress) event for a completed tool call.\"\"\"\n     kind = get_tool_kind(tool_name)\n-    content = _build_tool_complete_content(\n-        tool_name,\n-        result,\n-        function_args=function_args,\n-        snapshot=snapshot,\n-    )\n+    if tool_name == \"web_extract\":\n+        error_text = _format_web_extract_result(result)\n+        content = [_text(error_text)] if error_text else None\n+    else:\n+        content = _build_tool_complete_content(\n+            tool_name,\n+            result,\n+            function_args=function_args,\n+            snapshot=snapshot,\n+        )\n     return acp.update_tool_call(\n         tool_call_id,\n         kind=kind,\n         status=\"completed\",\n         content=content,\n-        raw_output=result,\n+        raw_output=None if tool_name in _POLISHED_TOOLS else result,\n     )\n \n "]},{"file":"tests/acp/test_mcp_e2e.py","hunks":["@@ -178,9 +178,10 @@ def mock_run_conversation(user_message, conversation_history=None, task_id=None,\n         complete_event = completions[0]\n         assert isinstance(complete_event, ToolCallProgress)\n         assert complete_event.status == \"completed\"\n-        # rawOutput should contain the tool result string\n-        assert complete_event.raw_output is not None\n-        assert \"hello\" in str(complete_event.raw_output)\n+        # Completion should contain human-readable output rather than forcing raw JSON panes.\n+        assert complete_event.content\n+        assert \"hello\" in complete_event.content[0].content.text\n+        assert complete_event.raw_output is None\n \n     def test_patch_mode_tool_start_emits_diff_blocks_for_v4a_patch(self):\n         update = build_tool_start("]},{"file":"tests/acp/test_server.py","hunks":["@@ -27,7 +27,10 @@\n     SetSessionModeResponse,\n     SessionInfo,\n     TextContentBlock,\n+    ToolCallProgress,\n+    ToolCallStart,\n     Usage,\n+    UsageUpdate,\n     UserMessageChunk,\n )\n from acp_adapter.server import HermesACPAgent, HERMES_VERSION","@@ -200,6 +203,8 @@ async def test_send_available_commands_update(self, agent):\n             \"context\",\n             \"reset\",\n             \"compact\",\n+            \"steer\",\n+            \"queue\",\n             \"version\",\n         ]\n         model_cmd = next(","@@ -208,6 +213,46 @@ async def test_send_available_commands_update(self, agent):\n         assert model_cmd.input is not None\n         assert model_cmd.input.root.hint == \"model name to switch to\"\n \n+    def test_build_usage_update_for_zed_context_indicator(self, agent, mock_manager):\n+        state = mock_manager.create_session(cwd=\"/tmp\")\n+        state.history = [{\"role\": \"user\", \"content\": \"hello\"}]\n+        state.agent.context_compressor = MagicMock(context_length=100_000)\n+        state.agent._cached_system_prompt = \"system\"\n+        state.agent.tools = [{\"type\": \"function\", \"function\": {\"name\": \"demo\"}}]\n+\n+        with patch(\n+            \"agent.model_metadata.estimate_request_tokens_rough\",\n+            return_value=25_000,\n+        ):\n+            update = agent._build_usage_update(state)\n+\n+        assert isinstance(update, UsageUpdate)\n+        assert update.session_update == \"usage_update\"\n+        assert update.size == 100_000\n+        assert update.used == 25_000\n+\n+    @pytest.mark.asyncio\n+    async def test_send_usage_update_to_client(self, agent, mock_manager):\n+        state = mock_manager.create_session(cwd=\"/tmp\")\n+        state.agent.context_compressor = MagicMock(context_length=100_000)\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        with patch(\n+            \"agent.model_metadata.estimate_request_tokens_rough\",\n+            return_value=25_000,\n+        ):\n+            await agent._send_usage_update(state)\n+\n+        mock_conn.session_update.assert_awaited_once()\n+        call = mock_conn.session_update.await_args\n+        assert call.kwargs[\"session_id\"] == state.session_id\n+        update = call.kwargs[\"update\"]\n+        assert isinstance(update, UsageUpdate)\n+        assert update.size == 100_000\n+        assert update.used == 25_000\n+\n     @pytest.mark.asyncio\n     async def test_cancel_sets_event(self, agent):\n         resp = await agent.new_session(cwd=\".\")","@@ -238,11 +283,31 @@ async def test_load_session_replays_persisted_history_to_client(self, agent):\n             {\"role\": \"system\", \"content\": \"hidden system\"},\n             {\"role\": \"user\", \"content\": \"what controls the / slash commands?\"},\n             {\"role\": \"assistant\", \"content\": \"HermesACPAgent._ADVERTISED_COMMANDS controls them.\"},\n-            {\"role\": \"tool\", \"content\": \"tool output should not replay\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_search_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\n+                            \"name\": \"search_files\",\n+                            \"arguments\": '{\"pattern\":\"slash commands\",\"path\":\".\"}',\n+                        },\n+                    }\n+                ],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"call_search_1\",\n+                \"content\": '{\"total_count\":1,\"matches\":[{\"path\":\"cli.py\",\"line\":42,\"content\":\"slash commands\"}]}',\n+            },\n         ]\n \n         mock_conn.session_update.reset_mock()\n         resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n \n         assert isinstance(resp, LoadSessionResponse)\n         calls = mock_conn.session_update.await_args_list","@@ -257,6 +322,21 @@ async def test_load_session_replays_persisted_history_to_client(self, agent):\n         assert isinstance(replay_calls[1].kwargs[\"update\"], AgentMessageChunk)\n         assert replay_calls[1].kwargs[\"update\"].content.text.startswith(\"HermesACPAgent\")\n \n+        tool_updates = [\n+            call.kwargs[\"update\"]\n+            for call in calls\n+            if getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            in {\"tool_call\", \"tool_call_update\"}\n+        ]\n+        assert len(tool_updates) == 2\n+        assert isinstance(tool_updates[0], ToolCallStart)\n+        assert tool_updates[0].tool_call_id == \"call_search_1\"\n+        assert tool_updates[0].title == \"search: slash commands\"\n+        assert isinstance(tool_updates[1], ToolCallProgress)\n+        assert tool_updates[1].tool_call_id == \"call_search_1\"\n+        assert \"Search results\" in tool_updates[1].content[0].content.text\n+        assert \"cli.py:42\" in tool_updates[1].content[0].content.text\n+\n     @pytest.mark.asyncio\n     async def test_resume_session_replays_persisted_history_to_client(self, agent):\n         mock_conn = MagicMock(spec=acp.Client)","@@ -269,6 +349,8 @@ async def test_resume_session_replays_persisted_history_to_client(self, agent):\n \n         mock_conn.session_update.reset_mock()\n         resp = await agent.resume_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n \n         assert isinstance(resp, ResumeSessionResponse)\n         updates = [call.kwargs[\"update\"] for call in mock_conn.session_update.await_args_list]","@@ -278,6 +360,27 @@ async def test_resume_session_replays_persisted_history_to_client(self, agent):\n             for update in updates\n         )\n \n+    @pytest.mark.asyncio\n+    async def test_load_session_schedules_history_replay_after_response(self, agent):\n+        \"\"\"Zed only attaches replayed updates after session/load has completed.\"\"\"\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [{\"role\": \"user\", \"content\": \"hello from history\"}]\n+        events = []\n+\n+        async def replay_after_response(_state):\n+            events.append(\"replay\")\n+\n+        with patch.object(agent, \"_replay_session_history\", side_effect=replay_after_response):\n+            resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+            events.append(\"returned\")\n+\n+        assert isinstance(resp, LoadSessionResponse)\n+        assert events == [\"returned\"]\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+        assert events == [\"returned\", \"replay\"]\n+\n     @pytest.mark.asyncio\n     async def test_resume_session_creates_new_if_missing(self, agent):\n         resume_resp = await agent.resume_session(cwd=\"/tmp\", session_id=\"nonexistent\")","@@ -522,6 +625,11 @@ async def test_prompt_runs_agent(self, agent):\n         assert isinstance(resp, PromptResponse)\n         assert resp.stop_reason == \"end_turn\"\n         state.agent.run_conversation.assert_called_once()\n+        assert state.agent.tool_progress_callback is not None\n+        assert state.agent.step_callback is not None\n+        assert state.agent.stream_delta_callback is not None\n+        assert state.agent.reasoning_callback is not None\n+        assert state.agent.thinking_callback is None\n \n     @pytest.mark.asyncio\n     async def test_prompt_updates_history(self, agent):","@@ -565,12 +673,40 @@ async def test_prompt_sends_final_message_update(self, agent):\n         prompt = [TextContentBlock(type=\"text\", text=\"help me\")]\n         await agent.prompt(prompt=prompt, session_id=new_resp.session_id)\n \n-        # session_update should have been called with the final message\n+        # session_update should include the final message (usage_update may follow it)\n         mock_conn.session_update.assert_called()\n-        # Get the last call's update argument\n-        last_call = mock_conn.session_update.call_args_list[-1]\n-        update = last_call[1].get(\"update\") or last_call[0][1]\n-        assert update.session_update == \"agent_message_chunk\"\n+        updates = [\n+            call.kwargs.get(\"update\") or call.args[1]\n+            for call in mock_conn.session_update.call_args_list\n+        ]\n+        assert any(update.session_update == \"agent_message_chunk\" for update in updates)\n+\n+    @pytest.mark.asyncio\n+    async def test_prompt_does_not_duplicate_streamed_final_message(self, agent):\n+        \"\"\"If ACP already streamed response chunks, final_response should not be sent again.\"\"\"\n+        new_resp = await agent.new_session(cwd=\".\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+\n+        def mock_run(*args, **kwargs):\n+            state.agent.stream_delta_callback(\"streamed answer\")\n+            return {\"final_response\": \"streamed answer\", \"messages\": []}\n+\n+        state.agent.run_conversation = mock_run\n+\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        prompt = [TextContentBlock(type=\"text\", text=\"hello\")]\n+        await agent.prompt(prompt=prompt, session_id=new_resp.session_id)\n+\n+        updates = [\n+            call.kwargs.get(\"update\") or call.args[1]\n+            for call in mock_conn.session_update.call_args_list\n+        ]\n+        agent_chunks = [update for update in updates if update.session_update == \"agent_message_chunk\"]\n+        assert len(agent_chunks) == 1\n+        assert agent_chunks[0].content.text == \"streamed answer\"\n \n     @pytest.mark.asyncio\n     async def test_prompt_auto_titles_session(self, agent):","@@ -708,6 +844,43 @@ def test_context_with_messages(self, agent, mock_manager):\n         assert \"2 messages\" in result\n         assert \"user: 1\" in result\n \n+    def test_context_shows_usage_and_compression_threshold(self, agent, mock_manager):\n+        state = self._make_state(mock_manager)\n+        state.history = [{\"role\": \"user\", \"content\": \"hello\"}]\n+        state.agent.context_compressor = MagicMock(\n+            context_length=100_000,\n+            threshold_tokens=[redacted:secret-assignment]\n+        )\n+        state.agent._cached_system_prompt = \"system\"\n+        state.agent.tools = [{\"type\": \"function\", \"function\": {\"name\": \"demo\"}}]\n+\n+        with patch(\n+            \"agent.model_metadata.estimate_request_tokens_rough\",\n+            return_value=25_000,\n+        ):\n+            result = agent._handle_slash_command(\"/context\", state)\n+\n+        assert \"Context usage: ~25,000 / 100,000 tokens (25.0%)\" in result\n+        assert \"Compression: ~55,000 tokens until threshold (~80,000, 80%)\" in result\n+        assert \"Tip: run /compact\" in result\n+\n+    def test_context_says_compression_due_when_past_threshold(self, agent, mock_manager):\n+        state = self._make_state(mock_manager)\n+        state.history = [{\"role\": \"user\", \"content\": \"hello\"}]\n+        state.agent.context_compressor = MagicMock(\n+            context_length=100_000,\n+            threshold_tokens=[redacted:secret-assignment]\n+        )\n+\n+        with patch(\n+            \"agent.model_metadata.estimate_request_tokens_rough\",\n+            return_value=82_000,\n+        ):\n+            result = agent._handle_slash_command(\"/context\", state)\n+\n+        assert \"Context usage: ~82,000 / 100,000 tokens (82.0%)\" in result\n+        assert \"Compression: due now (threshold ~80,000, 80%). Run /compact.\" in result\n+\n     def test_reset_clears_history(self, agent, mock_manager):\n         state = self._make_state(mock_manager)\n         state.history = [{\"role\": \"user\", \"content\": \"hello\"}]","@@ -787,7 +960,12 @@ async def test_slash_command_intercepted_in_prompt(self, agent, mock_manager):\n         resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id)\n \n         assert resp.stop_reason == \"end_turn\"\n-        mock_conn.session_update.assert_called_once()\n+        updates = [\n+            call.kwargs.get(\"update\") or call.args[1]\n+            for call in mock_conn.session_update.call_args_list\n+        ]\n+        assert any(update.session_update == \"agent_message_chunk\" for update in updates)\n+        assert any(update.session_update == \"usage_update\" for update in updates)\n \n     @pytest.mark.asyncio\n     async def test_unknown_slash_falls_through_to_llm(self, agent, mock_manager):"]},{"file":"tests/acp/test_tools.py","hunks":["@@ -52,6 +52,12 @@ def test_tool_kind_web_search(self):\n     def test_tool_kind_execute_code(self):\n         assert get_tool_kind(\"execute_code\") == \"execute\"\n \n+    def test_tool_kind_todo(self):\n+        assert get_tool_kind(\"todo\") == \"other\"\n+\n+    def test_tool_kind_skill_view(self):\n+        assert get_tool_kind(\"skill_view\") == \"read\"\n+\n     def test_tool_kind_browser_navigate(self):\n         assert get_tool_kind(\"browser_navigate\") == \"fetch\"\n ","@@ -110,6 +116,25 @@ def test_web_search_title(self):\n         title = build_tool_title(\"web_search\", {\"query\": \"python asyncio\"})\n         assert \"python asyncio\" in title\n \n+    def test_skill_view_title_includes_skill_name(self):\n+        title = build_tool_title(\"skill_view\", {\"name\": \"github-pitfalls\"})\n+        assert title == \"skill view (github-pitfalls)\"\n+\n+    def test_skill_view_title_includes_linked_file(self):\n+        title = build_tool_title(\"skill_view\", {\"name\": \"github-pitfalls\", \"file_path\": \"references/api.md\"})\n+        assert title == \"skill view (github-pitfalls/references/api.md)\"\n+\n+    def test_execute_code_title_includes_first_code_line(self):\n+        title = build_tool_title(\"execute_code\", {\"code\": \"\\nfrom hermes_tools import terminal\\nprint('done')\"})\n+        assert title == \"python: from hermes_tools import terminal\"\n+\n+    def test_skill_manage_title_includes_action_and_target(self):\n+        title = build_tool_title(\n+            \"skill_manage\",\n+            {\"action\": \"patch\", \"name\": \"hermes-agent-operations\", \"file_path\": \"references/acp.md\"},\n+        )\n+        assert title == \"skill patch: hermes-agent-operations/references/acp.md\"\n+\n     def test_unknown_tool_uses_name(self):\n         title = build_tool_title(\"some_new_tool\", {\"foo\": \"bar\"})\n         assert title == \"some_new_tool\"","@@ -164,15 +189,23 @@ def test_build_tool_start_for_terminal(self):\n         assert \"ls -la /tmp\" in text\n \n     def test_build_tool_start_for_read_file(self):\n-        \"\"\"read_file should include the path in content.\"\"\"\n+        \"\"\"read_file start should stay compact; completion carries file contents.\"\"\"\n         args = {\"path\": \"/etc/hosts\", \"offset\": 1, \"limit\": 50}\n         result = build_tool_start(\"tc-3\", \"read_file\", args)\n         assert isinstance(result, ToolCallStart)\n         assert result.kind == \"read\"\n-        assert len(result.content) >= 1\n-        content_item = result.content[0]\n-        assert isinstance(content_item, ContentToolCallContent)\n-        assert \"/etc/hosts\" in content_item.content.text\n+        assert result.content is None\n+        assert result.raw_input is None\n+\n+    def test_build_tool_start_for_web_extract_is_compact(self):\n+        \"\"\"web_extract start should stay compact; title identifies URLs.\"\"\"\n+        args = {\"urls\": [\"https://example.com/docs\"]}\n+        result = build_tool_start(\"tc-web-start\", \"web_extract\", args)\n+        assert isinstance(result, ToolCallStart)\n+        assert result.title == \"extract: https://example.com/docs\"\n+        assert result.kind == \"fetch\"\n+        assert result.content is None\n+        assert result.raw_input is None\n \n     def test_build_tool_start_for_search(self):\n         \"\"\"search_files should include pattern in content.\"\"\"","@@ -181,6 +214,48 @@ def test_build_tool_start_for_search(self):\n         assert isinstance(result, ToolCallStart)\n         assert result.kind == \"search\"\n         assert \"TODO\" in result.content[0].content.text\n+        assert result.raw_input is None\n+\n+    def test_build_tool_start_for_todo_is_human_readable(self):\n+        args = {\"todos\": [{\"id\": \"one\", \"content\": \"Fix ACP rendering\", \"status\": \"in_progress\"}]}\n+        result = build_tool_start(\"tc-todo\", \"todo\", args)\n+        assert result.title == \"todo (1 item)\"\n+        assert \"Fix ACP rendering\" in result.content[0].content.text\n+        assert result.raw_input is None\n+\n+    def test_build_tool_start_for_skill_view_is_human_readable(self):\n+        result = build_tool_start(\"tc-skill\", \"skill_view\", {\"name\": \"github-pitfalls\"})\n+        assert result.title == \"skill view (github-pitfalls)\"\n+        assert \"github-pitfalls\" in result.content[0].content.text\n+        assert result.raw_input is None\n+\n+    def test_build_tool_start_for_execute_code_shows_code_preview(self):\n+        result = build_tool_start(\"tc-code\", \"execute_code\", {\"code\": \"print('hello')\"})\n+        assert result.kind == \"execute\"\n+        assert result.title == \"python: print('hello')\"\n+        assert \"```python\" in result.content[0].content.text\n+        assert \"print('hello')\" in result.content[0].content.text\n+        assert result.raw_input is None\n+\n+    def test_build_tool_start_for_skill_manage_patch_shows_diff(self):\n+        result = build_tool_start(\n+            \"tc-skill-manage\",\n+            \"skill_manage\",\n+            {\n+                \"action\": \"patch\",\n+                \"name\": \"hermes-agent-operations\",\n+                \"file_path\": \"references/acp.md\",\n+                \"old_string\": \"old advice\",\n+                \"new_string\": \"new advice\",\n+            },\n+        )\n+        assert result.kind == \"edit\"\n+        assert result.title == \"skill patch: hermes-agent-operations/references/acp.md\"\n+        assert isinstance(result.content[0], FileEditToolCallContent)\n+        assert result.content[0].path == \"skills/hermes-agent-operations/references/acp.md\"\n+        assert result.content[0].old_text == \"old advice\"\n+        assert result.content[0].new_text == \"new advice\"\n+        assert result.raw_input is None\n \n     def test_build_tool_start_generic_fallback(self):\n         \"\"\"Unknown tools should get a generic text representation.\"\"\"","@@ -205,6 +280,158 @@ def test_build_tool_complete_for_terminal(self):\n         content_item = result.content[0]\n         assert isinstance(content_item, ContentToolCallContent)\n         assert \"total 42\" in content_item.content.text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_todo_is_checklist(self):\n+        result = build_tool_complete(\n+            \"tc-todo\",\n+            \"todo\",\n+            '{\"todos\":[{\"id\":\"a\",\"content\":\"Inspect ACP\",\"status\":\"completed\"},{\"id\":\"b\",\"content\":\"Patch renderers\",\"status\":\"in_progress\"}],\"summary\":{\"total\":2,\"pending\":0,\"in_progress\":1,\"completed\":1,\"cancelled\":0}}',\n+        )\n+        text = result.content[0].content.text\n+        assert \"✅ Inspect ACP\" in text\n+        assert \"- 🔄 Patch renderers\" in text\n+        assert \"**Progress:** 1 completed, 1 in progress, 0 pending\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_skill_view_summarizes_content_without_raw_json(self):\n+        result = build_tool_complete(\n+            \"tc-skill\",\n+            \"skill_view\",\n+            '{\"success\":true,\"name\":\"github-pitfalls\",\"description\":\"GitHub gotchas\",\"content\":\"# GitHub Pitfalls\\\\nUse gh carefully.\",\"path\":\"github/github-pitfalls/SKILL.md\"}',\n+        )\n+        text = result.content[0].content.text\n+        assert \"**Skill loaded**\" in text\n+        assert \"`github-pitfalls`\" in text\n+        assert \"GitHub gotchas\" in text\n+        assert \"GitHub Pitfalls\" in text\n+        assert \"Use gh carefully\" not in text\n+        assert \"Full skill content is available to the agent\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_execute_code_formats_output(self):\n+        result = build_tool_complete(\"tc-code\", \"execute_code\", '{\"output\":\"hello\\\\n\",\"exit_code\":0}')\n+        text = result.content[0].content.text\n+        assert \"Exit code: 0\" in text\n+        assert \"hello\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_skill_manage_summarizes_without_raw_json(self):\n+        result = build_tool_complete(\n+            \"tc-skill-manage\",\n+            \"skill_manage\",\n+            '{\"success\":true,\"message\":\"Patched references/hermes-acp-zed-rendering.md in skill \\'hermes-agent-operations\\' (1 replacement).\"}',\n+            function_args={\n+                \"action\": \"patch\",\n+                \"name\": \"hermes-agent-operations\",\n+                \"file_path\": \"references/hermes-acp-zed-rendering.md\",\n+            },\n+        )\n+        text = result.content[0].content.text\n+        assert \"**✅ Skill updated**\" in text\n+        assert \"`patch`\" in text\n+        assert \"`hermes-agent-operations`\" in text\n+        assert \"references/hermes-acp-zed-rendering.md\" in text\n+        assert \"{\\\"success\\\"\" not in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_read_file_formats_content(self):\n+        result = build_tool_complete(\n+            \"tc-read\",\n+            \"read_file\",\n+            '{\"content\":\"1|hello\\\\n2|world\",\"total_lines\":2}',\n+            function_args={\"path\":\"README.md\",\"offset\":1,\"limit\":20},\n+        )\n+        text = result.content[0].content.text\n+        assert \"Read README.md\" in text\n+        assert \"```\\n1|hello\\n2|world\\n```\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_search_files_formats_matches(self):\n+        result = build_tool_complete(\n+            \"tc-search\",\n+            \"search_files\",\n+            '{\"total_count\":2,\"matches\":[{\"path\":\"README.md\",\"line\":3,\"content\":\"TODO: fix this\"},{\"path\":\"src/app.py\",\"line\":9,\"content\":\"needle\"}],\"truncated\":true}\\n\\n[Hint: Results truncated. Use offset=12 to see more.]',\n+        )\n+        text = result.content[0].content.text\n+        assert \"Search results\" in text\n+        assert \"Found 2 matches\" in text\n+        assert \"README.md:3\" in text\n+        assert \"TODO: fix this\" in text\n+        assert \"Results truncated\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_process_list_formats_table(self):\n+        result = build_tool_complete(\n+            \"tc-process\",\n+            \"process\",\n+            '{\"processes\":[{\"session_id\":\"p1\",\"status\":\"running\",\"pid\":123,\"command\":\"npm run dev\"}]}',\n+            function_args={\"action\":\"list\"},\n+        )\n+        text = result.content[0].content.text\n+        assert \"Processes: 1\" in text\n+        assert \"`p1`\" in text\n+        assert \"npm run dev\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_delegate_task_summarizes_children(self):\n+        result = build_tool_complete(\n+            \"tc-delegate\",\n+            \"delegate_task\",\n+            '{\"results\":[{\"task_index\":0,\"status\":\"completed\",\"summary\":\"Reviewed ACP rendering.\",\"model\":\"gpt-5.5\",\"duration_seconds\":3.2,\"tool_trace\":[{\"tool\":\"read_file\"}]}],\"total_duration_seconds\":3.4}',\n+        )\n+        text = result.content[0].content.text\n+        assert \"Delegation results: 1 task\" in text\n+        assert \"Reviewed ACP rendering\" in text\n+        assert \"gpt-5.5\" in text\n+        assert \"Tools: read_file\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_session_search_recent(self):\n+        result = build_tool_complete(\n+            \"tc-session\",\n+            \"session_search\",\n+            '{\"success\":true,\"mode\":\"recent\",\"results\":[{\"session_id\":\"s1\",\"title\":\"ACP work\",\"last_active\":\"2026-05-02\",\"message_count\":12,\"preview\":\"Polished tool rendering.\"}],\"count\":1}',\n+        )\n+        text = result.content[0].content.text\n+        assert \"Recent sessions\" in text\n+        assert \"ACP work\" in text\n+        assert \"Polished tool rendering\" in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_memory_avoids_dumping_entries(self):\n+        result = build_tool_complete(\n+            \"tc-memory\",\n+            \"memory\",\n+            '{\"success\":true,\"target\":\"user\",\"entries\":[\"private long memory\"],\"usage\":\"1% — 19/2000 chars\",\"entry_count\":1,\"message\":\"Entry added.\"}',\n+            function_args={\"action\":\"add\",\"target\":\"user\",\"content\":\"User likes concise ACP rendering.\"},\n+        )\n+        text = result.content[0].content.text\n+        assert \"Memory add saved\" in text\n+        assert \"User likes concise ACP rendering\" in text\n+        assert \"private long memory\" not in text\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_web_extract_success_stays_compact(self):\n+        result = build_tool_complete(\n+            \"tc-web-extract\",\n+            \"web_extract\",\n+            '{\"results\":[{\"url\":\"https://example.com\",\"title\":\"Example\",\"content\":\"# Intro\\\\nThis is extracted content.\"}]}',\n+        )\n+        assert result.content is None\n+        assert result.raw_output is None\n+\n+    def test_build_tool_complete_for_web_extract_error_shows_error(self):\n+        result = build_tool_complete(\n+            \"tc-web-extract-error\",\n+            \"web_extract\",\n+            '{\"results\":[{\"url\":\"https://example.com\",\"title\":\"Example\",\"error\":\"timeout\"}]}',\n+        )\n+        text = result.content[0].content.text\n+        assert \"Web extract failed\" in text\n+        assert \"https://example.com\" in text\n+        assert \"timeout\" in text\n+        assert result.raw_output is None\n \n     def test_build_tool_complete_truncates_large_output(self):\n         \"\"\"Very large outputs should be truncated.\"\"\"\n"]}]},{"kind":"pr","number":26943,"title":"fix(acp): replay assistant reasoning as agent_thought_chunk on session/load (#12285)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","tests/acp/test_server.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -18,6 +18,7 @@\n from acp.schema import (\n     AgentCapabilities,\n     AgentMessageChunk,\n+    AgentThoughtChunk,\n     AuthenticateResponse,\n     AvailableCommand,\n     AvailableCommandsUpdate,","@@ -788,14 +789,20 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateRespo\n     # ---- Session management -------------------------------------------------\n \n     @staticmethod\n-    def _history_message_text(message: dict[str, Any]) -> str:\n-        \"\"\"Extract displayable text from a persisted OpenAI-style message.\"\"\"\n-        content = message.get(\"content\")\n-        if isinstance(content, str):\n-            return content.strip()\n-        if isinstance(content, list):\n+    def _flatten_history_text(value: Any) -> str:\n+        \"\"\"Normalize a persisted text-or-text-parts value into a single string.\n+\n+        OpenAI-style assistant content (and provider reasoning fields) can arrive\n+        as either a scalar string or a list of ``{\"text\": ...}`` /\n+        ``{\"type\": \"text\", \"content\": ...}`` parts. Whitespace-only inputs\n+        collapse to an empty string so callers can treat ``\"\"`` as \"nothing to\n+        emit\".\n+        \"\"\"\n+        if isinstance(value, str):\n+            return value.strip()\n+        if isinstance(value, list):\n             parts: list[str] = []\n-            for item in content:\n+            for item in value:\n                 if isinstance(item, dict):\n                     text = item.get(\"text\")\n                     if isinstance(text, str):","@@ -807,6 +814,29 @@ def _history_message_text(message: dict[str, Any]) -> str:\n             return \"\\n\".join(part.strip() for part in parts if part and part.strip()).strip()\n         return \"\"\n \n+    @classmethod\n+    def _history_message_text(cls, message: dict[str, Any]) -> str:\n+        \"\"\"Extract displayable text from a persisted OpenAI-style message.\"\"\"\n+        return cls._flatten_history_text(message.get(\"content\"))\n+\n+    @classmethod\n+    def _history_reasoning_text(cls, message: dict[str, Any]) -> str:\n+        \"\"\"Extract displayable reasoning/thought text from a persisted assistant message.\n+\n+        Returns the first non-empty value among ``reasoning_content`` (the\n+        canonical field used by DeepSeek / Moonshot and the post-#16892\n+        chat-completions normalizer) and ``reasoning`` (used by the codex\n+        event projector and several other transports). Both keys are\n+        actively written by live code paths, so neither branch is\n+        deprecated — they cover different transports rather than old vs.\n+        new sessions.\n+        \"\"\"\n+        for key in (\"reasoning_content\", \"reasoning\"):\n+            text = cls._flatten_history_text(message.get(key))\n+            if text:\n+                return text\n+        return \"\"\n+\n     @staticmethod\n     def _history_message_update(\n         *,","@@ -827,6 +857,11 @@ def _history_message_update(\n             )\n         return None\n \n+    @staticmethod\n+    def _history_thought_update(text: str) -> AgentThoughtChunk:\n+        \"\"\"Build an ACP history replay update for an assistant thought.\"\"\"\n+        return acp.update_agent_thought_text(text)\n+\n     @staticmethod\n     def _history_tool_call_name_args(tool_call: dict[str, Any]) -> tuple[str, dict[str, Any]]:\n         \"\"\"Extract function name/arguments from an OpenAI-style tool_call.\"\"\"","@@ -858,9 +893,10 @@ async def _replay_session_history(self, state: SessionState) -> None:\n \n         Zed's ACP history UI calls ``session/load`` after the user picks an item\n         from the Agents sidebar. The agent must then replay the full conversation\n-        as user/assistant chunks plus reconstructed tool-call start/completion\n-        notifications; merely restoring server-side state makes Hermes remember\n-        context, but leaves the editor looking like a clean thread.\n+        as user/assistant chunks, thinking-mode thought chunks, plus reconstructed\n+        tool-call start/completion notifications; merely restoring server-side\n+        state makes Hermes remember context, but leaves the editor looking like a\n+        clean thread.\n         \"\"\"\n         if not self._conn or not state.history:\n             return","@@ -882,24 +918,37 @@ async def _send(update: Any) -> bool:\n         for message in state.history:\n             role = str(message.get(\"role\") or \"\")\n \n-            if role in {\"user\", \"assistant\"}:\n+            if role == \"user\":\n                 text = self._history_message_text(message)\n                 if text:\n                     update = self._history_message_update(role=role, text=text)\n                     if update is not None and not await _send(update):\n                         return\n+                continue\n \n-            if role == \"assistant\" and isinstance(message.get(\"tool_calls\"), list):\n-                for tool_call in message[\"tool_calls\"]:\n-                    if not isinstance(tool_call, dict):\n-                        continue\n-                    tool_call_id = self._history_tool_call_id(tool_call)\n-                    if not tool_call_id:\n-                        continue\n-                    tool_name, args = self._history_tool_call_name_args(tool_call)\n-                    active_tool_calls[tool_call_id] = (tool_name, args)\n-                    if not await _send(build_tool_start(tool_call_id, tool_name, args)):\n+            if role == \"assistant\":\n+                thought = self._history_reasoning_text(message)\n+                if thought and not await _send(self._history_thought_update(thought)):\n+                    return\n+\n+                text = self._history_message_text(message)\n+                if text:\n+                    update = self._history_message_update(role=role, text=text)\n+                    if update is not None and not await _send(update):\n                         return\n+\n+                tool_calls = message.get(\"tool_calls\")\n+                if isinstance(tool_calls, list):\n+                    for tool_call in tool_calls:\n+                        if not isinstance(tool_call, dict):\n+                            continue\n+                        tool_call_id = self._history_tool_call_id(tool_call)\n+                        if not tool_call_id:\n+                            continue\n+                        tool_name, args = self._history_tool_call_name_args(tool_call)\n+                        active_tool_calls[tool_call_id] = (tool_name, args)\n+                        if not await _send(build_tool_start(tool_call_id, tool_name, args)):\n+                            return\n                 continue\n \n             if role == \"tool\":"]},{"file":"tests/acp/test_server.py","hunks":["@@ -13,6 +13,7 @@\n     AgentCapabilities,\n     AgentMessageChunk,\n     AgentPlanUpdate,\n+    AgentThoughtChunk,\n     AuthenticateResponse,\n     AvailableCommandsUpdate,\n     Implementation,","@@ -466,6 +467,211 @@ async def test_resume_session_replays_persisted_history_to_client(self, agent):\n             for update in updates\n         )\n \n+    @pytest.mark.asyncio\n+    async def test_load_session_replays_reasoning_thought_before_message(self, agent):\n+        \"\"\"Thinking-model thoughts must be replayed via ``agent_thought_chunk``.\n+\n+        Regression for #12285 — when a session is loaded, persisted assistant\n+        ``reasoning_content`` / ``reasoning`` fields must surface as ACP\n+        ``AgentThoughtChunk`` notifications in the same relative position they\n+        had live (thought streams before the assistant message text), so Zed's\n+        collapsed Thinking pane rebuilds instead of vanishing on reconnect.\n+        \"\"\"\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [\n+            {\"role\": \"user\", \"content\": \"Walk me through it.\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"Let me think step by step about the request.\",\n+                \"content\": \"Here is the plan.\",\n+            },\n+            {\"role\": \"user\", \"content\": \"And the legacy case?\"},\n+            {\n+                \"role\": \"assistant\",\n+                # No reasoning_content — exercise the legacy \"reasoning\" fallback\n+                # path so sessions persisted before #16892 still replay thoughts.\n+                \"reasoning\": \"Older sessions stored the trace under the internal key.\",\n+                \"content\": \"Same idea, older field name.\",\n+            },\n+        ]\n+\n+        mock_conn.session_update.reset_mock()\n+        resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        assert isinstance(resp, LoadSessionResponse)\n+\n+        replay_kinds = [\n+            getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            for call in mock_conn.session_update.await_args_list\n+            if getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            in {\"user_message_chunk\", \"agent_message_chunk\", \"agent_thought_chunk\"}\n+        ]\n+        assert replay_kinds == [\n+            \"user_message_chunk\",\n+            \"agent_thought_chunk\",\n+            \"agent_message_chunk\",\n+            \"user_message_chunk\",\n+            \"agent_thought_chunk\",\n+            \"agent_message_chunk\",\n+        ]\n+\n+        thought_updates = [\n+            call.kwargs[\"update\"]\n+            for call in mock_conn.session_update.await_args_list\n+            if isinstance(call.kwargs.get(\"update\"), AgentThoughtChunk)\n+        ]\n+        assert len(thought_updates) == 2\n+        assert thought_updates[0].content.text == \"Let me think step by step about the request.\"\n+        assert thought_updates[1].content.text == \"Older sessions stored the trace under the internal key.\"\n+\n+    @pytest.mark.asyncio\n+    async def test_load_session_replays_reasoning_only_turn(self, agent):\n+        \"\"\"Assistant turns with reasoning but no content should still emit a thought.\n+\n+        Pure reasoning-only assistant entries (e.g. a thinking step before a\n+        tool-call turn) commonly carry ``reasoning_content`` with empty\n+        ``content``. The replay must still surface the thought so the editor's\n+        Thinking pane rebuilds, even when there is no message text to follow.\n+        \"\"\"\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"I should call the search tool next.\",\n+                \"content\": \"\",\n+            },\n+        ]\n+\n+        mock_conn.session_update.reset_mock()\n+        await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        thought_updates = [\n+            call.kwargs[\"update\"]\n+            for call in mock_conn.session_update.await_args_list\n+            if isinstance(call.kwargs.get(\"update\"), AgentThoughtChunk)\n+        ]\n+        message_updates = [\n+            call.kwargs[\"update\"]\n+            for call in mock_conn.session_update.await_args_list\n+            if isinstance(call.kwargs.get(\"update\"), AgentMessageChunk)\n+        ]\n+        assert len(thought_updates) == 1\n+        assert thought_updates[0].content.text == \"I should call the search tool next.\"\n+        assert message_updates == []\n+\n+    @pytest.mark.asyncio\n+    async def test_load_session_skips_empty_reasoning_fields(self, agent):\n+        \"\"\"Empty/whitespace reasoning fields must not produce notifications.\"\"\"\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"\",\n+                \"reasoning\": \"   \\n\\t\",\n+                \"content\": \"Just a regular answer.\",\n+            },\n+        ]\n+\n+        mock_conn.session_update.reset_mock()\n+        await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        thought_updates = [\n+            call.kwargs[\"update\"]\n+            for call in mock_conn.session_update.await_args_list\n+            if isinstance(call.kwargs.get(\"update\"), AgentThoughtChunk)\n+        ]\n+        assert thought_updates == []\n+\n+    @pytest.mark.asyncio\n+    async def test_load_session_replays_thought_then_tool_call_without_message(self, agent):\n+        \"\"\"Canonical thinking-model shape: reasoning + tool_call + no body text.\n+\n+        Thinking models commonly emit a pre-tool thought followed by a\n+        tool_calls turn with empty ``content``. Replay must emit:\n+        ``agent_thought_chunk`` then ``tool_call`` then ``tool_call_update``\n+        for the matching tool result — and crucially, NO ``agent_message_chunk``\n+        for the empty-text assistant body. Regression for the canonical\n+        thinking-then-tool flow on #12285.\n+        \"\"\"\n+        mock_conn = MagicMock(spec=acp.Client)\n+        mock_conn.session_update = AsyncMock()\n+        agent._conn = mock_conn\n+\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [\n+            {\"role\": \"user\", \"content\": \"Find the bug.\"},\n+            {\n+                \"role\": \"assistant\",\n+                \"reasoning_content\": \"I should grep for the function name first.\",\n+                \"content\": \"\",\n+                \"tool_calls\": [\n+                    {\n+                        \"id\": \"call_grep_1\",\n+                        \"type\": \"function\",\n+                        \"function\": {\n+                            \"name\": \"search_files\",\n+                            \"arguments\": '{\"pattern\":\"foo\",\"path\":\".\"}',\n+                        },\n+                    }\n+                ],\n+            },\n+            {\n+                \"role\": \"tool\",\n+                \"tool_call_id\": \"call_grep_1\",\n+                \"content\": '{\"total_count\":1,\"matches\":[{\"path\":\"x.py\",\"line\":1,\"content\":\"foo\"}]}',\n+            },\n+        ]\n+\n+        mock_conn.session_update.reset_mock()\n+        await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        kinds = [\n+            getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            for call in mock_conn.session_update.await_args_list\n+            if getattr(call.kwargs.get(\"update\"), \"session_update\", None)\n+            in {\n+                \"user_message_chunk\",\n+                \"agent_thought_chunk\",\n+                \"agent_message_chunk\",\n+                \"tool_call\",\n+                \"tool_call_update\",\n+            }\n+        ]\n+        # No agent_message_chunk for the empty-content assistant turn.\n+        assert \"agent_message_chunk\" not in kinds\n+        # Thought must precede the tool_call_start within the assistant turn,\n+        # and the tool result follows.\n+        assert kinds == [\n+            \"user_message_chunk\",\n+            \"agent_thought_chunk\",\n+            \"tool_call\",\n+            \"tool_call_update\",\n+        ]\n+\n     @pytest.mark.asyncio\n     async def test_load_session_schedules_history_replay_after_response(self, agent):\n         \"\"\"Zed only attaches replayed updates after session/load has completed.\"\"\"\n"]}]},{"kind":"pr","number":26957,"title":"fix(acp): replay session history before responding to session/load (#12285 follow-up)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["acp_adapter/server.py","tests/acp/test_server.py"],"fix_hunks":[{"file":"acp_adapter/server.py","hunks":["@@ -889,14 +889,17 @@ def _history_tool_call_id(tool_call: dict[str, Any]) -> str:\n         ).strip()\n \n     async def _replay_session_history(self, state: SessionState) -> None:\n-        \"\"\"Send persisted user/assistant history to clients during session/load.\n-\n-        Zed's ACP history UI calls ``session/load`` after the user picks an item\n-        from the Agents sidebar. The agent must then replay the full conversation\n-        as user/assistant chunks, thinking-mode thought chunks, plus reconstructed\n-        tool-call start/completion notifications; merely restoring server-side\n-        state makes Hermes remember context, but leaves the editor looking like a\n-        clean thread.\n+        \"\"\"Replay persisted user/assistant history during session/load or session/resume.\n+\n+        Invoked inline (``await``) from both ``load_session`` and\n+        ``resume_session`` so that spec-compliant ACP clients receive the\n+        full transcript within the request's lifetime — see the comment at\n+        the call sites for the rationale and prior-art citations.\n+\n+        Replays the conversation as user/assistant chunks, thinking-mode\n+        thought chunks, plus reconstructed tool-call start/completion\n+        notifications. Merely restoring server-side state makes Hermes\n+        remember context, but leaves the editor looking like a clean thread.\n         \"\"\"\n         if not self._conn or not state.history:\n             return","@@ -991,18 +994,6 @@ async def new_session(\n             models=self._build_model_state(state),\n         )\n \n-    def _schedule_history_replay(self, state: SessionState) -> None:\n-        \"\"\"Replay persisted history after session/load or session/resume returns.\n-\n-        Zed only attaches streamed transcript/tool updates once the load/resume\n-        response has completed. Sending replay notifications while the request is\n-        still in-flight can make the server look correct in logs while the editor\n-        drops or fails to attach the tool-call history.\n-        \"\"\"\n-        loop = asyncio.get_running_loop()\n-        replay_coro = self._replay_session_history(state)\n-        loop.call_soon(asyncio.create_task, replay_coro)\n-\n     async def load_session(\n         self,\n         cwd: str,","@@ -1016,7 +1007,30 @@ async def load_session(\n             return None\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Loaded session %s\", session_id)\n-        self._schedule_history_replay(state)\n+        # Per ACP spec, `session/load` must stream the prior conversation back\n+        # to the client via `session/update` notifications BEFORE responding,\n+        # so the client receives the full transcript within the load request's\n+        # lifetime. Awaiting the replay here matches Codex / Claude Code /\n+        # OpenCode / Pi and the Zed client (which registers the session-update\n+        # routing entry before awaiting the loadSession RPC specifically so\n+        # in-call history replay updates can find the thread). Deferring this\n+        # via `loop.call_soon` (as we did briefly in May 2026) broke every\n+        # spec-compliant ACP client that measures notifications synchronously\n+        # against the load response — see #12285 follow-up.\n+        try:\n+            await self._replay_session_history(state)\n+        except Exception:\n+            # Replay is best-effort — a corrupted or unexpected message shape\n+            # must not turn a successful session/load into a JSON-RPC error\n+            # response. Per-notification failures are already caught inside\n+            # ``_replay_session_history``; this outer guard covers anything\n+            # raised by the helpers themselves before reaching ``_send``.\n+            logger.warning(\n+                \"ACP history replay raised during session/load for %s — \"\n+                \"load will still succeed, partial transcript may be missing\",\n+                session_id,\n+                exc_info=True,\n+            )\n         self._schedule_available_commands_update(session_id)\n         self._schedule_usage_update(state)\n         return LoadSessionResponse(models=self._build_model_state(state))","@@ -1034,7 +1048,18 @@ async def resume_session(\n             state = self.session_manager.create_session(cwd=cwd)\n         await self._register_session_mcp_servers(state, mcp_servers)\n         logger.info(\"Resumed session %s\", state.session_id)\n-        self._schedule_history_replay(state)\n+        # See `load_session` above for the spec rationale — replay must\n+        # complete before the response so clients receive the full transcript\n+        # within the request's lifetime.\n+        try:\n+            await self._replay_session_history(state)\n+        except Exception:\n+            logger.warning(\n+                \"ACP history replay raised during session/resume for %s — \"\n+                \"resume will still succeed, partial transcript may be missing\",\n+                state.session_id,\n+                exc_info=True,\n+            )\n         self._schedule_available_commands_update(state.session_id)\n         self._schedule_usage_update(state)\n         return ResumeSessionResponse(models=self._build_model_state(state))"]},{"file":"tests/acp/test_server.py","hunks":["@@ -673,25 +673,91 @@ async def test_load_session_replays_thought_then_tool_call_without_message(self,\n         ]\n \n     @pytest.mark.asyncio\n-    async def test_load_session_schedules_history_replay_after_response(self, agent):\n-        \"\"\"Zed only attaches replayed updates after session/load has completed.\"\"\"\n+    async def test_load_session_replays_history_before_returning_response(self, agent):\n+        \"\"\"Per ACP spec, replay must complete BEFORE load_session returns.\n+\n+        Spec-compliant ACP clients (Codex, Claude Code, OpenCode, Pi, Zed)\n+        attach their ``session/update`` listeners before awaiting the\n+        ``loadSession`` RPC and rely on receiving the full transcript within\n+        the request's lifetime. Deferring replay via ``loop.call_soon`` (the\n+        prior behavior in May 2026) broke clients that read notification\n+        counts synchronously against the load response — see #12285 follow-up.\n+        \"\"\"\n         new_resp = await agent.new_session(cwd=\"/tmp\")\n         state = agent.session_manager.get_session(new_resp.session_id)\n         state.history = [{\"role\": \"user\", \"content\": \"hello from history\"}]\n-        events = []\n+        events: list[str] = []\n \n-        async def replay_after_response(_state):\n+        async def replay_records(_state):\n             events.append(\"replay\")\n \n-        with patch.object(agent, \"_replay_session_history\", side_effect=replay_after_response):\n+        with patch.object(agent, \"_replay_session_history\", side_effect=replay_records):\n             resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n             events.append(\"returned\")\n \n         assert isinstance(resp, LoadSessionResponse)\n-        assert events == [\"returned\"]\n-        await asyncio.sleep(0)\n-        await asyncio.sleep(0)\n-        assert events == [\"returned\", \"replay\"]\n+        # Replay must have happened BEFORE the response was constructed —\n+        # i.e. before the `events.append(\"returned\")` after the await resolves.\n+        assert events == [\"replay\", \"returned\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_resume_session_replays_history_before_returning_response(self, agent):\n+        \"\"\"Same spec rationale as ``load_session`` — replay before responding.\"\"\"\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [{\"role\": \"user\", \"content\": \"hello from history\"}]\n+        events: list[str] = []\n+\n+        async def replay_records(_state):\n+            events.append(\"replay\")\n+\n+        with patch.object(agent, \"_replay_session_history\", side_effect=replay_records):\n+            resp = await agent.resume_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+            events.append(\"returned\")\n+\n+        assert isinstance(resp, ResumeSessionResponse)\n+        assert events == [\"replay\", \"returned\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_load_session_survives_replay_helper_exception(self, agent, caplog):\n+        \"\"\"A replay helper raising must not turn load_session into an error.\n+\n+        With awaited replay, an exception in ``_replay_session_history`` now\n+        propagates into the ``load_session`` handler. The defensive try/except\n+        guard at the call site must catch and log it so the JSON-RPC client\n+        still receives a ``LoadSessionResponse`` — partial transcripts are\n+        acceptable, total load failure is not.\n+        \"\"\"\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [{\"role\": \"user\", \"content\": \"hi\"}]\n+\n+        async def boom(_state):\n+            raise RuntimeError(\"simulated replay helper crash\")\n+\n+        with caplog.at_level(\"WARNING\", logger=\"acp_adapter.server\"):\n+            with patch.object(agent, \"_replay_session_history\", side_effect=boom):\n+                resp = await agent.load_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+\n+        assert isinstance(resp, LoadSessionResponse)\n+        assert \"history replay raised during session/load\" in caplog.text\n+\n+    @pytest.mark.asyncio\n+    async def test_resume_session_survives_replay_helper_exception(self, agent, caplog):\n+        \"\"\"Same guarantee as ``load_session`` for the resume path.\"\"\"\n+        new_resp = await agent.new_session(cwd=\"/tmp\")\n+        state = agent.session_manager.get_session(new_resp.session_id)\n+        state.history = [{\"role\": \"user\", \"content\": \"hi\"}]\n+\n+        async def boom(_state):\n+            raise RuntimeError(\"simulated replay helper crash\")\n+\n+        with caplog.at_level(\"WARNING\", logger=\"acp_adapter.server\"):\n+            with patch.object(agent, \"_replay_session_history\", side_effect=boom):\n+                resp = await agent.resume_session(cwd=\"/tmp\", session_id=new_resp.session_id)\n+\n+        assert isinstance(resp, ResumeSessionResponse)\n+        assert \"history replay raised during session/resume\" in caplog.text\n \n     @pytest.mark.asyncio\n     async def test_resume_session_creates_new_if_missing(self, agent):\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}