Files
EvoScientist-Multi/tests/test_interaction_engine.py
T
dinos db1abce8d8 refactor: extract a shared HITL/ask_user interaction engine (#342)
* chore: add pytest-asyncio in auto mode

* test: migrate channel and stream tests to native async

Convert run_async() wrapper tests to plain 'async def test_*' under
pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a
coroutine awaited at every call site.

* test: migrate command and model/middleware tests to native async

Convert run_async() wrappers (import, alias, and fixture forms) to plain
'async def test_*'. Multi-call tests merge onto one loop as sequential
awaits; none asserted on loop identity.

* test: migrate TUI, notifier, gateway, and session tests to native async

TUI/notifier/gateway files convert run_async wrappers to plain async
tests. test_sessions.py's unittest.TestCase classes move to
unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async
methods on plain TestCase; converting blindly would have made ~70 tests
silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since
IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget
in test_tui_widgets.py drops its TestCase base for the same reason.

* test: replace direct asyncio.run() calls with native async tests

Convert tests that called asyncio.run() (directly or via a local _run
helper) to plain 'async def test_*'; delete the local helpers.

* test: drop undeclared anyio markers and delete run_async helper

The @pytest.mark.anyio tests relied on anyio being a transitive dep of
httpx; auto-mode pytest-asyncio collects them natively. run_async() and
its fixture are unreferenced after the migration, so remove them —
pytest-asyncio's per-test loop teardown covers the pending-task
cancellation the helper existed for (verified: full suite runs with no
'Event loop is closed' errors or destroyed-task warnings).

* test: add autouse fixture for watcher cleanup

* refactor: remove redundant hasattr calls

* refactor: extract shared HITL/ask_user interaction grammar

Extract prompt/question formatting, the reply grammar (approval letters,
ask_user choice letters + the 'Other' sub-flow, stop-commands), the
ApprovalPolicy (config auto-approve rule + session registry +
session-key derivation), per-flow timeout constants, and the bilingual
feedback strings into channels/interaction.py. Both drivers now point at
the shared functions: this reverses cli/channel.py's imports of consumer
privates and closes the /stop drift at the parsing layer (serve-mode
ask_user now checks stop-commands before parsing an answer, matching the
CLI path).

* refactor: add interaction engine + registry; port InboundConsumer

Introduce InteractionIO (transport adapter Protocol),
PendingReplyRegistry (one asyncio-based reply router per process), and
the engine coroutines resolve_ask_user / resolve_approval in
channels/interaction.py. Port InboundConsumer onto them: a _ConsumerIO
adapter over bus.publish_outbound + the registry, one ApprovalPolicy
replacing the config/session auto-approve checks, and a single
reply-interception point (registry.try_resolve) replacing the parallel
ask_user/HITL pending dicts. _resolve_ask_user and the approval section
of _stream_with_hitl are now thin engine calls.

Behavior unification (serve mode): an unrecognized HITL reply now
declines with the shared 'Unrecognized reply' notice instead of
rejecting-and-refeeding as a fresh turn, and /stop mid-approval cancels
cleanly — both via the shared parser.

* refactor: port CLI channel bridge onto the interaction engine

Replace the ~250-line parallel bodies of channel_ask_user_prompt /
channel_hitl_prompt with thin bridges that run resolve_ask_user /
resolve_approval on the bus loop via
run_coroutine_threadsafe(...).result() (outer = engine per-flow timeout
+ slack, so the engine's own timeout fires first). The 15s send timeout
moves into the _BridgeIO adapter.

Delete the _pending_hitl / _hitl_lock / _hitl_auto_approve module
globals and the _register_hitl_wait / _try_set_hitl_reply /
_pop_hitl_reply helpers, absorbed by one bus-loop PendingReplyRegistry +
one ApprovalPolicy. The bus consumer feeds the registry via try_resolve
ahead of normal enqueue.

* refactor: restore serve-mode refeed for unrecognized HITL replies

Gate-review fix: the engine no longer decides transport policy for
unparseable approval replies. resolve_approval now returns an
ApprovalOutcome carrying unrecognized_reply (raw text) when parsing
fails, sending no feedback itself; recognized reject keeps the sharedi
rejection message.

Consumer driver (serve mode) restores the pre-engine semantics: an
unrecognized reply rejects the pending action, confirms with the
rejection message, and the text is re-dispatched as a NEW agent turn —
_stream_with_hitl returns the captured text and _handle_message starts
the refeed turn only after the current one has released the chat lock
(old fall-through ordering). CLI bridge keeps its old no-refeed path
byte-for-byte: 'Unrecognized reply. Action rejected.' and decline.

Tests: serve refeed pinned end-to-end (prompt → unrecognized text →
rejection feedback → text reaches the stream path as a new turn), CLI
no-refeed pinned (notice sent, nothing enqueued), engine test updated to
assert the outcome struct with no engine-side feedback.

* refactor: polish the interaction engine surface

- English feedback strings (Approved / Rejected / auto-approving)
- drop the consumer's backwards-compatible re-exports and both modules'
  private timeout aliases; callers use the canonical interaction names
- replace byte-for-byte prompt goldens with structural format tests and
  assert feedback via the shared constants instead of string literals
- strip audit/design shorthand (R1/R2/G3, stage numbers) from comments

* fix: propagate pending reply task cancellation

* fix: preserve reply context when refeeding HITL replies

* fix: honor HITL session grants without bus loop

* fix: bound bridge waits by send latency

* fix: handle empty ask_user replies explicitly

* chore: remove stale interaction helpers

* fix: harden interaction engine reply edge cases

Review follow-ups on the interaction engine:
- normalize ask_user choices before .get(): the tool args come from model
  JSON and only presence is validated, so plain-string choices must render
  and parse instead of crashing the turn
- treat only None as an approval timeout, so an empty/media-only reply
  flows through the unrecognized path and serve mode refeeds it with its
  preserved context
- intercept prompt replies before _get_thread_id so a consumed reply
  cannot create an orphan graph thread or touch the sender-session LRU
- close engine coroutines the bridge failed to schedule (no bus loop /
  scheduling error) to avoid never-awaited warnings
- clear pending-response and channel-request state in the bridge test
  fixture

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-14 14:59:41 +00:00

564 lines
24 KiB
Python

"""Tests for the interaction engine coroutines + reply registry.
The engine (:func:`resolve_ask_user`, :func:`resolve_approval`) is pure
async with an injected :class:`InteractionIO`, so it is exercised here with
a scripted ``FakeIO``: assert the prompts it emits and feed it the replies
a user would send. Covers the whole grammar — single/multi question,
optional, choice letters, "Other", timeout, ``/stop``, approve / reject /
approve-all, and capability-driven button formatting.
"""
import asyncio
import pytest
from EvoScientist.channels import interaction as I
from EvoScientist.channels.capabilities import ChannelCapabilities
# ═══════════════════════════════════════════════════════════════════════
# Scripted fake IO
# ═══════════════════════════════════════════════════════════════════════
class FakeIO(I.InteractionIO):
"""A scripted :class:`InteractionIO`.
*replies* is the queue of reply strings ``wait_reply`` hands back in
order; a ``None`` entry (or exhausting the queue) simulates a timeout.
Every ``send`` is recorded as ``(content, metadata)`` in ``sent``.
"""
def __init__(self, replies=None, *, capabilities=None, base_metadata=None):
self.capabilities = capabilities or ChannelCapabilities()
self.base_metadata = base_metadata
self._replies = list(replies or [])
self.sent: list[tuple[str, dict | None]] = []
self.send_ok = True
async def send(self, content, *, metadata=None):
self.sent.append((content, metadata))
return self.send_ok
async def wait_reply(self, *, timeout):
if not self._replies:
return None
return self._replies.pop(0)
@property
def contents(self):
return [c for c, _ in self.sent]
QQ_CAPS = ChannelCapabilities(inline_buttons=True)
# ═══════════════════════════════════════════════════════════════════════
# resolve_ask_user
# ═══════════════════════════════════════════════════════════════════════
class TestResolveAskUser:
async def test_empty_questions(self):
io = FakeIO()
result = await I.resolve_ask_user([], io)
assert result == {"answers": [], "status": "answered"}
assert io.sent == []
async def test_single_text_answered(self):
io = FakeIO(["CIFAR-10"])
result = await I.resolve_ask_user(
[{"question": "Which dataset?", "type": "text"}], io
)
assert result == {"answers": ["CIFAR-10"], "status": "answered"}
assert "Quick check-in" in io.contents[0]
async def test_multi_question_answered(self):
io = FakeIO(["ans1", "ans2"])
result = await I.resolve_ask_user(
[
{"question": "Q1?", "type": "text"},
{"question": "Q2?", "type": "text"},
],
io,
)
assert result == {"answers": ["ans1", "ans2"], "status": "answered"}
assert io.contents[0].startswith("❓ Question 1/2")
assert io.contents[1].startswith("❓ Question 2/2")
async def test_choice_letter(self):
io = FakeIO(["B"])
q = {
"question": "Which?",
"type": "multiple_choice",
"choices": [{"value": "CIFAR-10"}, {"value": "ImageNet"}],
}
result = await I.resolve_ask_user([q], io)
assert result == {"answers": ["ImageNet"], "status": "answered"}
async def test_choice_other_subflow(self):
# "C" is the Other letter for two choices; then a free-form answer.
io = FakeIO(["C", "my custom dataset"])
q = {
"question": "Which?",
"type": "multiple_choice",
"choices": [{"value": "CIFAR-10"}, {"value": "ImageNet"}],
}
result = await I.resolve_ask_user([q], io)
assert result == {"answers": ["my custom dataset"], "status": "answered"}
assert io.contents[1] == I.OTHER_PROMPT
async def test_optional_suffix_in_prompt(self):
io = FakeIO(["ans"])
await I.resolve_ask_user(
[{"question": "Notes?", "type": "text", "required": False}], io
)
assert "(optional)" in io.contents[0]
assert "Leave empty to skip." in io.contents[0]
async def test_optional_empty_reply_skips_and_continues(self):
io = FakeIO(["", "next"])
result = await I.resolve_ask_user(
[
{"question": "Notes?", "type": "text", "required": False},
{"question": "Next?", "type": "text"},
],
io,
)
assert result == {"answers": ["", "next"], "status": "answered"}
assert io.contents[1].startswith("❓ Question 2/2")
assert I.ASK_USER_TIMEOUT_FEEDBACK not in io.contents
async def test_required_empty_reply_cancels_without_timeout_notice(self):
io = FakeIO([""])
result = await I.resolve_ask_user(
[{"question": "Required?", "type": "text"}],
io,
)
assert result == {"status": "cancelled"}
assert I.ASK_USER_TIMEOUT_FEEDBACK not in io.contents
async def test_timeout_first_question(self):
io = FakeIO([]) # no replies -> timeout
result = await I.resolve_ask_user([{"question": "Q?", "type": "text"}], io)
assert result == {"status": "cancelled"}
assert io.contents[-1] == I.ASK_USER_TIMEOUT_FEEDBACK
async def test_timeout_in_other_subflow(self):
io = FakeIO(["C"]) # picks Other, then times out on free-form
q = {
"question": "Which?",
"type": "multiple_choice",
"choices": [{"value": "A"}, {"value": "B"}],
}
result = await I.resolve_ask_user([q], io)
assert result == {"status": "cancelled"}
assert io.contents[-1] == I.ASK_USER_TIMEOUT_FEEDBACK
async def test_stop_command_cancels(self):
io = FakeIO(["/stop"])
result = await I.resolve_ask_user([{"question": "Q?", "type": "text"}], io)
assert result == {"status": "cancelled"}
# /stop is a pure cancel — no timeout notice sent.
assert I.ASK_USER_TIMEOUT_FEEDBACK not in io.contents
async def test_stop_command_in_other_subflow(self):
io = FakeIO(["C", "/stop"])
q = {
"question": "Which?",
"type": "multiple_choice",
"choices": [{"value": "A"}, {"value": "B"}],
}
result = await I.resolve_ask_user([q], io)
assert result == {"status": "cancelled"}
async def test_cancel_reply(self):
io = FakeIO(["cancel"])
result = await I.resolve_ask_user([{"question": "Q?", "type": "text"}], io)
assert result == {"status": "cancelled"}
async def test_send_failure_cancels(self):
io = FakeIO(["ans"])
io.send_ok = False
result = await I.resolve_ask_user([{"question": "Q?", "type": "text"}], io)
assert result == {"status": "cancelled"}
# ═══════════════════════════════════════════════════════════════════════
# resolve_approval
# ═══════════════════════════════════════════════════════════════════════
REQS = [{"name": "execute", "args": {"command": "rm -rf /tmp/x"}}]
class TestResolveApproval:
async def test_session_granted_short_circuits(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
p = I.ApprovalPolicy()
p.grant_session("tg:c1")
io = FakeIO()
result = await I.resolve_approval(REQS, io, p, "tg:c1")
assert result == I.ApprovalOutcome(decisions=[{"type": "approve"}])
assert io.sent == [] # no prompt, silent
async def test_config_auto_approve_short_circuits(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: True)
io = FakeIO()
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome(decisions=[{"type": "approve"}])
assert io.sent == []
async def test_approve(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["1"])
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome(decisions=[{"type": "approve"}])
assert io.contents[0].startswith("⚠️ Approval Required")
assert io.contents[-1] == I.APPROVED_FEEDBACK
async def test_reject(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["2"])
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome()
assert io.contents[-1] == I.REJECTED_FEEDBACK
async def test_approve_all_grants_session(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["3"])
p = I.ApprovalPolicy()
result = await I.resolve_approval(REQS, io, p, "tg:c1")
assert result == I.ApprovalOutcome(decisions=[{"type": "approve"}])
assert io.contents[-1] == I.APPROVED_AUTO_FEEDBACK
assert p.is_session_granted("tg:c1") # future prompts auto-approve
async def test_multi_request_approve_length(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
reqs = [
{"name": "execute", "args": {"command": "a"}},
{"name": "execute", "args": {"command": "b"}},
]
io = FakeIO(["1"])
result = await I.resolve_approval(reqs, io, I.ApprovalPolicy(), "tg:c1")
assert result.decisions == [{"type": "approve"}, {"type": "approve"}]
async def test_unrecognized_reply_reported_not_judged(self, monkeypatch):
# The engine declines but hands the raw text back — the *driver*
# decides the feedback / refeed policy (consumer refeeds as a new
# turn; CLI bridge sends the unrecognized notice).
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["huh?"])
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result.decisions is None
assert result.unrecognized_reply == "huh?"
# No feedback sent by the engine itself on the unrecognized path.
assert I.UNRECOGNIZED_FEEDBACK not in io.contents
assert I.REJECTED_FEEDBACK not in io.contents
async def test_timeout(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO([]) # times out
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome()
assert io.contents[-1] == I.APPROVAL_TIMEOUT_FEEDBACK
async def test_stop_command_silent_cancel(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["/stop"])
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome()
# /stop already got its own ack; no reject/unrecognized feedback here.
assert I.REJECTED_FEEDBACK not in io.contents
assert I.UNRECOGNIZED_FEEDBACK not in io.contents
async def test_send_failure_declines(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["1"])
io.send_ok = False
result = await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
assert result == I.ApprovalOutcome()
# ── R3: button-capability formatting + payload normalization ──
async def test_buttons_attached_when_capable(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["1"], capabilities=QQ_CAPS, base_metadata={"chat": "x"})
await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
prompt, metadata = io.sent[0]
# Button channels drop the textual "Reply: 1=..." cue.
assert "Reply: 1=Approve" not in prompt
assert metadata["buttons"] == [
{"text": "Approve", "value": "1", "type": "primary"},
{"text": "Reject", "value": "2", "type": "danger"},
{"text": "Approve all", "value": "3"},
]
assert metadata["chat"] == "x" # base metadata preserved
async def test_no_buttons_when_incapable(self, monkeypatch):
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["1"]) # default caps: no inline_buttons
await I.resolve_approval(REQS, io, I.ApprovalPolicy(), "tg:c1")
prompt, metadata = io.sent[0]
assert "Reply: 1=Approve, 2=Reject, 3=Approve all" in prompt
assert "buttons" not in (metadata or {})
async def test_button_press_payload_normalizes(self, monkeypatch):
# A button click delivers its `value` ("3") through the same reply
# path; the engine must treat it exactly like a typed "3".
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(["3"], capabilities=QQ_CAPS)
p = I.ApprovalPolicy()
result = await I.resolve_approval(REQS, io, p, "tg:c1")
assert result == I.ApprovalOutcome(decisions=[{"type": "approve"}])
assert p.is_session_granted("tg:c1")
# ═══════════════════════════════════════════════════════════════════════
# PendingReplyRegistry
# ═══════════════════════════════════════════════════════════════════════
class TestPendingReplyRegistry:
async def test_register_wait_resolve(self):
reg = I.PendingReplyRegistry()
async def _resolver():
# Give wait() a tick to register before resolving.
await asyncio.sleep(0.01)
assert reg.try_resolve("s1", "hello") is True
got, _ = await asyncio.gather(reg.wait("s1", timeout=1.0), _resolver())
assert got == "hello"
assert "s1" not in reg # cleaned up after wait
async def test_wait_event_returns_reply_context(self):
reg = I.PendingReplyRegistry()
context = object()
async def _resolver():
await asyncio.sleep(0.01)
assert reg.try_resolve("s1", "hello", context=context) is True
got, _ = await asyncio.gather(reg.wait_event("s1", timeout=1.0), _resolver())
assert got is not None
assert got.content == "hello"
assert got.context is context
assert "s1" not in reg
async def test_wait_timeout_returns_none(self):
reg = I.PendingReplyRegistry()
got = await reg.wait("s1", timeout=0.02)
assert got is None
assert "s1" not in reg
def test_try_resolve_no_pending(self):
reg = I.PendingReplyRegistry()
assert reg.try_resolve("nope", "x") is False
async def test_reregister_cancels_stale(self):
reg = I.PendingReplyRegistry()
first = asyncio.ensure_future(reg.wait("s1", timeout=1.0))
await asyncio.sleep(0.01) # let first register
# A second interaction on the same chat re-registers, cancelling the
# first waiter so it unwinds promptly (returns None) instead of
# hanging until its own timeout.
new_fut = reg.register("s1")
assert await first is None
# The stale waiter's cleanup must not evict the newer registration.
assert reg._pending.get("s1") is new_fut
reg.discard("s1")
async def test_clear_cancels_all(self):
reg = I.PendingReplyRegistry()
fut = asyncio.ensure_future(reg.wait("s1", timeout=1.0))
await asyncio.sleep(0.01)
reg.clear()
got = await fut
assert got is None
async def test_task_cancellation_propagates(self):
reg = I.PendingReplyRegistry()
task = asyncio.create_task(reg.wait("s1", timeout=1.0))
await asyncio.sleep(0.01)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert "s1" not in reg
# ═══════════════════════════════════════════════════════════════════════
# Consumer driver: unrecognized-reply refeed (serve-mode policy)
# ═══════════════════════════════════════════════════════════════════════
# Pre-engine semantics that must survive the extraction: an unrecognized
# reply while a HITL approval is pending REJECTS the pending action, sends
# the rejection feedback, and the user's text is then processed as a NEW
# agent turn — a user who ignores the prompt and types a fresh instruction
# must not lose it. (The CLI bridge deliberately does NOT refeed; see
# tests/test_cli_channel_bridge.py)
class TestConsumerUnrecognizedRefeed:
async def test_unrecognized_reply_rejects_and_refeeds(self, monkeypatch):
from unittest.mock import MagicMock
from EvoScientist.channels.bus.events import (
InboundMessage as BusInbound,
)
from EvoScientist.channels.bus.message_bus import MessageBus
from EvoScientist.channels.channel_manager import ChannelManager
from EvoScientist.channels.consumer import InboundConsumer
from tests.fakes import FakeGraphGateway, StubChannel
# Force the manual-prompt path (no config auto-approve).
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
bus = MessageBus()
mgr = ChannelManager(bus)
mgr.register(StubChannel())
stream_calls = 0
async def _fake_stream(request):
nonlocal stream_calls
stream_calls += 1
if stream_calls == 1:
# First turn hits a HITL interrupt.
yield {
"type": "interrupt",
"interrupt_id": "main",
"action_requests": [
{"name": "execute", "args": {"command": "rm -rf /x"}}
],
"review_configs": [],
}
return
# The refeed turn: echo what we were given.
yield {"type": "text", "content": f"handled: {request.message}"}
yield {"type": "done", "content": f"handled: {request.message}"}
gateway = FakeGraphGateway(
stream=_fake_stream,
generated_thread_ids=["thread-original", "thread-reply"],
)
consumer = InboundConsumer(
bus=bus,
manager=mgr,
agent=MagicMock(),
thread_id="",
graph_gateway=gateway,
max_concurrent=2,
max_pending=10,
inference_timeout=5.0,
drain_timeout=1.0,
)
task = asyncio.create_task(consumer.run())
try:
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="do the thing",
message_id="msg-original",
metadata={"origin": "original"},
)
)
# 1. Approval prompt goes out.
prompt = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert prompt.content.startswith("⚠️ Approval Required")
assert prompt.metadata == {"origin": "original"}
# 2. User ignores the prompt and types a fresh instruction.
await bus.publish_inbound(
BusInbound(
channel="stub",
sender_id="u2",
chat_id="c1",
content="actually, summarize the report",
message_id="msg-reply",
media=["file-report.pdf"],
metadata={"origin": "reply"},
)
)
# 3. Pending action is rejected with the old serve feedback...
feedback = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert feedback.content == I.REJECTED_FEEDBACK
assert feedback.metadata == {"origin": "original"}
# 4. ...and the text is processed as a NEW agent turn.
response = await asyncio.wait_for(bus.consume_outbound(), timeout=5.0)
assert response.content == "handled: actually, summarize the report"
assert response.reply_to == "msg-reply"
assert response.metadata == {"origin": "reply"}
# The refeed reached the stream path as its own request.
assert stream_calls == 2
assert gateway.requests[0].thread_id == "thread-original"
assert gateway.requests[-1].message == "actually, summarize the report"
assert gateway.requests[-1].thread_id == "thread-reply"
assert gateway.requests[-1].media == ["file-report.pdf"]
finally:
await consumer.stop()
await task
class TestApprovalEmptyReply:
async def test_empty_reply_is_unrecognized_not_timeout(self, monkeypatch):
"""A media-only/empty reply must reach the refeed path, not timeout."""
monkeypatch.setattr(I, "config_auto_approve", lambda reqs: False)
io = FakeIO(replies=[""])
policy = I.ApprovalPolicy()
outcome = await I.resolve_approval(
[{"name": "execute", "args": {"command": "ls"}}],
io,
policy,
"stub:c1",
timeout=1.0,
)
assert outcome.decisions is None
assert outcome.unrecognized_reply == ""
sent = [content for content, _ in io.sent]
assert I.APPROVAL_TIMEOUT_FEEDBACK not in sent
class TestReplyInterceptionSkipsThreadCreation:
async def test_consumed_reply_creates_no_thread(self):
"""A registry-consumed reply must not create a graph thread or touch
the sender-session LRU."""
from unittest.mock import AsyncMock, MagicMock
from EvoScientist.channels.bus.events import InboundMessage as BusInbound
from EvoScientist.channels.consumer import InboundConsumer
gateway = MagicMock()
gateway.create_thread = AsyncMock(return_value="t-should-not-exist")
consumer = InboundConsumer(
bus=MagicMock(),
manager=MagicMock(),
agent=MagicMock(),
thread_id="",
graph_gateway=gateway,
max_concurrent=1,
max_pending=5,
inference_timeout=1.0,
drain_timeout=0.5,
)
msg = BusInbound(
channel="stub",
sender_id="u1",
chat_id="c1",
content="1",
message_id="m1",
)
fut = consumer._reply_registry.register(msg.session_key)
await consumer._handle_message(msg)
assert fut.done()
assert fut.result().content == "1"
gateway.create_thread.assert_not_awaited()
assert consumer._sessions == {}