import asyncio from contextlib import asynccontextmanager from types import SimpleNamespace import pytest from bumblehive.observability.events import make_event from bumblehive.agent.control import _RunControl from bumblehive_server.chat.session import InputError, SessionController from bumblehive_server.schemas import ChatRequest class Stream: def __init__(self, content, session_id): self.content = content self.session_id = session_id self.release = asyncio.Event() self.inputs = [] self.control = _RunControl() self.closed = False self.error = None self.failure = None def steer(self, text): if self.closed: raise RuntimeError("closed") self.inputs.append(text) self.control.steer(text) def consume_inputs(self): return self.control._drain_all() def take_pending(self): return self.control.take_pending() def __aiter__(self): return self.iterate() async def iterate(self): yield make_event( "model.stream.content_delta", run_id="run", session_id=self.session_id, delta=self.content, ) await self.release.wait() if self.failure: raise self.failure if self.error is None: self.consume_inputs() self.closed = True async def result(self): return SimpleNamespace( final_content=self.content, tools_used=[], usage={}, stop_reason="model_error" if self.error else "completed", error=self.error, ) async def aclose(self): self.closed = True self.control._close() class Service: def __init__(self): self.streams = [] self.lease_gate = asyncio.Event() self.lease_gate.set() @asynccontextmanager async def lease(self): await self.lease_gate.wait() yield self def stream(self, content, *, session_id, **kwargs): stream = Stream(content, session_id) self.streams.append(stream) return stream def message(text): return ChatRequest(message_id=text, content=text) async def frame(queue, kind, *, status=None, message_id=None): async with asyncio.timeout(2): while True: item = await queue.get() if ( item["type"] == kind and (status is None or item.get("status") == status) and (message_id is None or item.get("message_id") == message_id) ): return item @pytest.mark.asyncio async def test_followups_are_fifo_and_result_precedes_next_started(): service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) await frame(queue, "message_status", status="started") await controller.submit(message("B")) await controller.submit(message("C")) assert [s.content for s in service.streams] == ["A"] for current, following in [("A", "B"), ("B", "C")]: service.streams[-1].release.set() assert (await frame(queue, "result"))["final_content"] == current await frame(queue, "message_status", status="started", message_id=following) assert service.streams[-1].content == following last_task = controller.active_task service.streams[-1].release.set() await frame(queue, "result") await last_task assert controller.active_task is None assert [s.content for s in service.streams] == ["A", "B", "C"] finally: await controller.close() @pytest.mark.asyncio async def test_multiple_steers_non_head_delete_and_direct_input(): service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) await frame(queue, "message_status", status="started") for text in ("B", "C", "D", "E"): await controller.submit(message(text)) await controller.steer("C") await controller.steer("B") await controller.submit(message("backend input"), steer=True) await controller.remove_pending("D") assert service.streams[0].inputs == ["C", "B", "backend input"] assert [item.request.content for item in controller.pending] == ["E"] for method in (controller.steer, controller.remove_pending): with pytest.raises(InputError, match="no longer pending"): await method("C") service.streams[0].release.set() await frame(queue, "message_status", status="started", message_id="E") assert [s.content for s in service.streams] == ["A", "E"] finally: await controller.close() @pytest.mark.asyncio async def test_steer_before_stream_and_after_input_closed(): service = Service() service.lease_gate.clear() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) await controller.submit(message("B")) await controller.steer("B") service.lease_gate.set() await frame(queue, "message_status", status="steered", message_id="B") assert service.streams[0].inputs == ["B"] service.streams[0].closed = True await controller.submit(message("C")) await controller.steer("C") assert [item.request.content for item in controller.pending] == ["C"] service.streams[0].release.set() await frame(queue, "message_status", status="started", message_id="C") finally: await controller.close() @pytest.mark.asyncio @pytest.mark.parametrize("before_start", [True, False]) async def test_cancel_retains_queue_and_manual_send_resumes(before_start): service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) if not before_start: await frame(queue, "message_status", status="started") await controller.submit(message("B")) await controller.cancel() assert controller.active_task is None assert not service.streams or service.streams[0].closed await controller.steer("B") await frame(queue, "message_status", status="started", message_id="B") assert service.streams[-1].content == "B" finally: await controller.close() @pytest.mark.asyncio async def test_two_sessions_reconnect_multiple_subscribers_and_steers_are_isolated(): service = Service() one = SessionController(service.lease, "one") two = SessionController(service.lease, "two") first_queue, _ = one.subscribe() second_queue, _ = two.subscribe() try: await asyncio.gather(one.submit(message("A")), two.submit(message("X"))) await frame(first_queue, "message_status", status="started") await frame(second_queue, "message_status", status="started") await asyncio.gather(one.submit(message("B")), two.submit(message("Y"))) one.unsubscribe(first_queue) assert not service.streams[0].closed reconnected, snapshot = one.subscribe() assert snapshot["running"] is True assert [m["message_id"] for m in snapshot["pending"]] == ["B"] extra, _ = one.subscribe() await one.steer("B") await two.steer("Y") assert [(s.session_id, s.inputs) for s in service.streams] == [ ("one", ["B"]), ("two", ["Y"]), ] await frame(reconnected, "message_status", status="steered", message_id="B") await frame(extra, "message_status", status="steered", message_id="B") with pytest.raises(InputError): await one.steer("Y") one.unsubscribe(reconnected) one.unsubscribe(extra) for stream in service.streams: stream.release.set() await asyncio.gather(one.active_task, two.active_task) assert one.active_task is two.active_task is None _, completed = one.subscribe() assert completed["running"] is False and completed["pending"] == [] finally: await asyncio.gather(one.close(), two.close()) @pytest.mark.asyncio async def test_model_error_pauses_pending_and_close_does_not_restart(): from bumblehive.protocols.errors import AgentError service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() await controller.submit(message("A")) await frame(queue, "message_status", status="started") await controller.submit(message("B")) service.streams[0].error = AgentError(code="model_error", message="failed") service.streams[0].release.set() await asyncio.gather(controller.active_task) assert [s.content for s in service.streams] == ["A"] await controller.submit(message("C")) assert controller.active_task is None assert [i.request.content for i in controller.pending] == ["B", "C"] await controller.close() assert controller.pending == [] with pytest.raises(InputError): await controller.submit(message("D")) @pytest.mark.asyncio async def test_service_delete_and_shutdown_close_only_the_owned_runs(tmp_path): from bumblehive_server.runtime_service import RuntimeService streams = Service() deleted = [] closed = [] async def delete(session_id): deleted.append(session_id) return True async def close(): closed.append(True) service = RuntimeService(tmp_path / "unused.json") service._runtime = SimpleNamespace( stream=streams.stream, delete_session=delete, close=close ) one = service.get_session_controller("one") two = service.get_session_controller("two") first, _ = one.subscribe() second, _ = two.subscribe() try: await asyncio.gather(one.submit(message("A")), two.submit(message("B"))) await frame(first, "message_status", status="started") await frame(second, "message_status", status="started") await two.submit(message("B follow-up")) assert service._active_runs == 2 assert await service.delete_session("one") is True assert deleted == ["one"] assert one.active_task is None assert streams.streams[0].closed assert not streams.streams[1].closed assert two.active_task is not None assert service._active_runs == 1 finally: await service.shutdown() assert two.active_task is None and two.pending == [] assert streams.streams[1].closed and closed == [True] assert service._active_runs == 0 assert service._session_controllers == {} @pytest.mark.asyncio @pytest.mark.parametrize("consume_first", [False, True]) async def test_cancel_restores_only_unconsumed_steers_with_original_ids_config_and_order( consume_first, ): service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() left, tail = message("left"), message("tail") first = ChatRequest( message_id="B", content="same text", config={"generation": {"temperature": 0.2}}, approval_mode="full_access", ) second = ChatRequest( message_id="C", content="same text", config={"generation": {"temperature": 0.8}} ) try: await controller.submit(message("A")) await frame(queue, "message_status", status="started") for request in (left, first, second, tail): await controller.submit(request) await controller.steer("C") if consume_first: assert service.streams[0].consume_inputs() == [ {"role": "user", "content": "same text"} ] await controller.steer("B") while not queue.empty(): queue.get_nowait() await controller.cancel() expected = [left, first, tail] if consume_first else [left, first, second, tail] assert [item.request for item in controller.pending] == expected assert controller.pending[1].request is first assert controller.active_task is None assert len(service.streams) == 1 # Cancellation must leave the queue paused. receipts = [] while not queue.empty(): receipts.append(queue.get_nowait()) assert [ (item["message_id"], item["position"]) for item in receipts if item["type"] == "message_status" ] == ([("B", 1)] if consume_first else [("B", 1), ("C", 2)]) assert receipts[-1]["type"] == "cancelled" _, snapshot = controller.subscribe() assert [item["message_id"] for item in snapshot["pending"]] == [ item.message_id for item in expected ] await controller.steer("B") await frame(queue, "message_status", status="started", message_id="B") assert controller.active_request is first assert controller.approvals.mode == "full_access" assert service.streams[-1].inputs == [] finally: await controller.close() @pytest.mark.asyncio @pytest.mark.parametrize("failure_kind", ["exception", "result_error"]) async def test_failed_run_restores_unconsumed_steers_and_keeps_followups_paused( failure_kind, ): from bumblehive.protocols.errors import AgentError service = Service() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) await frame(queue, "message_status", status="started") await controller.submit(message("B"), steer=True) await controller.submit(message("C")) stream = service.streams[0] if failure_kind == "exception": stream.failure = RuntimeError("request failed") else: stream.error = AgentError(code="model_error", message="request failed") task = controller.active_task stream.release.set() await task assert controller.active_task is None assert [item.request.message_id for item in controller.pending] == ["B", "C"] assert len(service.streams) == 1 assert controller._steered == [] finally: await controller.close() @pytest.mark.asyncio async def test_cancel_one_session_does_not_restore_or_consume_another_sessions_steer(): service = Service() one = SessionController(service.lease, "one") two = SessionController(service.lease, "two") first, _ = one.subscribe() second, _ = two.subscribe() try: await asyncio.gather(one.submit(message("A")), two.submit(message("X"))) await frame(first, "message_status", status="started") await frame(second, "message_status", status="started") await one.submit(message("same"), steer=True) await two.submit(message("same"), steer=True) await one.cancel() assert [item.request.message_id for item in one.pending] == ["same"] assert two.pending == [] and two.active_task is not None assert two.active_stream.inputs == ["same"] task = two.active_task two.active_stream.release.set() await task assert two.pending == [] and two._steered == [] finally: await asyncio.gather(one.close(), two.close()) @pytest.mark.asyncio async def test_cancel_during_startup_clears_deferred_steering_intent(): service = Service() service.lease_gate.clear() controller = SessionController(service.lease, "one") queue, _ = controller.subscribe() try: await controller.submit(message("A")) await controller.submit(message("B"), steer=True) await controller.submit(message("C")) await controller.cancel() assert all(not item.steer_requested for item in controller.pending) service.lease_gate.set() await controller.steer("C") await frame(queue, "message_status", status="started", message_id="C") assert service.streams[-1].inputs == [] finally: await controller.close()