Files
hermes-agent/tests/agent/test_turn_api_call_interrupt.py
kshitijk4poor 2bbb37b973 refactor(agent): move interrupt placeholders into agent_runtime_helpers_placeholders to keep files under the size cap
Code-health ratchet: agent/agent_runtime_helpers.py was 3787 > 3776 and
tests/agent/test_run_agent.py 7014 > 7012 (both over the 2,000 target, so
they may only shrink vs main).

- _INTERRUPTED_PLACEHOLDER / _LEGACY_INTERRUPTED_PLACEHOLDER (and their
  rationale comment) move byte-identically into the leaf sibling
  agent/agent_runtime_helpers_placeholders.py. Every importer (runtime
  helpers via late import, conversation_loop, message_sanitization,
  turn_api_call, turn_iteration_prep and five test files) now reads the
  sibling; no shim is left in the facade. Nothing patches the old path.
- test_keyboard_interrupt_emits_cancelled_post_tool_hook moves unchanged
  into tests/agent/test_run_agent_interrupt_hook.py, reusing the facade's
  agent fixture.
2026-10-07 18:15:29 +05:30

77 lines
3.1 KiB
Python

"""Tests for ``agent/turn_api_call.py::handle_api_interrupt`` — the plain (non-redirect)
interrupt that lands mid provider call and records the streamed partial as the interrupted
assistant row."""
from __future__ import annotations
import threading
import time
from agent.agent_runtime_helpers_placeholders import _INTERRUPTED_PLACEHOLDER
from agent.repetition_guard import REPETITION_LOOP_INTERRUPTED
from agent.turn_api_call import handle_api_interrupt
from agent.turn_retry_state import TurnRetryState
from run_agent import AIAgent
def _bare_agent(streamed: str) -> AIAgent:
agent = object.__new__(AIAgent)
agent._pending_redirect = None
agent._pending_redirect_lock = threading.Lock()
agent._interrupt_requested = False
agent._interrupt_message = None
agent._current_streamed_assistant_text = streamed
agent._strip_think_blocks = lambda content: content
agent.quiet_mode = True
agent.log_prefix = ""
agent.thinking_callback = None
agent._print_fn = lambda *args, **kwargs: None
agent._persist_session = lambda *args, **kwargs: None
return agent
def _interrupt(streamed: str):
messages = [{"role": "user", "content": "start"}]
verdict = handle_api_interrupt(
_bare_agent(streamed), _retry=TurnRetryState(), thinking_spinner=None, messages=messages,
conversation_history=[], api_start_time=time.time(), interrupted=False, final_response=None,
)
return messages, verdict
def test_repetition_dominated_partial_is_not_kept_as_the_interrupted_row():
"""A looped partial replayed as the interrupted assistant row re-seeds the loop on the next
turn (#112764): the row keeps the neutral placeholder and the user is told what happened."""
looped = "I. " * 1941
messages, verdict = _interrupt(looped)
# Same hidden shape as the redirect placeholder: no visible bubble in transcript replays.
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"] == ""
assert messages[-1]["display_kind"] == "hidden"
assert messages[-1]["api_content"] == _INTERRUPTED_PLACEHOLDER
assert verdict.final_response == REPETITION_LOOP_INTERRUPTED
assert "I. I. I." not in verdict.final_response
def test_distinct_batch_rows_are_not_mistaken_for_a_loop():
"""Legitimately repetitive output (distinct INSERT rows sharing a long prefix) trips the
window scan but is not a runaway loop: the partial must stay the interrupted row and must
not be relabelled as a degenerate reply."""
rows = "\n".join(
f"INSERT INTO users (id, name, email, created_at) VALUES ({i}, 'user{i}', 'user{i}@example.com', NOW());"
for i in range(12)
)
messages, verdict = _interrupt(rows)
assert (messages[-1]["role"], messages[-1]["content"]) == ("assistant", rows)
assert verdict.final_response == rows
def test_ordinary_partial_is_kept_as_the_interrupted_row():
messages, verdict = _interrupt("Visible draft.")
assert (messages[-1]["role"], messages[-1]["content"]) == ("assistant", "Visible draft.")
assert messages[-1]["display_metadata"] == {"interrupted": True}
assert verdict.final_response == "Visible draft."