fix(acp): suppress cancel interrupt sentinel

This commit is contained in:
lsaether 2026-05-24 15:04:40 -05:00 committed by Teknium
parent 2789bf4e25
commit 9b631e4ae1
2 changed files with 103 additions and 3 deletions

View file

@ -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) -------------------------------------------

View file

@ -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