# -*- coding: utf-8 -*- """Tests for :class:`InMemoryMessageBus`. The same abstract surface exercised in ``service_message_bus_test.py`` (queue / log / pubsub / lock / registry) is tested here against the pure-Python in-memory backend, plus the domain helpers inherited from the base :class:`MessageBus` class. No external dependencies (no Redis, no fakeredis) — just asyncio. """ import asyncio from contextlib import AsyncExitStack from unittest import IsolatedAsyncioTestCase from agentscope.app.message_bus import InMemoryMessageBus class TestQueuePrimitive(IsolatedAsyncioTestCase): """Mode A — ``queue_push`` + ``queue_drain`` semantics.""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_push_drain_returns_payloads_in_order(self) -> None: """Entries pushed in order come back out in order, once each.""" await self.bus.queue_push("k", {"i": 1}) await self.bus.queue_push("k", {"i": 2}) entries = await self.bus.queue_drain("k", max_count=10) self.assertEqual([p for _id, p in entries], [{"i": 1}, {"i": 2}]) async def test_drain_is_destructive(self) -> None: """A drained entry is gone; a second drain yields nothing.""" await self.bus.queue_push("k", {"x": 1}) await self.bus.queue_drain("k", max_count=10) self.assertEqual(await self.bus.queue_drain("k", max_count=10), []) async def test_drain_respects_max_count(self) -> None: """``max_count`` caps the batch size; remaining entries persist.""" for i in range(5): await self.bus.queue_push("k", {"i": i}) first = await self.bus.queue_drain("k", max_count=3) rest = await self.bus.queue_drain("k", max_count=10) self.assertEqual([p["i"] for _id, p in first], [0, 1, 2]) self.assertEqual([p["i"] for _id, p in rest], [3, 4]) async def test_drain_empty_queue_returns_empty(self) -> None: """Draining a key that was never pushed returns an empty list.""" self.assertEqual(await self.bus.queue_drain("nope"), []) async def test_push_returns_unique_ids(self) -> None: """Each ``queue_push`` returns a distinct entry id.""" id1 = await self.bus.queue_push("k", {"a": 1}) id2 = await self.bus.queue_push("k", {"a": 2}) self.assertNotEqual(id1, id2) async def test_queue_delete_removes_all(self) -> None: """``queue_delete`` drops the entire queue.""" await self.bus.queue_push("k", {"i": 1}) await self.bus.queue_push("k", {"i": 2}) await self.bus.queue_delete("k") self.assertEqual(await self.bus.queue_drain("k", max_count=10), []) async def test_queue_delete_missing_is_noop(self) -> None: """Deleting a non-existent queue does not raise.""" await self.bus.queue_delete("never-existed") async def test_queue_isolation_between_keys(self) -> None: """Pushes to different keys are independent.""" await self.bus.queue_push("a", {"x": 1}) await self.bus.queue_push("b", {"x": 2}) a = await self.bus.queue_drain("a", max_count=10) b = await self.bus.queue_drain("b", max_count=10) self.assertEqual([p for _id, p in a], [{"x": 1}]) self.assertEqual([p for _id, p in b], [{"x": 2}]) class TestLogPrimitive(IsolatedAsyncioTestCase): """Mode C — replay log: append / read with cursor / trim.""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_read_returns_everything_when_no_cursor(self) -> None: """Without a ``since`` cursor, the whole log comes back.""" await self.bus.log_append("k", {"i": 1}) await self.bus.log_append("k", {"i": 2}) entries = await self.bus.log_read("k") self.assertEqual([p["i"] for _id, p in entries], [1, 2]) async def test_read_with_cursor_is_exclusive(self) -> None: """``since=last_id`` skips that id and returns only newer.""" await self.bus.log_append("k", {"i": 1}) await self.bus.log_append("k", {"i": 2}) await self.bus.log_append("k", {"i": 3}) all_entries = await self.bus.log_read("k") cursor = all_entries[1][0] # id of entry 2 rest = await self.bus.log_read("k", since=cursor) self.assertEqual([p["i"] for _id, p in rest], [3]) async def test_read_respects_max_count(self) -> None: """``max_count`` caps the batch; remaining entries persist.""" for i in range(5): await self.bus.log_append("k", {"i": i}) first = await self.bus.log_read("k", max_count=3) self.assertEqual([p["i"] for _id, p in first], [0, 1, 2]) async def test_read_is_non_destructive(self) -> None: """Multiple reads on the same log return the same data.""" await self.bus.log_append("k", {"i": 1}) r1 = await self.bus.log_read("k") r2 = await self.bus.log_read("k") self.assertEqual( [p["i"] for _id, p in r1], [p["i"] for _id, p in r2], ) async def test_read_empty_log(self) -> None: """Reading a log that never had entries returns ``[]``.""" self.assertEqual(await self.bus.log_read("nope"), []) async def test_read_all_before_cursor(self) -> None: """When all entries are at or before the cursor, result is ``[]``.""" id2 = await self.bus.log_append("k", {"i": 2}) self.assertEqual(await self.bus.log_read("k", since=id2), []) async def test_trim_without_before_drops_entire_log(self) -> None: """``log_trim(key)`` empties the log.""" await self.bus.log_append("k", {"i": 1}) await self.bus.log_append("k", {"i": 2}) await self.bus.log_trim("k") self.assertEqual(await self.bus.log_read("k"), []) async def test_trim_with_before_id_keeps_newer(self) -> None: """``log_trim(key, before_id)`` drops older entries only.""" await self.bus.log_append("k", {"i": 1}) id2 = await self.bus.log_append("k", {"i": 2}) await self.bus.log_append("k", {"i": 3}) await self.bus.log_trim("k", before_id=id2) entries = await self.bus.log_read("k") self.assertEqual([p["i"] for _id, p in entries], [2, 3]) async def test_trim_missing_key_is_noop(self) -> None: """Trimming a non-existent log does not raise.""" await self.bus.log_trim("nope") async def test_max_len_caps_log_size(self) -> None: """``max_len`` on ``log_append`` trims older entries when the log exceeds the cap.""" for i in range(10): await self.bus.log_append("k", {"i": i}, max_len=5) entries = await self.bus.log_read("k", max_count=100) self.assertLessEqual(len(entries), 5) # The newest entries must survive. self.assertEqual(entries[-1][1]["i"], 9) class TestPubSubPrimitive(IsolatedAsyncioTestCase): """Mode D — transient broadcast: publish / subscribe.""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_subscribe_receives_messages_published_after_ready( self, ) -> None: """Subscribers receive payloads published after the subscription is established.""" ready = asyncio.Event() received: list[dict] = [] async def _consumer() -> None: async for payload in self.bus.subscribe( "ch", on_ready=ready.set, ): received.append(payload) if len(received) == 2: break task = asyncio.create_task(_consumer()) await asyncio.wait_for(ready.wait(), timeout=2.0) await self.bus.publish("ch", {"i": 1}) await self.bus.publish("ch", {"i": 2}) await asyncio.wait_for(task, timeout=2.0) self.assertEqual([p["i"] for p in received], [1, 2]) async def test_publish_without_subscribers_is_noop(self) -> None: """Publishing when no one is listening does not raise.""" await self.bus.publish("ch", {"i": 1}) async def test_multiple_subscribers_each_receive(self) -> None: """All active subscribers on a channel receive the payload.""" ready1 = asyncio.Event() ready2 = asyncio.Event() r1: list[dict] = [] r2: list[dict] = [] async def _c1() -> None: async for payload in self.bus.subscribe( "ch", on_ready=ready1.set, ): r1.append(payload) break async def _c2() -> None: async for payload in self.bus.subscribe( "ch", on_ready=ready2.set, ): r2.append(payload) break t1 = asyncio.create_task(_c1()) t2 = asyncio.create_task(_c2()) await asyncio.wait_for(ready1.wait(), timeout=2.0) await asyncio.wait_for(ready2.wait(), timeout=2.0) await self.bus.publish("ch", {"x": 42}) await asyncio.wait_for(t1, timeout=2.0) await asyncio.wait_for(t2, timeout=2.0) self.assertEqual(r1, [{"x": 42}]) self.assertEqual(r2, [{"x": 42}]) class TestLockPrimitive(IsolatedAsyncioTestCase): """Mode E — distributed mutex (process-local asyncio.Lock).""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_is_locked_reflects_acquire_release(self) -> None: """``is_locked`` flips to True while the body runs and back to False once the context exits.""" self.assertFalse(await self.bus.is_locked("k")) async with self.bus.acquire_lock("k", ttl_secs=10): self.assertTrue(await self.bus.is_locked("k")) self.assertFalse(await self.bus.is_locked("k")) async def test_second_acquirer_waits_until_release(self) -> None: """A second ``acquire_lock`` on the same key blocks until the first releases.""" order: list[str] = [] async def _holder() -> None: async with self.bus.acquire_lock("k", ttl_secs=10): order.append("first-in") await asyncio.sleep(0.05) order.append("first-out") async def _challenger() -> None: await asyncio.sleep(0.005) async with self.bus.acquire_lock("k", ttl_secs=10): order.append("second-in") await asyncio.gather(_holder(), _challenger()) self.assertEqual( order, ["first-in", "first-out", "second-in"], ) class TestRegistryPrimitive(IsolatedAsyncioTestCase): """Mode F — ``registry_*`` hash-keyed namespace operations.""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_set_then_exists_and_getall(self) -> None: """``registry_set`` stores a field; ``exists`` and ``getall`` round-trip correctly.""" await self.bus.registry_set("ns", "f1", "v1") await self.bus.registry_set("ns", "f2", "v2") self.assertTrue(await self.bus.registry_exists("ns", "f1")) self.assertTrue(await self.bus.registry_exists("ns", "f2")) self.assertFalse(await self.bus.registry_exists("ns", "missing")) self.assertFalse(await self.bus.registry_exists("other-ns", "f1")) self.assertEqual( await self.bus.registry_getall("ns"), {"f1": "v1", "f2": "v2"}, ) async def test_set_overwrites_existing_field(self) -> None: """A second ``registry_set`` for the same field overwrites.""" await self.bus.registry_set("ns", "f", "v1") await self.bus.registry_set("ns", "f", "v2") self.assertEqual( await self.bus.registry_getall("ns"), {"f": "v2"}, ) async def test_del_removes_only_the_named_field(self) -> None: """``registry_del`` removes a single field; siblings survive.""" await self.bus.registry_set("ns", "keep", "k") await self.bus.registry_set("ns", "drop", "d") await self.bus.registry_del("ns", "drop") self.assertFalse(await self.bus.registry_exists("ns", "drop")) self.assertTrue(await self.bus.registry_exists("ns", "keep")) async def test_del_missing_field_is_noop(self) -> None: """Deleting a non-existent field does not raise.""" await self.bus.registry_del("ns", "nope") async def test_getall_on_missing_namespace_returns_empty(self) -> None: """``registry_getall`` for an unknown namespace returns ``{}``.""" self.assertEqual(await self.bus.registry_getall("ghost"), {}) async def test_drop_deletes_entire_namespace(self) -> None: """``registry_drop`` removes every field under the namespace.""" await self.bus.registry_set("ns", "f1", "v1") await self.bus.registry_set("ns", "f2", "v2") await self.bus.registry_drop("ns") self.assertEqual(await self.bus.registry_getall("ns"), {}) async def test_drop_missing_namespace_is_noop(self) -> None: """Dropping a namespace that was never written does not raise.""" await self.bus.registry_drop("never-existed") async def test_getall_returns_copy(self) -> None: """Mutating the returned dict does not affect bus state.""" await self.bus.registry_set("ns", "f", "v") out = await self.bus.registry_getall("ns") out["injected"] = "evil" self.assertEqual( await self.bus.registry_getall("ns"), {"f": "v"}, ) class TestDomainHelpers(IsolatedAsyncioTestCase): """Domain helpers inherited from ``MessageBus`` work end-to-end on the in-memory backend.""" async def asyncSetUp(self) -> None: self._stack = AsyncExitStack() self.bus = await self._stack.enter_async_context( InMemoryMessageBus(), ) async def asyncTearDown(self) -> None: await self._stack.aclose() async def test_session_run_trims_log_on_exit(self) -> None: """``session_run`` + ``session_publish_event`` + auto trim.""" sid = "s-trim" async with self.bus.session_run(sid): await self.bus.session_publish_event(sid, {"i": 1}) await self.bus.session_publish_event(sid, {"i": 2}) mid = await self.bus.session_read_events(sid) self.assertEqual([p["i"] for _id, p in mid], [1, 2]) self.assertEqual(await self.bus.session_read_events(sid), []) async def test_session_is_running_reflects_lock(self) -> None: """``session_is_running`` is True while inside ``session_run``.""" sid = "s-isrun" self.assertFalse(await self.bus.session_is_running(sid)) async with self.bus.session_run(sid): self.assertTrue(await self.bus.session_is_running(sid)) self.assertFalse(await self.bus.session_is_running(sid)) async def test_inbox_round_trip(self) -> None: """``inbox_push`` + ``inbox_drain`` FIFO semantics.""" sid = "s-inbox" await self.bus.inbox_push(sid, {"hint": "a"}) await self.bus.inbox_push(sid, {"hint": "b"}) entries = await self.bus.inbox_drain(sid, max_count=10) self.assertEqual( [p["hint"] for _id, p in entries], ["a", "b"], ) self.assertEqual( await self.bus.inbox_drain(sid, max_count=10), [], ) async def test_enqueue_wakeup_round_trip(self) -> None: """``enqueue_wakeup`` → ``dequeue_wakeups`` round-trip.""" ready = asyncio.Event() received: list[dict] = [] async def _signal_consumer() -> None: async for payload in self.bus.subscribe_wakeup_signal( on_ready=ready.set, ): received.append(payload) break task = asyncio.create_task(_signal_consumer()) await asyncio.wait_for(ready.wait(), timeout=2.0) await self.bus.enqueue_wakeup( user_id="u", session_id="s", agent_id="a", ) await asyncio.wait_for(task, timeout=2.0) self.assertEqual(len(received), 1) entries = await self.bus.dequeue_wakeups(max_count=10) self.assertEqual(len(entries), 1) self.assertEqual( entries[0], { "user_id": "u", "session_id": "s", "agent_id": "a", "kind": "wake", "input": None, }, ) async def test_bg_task_round_trip(self) -> None: """``bg_task_register / exists / list / unregister / purge`` work on the in-memory backend.""" sid = "s-bg" self.assertFalse(await self.bus.bg_task_exists(sid, "t1")) await self.bus.bg_task_register(sid, "t1", '{"tool":"a"}') await self.bus.bg_task_register(sid, "t2", '{"tool":"b"}') self.assertTrue(await self.bus.bg_task_exists(sid, "t1")) self.assertEqual( await self.bus.bg_task_list(sid), {"t1": '{"tool":"a"}', "t2": '{"tool":"b"}'}, ) await self.bus.bg_task_unregister(sid, "t1") self.assertFalse(await self.bus.bg_task_exists(sid, "t1")) await self.bus.bg_task_purge(sid) self.assertEqual(await self.bus.bg_task_list(sid), {}) async def test_session_purge_clears_all_bus_state(self) -> None: """``session_purge`` deletes the session's events + inbox + bg_tasks in one call.""" sid = "s-purge" await self.bus.session_publish_event(sid, {"e": 1}) await self.bus.inbox_push(sid, {"m": 1}) await self.bus.bg_task_register(sid, "t1", "{}") await self.bus.session_purge(sid) self.assertEqual(await self.bus.session_read_events(sid), []) self.assertEqual( await self.bus.inbox_drain(sid, max_count=10), [], ) self.assertEqual(await self.bus.bg_task_list(sid), {}) async def test_task_cancel_pub_sub(self) -> None: """``task_publish_cancel`` → ``task_subscribe_cancel`` round-trip.""" ready = asyncio.Event() received: list[str] = [] async def _consumer() -> None: async for tid in self.bus.task_subscribe_cancel( on_ready=ready.set, ): received.append(tid) break task = asyncio.create_task(_consumer()) await asyncio.wait_for(ready.wait(), timeout=2.0) await self.bus.task_publish_cancel("task-X") await asyncio.wait_for(task, timeout=2.0) self.assertEqual(received, ["task-X"])