From 1bd6623897728c7173730db8db89c20c77e7638b Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 7 Mar 2026 11:15:07 +0800 Subject: [PATCH 1/2] test: add unit tests for tools and utils functionality Signed-off-by: Frost Ming --- tests/test_builtin_hook_impl.py | 240 ++++++++++++++++++++++++++++++++ tests/test_channels_utils.py | 25 ---- tests/test_tools.py | 111 +++++++++++++++ tests/test_utils.py | 68 +++++++++ 4 files changed, 419 insertions(+), 25 deletions(-) create mode 100644 tests/test_builtin_hook_impl.py delete mode 100644 tests/test_channels_utils.py create mode 100644 tests/test_tools.py create mode 100644 tests/test_utils.py diff --git a/tests/test_builtin_hook_impl.py b/tests/test_builtin_hook_impl.py new file mode 100644 index 00000000..9df09c45 --- /dev/null +++ b/tests/test_builtin_hook_impl.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from bub.builtin.hook_impl import AGENTS_FILE_NAME, DEFAULT_SYSTEM_PROMPT, BuiltinImpl +from bub.builtin.store import FileTapeStore +from bub.channels.message import ChannelMessage +from bub.framework import BubFramework + + +class RecordingLifespan: + def __init__(self) -> None: + self.entered = False + self.exit_args: tuple[object, object, object] | None = None + + async def __aenter__(self) -> None: + self.entered = True + + async def __aexit__(self, exc_type, exc, traceback) -> None: + self.exit_args = (exc_type, exc, traceback) + + +class FakeAgent: + def __init__(self, home: Path) -> None: + self.settings = SimpleNamespace(home=home) + self.calls: list[tuple[str, str, dict[str, object]]] = [] + + async def run(self, *, session_id: str, prompt: str, state: dict[str, object]) -> str: + self.calls.append((session_id, prompt, state)) + return "agent-output" + + +def _raise_value_error() -> None: + raise ValueError("boom") + + +def _build_impl(tmp_path: Path) -> tuple[BubFramework, BuiltinImpl, FakeAgent]: + framework = BubFramework() + impl = BuiltinImpl(framework) + agent = FakeAgent(tmp_path) + impl.agent = agent # type: ignore[assignment] + return framework, impl, agent + + +def test_resolve_session_prefers_explicit_session_id(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + + message = ChannelMessage(session_id=" keep-me ", channel="cli", chat_id="room", content="hello") + + assert impl.resolve_session(message) == " keep-me " + + +def test_resolve_session_falls_back_to_channel_and_chat_id(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + + message = {"session_id": " ", "channel": "telegram", "chat_id": "42", "content": "hello"} + + assert impl.resolve_session(message) == "telegram:42" + + +@pytest.mark.asyncio +async def test_load_state_and_save_state_manage_lifespan_and_context(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + lifespan = RecordingLifespan() + message = ChannelMessage( + session_id="session", + channel="cli", + chat_id="room", + content="hello", + lifespan=lifespan, + ) + + state = await impl.load_state(message=message, session_id="resolved-session") + + assert lifespan.entered is True + assert state["session_id"] == "resolved-session" + assert state["_runtime_agent"] is impl.agent + assert state["context"] == message.context_str + + try: + _raise_value_error() + except ValueError as exc: + await impl.save_state( + session_id="resolved-session", + state=state, + message=message, + model_output="ignored", + ) + assert isinstance(exc, ValueError) + + assert lifespan.exit_args is not None + assert lifespan.exit_args[0] is ValueError + assert isinstance(lifespan.exit_args[1], ValueError) + + +def test_build_prompt_marks_commands_and_prefixes_context(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + command = ChannelMessage(session_id="s", channel="cli", chat_id="room", content=",help") + normal = ChannelMessage(session_id="s", channel="cli", chat_id="room", content="hello") + + command_prompt = impl.build_prompt(command, session_id="s", state={}) + normal_prompt = impl.build_prompt(normal, session_id="s", state={}) + + assert command_prompt == ",help" + assert command.kind == "command" + assert normal_prompt == f"{normal.context_str}\n---\nhello" + + +@pytest.mark.asyncio +async def test_run_model_delegates_to_agent(tmp_path: Path) -> None: + _, impl, agent = _build_impl(tmp_path) + state = {"context": "ctx"} + + result = await impl.run_model(prompt="prompt", session_id="session", state=state) + + assert result == "agent-output" + assert agent.calls == [("session", "prompt", state)] + + +def test_system_prompt_appends_workspace_agents_file(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + (tmp_path / AGENTS_FILE_NAME).write_text("local rules", encoding="utf-8") + + result = impl.system_prompt(prompt="hello", state={"_runtime_workspace": str(tmp_path)}) + + assert result == DEFAULT_SYSTEM_PROMPT + "\n\nlocal rules" + + +def test_system_prompt_ignores_missing_agents_file(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + + result = impl.system_prompt(prompt="hello", state={"_runtime_workspace": str(tmp_path)}) + + assert result == DEFAULT_SYSTEM_PROMPT + "\n\n" + + +def test_provide_channels_returns_cli_and_telegram(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _, impl, agent = _build_impl(tmp_path) + + class DummyCliChannel: + name = "cli" + + def __init__(self, on_receive, agent) -> None: + self.on_receive = on_receive + self.agent = agent + + class DummyTelegramChannel: + name = "telegram" + + def __init__(self, on_receive) -> None: + self.on_receive = on_receive + + import bub.channels.cli + import bub.channels.telegram + + monkeypatch.setattr(bub.channels.cli, "CliChannel", DummyCliChannel) + monkeypatch.setattr(bub.channels.telegram, "TelegramChannel", DummyTelegramChannel) + + def message_handler(message) -> None: + return None + + channels = impl.provide_channels(message_handler) + + assert [channel.name for channel in channels] == ["telegram", "cli"] + assert channels[0].on_receive is message_handler + assert channels[1].on_receive is message_handler + assert channels[1].agent is agent + + +@pytest.mark.asyncio +async def test_on_error_dispatches_outbound_message(tmp_path: Path) -> None: + framework, impl, _ = _build_impl(tmp_path) + calls: list[tuple[str, dict[str, object]]] = [] + + async def call_many(name: str, **kwargs: object) -> list[object]: + calls.append((name, kwargs)) + return [] + + framework._hook_runtime.call_many = call_many # type: ignore[method-assign] + + await impl.on_error(stage="turn", error=RuntimeError("bad"), message={"channel": "cli", "chat_id": "room"}) + + assert len(calls) == 1 + hook_name, kwargs = calls[0] + outbound = kwargs["message"] + assert hook_name == "dispatch_outbound" + assert outbound.channel == "cli" + assert outbound.chat_id == "room" + assert outbound.kind == "error" + assert outbound.content == "An error occurred at stage 'turn': bad" + + +@pytest.mark.asyncio +async def test_dispatch_outbound_uses_framework_router(tmp_path: Path) -> None: + framework, impl, _ = _build_impl(tmp_path) + dispatched: list[object] = [] + + async def dispatch_via_router(message: object) -> bool: + dispatched.append(message) + return True + + framework.dispatch_via_router = dispatch_via_router # type: ignore[method-assign] + outbound = {"session_id": "session", "channel": "cli", "chat_id": "room", "content": "hello"} + + result = await impl.dispatch_outbound(outbound) + + assert result is True + assert dispatched == [outbound] + + +def test_render_outbound_preserves_message_metadata(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + + rendered = impl.render_outbound( + message={"channel": "telegram", "chat_id": "room", "kind": "command", "output_channel": "cli"}, + session_id="session", + state={}, + model_output="result", + ) + + assert len(rendered) == 1 + outbound = rendered[0] + assert outbound.session_id == "session" + assert outbound.channel == "telegram" + assert outbound.chat_id == "room" + assert outbound.output_channel == "cli" + assert outbound.kind == "command" + assert outbound.content == "result" + + +def test_provide_tape_store_uses_agent_home_directory(tmp_path: Path) -> None: + _, impl, _ = _build_impl(tmp_path) + + store = impl.provide_tape_store() + + assert isinstance(store, FileTapeStore) + assert store._directory == tmp_path / "tapes" diff --git a/tests/test_channels_utils.py b/tests/test_channels_utils.py deleted file mode 100644 index 9237dbc0..00000000 --- a/tests/test_channels_utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import asyncio - -import pytest - -from bub.utils import exclude_none, wait_until_stopped - - -def test_exclude_none_keeps_non_none_values() -> None: - payload = {"a": 1, "b": None, "c": "x", "d": False} - assert exclude_none(payload) == {"a": 1, "c": "x", "d": False} - - -@pytest.mark.asyncio -async def test_wait_until_stopped_returns_result_when_coroutine_finishes_first() -> None: - stop_event = asyncio.Event() - result = await wait_until_stopped(asyncio.sleep(0.01, result="done"), stop_event) - assert result == "done" - - -@pytest.mark.asyncio -async def test_wait_until_stopped_cancels_when_stop_event_set() -> None: - stop_event = asyncio.Event() - stop_event.set() - with pytest.raises(asyncio.CancelledError): - await wait_until_stopped(asyncio.sleep(0.2, result="done"), stop_event) diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 00000000..0c113f61 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from loguru import logger +from pydantic import BaseModel + +from bub.tools import REGISTRY, model_tools, render_tools_prompt, tool + + +class EchoInput(BaseModel): + value: str + + +@pytest.mark.asyncio +async def test_tool_decorator_registers_tool_and_preserves_metadata() -> None: + tool_name = "tests.sync_tool" + REGISTRY.pop(tool_name, None) + + @tool(name=tool_name, description="Sync test tool", model=EchoInput) + def sync_tool(payload: EchoInput) -> str: + return payload.value.upper() + + assert sync_tool.name == tool_name + assert sync_tool.description == "Sync test tool" + assert REGISTRY[tool_name] is sync_tool + assert await sync_tool.run(value="hello") == "HELLO" + + +@pytest.mark.asyncio +async def test_tool_wrapper_logs_and_omits_context_from_log_payload(monkeypatch: pytest.MonkeyPatch) -> None: + tool_name = "tests.async_tool" + REGISTRY.pop(tool_name, None) + messages: list[str] = [] + + def record(message: str, *args: Any, **kwargs: Any) -> None: + messages.append(message.format(*args, **kwargs)) + + monkeypatch.setattr(logger, "info", record) + + @tool(name=tool_name, description="Async test tool", context=True) + async def async_tool(value: str, context: object) -> str: + return f"{value}:{context}" + + result = await async_tool.run("hello", context="ctx") + + assert result == "hello:ctx" + assert REGISTRY[tool_name] is async_tool + assert len(messages) == 2 + assert messages[0] == 'tool.call.start name=tests.async_tool { "hello" }' + assert messages[1].startswith("tool.call.success name=tests.async_tool elapsed_time=") + + +@pytest.mark.asyncio +async def test_tool_wrapper_logs_failures_before_reraising(monkeypatch: pytest.MonkeyPatch) -> None: + tool_name = "tests.failing_tool" + REGISTRY.pop(tool_name, None) + errors: list[str] = [] + + def record_exception(message: str, *args: Any, **kwargs: Any) -> None: + errors.append(message.format(*args, **kwargs)) + + monkeypatch.setattr(logger, "exception", record_exception) + + @tool(name=tool_name) + def failing_tool() -> str: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + await failing_tool.run() + + assert len(errors) == 1 + assert errors[0].startswith("tool.call.error name=tests.failing_tool elapsed_time=") + + +def test_model_tools_rewrites_dotted_names_without_mutating_original() -> None: + tool_name = "tests.rename_me" + REGISTRY.pop(tool_name, None) + + @tool(name=tool_name, description="rename") + def rename_me() -> str: + return "ok" + + rewritten = model_tools([rename_me]) + + assert [item.name for item in rewritten] == ["tests_rename_me"] + assert rename_me.name == tool_name + + +def test_render_tools_prompt_renders_available_tools_block() -> None: + first_name = "tests.prompt_one" + second_name = "tests.prompt_two" + REGISTRY.pop(first_name, None) + REGISTRY.pop(second_name, None) + + @tool(name=first_name, description="First tool") + def prompt_one() -> str: + return "one" + + @tool(name=second_name) + def prompt_two() -> str: + return "two" + + rendered = render_tools_prompt([prompt_one, prompt_two]) + + assert rendered == "\n- tests_prompt_one: First tool\n- tests_prompt_two\n" + + +def test_render_tools_prompt_returns_empty_string_for_empty_input() -> None: + assert render_tools_prompt([]) == "" diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..8fa69469 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,68 @@ +import asyncio +from pathlib import Path + +import pytest + +from bub.utils import exclude_none, wait_until_stopped, workspace_from_state + + +def test_exclude_none_keeps_non_none_values() -> None: + payload = {"a": 1, "b": None, "c": "x", "d": False} + assert exclude_none(payload) == {"a": 1, "c": "x", "d": False} + + +@pytest.mark.asyncio +async def test_wait_until_stopped_returns_result_when_coroutine_finishes_first() -> None: + stop_event = asyncio.Event() + result = await wait_until_stopped(asyncio.sleep(0.01, result="done"), stop_event) + assert result == "done" + + +@pytest.mark.asyncio +async def test_wait_until_stopped_cancels_when_stop_event_set() -> None: + stop_event = asyncio.Event() + stop_event.set() + with pytest.raises(asyncio.CancelledError): + await wait_until_stopped(asyncio.sleep(0.2, result="done"), stop_event) + + +@pytest.mark.asyncio +async def test_wait_until_stopped_cancels_running_task_when_stop_event_flips() -> None: + stop_event = asyncio.Event() + task_cancelled = asyncio.Event() + + async def never_finish() -> str: + try: + await asyncio.sleep(1) + except asyncio.CancelledError: + task_cancelled.set() + raise + return "unexpected" + + async def trigger_stop() -> None: + await asyncio.sleep(0.01) + stop_event.set() + + trigger_task = asyncio.create_task(trigger_stop()) + with pytest.raises(asyncio.CancelledError): + await wait_until_stopped(never_finish(), stop_event) + await trigger_task + + assert task_cancelled.is_set() + + +def test_workspace_from_state_prefers_runtime_workspace_and_expands_user_home(monkeypatch: pytest.MonkeyPatch) -> None: + expected = Path.home().resolve() + monkeypatch.setenv("HOME", str(expected)) + + workspace = workspace_from_state({"_runtime_workspace": "~"}) + + assert workspace == expected + + +def test_workspace_from_state_falls_back_to_current_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + + workspace = workspace_from_state({"_runtime_workspace": " "}) + + assert workspace == tmp_path.resolve() From b4ea7d4bc8ab3ddd340bba4e3aa2b95bc78c9646 Mon Sep 17 00:00:00 2001 From: Frost Ming Date: Sat, 7 Mar 2026 11:19:19 +0800 Subject: [PATCH 2/2] test: add comprehensive tests for channel management and message handling Signed-off-by: Frost Ming --- tests/test_channels.py | 306 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 tests/test_channels.py diff --git a/tests/test_channels.py b/tests/test_channels.py new file mode 100644 index 00000000..03f08029 --- /dev/null +++ b/tests/test_channels.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from bub.channels.cli import CliChannel +from bub.channels.handler import BufferedMessageHandler +from bub.channels.manager import ChannelManager +from bub.channels.message import ChannelMessage +from bub.channels.telegram import BubMessageFilter, TelegramChannel + + +class FakeChannel: + def __init__(self, name: str, *, needs_debounce: bool = False) -> None: + self.name = name + self._needs_debounce = needs_debounce + self.sent: list[ChannelMessage] = [] + self.started = False + self.stopped = False + + @property + def needs_debounce(self) -> bool: + return self._needs_debounce + + async def start(self, stop_event: asyncio.Event) -> None: + self.started = True + self.stop_event = stop_event + + async def stop(self) -> None: + self.stopped = True + + async def send(self, message: ChannelMessage) -> None: + self.sent.append(message) + + +class FakeFramework: + def __init__(self, channels: dict[str, FakeChannel]) -> None: + self._channels = channels + self.router = None + + def get_channels(self, message_handler): + self.message_handler = message_handler + return self._channels + + def bind_outbound_router(self, router) -> None: + self.router = router + + +def _message( + content: str, + *, + channel: str = "telegram", + session_id: str = "telegram:chat", + chat_id: str = "chat", + is_active: bool = False, + kind: str = "normal", +) -> ChannelMessage: + return ChannelMessage( + session_id=session_id, + channel=channel, + chat_id=chat_id, + content=content, + is_active=is_active, + kind=kind, + ) + + +@pytest.mark.asyncio +async def test_buffered_handler_passes_commands_through_immediately() -> None: + handled: list[str] = [] + + async def receive(message: ChannelMessage) -> None: + handled.append(message.content) + + handler = BufferedMessageHandler( + receive, + active_time_window=10, + max_wait_seconds=10, + debounce_seconds=0.01, + ) + + await handler(_message(",help")) + + assert handled == [",help"] + + +def test_buffered_handler_prettify_masks_base64_payload() -> None: + content = 'look data:image/png;base64,abcdef" end' + + assert BufferedMessageHandler.prettify(content) == 'look [media]" end' + + +@pytest.mark.asyncio +async def test_channel_manager_dispatch_uses_output_channel_and_preserves_metadata() -> None: + cli_channel = FakeChannel("cli") + manager = ChannelManager(FakeFramework({"cli": cli_channel}), enabled_channels=["cli"]) + + result = await manager.dispatch({ + "session_id": "session", + "channel": "telegram", + "output_channel": "cli", + "chat_id": "room", + "content": "hello", + "kind": "command", + "context": {"source": "test"}, + }) + + assert result is True + assert len(cli_channel.sent) == 1 + outbound = cli_channel.sent[0] + assert outbound.channel == "cli" + assert outbound.chat_id == "room" + assert outbound.content == "hello" + assert outbound.kind == "command" + assert outbound.context["source"] == "test" + + +def test_channel_manager_enabled_channels_excludes_cli_from_all() -> None: + channels = {"cli": FakeChannel("cli"), "telegram": FakeChannel("telegram"), "discord": FakeChannel("discord")} + manager = ChannelManager(FakeFramework(channels), enabled_channels=["all"]) + + assert [channel.name for channel in manager.enabled_channels()] == ["telegram", "discord"] + + +@pytest.mark.asyncio +async def test_channel_manager_on_receive_uses_buffer_for_debounced_channel(monkeypatch: pytest.MonkeyPatch) -> None: + telegram = FakeChannel("telegram", needs_debounce=True) + manager = ChannelManager(FakeFramework({"telegram": telegram}), enabled_channels=["telegram"]) + calls: list[ChannelMessage] = [] + + class StubBufferedMessageHandler: + def __init__( + self, handler, *, active_time_window: float, max_wait_seconds: float, debounce_seconds: float + ) -> None: + self.handler = handler + self.settings = (active_time_window, max_wait_seconds, debounce_seconds) + + async def __call__(self, message: ChannelMessage) -> None: + calls.append(message) + + import bub.channels.manager as manager_module + + monkeypatch.setattr(manager_module, "BufferedMessageHandler", StubBufferedMessageHandler) + + message = _message("hello", channel="telegram") + await manager.on_receive(message) + await manager.on_receive(message) + + assert calls == [message, message] + assert message.session_id in manager._session_handlers + assert isinstance(manager._session_handlers[message.session_id], StubBufferedMessageHandler) + + +@pytest.mark.asyncio +async def test_channel_manager_shutdown_cancels_tasks_and_stops_enabled_channels() -> None: + telegram = FakeChannel("telegram") + cli = FakeChannel("cli") + manager = ChannelManager(FakeFramework({"telegram": telegram, "cli": cli}), enabled_channels=["all"]) + + async def never_finish() -> None: + await asyncio.sleep(10) + + task = asyncio.create_task(never_finish()) + manager._ongoing_tasks.add(task) + + await manager.shutdown() + + assert task.cancelled() + assert telegram.stopped is True + assert cli.stopped is False + + +def test_cli_channel_normalize_input_prefixes_shell_commands() -> None: + channel = CliChannel.__new__(CliChannel) + channel._mode = "shell" + + assert channel._normalize_input("ls") == ",ls" + assert channel._normalize_input(",help") == ",help" + + +@pytest.mark.asyncio +async def test_cli_channel_send_routes_by_message_kind() -> None: + channel = CliChannel.__new__(CliChannel) + events: list[tuple[str, str]] = [] + channel._renderer = SimpleNamespace( + error=lambda content: events.append(("error", content)), + command_output=lambda content: events.append(("command", content)), + assistant_output=lambda content: events.append(("assistant", content)), + ) + + await channel.send(_message("bad", channel="cli", kind="error")) + await channel.send(_message("ok", channel="cli", kind="command")) + await channel.send(_message("hi", channel="cli")) + + assert events == [("error", "bad"), ("command", "ok"), ("assistant", "hi")] + + +def test_cli_channel_history_file_uses_workspace_hash(tmp_path: Path) -> None: + home = tmp_path / "home" + workspace = tmp_path / "workspace" + + result = CliChannel._history_file(home, workspace) + + assert result.parent == home / "history" + assert result.suffix == ".history" + + +def test_bub_message_filter_accepts_private_messages() -> None: + message = SimpleNamespace(chat=SimpleNamespace(type="private"), text="hello") + + assert BubMessageFilter().filter(message) is True + + +def test_bub_message_filter_requires_group_mention_or_reply() -> None: + bot = SimpleNamespace(id=1, username="BubBot") + message = SimpleNamespace( + chat=SimpleNamespace(type="group"), + text="hello team", + caption=None, + entities=[], + caption_entities=[], + reply_to_message=None, + get_bot=lambda: bot, + ) + + assert BubMessageFilter().filter(message) is False + + +def test_bub_message_filter_accepts_group_mention() -> None: + bot = SimpleNamespace(id=1, username="BubBot") + message = SimpleNamespace( + chat=SimpleNamespace(type="group"), + text="ping @bubbot", + caption=None, + entities=[SimpleNamespace(type="mention", offset=5, length=7)], + caption_entities=[], + reply_to_message=None, + get_bot=lambda: bot, + ) + + assert BubMessageFilter().filter(message) is True + + +@pytest.mark.asyncio +async def test_telegram_channel_send_extracts_json_message_and_skips_blank() -> None: + channel = TelegramChannel(lambda message: None) + sent: list[tuple[str, str]] = [] + + async def send_message(chat_id: str, text: str) -> None: + sent.append((chat_id, text)) + + channel._app = SimpleNamespace(bot=SimpleNamespace(send_message=send_message)) + + await channel.send(_message('{"message":"hello"}', chat_id="42")) + await channel.send(_message(" ", chat_id="42")) + + assert sent == [("42", "hello")] + + +@pytest.mark.asyncio +async def test_telegram_channel_build_message_returns_command_directly() -> None: + channel = TelegramChannel(lambda message: None) + channel._parser = SimpleNamespace(parse=_async_return((",help", {"type": "text"})), get_reply=_async_return(None)) + + message = SimpleNamespace(chat_id=42) + + result = await channel._build_message(message) + + assert result.channel == "telegram" + assert result.chat_id == "42" + assert result.content == ",help" + assert result.output_channel == "telegram" + + +@pytest.mark.asyncio +async def test_telegram_channel_build_message_wraps_payload_and_disables_outbound( + monkeypatch: pytest.MonkeyPatch, +) -> None: + channel = TelegramChannel(lambda message: None) + parser = SimpleNamespace( + parse=_async_return(("hello", {"type": "text", "sender_id": "7"})), + get_reply=_async_return({"message": "prev", "type": "text"}), + ) + channel._parser = parser + monkeypatch.setattr("bub.channels.telegram.MESSAGE_FILTER.filter", lambda message: True) + + message = SimpleNamespace(chat_id=42) + + result = await channel._build_message(message) + + assert result.output_channel == "null" + assert result.is_active is True + assert '"message": "hello"' in result.content + assert '"chat_id": "42"' in result.content + assert '"reply_to_message"' in result.content + assert result.lifespan is not None + + +def _async_return(value): + async def runner(*args, **kwargs): + return value + + return runner