diff --git a/docs/features/dialogs.md b/docs/features/dialogs.md index dec19ece..23d0bd29 100644 --- a/docs/features/dialogs.md +++ b/docs/features/dialogs.md @@ -19,6 +19,7 @@ === "CLI" ```bash python -m src.main dialogs send @username "текст" --yes + python -m src.main dialogs react @chat 42 👍 --phone +79001234567 python -m src.main dialogs edit-message @chat 42 "новый текст" --yes python -m src.main dialogs delete-message @chat 42 43 44 --yes python -m src.main dialogs pin-message @chat 42 --notify diff --git a/docs/reference/agent-tools.md b/docs/reference/agent-tools.md index 624c4f9b..db82da02 100644 --- a/docs/reference/agent-tools.md +++ b/docs/reference/agent-tools.md @@ -26,6 +26,10 @@ | `import_channels` | WRITE | Массовый импорт | | `refresh_channel_types` | WRITE | Обновить типы каналов | | `refresh_channel_meta` | WRITE | Обновить метаданные (about, linked_chat_id, has_comments) | +| `list_tags` | READ | Список тегов | +| `create_tag` | WRITE | Создать тег | +| `delete_tag` | DELETE | Удалить тег | +| `set_channel_tags` | WRITE | Обновить теги канала | ## Сбор @@ -52,6 +56,13 @@ | `get_pipeline_run` | READ | Детали запуска | | `publish_pipeline_run` | WRITE | Опубликовать | | `get_pipeline_queue` | READ | Очередь модерации | +| `get_refinement_steps` | READ | Шаги refinement | +| `set_refinement_steps` | WRITE | Сохранить шаги refinement | +| `export_pipeline_json` | READ | Экспорт JSON | +| `import_pipeline_json` | WRITE | Импорт JSON | +| `list_pipeline_templates` | READ | Список шаблонов | +| `create_pipeline_from_template` | WRITE | Создать pipeline из шаблона | +| `ai_edit_pipeline` | WRITE | AI-редактирование pipeline | ## Модерация @@ -169,12 +180,16 @@ | `get_forum_topics` | READ | Топики форума | | `clear_dialog_cache` | WRITE | Очистить кеш | | `get_cache_status` | READ | Статус кеша | +| `resolve_entity` | READ | Resolve @username, t.me ссылку или numeric ID | ## Сообщения | Tool | Категория | Описание | |------|-----------|----------| +| `read_messages` | READ | Читать сообщения из чата | | `send_message` | WRITE | Отправить сообщение | +| `send_reaction` | WRITE | Поставить emoji-реакцию на сообщение | +| `forward_messages` | WRITE | Переслать сообщения | | `edit_message` | WRITE | Редактировать | | `delete_message` | DELETE | Удалить (⚠️ destructive) | | `pin_message` | WRITE | Закрепить сообщение | @@ -201,6 +216,7 @@ | `list_image_models` | READ | Поиск моделей | | `list_image_providers` | READ | Список провайдеров | | `generate_image` | WRITE | Генерация изображения | +| `list_generated_images` | READ | История сгенерированных изображений | ## Настройки diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7c2ea23a..7d46e9f6 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -157,6 +157,7 @@ python -m src.main dialogs topics --channel-id ID [--phone PHONE] python -m src.main dialogs cache-status python -m src.main dialogs cache-clear [--phone PHONE] python -m src.main dialogs send RECIPIENT TEXT [--phone PHONE] [--yes] +python -m src.main dialogs react CHAT_ID MESSAGE_ID EMOJI [--phone PHONE] python -m src.main dialogs forward FROM_CHAT TO_CHAT MESSAGE_ID [MESSAGE_ID ...] [--phone PHONE] [--yes] python -m src.main dialogs edit-message CHAT_ID MESSAGE_ID TEXT [--phone PHONE] [--yes] python -m src.main dialogs delete-message CHAT_ID MESSAGE_ID [MESSAGE_ID ...] [--phone PHONE] [--yes] diff --git a/docs/reference/parity.md b/docs/reference/parity.md index dbfc4e48..188d2be5 100644 --- a/docs/reference/parity.md +++ b/docs/reference/parity.md @@ -176,6 +176,7 @@ | Топики форума | `dialogs topics` | `GET /agent/forum-topics` | `get_forum_topics` | | Создать канал | `dialogs create-channel` | `POST /dialogs/create-channel` | `create_telegram_channel` | | Отправить сообщение | `dialogs send` | `POST /dialogs/send` | `send_message` | +| Поставить реакцию | `dialogs react` | — | `send_reaction` | | Переслать сообщение | `dialogs forward` | `POST /dialogs/forward` | `forward_messages` | | Редактировать | `dialogs edit-message` | `POST /dialogs/edit-message` | `edit_message` | | Удалить | `dialogs delete-message` | `POST /dialogs/delete-message` | `delete_message` | diff --git a/src/agent/tools/deepagents_sync.py b/src/agent/tools/deepagents_sync.py index b5931a2c..21f06a90 100644 --- a/src/agent/tools/deepagents_sync.py +++ b/src/agent/tools/deepagents_sync.py @@ -84,6 +84,7 @@ "get_forum_topics": frozenset({"channel_id"}), "resolve_entity": frozenset({"identifier"}), "send_message": frozenset({"recipient", "text"}), + "send_reaction": frozenset({"chat_id", "message_id", "emoji"}), "edit_message": frozenset({"chat_id", "message_id", "text"}), "delete_message": frozenset({"chat_id", "message_ids"}), "forward_messages": frozenset({"from_chat", "to_chat", "message_ids"}), diff --git a/src/agent/tools/messaging_schemas.py b/src/agent/tools/messaging_schemas.py index f7c2f680..7e594159 100644 --- a/src/agent/tools/messaging_schemas.py +++ b/src/agent/tools/messaging_schemas.py @@ -13,6 +13,14 @@ "confirm": CONFIRM_ARG, } +SEND_REACTION_SCHEMA = { + "phone": PHONE_ARG, + "chat_id": CHAT_ID_ARG, + "message_id": Annotated[int, "ID сообщения в Telegram"], + "emoji": Annotated[str, "Emoji-реакция"], + "confirm": CONFIRM_ARG, +} + EDIT_MESSAGE_SCHEMA = { "phone": PHONE_ARG, "chat_id": CHAT_ID_ARG, diff --git a/src/agent/tools/messaging_write.py b/src/agent/tools/messaging_write.py index 833c93c2..35e39163 100644 --- a/src/agent/tools/messaging_write.py +++ b/src/agent/tools/messaging_write.py @@ -9,6 +9,7 @@ ToolInputError, _text_response, arg_csv_ints, + arg_int, arg_str, require_confirmation, ) @@ -18,6 +19,7 @@ EDIT_MESSAGE_SCHEMA, FORWARD_MESSAGES_SCHEMA, SEND_MESSAGE_SCHEMA, + SEND_REACTION_SCHEMA, ) from src.services.telegram_actions import TelegramActionClientUnavailableError, TelegramActionService @@ -58,6 +60,43 @@ async def send_message(args): tools.append(send_message) + @tool( + "send_reaction", + "Set an emoji reaction on a Telegram message. " + "chat_id accepts @username, t.me link, numeric ID, or 'me'. Ask user for confirmation first.", + SEND_REACTION_SCHEMA, + ) + async def send_reaction(args): + phone, err = await prepare_telegram_tool(ctx, args, tool_name="send_reaction", action="Реакция на сообщение") + if err: + return err + try: + chat_id = arg_str(args, "chat_id", required=True) + message_id = arg_int(args, "message_id", required=True) + emoji = arg_str(args, "emoji", required=True) + except ToolInputError: + return _text_response("Ошибка: chat_id, message_id и emoji обязательны.") + gate = require_confirmation( + f"поставит реакцию {emoji} на сообщение #{message_id} в чате {chat_id} от аккаунта {phone}", + args, + ) + if gate: + return gate + try: + await TelegramActionService(client_pool).send_reaction( + phone=phone, + chat_id=chat_id, + message_id=int(message_id), + emoji=emoji, + ) + return _text_response(f"Реакция {emoji} поставлена на сообщение #{message_id} в чате {chat_id}.") + except TelegramActionClientUnavailableError: + return _text_response(f"Клиент для {phone} не найден или flood-wait активен.") + except Exception as e: + return _text_response(f"Ошибка установки реакции: {e}") + + tools.append(send_reaction) + @tool( "edit_message", "Edit a previously sent message. " diff --git a/src/agent/tools/permissions.py b/src/agent/tools/permissions.py index f343cacd..fe2e71bd 100644 --- a/src/agent/tools/permissions.py +++ b/src/agent/tools/permissions.py @@ -171,8 +171,10 @@ class ToolCategory(str, Enum): "get_forum_topics": ToolCategory.READ, "clear_dialog_cache": ToolCategory.WRITE, "get_cache_status": ToolCategory.READ, + "resolve_entity": ToolCategory.READ, # Messaging "send_message": ToolCategory.WRITE, + "send_reaction": ToolCategory.WRITE, "forward_messages": ToolCategory.WRITE, "edit_message": ToolCategory.WRITE, "delete_message": ToolCategory.DELETE, @@ -276,10 +278,10 @@ class ToolCategory(str, Enum): ]), ("Диалоги", [ "search_dialogs", "refresh_dialogs", "leave_dialogs", "create_telegram_channel", - "get_forum_topics", "clear_dialog_cache", "get_cache_status", + "get_forum_topics", "clear_dialog_cache", "get_cache_status", "resolve_entity", ]), ("Сообщения", [ - "send_message", "forward_messages", "edit_message", "delete_message", + "send_message", "send_reaction", "forward_messages", "edit_message", "delete_message", "pin_message", "unpin_message", "download_media", "read_messages", ]), ("Управление чатом", [ diff --git a/src/services/pipeline_service.py b/src/services/pipeline_service.py index 577f390a..9afe8ba7 100644 --- a/src/services/pipeline_service.py +++ b/src/services/pipeline_service.py @@ -337,7 +337,7 @@ async def list_cached_dialogs_by_phone( self, active_only: bool = False, ) -> dict[str, list[dict]]: - accounts = await self._bundle.list_accounts(active_only=active_only) + accounts = await self._bundle.list_account_summaries(active_only=active_only) dialogs = await asyncio.gather( *[self._bundle.list_cached_dialogs(account.phone) for account in accounts] ) diff --git a/src/services/telegram_action_inventory.py b/src/services/telegram_action_inventory.py index 9c7b0fed..309666f6 100644 --- a/src/services/telegram_action_inventory.py +++ b/src/services/telegram_action_inventory.py @@ -20,6 +20,7 @@ class TelegramActionInventoryItem: TelegramActionInventoryItem( action="send_reaction", cli="dialogs react", + agent_tool="send_reaction", pipeline_node="react", backend_method="send_reaction", ), diff --git a/src/web/pipelines/handlers.py b/src/web/pipelines/handlers.py index 0771ff7c..b62a3293 100644 --- a/src/web/pipelines/handlers.py +++ b/src/web/pipelines/handlers.py @@ -121,7 +121,7 @@ async def api_channels_search(request: Request, q: str = ""): async def _page_context(request: Request) -> dict: svc = deps.pipeline_service(request) channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True) - accounts = await deps.get_account_bundle(request).list_accounts() + accounts = await deps.get_account_bundle(request).list_account_summaries() selected_phone = request.query_params.get("phone") or (accounts[0].phone if accounts else "") if selected_phone: refresh = request.query_params.get("refresh") == "1" @@ -188,7 +188,7 @@ async def pipelines_page(request: Request): async def create_wizard_page(request: Request): svc = deps.pipeline_service(request) - accounts = await deps.get_account_bundle(request).list_accounts() + accounts = await deps.get_account_bundle(request).list_account_summaries() cached_dialogs = await svc.list_cached_dialogs_by_phone() llm_provider_svc = deps.get_llm_provider_service(request) llm_configured = llm_provider_svc.has_providers() @@ -413,7 +413,7 @@ async def edit_page(request: Request, pipeline_id: int): return _pipeline_redirect("pipeline_invalid", error=True) db = deps.get_db(request) channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True) - accounts = await deps.get_account_bundle(request).list_accounts() + accounts = await deps.get_account_bundle(request).list_account_summaries() selected_phone = request.query_params.get("phone") or (accounts[0].phone if accounts else "") if selected_phone and request.query_params.get("refresh") == "1": try: @@ -665,7 +665,7 @@ async def templates_page(request: Request): svc: PipelineService = deps.pipeline_service(request) templates = await svc.list_templates() channels = await deps.get_channel_bundle(request).list_channels(include_filtered=True) - accounts = await deps.get_account_bundle(request).list_accounts() + accounts = await deps.get_account_bundle(request).list_account_summaries() cached_dialogs = await svc.list_cached_dialogs_by_phone() llm_configured = deps.get_llm_provider_service(request).has_providers() return deps.get_templates(request).TemplateResponse( diff --git a/tests/routes/test_pipelines_routes.py b/tests/routes/test_pipelines_routes.py index 5e3b34aa..876923c2 100644 --- a/tests/routes/test_pipelines_routes.py +++ b/tests/routes/test_pipelines_routes.py @@ -7,6 +7,8 @@ import pytest from httpx import ASGITransport, AsyncClient +from src.models import Account + _ADD_DATA = { "name": "Test Pipeline", "prompt_template": "Write a summary", @@ -139,6 +141,26 @@ async def test_pipeline_edit_page_loads(client): assert "Редактировать" in resp.text +@pytest.mark.anyio +async def test_pipeline_pages_render_with_encrypted_account_missing_key(client): + """Read-only pipeline pages must not decrypt account sessions to render.""" + await client.post("/pipelines/add", data=_ADD_DATA) + + app = client._transport.app # type: ignore[attr-defined] + await app.state.db.add_account( + Account( + phone="+19999999999", + session_string="enc:v1:not-a-valid-token", + is_active=True, + ) + ) + + for path in ("/pipelines/", "/pipelines/create", "/pipelines/templates", "/pipelines/1/edit"): + resp = await client.get(path) + assert resp.status_code == 200, path + assert "Ошибка сервера" not in resp.text + + @pytest.mark.anyio async def test_pipeline_edit_page_not_found(client): """GET /pipelines//edit redirects for invalid ID.""" diff --git a/tests/routes/test_pipelines_routes_dag_and_templates.py b/tests/routes/test_pipelines_routes_dag_and_templates.py index 1a3db924..14e46712 100644 --- a/tests/routes/test_pipelines_routes_dag_and_templates.py +++ b/tests/routes/test_pipelines_routes_dag_and_templates.py @@ -1203,7 +1203,7 @@ async def test_templates_page_renders(route_client, base_app): mock_svc.return_value.list_templates = AsyncMock(return_value=[]) mock_svc.return_value.list_cached_dialogs_by_phone = AsyncMock(return_value={}) mock_ch.return_value.list_channels = AsyncMock(return_value=[]) - mock_acct.return_value.list_accounts = AsyncMock(return_value=[]) + mock_acct.return_value.list_account_summaries = AsyncMock(return_value=[]) resp = await route_client.get("/pipelines/templates") assert resp.status_code == 200 diff --git a/tests/test_agent_tool_smoke_contract.py b/tests/test_agent_tool_smoke_contract.py index e0d92ddc..0ac5364b 100644 --- a/tests/test_agent_tool_smoke_contract.py +++ b/tests/test_agent_tool_smoke_contract.py @@ -4,6 +4,8 @@ from collections.abc import Callable from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import MagicMock import pytest @@ -21,6 +23,48 @@ def _assert_no_formatter_contract_error(tool_name: str, text: str) -> None: assert "required positional" not in lowered, tool_name +def _minimal_deepagents_kwargs(tool_name: str) -> dict[str, object]: + from src.agent.tools.deepagents_sync import _REQUIRED_ARGS_BY_TOOL + + samples: dict[str, object] = { + "account_id": 1, + "channel_id": 1001, + "chat_id": "@contract_chat", + "dialog_ids": "1001", + "emoji": "👍", + "file_paths": "/tmp/contract-smoke.jpg", + "folder_path": "/tmp", + "from_chat": "@from_chat", + "identifier": "@contract_channel", + "instruction": "noop", + "item_id": 1, + "job_id": "collect", + "json_text": "{}", + "message_id": 1, + "message_ids": "1", + "minutes": 5, + "name": "contract", + "pipeline_id": 1, + "pk": 1, + "prompt": "contract smoke", + "provider": "stub", + "query": "contract", + "recipient": "@recipient", + "run_id": 1, + "schedule_at": "2030-01-01T00:00:00", + "sq_id": 1, + "source_channel_ids": "1001", + "steps_json": "[]", + "target": "1001", + "target_refs": "+79001234567|1001", + "text": "contract smoke", + "thread_id": 1, + "to_chat": "@to_chat", + "user_id": "@user", + } + return {name: samples.get(name, "contract") for name in _REQUIRED_ARGS_BY_TOOL.get(tool_name, frozenset())} + + async def _seed_read_only_contract_data(db) -> None: now = datetime.now() await db.add_channel(Channel(channel_id=1001, title="Named Contract", username="named_contract")) @@ -41,6 +85,41 @@ async def _seed_read_only_contract_data(db) -> None: ) +def test_agent_registry_matches_permission_contract(mock_db): + from src.agent.tools import build_agent_tool_registry + from src.agent.tools.permissions import BUILTIN_TOOLS, TOOL_CATEGORIES + + registry_names = { + tool.name for tool in build_agent_tool_registry(mock_db, client_pool=MagicMock(), wrap_session_gate=False) + } + permission_names = set(TOOL_CATEGORIES) - set(BUILTIN_TOOLS) + + assert registry_names - permission_names == set() + assert permission_names - registry_names == set() + + +def test_agent_tools_reference_docs_cover_registry(mock_db): + from src.agent.tools import build_agent_tool_registry + + doc = Path("docs/reference/agent-tools.md").read_text(encoding="utf-8") + missing = [ + tool.name + for tool in build_agent_tool_registry(mock_db, client_pool=MagicMock(), wrap_session_gate=False) + if f"`{tool.name}`" not in doc + ] + + assert missing == [] + + +def test_deepagents_all_tools_smoke_with_minimal_contract_args(cli_db): + tool_map: dict[str, Callable] = {tool.__name__: tool for tool in build_deepagents_tools(cli_db)} + + for tool_name, tool in sorted(tool_map.items()): + result = tool(**_minimal_deepagents_kwargs(tool_name)) + assert isinstance(result, str), tool_name + _assert_no_formatter_contract_error(tool_name, result) + + @pytest.mark.anyio async def test_pipeline_safe_agent_tools_smoke_on_empty_db(db): tools = build_agent_tools_dict(db, client_pool=None) diff --git a/tests/test_agent_tools_deepagents_sync.py b/tests/test_agent_tools_deepagents_sync.py index 18097b4a..e8f1d3b6 100644 --- a/tests/test_agent_tools_deepagents_sync.py +++ b/tests/test_agent_tools_deepagents_sync.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import inspect from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -32,6 +33,20 @@ def test_deepagents_read_messages_is_registered_and_uses_runtime_gate(mock_db): assert "требует Telegram-клиент" in result +def test_deepagents_send_reaction_signature_and_runtime_gate(mock_db): + from src.agent.tools.deepagents_sync import build_deepagents_tools + + tool_map = {tool.__name__: tool for tool in build_deepagents_tools(mock_db)} + + assert "send_reaction" in tool_map + signature = inspect.signature(tool_map["send_reaction"]) + for name in ("chat_id", "message_id", "emoji"): + assert signature.parameters[name].default is inspect.Parameter.empty + + result = tool_map["send_reaction"](chat_id="@chat", message_id=1, emoji="👍") + assert "требует Telegram-клиент" in result + + @pytest.mark.anyio async def test_live_runtime_sync_bridge_uses_owner_loop_not_asyncio_run(mock_db, monkeypatch): from src.agent.runtime_context import AgentRuntimeContext diff --git a/tests/test_agent_tools_messaging.py b/tests/test_agent_tools_messaging.py index b7ca8a07..8fb9a705 100644 --- a/tests/test_agent_tools_messaging.py +++ b/tests/test_agent_tools_messaging.py @@ -24,6 +24,7 @@ def _make_mock_pool(): mock_client = AsyncMock() mock_client.get_entity = AsyncMock(return_value=MagicMock(id=123456)) mock_client.send_message = AsyncMock() + mock_client.send_reaction = AsyncMock() mock_client.edit_message = AsyncMock() mock_client.delete_messages = AsyncMock(return_value=[MagicMock(pts_count=1)]) mock_client.forward_messages = AsyncMock(return_value=[MagicMock()]) @@ -84,6 +85,47 @@ async def test_missing_recipient_or_text_returns_error(self, mock_db): await assert_tool_text(handlers["send_message"], {"phone": "+79001234567"}, "обязательны") +class TestSendReaction: + @pytest.mark.anyio + async def test_no_pool_returns_gate(self, mock_db): + handlers = _get_tool_handlers(mock_db, client_pool=None) + result = await handlers["send_reaction"]( + {"phone": "+79001234567", "chat_id": "@chat", "message_id": 1, "emoji": "👍"} + ) + assert "CLI-режиме" in _text(result) + + @pytest.mark.anyio + async def test_missing_args_returns_error(self, mock_db): + mock_pool, _ = _make_mock_pool() + mock_db.get_accounts = AsyncMock(return_value=[_make_account()]) + handlers = _get_tool_handlers(mock_db, client_pool=mock_pool) + result = await handlers["send_reaction"]({"phone": "+79001234567", "chat_id": "@chat"}) + assert "обязательны" in _text(result) + + @pytest.mark.anyio + async def test_no_confirm_returns_gate(self, mock_db): + mock_pool, _ = _make_mock_pool() + mock_db.get_accounts = AsyncMock(return_value=[_make_account()]) + handlers = _get_tool_handlers(mock_db, client_pool=mock_pool) + result = await handlers["send_reaction"]( + {"phone": "+79001234567", "chat_id": "@chat", "message_id": 5, "emoji": "🔥"} + ) + assert "confirm=true" in _text(result) + + @pytest.mark.anyio + async def test_with_confirm_success(self, mock_db): + mock_pool, mock_client = _make_mock_pool() + mock_db.get_accounts = AsyncMock(return_value=[_make_account()]) + handlers = _get_tool_handlers(mock_db, client_pool=mock_pool) + result = await handlers["send_reaction"]( + {"phone": "+79001234567", "chat_id": "@chat", "message_id": 5, "emoji": "🔥", "confirm": True} + ) + + assert "Реакция 🔥 поставлена" in _text(result) + mock_client.get_entity.assert_awaited_with("@chat") + mock_client.send_reaction.assert_awaited_once() + + class TestEditMessage: @pytest.mark.anyio async def test_no_pool_returns_gate(self, mock_db): diff --git a/tests/test_agent_tools_real_telegram.py b/tests/test_agent_tools_real_telegram.py new file mode 100644 index 00000000..ee845461 --- /dev/null +++ b/tests/test_agent_tools_real_telegram.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from tests.agent_tools_helpers import _get_tool_handlers, _text + +pytestmark = pytest.mark.real_tg_manual + + +@dataclass +class _LiveSandboxPool: + phone: str + client: object + + @property + def clients(self) -> dict[str, object]: + return {self.phone: self.client} + + async def get_native_client_by_phone(self, phone: str): + if phone != self.phone: + return None + return self.client, self.phone + + async def get_client_by_phone(self, phone: str): + if phone != self.phone: + return None + return self.client, self.phone + + async def release_client(self, phone: str) -> None: + return None + + +@pytest.mark.anyio +async def test_send_reaction_agent_tool_live_sandbox(db, real_telegram_sandbox): + client = real_telegram_sandbox.client + message = await client.send_message(real_telegram_sandbox.saved_messages_target, "agent send_reaction live smoke") + try: + handlers = _get_tool_handlers( + db, + client_pool=_LiveSandboxPool(real_telegram_sandbox.phone, client), + ) + result = await handlers["send_reaction"]( + { + "phone": real_telegram_sandbox.phone, + "chat_id": real_telegram_sandbox.saved_messages_target, + "message_id": message.id, + "emoji": "👍", + "confirm": True, + } + ) + + assert "Реакция 👍 поставлена" in _text(result) + finally: + await client.delete_messages(real_telegram_sandbox.saved_messages_target, [message.id]) diff --git a/tests/test_telegram_action_contract.py b/tests/test_telegram_action_contract.py index 3cb228ca..6387af4e 100644 --- a/tests/test_telegram_action_contract.py +++ b/tests/test_telegram_action_contract.py @@ -3,7 +3,9 @@ import ast from pathlib import Path +from unittest.mock import MagicMock +from src.agent.tools import build_agent_tool_registry from src.services.telegram_action_inventory import TELEGRAM_ACTION_INVENTORY PROJECT_ROOT = Path(__file__).resolve().parents[1] @@ -38,6 +40,7 @@ "edit_folder", "download_media", "leave_channels", + "send_reaction", } @@ -53,6 +56,18 @@ def test_telegram_action_inventory_has_unique_complete_actions(): assert any((item.cli, item.web_command, item.agent_tool, item.pipeline_node)), item.action +def test_telegram_action_inventory_agent_tools_are_registered(): + registered = { + tool.name for tool in build_agent_tool_registry(MagicMock(), client_pool=MagicMock(), wrap_session_gate=False) + } + missing = [ + f"{item.action}:{item.agent_tool}" + for item in TELEGRAM_ACTION_INVENTORY + if item.agent_tool and item.agent_tool not in registered + ] + assert missing == [] + + def _is_entrypoint_path(relative: Path) -> bool: return relative in ENTRYPOINT_FILES or any(relative.is_relative_to(root) for root in ENTRYPOINT_ROOTS)