diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 81c22c187..1e699f617 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -88,6 +88,20 @@ _executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="acp-agent") # does not expose a client-side limit, so this is a fixed cap that clients # paginate against using `cursor` / `next_cursor`. _LIST_SESSIONS_PAGE_SIZE = 50 +_INTERRUPT_WAITING_FOR_MODEL_PREFIX = ( + "Operation interrupted: waiting for model response (" +) +_INTERRUPT_WAITING_FOR_MODEL_SUFFIX = " elapsed)." + + +def _is_interrupt_waiting_for_model_response(text: Any) -> bool: + """Return True for Hermes' local API-wait interruption status string.""" + response = str(text or "").strip() + return ( + response.startswith(_INTERRUPT_WAITING_FOR_MODEL_PREFIX) + and response.endswith(_INTERRUPT_WAITING_FOR_MODEL_SUFFIX) + ) + _MAX_ACP_RESOURCE_BYTES = 512 * 1024 _TEXT_RESOURCE_MIME_PREFIXES = ("text/",) _TEXT_RESOURCE_MIME_TYPES = { @@ -1513,7 +1527,12 @@ class HermesACPAgent(acp.Agent): self.session_manager.save_session(session_id) final_response = result.get("final_response", "") - if final_response: + cancelled = bool(state.cancel_event and state.cancel_event.is_set()) + interrupted = bool(result.get("interrupted")) or cancelled + suppress_interrupt_response = ( + interrupted and _is_interrupt_waiting_for_model_response(final_response) + ) + if final_response and not suppress_interrupt_response: try: from agent.title_generator import maybe_auto_title @@ -1534,7 +1553,12 @@ class HermesACPAgent(acp.Agent): ) except Exception: logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True) - if final_response and conn and (not streamed_message or result.get("response_transformed")): + if ( + final_response + and conn + and not suppress_interrupt_response + and (not streamed_message or result.get("response_transformed")) + ): # Deliver the final response when streaming did not already send it, # or when a plugin hook transformed the response after streaming # finished (e.g. transform_llm_output) — otherwise the appended / @@ -1576,7 +1600,7 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) - stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn" + stop_reason = "cancelled" if cancelled else "end_turn" return PromptResponse(stop_reason=stop_reason, usage=usage) # ---- Slash commands (headless) ------------------------------------------- diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index 33fb72c2e..3cfa90bb1 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -1100,6 +1100,82 @@ class TestPrompt: ] assert any(update.session_update == "agent_message_chunk" for update in updates) + @pytest.mark.asyncio + async def test_prompt_suppresses_cancel_interrupt_sentinel(self, agent): + """ACP cancel status text should not be emitted as assistant output.""" + new_resp = await agent.new_session(cwd=".") + state = agent.session_manager.get_session(new_resp.session_id) + sentinel = "Operation interrupted: waiting for model response (3.3s elapsed)." + + def mock_run(*args, **kwargs): + state.cancel_event.set() + return { + "final_response": sentinel, + "messages": list(state.history), + "interrupted": True, + "completed": False, + } + + state.agent.run_conversation = mock_run + + mock_conn = MagicMock(spec=acp.Client) + mock_conn.session_update = AsyncMock() + agent._conn = mock_conn + + with patch("agent.title_generator.maybe_auto_title") as mock_title: + prompt = [TextContentBlock(type="text", text="please do a long task")] + resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) + + updates = [ + call.kwargs.get("update") or call.args[1] + for call in mock_conn.session_update.call_args_list + ] + agent_texts = [ + update.content.text + for update in updates + if update.session_update == "agent_message_chunk" + ] + assert resp.stop_reason == "cancelled" + assert sentinel not in agent_texts + assert not any(text.startswith("Operation interrupted:") for text in agent_texts) + mock_title.assert_not_called() + + @pytest.mark.asyncio + async def test_prompt_keeps_real_final_response_on_cancelled_turn(self, agent): + """A cancel flag must not suppress actual assistant/model text.""" + new_resp = await agent.new_session(cwd=".") + state = agent.session_manager.get_session(new_resp.session_id) + final_text = "The actual model answer arrived before cancellation settled." + + def mock_run(*args, **kwargs): + state.cancel_event.set() + return { + "final_response": final_text, + "messages": [], + "interrupted": True, + } + + state.agent.run_conversation = mock_run + + mock_conn = MagicMock(spec=acp.Client) + mock_conn.session_update = AsyncMock() + agent._conn = mock_conn + + prompt = [TextContentBlock(type="text", text="finish if you can")] + resp = await agent.prompt(prompt=prompt, session_id=new_resp.session_id) + + updates = [ + call.kwargs.get("update") or call.args[1] + for call in mock_conn.session_update.call_args_list + ] + agent_texts = [ + update.content.text + for update in updates + if update.session_update == "agent_message_chunk" + ] + assert resp.stop_reason == "cancelled" + assert final_text in agent_texts + @pytest.mark.asyncio async def test_prompt_propagates_hermes_session_id_env(self, agent, monkeypatch): """ACP must propagate the originating session id to the agent loop