test(run_agent): keep concurrent-interrupt stub in sync with AIAgent surface

The stub in tests/run_agent/test_concurrent_interrupt.py mirrors a subset
of AIAgent attributes/methods deliberately ("Avoid full AIAgent init —
just import the class and build a stub"). Two recent additions to the
real AIAgent broke it:

1. Tool-call guardrails: AIAgent gained _tool_guardrails (line 1160) +
   _append_guardrail_observation / _guardrail_block_result /
   _set_tool_guardrail_halt that the concurrent-execution path now
   invokes during result collection. Add a MagicMock guardrail
   controller whose decision objects mirror the
   ToolGuardrailDecision shape (action="allow", allows_execution=True,
   should_halt=False) — the bound methods read sensible defaults
   instead of truthy MagicMock children. Bind the three methods the
   same way as other AIAgent methods.

2. _invoke_tool gained kwargs: messages= and pre_tool_block_checked=
   are now forwarded by the concurrent path. The two test fakes
   (slow_tool, polling_tool) didn't accept them and raised TypeError
   on every call. Add **kwargs so future kwargs additions don't break
   these fakes.

These are pure stub-drift fixes — production behaviour is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude-ceo-assistant 2026-05-08 03:41:31 +00:00
parent 74d5e5a899
commit 5f179d6d35

View File

@ -47,12 +47,32 @@ def _make_agent(monkeypatch):
# Worker-thread tracking state mirrored from AIAgent.__init__ so the
# real interrupt() method can fan out to concurrent-tool workers.
_active_children: list = []
# ToolCallGuardrailController instance on real AIAgent. The stub
# always allows execution — these tests exercise interrupt fanout,
# not guardrail behaviour. before_call returns a decision-shaped
# object with allows_execution=True; after_call is similar.
_tool_guardrails = MagicMock()
def __init__(self):
# Instance-level (not class-level) so each test gets a fresh set.
self._tool_worker_threads: set = set()
self._tool_worker_threads_lock = threading.Lock()
self._active_children_lock = threading.Lock()
# Re-assign per-instance so tests can mutate independently.
self._tool_guardrails = MagicMock()
# Decision shapes match agent.tool_guardrails.ToolGuardrailDecision —
# action="allow", allows_execution=True, should_halt=False — so
# the bound _append_guardrail_observation / _guardrail_block_result
# methods read sensible defaults instead of truthy MagicMock children.
_allow = MagicMock(
action="allow",
allows_execution=True,
should_halt=False,
code=None,
count=0,
)
self._tool_guardrails.before_call.return_value = _allow
self._tool_guardrails.after_call.return_value = _allow
def _touch_activity(self, desc):
self._last_activity = time.time()
@ -77,6 +97,21 @@ def _make_agent(monkeypatch):
stub._execute_tool_calls_concurrent = _ra.AIAgent._execute_tool_calls_concurrent.__get__(stub)
stub.interrupt = _ra.AIAgent.interrupt.__get__(stub)
stub.clear_interrupt = _ra.AIAgent.clear_interrupt.__get__(stub)
# Tool-guardrails wiring: the concurrent execution path now calls
# _append_guardrail_observation / _guardrail_block_result /
# _set_tool_guardrail_halt during the result-collection loop. These are
# thin pass-throughs over self._tool_guardrails; bind them for the stub
# the same way as other AIAgent methods.
stub._append_guardrail_observation = (
_ra.AIAgent._append_guardrail_observation.__get__(stub)
)
stub._guardrail_block_result = (
_ra.AIAgent._guardrail_block_result.__get__(stub)
)
stub._set_tool_guardrail_halt = (
_ra.AIAgent._set_tool_guardrail_halt.__get__(stub)
)
stub._tool_guardrail_halt_decision = None
# /steer injection (added in PR #12116) fires after every concurrent
# tool batch. Stub it as a no-op — this test exercises interrupt
# fanout, not steer injection.
@ -107,7 +142,11 @@ def test_concurrent_interrupt_cancels_pending(monkeypatch):
original_invoke = agent._invoke_tool
def slow_tool(name, args, task_id, call_id=None):
def slow_tool(name, args, task_id, call_id=None, **kwargs):
# **kwargs absorbs `messages=...` and `pre_tool_block_checked=...`
# that _invoke_tool now forwards. The test only cares about the
# positional shape; future kwargs added to _invoke_tool shouldn't
# break this fake.
if name == "slow_one":
# Block until the test sets the interrupt
barrier.wait(timeout=10)
@ -184,7 +223,10 @@ def test_running_concurrent_worker_sees_is_interrupted(monkeypatch):
observed = {"saw_true": False, "poll_count": 0, "worker_tid": None}
worker_started = threading.Event()
def polling_tool(name, args, task_id, call_id=None, messages=None):
def polling_tool(name, args, task_id, call_id=None, messages=None, **kwargs):
# **kwargs absorbs pre_tool_block_checked and any future kwargs added
# to _invoke_tool — see test_concurrent_interrupt_cancels_pending for
# the same pattern.
observed["worker_tid"] = threading.current_thread().ident
worker_started.set()
deadline = time.monotonic() + 5.0