chore: uptick
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Shared fixtures for Feishu adapter tests (admission, group policy, dispatch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def make_sender(sender_type: str = "user", open_id: str = "ou_human",
|
||||
user_id: Optional[str] = None, union_id: Optional[str] = None) -> Any:
|
||||
return SimpleNamespace(
|
||||
sender_type=sender_type,
|
||||
sender_id=SimpleNamespace(open_id=open_id, user_id=user_id, union_id=union_id),
|
||||
)
|
||||
|
||||
|
||||
def make_message(message_id: str = "om_xxx", chat_type: str = "p2p",
|
||||
chat_id: str = "oc_1", mentions: Optional[list] = None) -> Any:
|
||||
return SimpleNamespace(
|
||||
message_id=message_id,
|
||||
chat_type=chat_type,
|
||||
chat_id=chat_id,
|
||||
mentions=mentions,
|
||||
content="",
|
||||
message_type="text",
|
||||
)
|
||||
|
||||
|
||||
def make_adapter_skeleton(
|
||||
*,
|
||||
bot_open_id: str = "ou_me",
|
||||
bot_user_id: str = "",
|
||||
allow_bots: str = "none",
|
||||
require_mention: bool = True,
|
||||
group_policy: str = "allowlist",
|
||||
) -> Any:
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._bot_open_id = bot_open_id
|
||||
adapter._bot_user_id = bot_user_id
|
||||
adapter._bot_name = ""
|
||||
adapter._app_id = ""
|
||||
adapter._admins = set()
|
||||
adapter._group_rules = {}
|
||||
adapter._group_policy = group_policy
|
||||
adapter._default_group_policy = group_policy
|
||||
adapter._allowed_group_users = frozenset()
|
||||
adapter._allow_bots = allow_bots
|
||||
adapter._require_mention = require_mention
|
||||
return adapter
|
||||
|
||||
|
||||
def install_dedup_state(adapter: Any, seen: Optional[dict] = None) -> None:
|
||||
adapter._seen_message_ids = dict(seen) if seen else {}
|
||||
adapter._seen_message_order = list((seen or {}).keys())
|
||||
adapter._dedup_cache_size = 100
|
||||
adapter._dedup_lock = threading.Lock()
|
||||
adapter._dedup_state_path = None
|
||||
adapter._persist_seen_message_ids = lambda: None
|
||||
|
||||
|
||||
def stub_mention(adapter: Any, mentions_self: bool) -> None:
|
||||
adapter._mentions_self = lambda _message: mentions_self
|
||||
@@ -332,6 +332,36 @@ def auth_adapter():
|
||||
return _make_adapter(api_key="sk-secret")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAgentExecution:
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_uses_session_id_as_task_id(self, adapter):
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent.session_prompt_tokens = 1
|
||||
mock_agent.session_completion_tokens = 2
|
||||
mock_agent.session_total_tokens = 3
|
||||
|
||||
with patch.object(adapter, "_create_agent", return_value=mock_agent):
|
||||
result, usage = await adapter._run_agent(
|
||||
user_message="hello",
|
||||
conversation_history=[],
|
||||
session_id="session-123",
|
||||
)
|
||||
|
||||
assert result == {"final_response": "ok"}
|
||||
assert usage == {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
|
||||
mock_agent.run_conversation.assert_called_once_with(
|
||||
user_message="hello",
|
||||
conversation_history=[],
|
||||
task_id="session-123",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -253,10 +253,7 @@ class TestRunStatus:
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
mock_agent.run_conversation.assert_called_once()
|
||||
# task_id stays "default" so the Runs API shares one sandbox
|
||||
# container with CLI/gateway; session_id is surfaced in status
|
||||
# for external UIs to correlate runs with their own session IDs.
|
||||
assert mock_agent.run_conversation.call_args.kwargs["task_id"] == "default"
|
||||
assert mock_agent.run_conversation.call_args.kwargs["task_id"] == "space-session"
|
||||
assert status["session_id"] == "space-session"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -173,6 +173,23 @@ class TestBlockingGatewayApproval:
|
||||
assert e1.event.is_set()
|
||||
assert e2.event.is_set()
|
||||
|
||||
def test_clear_session_denies_and_signals_all_entries(self):
|
||||
"""clear_session must wake blocked entries during boundary cleanup."""
|
||||
from tools.approval import clear_session, _ApprovalEntry, _gateway_queues
|
||||
|
||||
session_key = "test-boundary-cleanup"
|
||||
e1 = _ApprovalEntry({"command": "cmd1"})
|
||||
e2 = _ApprovalEntry({"command": "cmd2"})
|
||||
_gateway_queues[session_key] = [e1, e2]
|
||||
|
||||
clear_session(session_key)
|
||||
|
||||
assert e1.event.is_set()
|
||||
assert e2.event.is_set()
|
||||
assert e1.result == "deny"
|
||||
assert e2.result == "deny"
|
||||
assert session_key not in _gateway_queues
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /approve command
|
||||
|
||||
@@ -64,11 +64,13 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance._cached_system_prompt = ""
|
||||
agent_instance.tools = None
|
||||
agent_instance.context_compressor.has_content_to_compress.return_value = True
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (list(history), "")
|
||||
|
||||
def _estimate(messages):
|
||||
def _estimate(messages, **_kwargs):
|
||||
assert messages == history
|
||||
return 100
|
||||
|
||||
@@ -76,13 +78,13 @@ async def test_compress_command_reports_noop_without_success_banner():
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
assert "No changes from compression" in result
|
||||
assert "Compressed:" not in result
|
||||
assert "Rough transcript estimate: ~100 tokens (unchanged)" in result
|
||||
assert "Approx request size: ~100 tokens (unchanged)" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
|
||||
@@ -99,11 +101,13 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance._cached_system_prompt = ""
|
||||
agent_instance.tools = None
|
||||
agent_instance.context_compressor.has_content_to_compress.return_value = True
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
def _estimate(messages, **_kwargs):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
@@ -114,12 +118,12 @@ async def test_compress_command_explains_when_token_estimate_rises():
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
assert "Compressed: 4 → 3 messages" in result
|
||||
assert "Rough transcript estimate: ~100 → ~120 tokens" in result
|
||||
assert "Approx request size: ~100 → ~120 tokens" in result
|
||||
assert "denser summaries" in result
|
||||
agent_instance.shutdown_memory_provider.assert_called_once()
|
||||
agent_instance.close.assert_called_once()
|
||||
@@ -143,6 +147,8 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance._cached_system_prompt = ""
|
||||
agent_instance.tools = None
|
||||
agent_instance.context_compressor.has_content_to_compress.return_value = True
|
||||
# Simulate summary-generation failure: fallback flag set, dropped count
|
||||
# populated, error string captured.
|
||||
@@ -154,7 +160,7 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
def _estimate(messages, **_kwargs):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
@@ -165,7 +171,7 @@ async def test_compress_command_appends_warning_when_summary_generation_fails():
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
@@ -200,6 +206,8 @@ async def test_compress_command_surfaces_aux_model_failure_even_when_recovered()
|
||||
agent_instance = MagicMock()
|
||||
agent_instance.shutdown_memory_provider = MagicMock()
|
||||
agent_instance.close = MagicMock()
|
||||
agent_instance._cached_system_prompt = ""
|
||||
agent_instance.tools = None
|
||||
agent_instance.context_compressor.has_content_to_compress.return_value = True
|
||||
# Fallback placeholder was NOT used — recovery succeeded.
|
||||
agent_instance.context_compressor._last_summary_fallback_used = False
|
||||
@@ -215,7 +223,7 @@ async def test_compress_command_surfaces_aux_model_failure_even_when_recovered()
|
||||
agent_instance.session_id = "sess-1"
|
||||
agent_instance._compress_context.return_value = (compressed, "")
|
||||
|
||||
def _estimate(messages):
|
||||
def _estimate(messages, **_kwargs):
|
||||
if messages == history:
|
||||
return 100
|
||||
if messages == compressed:
|
||||
@@ -226,7 +234,7 @@ async def test_compress_command_surfaces_aux_model_failure_even_when_recovered()
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "***"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="test-model"),
|
||||
patch("run_agent.AIAgent", return_value=agent_instance),
|
||||
patch("agent.model_metadata.estimate_messages_tokens_rough", side_effect=_estimate),
|
||||
patch("agent.model_metadata.estimate_request_tokens_rough", side_effect=_estimate),
|
||||
):
|
||||
result = await runner._handle_compress_command(_make_event())
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from gateway.config import (
|
||||
Platform,
|
||||
PlatformConfig,
|
||||
SessionResetPolicy,
|
||||
StreamingConfig,
|
||||
_apply_env_overrides,
|
||||
load_gateway_config,
|
||||
)
|
||||
@@ -149,6 +150,24 @@ class TestSessionResetPolicy:
|
||||
assert restored.notify is False
|
||||
|
||||
|
||||
class TestStreamingConfig:
|
||||
def test_from_dict_coerces_quoted_false_enabled(self):
|
||||
restored = StreamingConfig.from_dict({"enabled": "false"})
|
||||
assert restored.enabled is False
|
||||
|
||||
def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self):
|
||||
restored = StreamingConfig.from_dict(
|
||||
{
|
||||
"edit_interval": "oops",
|
||||
"buffer_threshold": "oops",
|
||||
"fresh_final_after_seconds": "oops",
|
||||
}
|
||||
)
|
||||
assert restored.edit_interval == 1.0
|
||||
assert restored.buffer_threshold == 40
|
||||
assert restored.fresh_final_after_seconds == 60.0
|
||||
|
||||
|
||||
class TestGatewayConfigRoundtrip:
|
||||
def test_full_roundtrip(self):
|
||||
config = GatewayConfig(
|
||||
@@ -194,6 +213,26 @@ class TestGatewayConfigRoundtrip:
|
||||
restored = GatewayConfig.from_dict({"always_log_local": "false"})
|
||||
assert restored.always_log_local is False
|
||||
|
||||
def test_get_notice_delivery_defaults_to_public(self):
|
||||
config = GatewayConfig(
|
||||
platforms={Platform.SLACK: PlatformConfig(enabled=True, token="***")}
|
||||
)
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "public"
|
||||
|
||||
def test_get_notice_delivery_honors_platform_override(self):
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra={"notice_delivery": "private"},
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "private"
|
||||
|
||||
|
||||
class TestLoadGatewayConfig:
|
||||
def test_bridges_quick_commands_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
@@ -360,6 +399,38 @@ class TestLoadGatewayConfig:
|
||||
"C01ABC": "Code review mode",
|
||||
}
|
||||
|
||||
def test_bridges_feishu_allow_bots_from_config_yaml_to_env(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"feishu:\n allow_bots: mentions\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("FEISHU_ALLOW_BOTS") == "mentions"
|
||||
|
||||
def test_feishu_allow_bots_env_takes_precedence_over_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"feishu:\n allow_bots: all\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "none")
|
||||
|
||||
load_gateway_config()
|
||||
|
||||
assert os.environ.get("FEISHU_ALLOW_BOTS") == "none"
|
||||
|
||||
def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
@@ -406,6 +477,22 @@ class TestLoadGatewayConfig:
|
||||
|
||||
assert config.platforms[Platform.TELEGRAM].extra["disable_link_previews"] is True
|
||||
|
||||
def test_bridges_notice_delivery_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"slack:\n"
|
||||
" notice_delivery: private\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.get_notice_delivery(Platform.SLACK) == "private"
|
||||
|
||||
def test_bridges_telegram_proxy_url_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
@@ -455,6 +542,15 @@ class TestHomeChannelEnvOverrides:
|
||||
{"SLACK_HOME_CHANNEL": "C123", "SLACK_HOME_CHANNEL_NAME": "Ops"},
|
||||
("C123", "Ops"),
|
||||
),
|
||||
(
|
||||
Platform.WHATSAPP,
|
||||
PlatformConfig(enabled=True),
|
||||
{
|
||||
"WHATSAPP_HOME_CHANNEL": "1234567890@lid",
|
||||
"WHATSAPP_HOME_CHANNEL_NAME": "Owner DM",
|
||||
},
|
||||
("1234567890@lid", "Owner DM"),
|
||||
),
|
||||
(
|
||||
Platform.SIGNAL,
|
||||
PlatformConfig(
|
||||
|
||||
@@ -65,4 +65,62 @@ class TestTargetToStringRoundtrip:
|
||||
assert reparsed.chat_id == "999"
|
||||
|
||||
|
||||
class TestCaseSensitiveChatIdParsing:
|
||||
"""Test that chat IDs preserve their original case (issue #11768)."""
|
||||
|
||||
def test_slack_uppercase_chat_id_preserved(self):
|
||||
"""Slack channel IDs like C123ABC should preserve case."""
|
||||
target = DeliveryTarget.parse("slack:C123ABC")
|
||||
assert target.platform == Platform.SLACK
|
||||
assert target.chat_id == "C123ABC" # Should NOT be lowercased to c123abc
|
||||
assert target.is_explicit is True
|
||||
|
||||
def test_slack_chat_id_with_thread_preserved(self):
|
||||
"""Slack channel:thread IDs should preserve case."""
|
||||
target = DeliveryTarget.parse("slack:C123ABC:thread123")
|
||||
assert target.platform == Platform.SLACK
|
||||
assert target.chat_id == "C123ABC"
|
||||
assert target.thread_id == "thread123"
|
||||
|
||||
def test_matrix_room_id_preserved(self):
|
||||
"""Matrix room IDs like !RoomABC:example.org should preserve case.
|
||||
|
||||
Note: Matrix room IDs contain colons (e.g., !RoomABC:example.org).
|
||||
Due to the platform:chat_id:thread_id format, these are parsed as
|
||||
chat_id=!RoomABC and thread_id=example.org. This is a known limitation
|
||||
of the current format. The fix preserves case but doesn't change the
|
||||
parsing structure.
|
||||
"""
|
||||
target = DeliveryTarget.parse("matrix:!RoomABC:example.org")
|
||||
assert target.platform == Platform.MATRIX
|
||||
# The room ID is split at the first colon after the platform prefix
|
||||
# This is a format limitation - the case is preserved but the structure is split
|
||||
assert target.chat_id == "!RoomABC"
|
||||
assert target.thread_id == "example.org"
|
||||
|
||||
def test_mixed_case_chat_id_roundtrip(self):
|
||||
"""Mixed-case chat IDs should survive parse-to_string roundtrip."""
|
||||
original = "telegram:ChatId123ABC"
|
||||
target = DeliveryTarget.parse(original)
|
||||
s = target.to_string()
|
||||
reparsed = DeliveryTarget.parse(s)
|
||||
assert reparsed.chat_id == "ChatId123ABC"
|
||||
|
||||
|
||||
class TestPlatformNameCaseInsensitivity:
|
||||
"""Test that platform names are case-insensitive."""
|
||||
|
||||
def test_uppercase_platform_name(self):
|
||||
"""Platform names should be case-insensitive."""
|
||||
target = DeliveryTarget.parse("TELEGRAM:12345")
|
||||
assert target.platform == Platform.TELEGRAM
|
||||
assert target.chat_id == "12345"
|
||||
|
||||
def test_mixed_case_platform_name(self):
|
||||
"""Mixed-case platform names should work."""
|
||||
target = DeliveryTarget.parse("TeleGram:12345")
|
||||
assert target.platform == Platform.TELEGRAM
|
||||
assert target.chat_id == "12345"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,26 @@ async def test_discord_free_response_channel_can_come_from_config_extra(adapter,
|
||||
assert event.text == "allowed from config"
|
||||
|
||||
|
||||
def test_discord_free_response_channels_bare_int(adapter, monkeypatch):
|
||||
# YAML `discord.free_response_channels: 1491973769726791812` (single bare
|
||||
# integer) is loaded as an int and previously fell through the
|
||||
# isinstance(str) branch in _discord_free_response_channels, silently
|
||||
# returning an empty set. Scalar → str coercion makes single-channel
|
||||
# config work without having to quote the ID in YAML.
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
adapter.config.extra["free_response_channels"] = 1491973769726791812
|
||||
|
||||
assert adapter._discord_free_response_channels() == {"1491973769726791812"}
|
||||
|
||||
|
||||
def test_discord_free_response_channels_int_list(adapter, monkeypatch):
|
||||
# YAML list form with bare numeric entries — each element should be coerced.
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
adapter.config.extra["free_response_channels"] = [1491973769726791812, 99999]
|
||||
|
||||
assert adapter._discord_free_response_channels() == {"1491973769726791812", "99999"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(adapter, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Tests for EphemeralReply — system-notice auto-delete in gateway adapters.
|
||||
|
||||
Slash-command handlers in ``gateway/run.py`` can return an
|
||||
``EphemeralReply`` wrapper to request auto-deletion of the reply message
|
||||
after a TTL. The base adapter unwraps the sentinel before sending and
|
||||
schedules a detached delete task when the platform supports
|
||||
``delete_message``.
|
||||
|
||||
Covered:
|
||||
|
||||
1. ``_unwrap_ephemeral`` returns text + ttl for EphemeralReply, and
|
||||
passes plain strings through unchanged.
|
||||
2. TTL is zeroed on platforms that don't override ``delete_message``
|
||||
(silent degrade — message stays in place).
|
||||
3. TTL is honored on platforms that DO override ``delete_message``.
|
||||
4. ``_schedule_ephemeral_delete`` invokes ``delete_message`` after the
|
||||
configured delay with the correct chat_id / message_id.
|
||||
5. ``_process_message_background`` sends the unwrapped text (not the
|
||||
sentinel object) and schedules deletion when appropriate.
|
||||
6. The two busy-session bypass paths also unwrap + schedule.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
EphemeralReply,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
SendResult,
|
||||
)
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
class _NoDeleteAdapter(BasePlatformAdapter):
|
||||
"""Adapter that does NOT override delete_message (silent degrade)."""
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, content="", **kwargs):
|
||||
return SendResult(success=True, message_id="m-1")
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {}
|
||||
|
||||
|
||||
class _DeleteCapableAdapter(BasePlatformAdapter):
|
||||
"""Adapter that overrides delete_message (TTL honored)."""
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, **kw)
|
||||
self.deleted: list[tuple[str, str]] = []
|
||||
|
||||
async def connect(self):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, content="", **kwargs):
|
||||
return SendResult(success=True, message_id="m-2")
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {}
|
||||
|
||||
async def delete_message(self, chat_id: str, message_id: str) -> bool:
|
||||
self.deleted.append((chat_id, message_id))
|
||||
return True
|
||||
|
||||
|
||||
def _no_delete_adapter():
|
||||
return _NoDeleteAdapter(
|
||||
PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM
|
||||
)
|
||||
|
||||
|
||||
def _delete_adapter():
|
||||
return _DeleteCapableAdapter(
|
||||
PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text="/stop", chat_id="42"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_id="msg-1",
|
||||
source=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
user_id="u-1",
|
||||
),
|
||||
message_type=MessageType.TEXT,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _unwrap_ephemeral
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unwrap_plain_string_is_passthrough():
|
||||
adapter = _delete_adapter()
|
||||
text, ttl = adapter._unwrap_ephemeral("hello")
|
||||
assert text == "hello"
|
||||
assert ttl == 0
|
||||
|
||||
|
||||
def test_unwrap_none_is_passthrough():
|
||||
adapter = _delete_adapter()
|
||||
text, ttl = adapter._unwrap_ephemeral(None)
|
||||
assert text is None
|
||||
assert ttl == 0
|
||||
|
||||
|
||||
def test_unwrap_ephemeral_explicit_ttl_on_capable_adapter():
|
||||
adapter = _delete_adapter()
|
||||
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye", ttl_seconds=60))
|
||||
assert text == "bye"
|
||||
assert ttl == 60
|
||||
|
||||
|
||||
def test_unwrap_ephemeral_zeros_ttl_on_incapable_adapter():
|
||||
"""Platforms without delete_message should silently degrade to normal send."""
|
||||
adapter = _no_delete_adapter()
|
||||
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye", ttl_seconds=60))
|
||||
assert text == "bye"
|
||||
assert ttl == 0 # forced to 0 — message will stay in place
|
||||
|
||||
|
||||
def test_unwrap_ephemeral_default_ttl_from_config():
|
||||
adapter = _delete_adapter()
|
||||
with patch.object(adapter, "_get_ephemeral_system_ttl_default", return_value=120):
|
||||
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
|
||||
assert text == "bye"
|
||||
assert ttl == 120
|
||||
|
||||
|
||||
def test_unwrap_ephemeral_default_ttl_zero_disables():
|
||||
"""Config default of 0 (the shipped default) means the feature is off."""
|
||||
adapter = _delete_adapter()
|
||||
with patch.object(adapter, "_get_ephemeral_system_ttl_default", return_value=0):
|
||||
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
|
||||
assert text == "bye"
|
||||
assert ttl == 0
|
||||
|
||||
|
||||
def test_unwrap_ephemeral_handles_unreadable_config():
|
||||
adapter = _delete_adapter()
|
||||
with patch.object(
|
||||
adapter,
|
||||
"_get_ephemeral_system_ttl_default",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
text, ttl = adapter._unwrap_ephemeral(EphemeralReply("bye"))
|
||||
# Fall back to 0 rather than crashing the handler pipeline.
|
||||
assert text == "bye"
|
||||
assert ttl == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _schedule_ephemeral_delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_ephemeral_delete_calls_delete_after_ttl():
|
||||
adapter = _delete_adapter()
|
||||
# Use a very short TTL to keep the test fast — the implementation
|
||||
# floors sleeps at 1s via ``max(1, int(ttl_seconds))``. Patch asyncio.sleep
|
||||
# inside the module under test; the test body uses the real one for
|
||||
# scheduler pumping.
|
||||
import gateway.platforms.base as base_module
|
||||
|
||||
sleeps: list[float] = []
|
||||
_real_sleep = base_module.asyncio.sleep
|
||||
|
||||
async def _fake_sleep(duration):
|
||||
sleeps.append(duration)
|
||||
# Yield control so the rest of the task body can run.
|
||||
await _real_sleep(0)
|
||||
|
||||
with patch.object(base_module.asyncio, "sleep", _fake_sleep):
|
||||
adapter._schedule_ephemeral_delete(
|
||||
chat_id="42", message_id="m-2", ttl_seconds=5
|
||||
)
|
||||
# Let the spawned task run.
|
||||
for _ in range(5):
|
||||
await _real_sleep(0)
|
||||
|
||||
# Only the ttl sleep shows up — the test pump uses the real sleep.
|
||||
assert 5 in sleeps
|
||||
assert adapter.deleted == [("42", "m-2")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_ephemeral_delete_swallows_errors():
|
||||
adapter = _delete_adapter()
|
||||
|
||||
async def _boom(*a, **kw):
|
||||
raise RuntimeError("permission denied")
|
||||
|
||||
adapter.delete_message = _boom # type: ignore[assignment]
|
||||
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()):
|
||||
adapter._schedule_ephemeral_delete(
|
||||
chat_id="42", message_id="m-2", ttl_seconds=1
|
||||
)
|
||||
# No exception should propagate even though delete_message raised.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
def test_schedule_ephemeral_delete_outside_event_loop_is_noop():
|
||||
"""No running loop → no crash, silently drops the request."""
|
||||
adapter = _delete_adapter()
|
||||
# No pytest.mark.asyncio → no loop. Must not raise.
|
||||
adapter._schedule_ephemeral_delete(
|
||||
chat_id="42", message_id="m-2", ttl_seconds=1
|
||||
)
|
||||
assert adapter.deleted == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _process_message_background unwraps EphemeralReply before send
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_unwraps_ephemeral_before_send():
|
||||
"""The adapter must send the wrapper's .text, never the wrapper object."""
|
||||
adapter = _delete_adapter()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="sent-1")
|
||||
)
|
||||
|
||||
async def _handler(evt):
|
||||
return EphemeralReply("⚡ Stopped.", ttl_seconds=5)
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
sleeps: list[float] = []
|
||||
|
||||
async def _fake_sleep(duration):
|
||||
sleeps.append(duration)
|
||||
|
||||
event = _make_event()
|
||||
session_key = "agent:main:telegram:private:42"
|
||||
with patch("gateway.platforms.base.asyncio.sleep", _fake_sleep), patch.object(
|
||||
adapter, "_keep_typing", new=AsyncMock()
|
||||
):
|
||||
await adapter._process_message_background(event, session_key)
|
||||
# Pump until the detached delete task completes.
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Sent text is the unwrapped string, NOT repr(EphemeralReply(...))
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
sent_text = adapter._send_with_retry.call_args.kwargs["content"]
|
||||
assert sent_text == "⚡ Stopped."
|
||||
# Auto-delete scheduled using the returned message_id
|
||||
assert ("42", "sent-1") in adapter.deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_incapable_platform_does_not_schedule_delete():
|
||||
adapter = _no_delete_adapter()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="sent-1")
|
||||
)
|
||||
|
||||
async def _handler(evt):
|
||||
return EphemeralReply("⚡ Stopped.", ttl_seconds=5)
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
# Spy on delete_message to confirm it is NOT invoked.
|
||||
delete_calls: list = []
|
||||
|
||||
async def _spy_delete(chat_id, message_id):
|
||||
delete_calls.append((chat_id, message_id))
|
||||
return False
|
||||
|
||||
adapter.delete_message = _spy_delete # type: ignore[assignment]
|
||||
|
||||
event = _make_event()
|
||||
session_key = "agent:main:telegram:private:42"
|
||||
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
|
||||
adapter, "_keep_typing", new=AsyncMock()
|
||||
):
|
||||
await adapter._process_message_background(event, session_key)
|
||||
for _ in range(10):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Send happened with the unwrapped text...
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
assert adapter._send_with_retry.call_args.kwargs["content"] == "⚡ Stopped."
|
||||
# ...but delete was never scheduled because the capability check skipped
|
||||
# the schedule call (TTL was zeroed in _unwrap_ephemeral).
|
||||
# Note: the capability gate on _unwrap_ephemeral checks for
|
||||
# ``type(adapter).delete_message is BasePlatformAdapter.delete_message``.
|
||||
# Monkeypatching the instance does NOT change the class, so this test
|
||||
# verifies the gate uses the class method to detect capability.
|
||||
assert delete_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_message_plain_string_behaves_unchanged():
|
||||
adapter = _delete_adapter()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="sent-1")
|
||||
)
|
||||
|
||||
async def _handler(evt):
|
||||
return "plain reply"
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
|
||||
event = _make_event()
|
||||
session_key = "agent:main:telegram:private:42"
|
||||
with patch("gateway.platforms.base.asyncio.sleep", AsyncMock()), patch.object(
|
||||
adapter, "_keep_typing", new=AsyncMock()
|
||||
):
|
||||
await adapter._process_message_background(event, session_key)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
adapter._send_with_retry.assert_called_once()
|
||||
assert adapter._send_with_retry.call_args.kwargs["content"] == "plain reply"
|
||||
assert adapter.deleted == [] # no auto-delete for plain replies
|
||||
+258
-115
@@ -8,6 +8,7 @@ import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from gateway.platforms.base import ProcessingOutcome
|
||||
@@ -557,6 +558,16 @@ class TestAdapterModule(unittest.TestCase):
|
||||
self.assertEqual(fake_client._ping_interval, 4)
|
||||
|
||||
|
||||
def _admits_group(adapter, message, sender_id, chat_id=""):
|
||||
"""Group-path shim: run a message through ``_admit`` and return a bool."""
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
if not hasattr(message, "chat_type"):
|
||||
message.chat_type = "group"
|
||||
if chat_id:
|
||||
message.chat_id = chat_id
|
||||
return adapter._admit(sender, message) is None
|
||||
|
||||
|
||||
class TestAdapterBehavior(unittest.TestCase):
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_event_handler_registers_reaction_and_card_processors(self):
|
||||
@@ -689,6 +700,67 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
adapter._on_reaction_event("im.message.reaction.created_v1", data)
|
||||
run_threadsafe.assert_called_once()
|
||||
|
||||
def _build_reaction_adapter(self, *, msg_sender_id: str):
|
||||
"""Build a FeishuAdapter wired up to return a single GET-message result."""
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._app_id = "cli_self_app"
|
||||
adapter._bot_open_id = "ou_self_bot"
|
||||
adapter._bot_user_id = "u_self_bot"
|
||||
|
||||
msg = SimpleNamespace(
|
||||
sender=SimpleNamespace(sender_type="app", id=msg_sender_id, id_type="app_id"),
|
||||
chat_id="oc_chat",
|
||||
chat_type="group",
|
||||
)
|
||||
response = SimpleNamespace(success=lambda: True, data=SimpleNamespace(items=[msg]))
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(message=SimpleNamespace(get=Mock(return_value=response)))
|
||||
)
|
||||
)
|
||||
adapter._build_get_message_request = Mock(return_value=object())
|
||||
adapter._handle_message_with_guards = AsyncMock()
|
||||
adapter._resolve_sender_profile = AsyncMock(
|
||||
return_value={"user_id": "u_human", "user_name": "Human", "user_id_alt": None}
|
||||
)
|
||||
adapter.get_chat_info = AsyncMock(return_value={"name": "Test Chat"})
|
||||
return adapter
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_reaction_on_peer_bot_message_is_not_routed(self):
|
||||
# GET im/v1/messages sender for bot messages carries id=app_id; a peer
|
||||
# bot's message has a different app_id than ours, so it must be dropped.
|
||||
adapter = self._build_reaction_adapter(msg_sender_id="cli_peer_app")
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_peer_msg",
|
||||
user_id=SimpleNamespace(open_id="ou_human", user_id=None, union_id=None),
|
||||
reaction_type=SimpleNamespace(emoji_type="THUMBSUP"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
asyncio.run(
|
||||
adapter._handle_reaction_event("im.message.reaction.created_v1", data)
|
||||
)
|
||||
adapter._handle_message_with_guards.assert_not_awaited()
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_reaction_on_our_own_bot_message_is_routed(self):
|
||||
adapter = self._build_reaction_adapter(msg_sender_id="cli_self_app")
|
||||
|
||||
event = SimpleNamespace(
|
||||
message_id="om_self_msg",
|
||||
user_id=SimpleNamespace(open_id="ou_human", user_id=None, union_id=None),
|
||||
reaction_type=SimpleNamespace(emoji_type="THUMBSUP"),
|
||||
)
|
||||
data = SimpleNamespace(event=event)
|
||||
asyncio.run(
|
||||
adapter._handle_reaction_event("im.message.reaction.created_v1", data)
|
||||
)
|
||||
adapter._handle_message_with_guards.assert_awaited_once()
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_requires_mentions_even_when_policy_open(self):
|
||||
from gateway.config import PlatformConfig
|
||||
@@ -697,10 +769,10 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
message = SimpleNamespace(mentions=[])
|
||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||
self.assertFalse(adapter._should_accept_group_message(message, sender_id, ""))
|
||||
self.assertFalse(_admits_group(adapter, message, sender_id, ""))
|
||||
|
||||
message_with_mention = SimpleNamespace(mentions=[SimpleNamespace(key="@_user_1")])
|
||||
self.assertFalse(adapter._should_accept_group_message(message_with_mention, sender_id, ""))
|
||||
self.assertFalse(_admits_group(adapter, message_with_mention, sender_id, ""))
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_with_other_user_mention_is_rejected_when_bot_identity_unknown(self):
|
||||
@@ -714,59 +786,10 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
|
||||
)
|
||||
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_other_bot_sender_is_not_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_other_bot", user_id="u_other_bot"),
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(event))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FEISHU_BOT_OPEN_ID": "ou_hermes",
|
||||
"FEISHU_BOT_USER_ID": "u_hermes",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
def test_self_bot_sender_is_treated_as_self_sent_message(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
by_open_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id="u_other"),
|
||||
)
|
||||
)
|
||||
by_user_id = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="app",
|
||||
sender_id=SimpleNamespace(open_id="ou_other", user_id="u_hermes"),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_open_id))
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(by_user_id))
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
@@ -792,14 +815,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
mentioned,
|
||||
SimpleNamespace(open_id="ou_allowed", user_id=None),
|
||||
"",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
mentioned,
|
||||
SimpleNamespace(open_id="ou_blocked", user_id=None),
|
||||
"",
|
||||
@@ -828,14 +851,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_alice", user_id=None),
|
||||
"oc_chat_a",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_charlie", user_id=None),
|
||||
"oc_chat_a",
|
||||
@@ -864,14 +887,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_alice", user_id=None),
|
||||
"oc_chat_b",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_blocked", user_id=None),
|
||||
"oc_chat_b",
|
||||
@@ -900,14 +923,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_c",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_regular", user_id=None),
|
||||
"oc_chat_c",
|
||||
@@ -936,14 +959,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_d",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_regular", user_id=None),
|
||||
"oc_chat_d",
|
||||
@@ -973,7 +996,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_admin", user_id=None),
|
||||
"oc_chat_e",
|
||||
@@ -997,7 +1020,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
adapter._should_accept_group_message(
|
||||
_admits_group(adapter,
|
||||
message,
|
||||
SimpleNamespace(open_id="ou_anyone", user_id=None),
|
||||
"oc_chat_unknown",
|
||||
@@ -1022,8 +1045,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
id=SimpleNamespace(open_id="ou_other", user_id="u_other"),
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[other_mention]), sender_id, ""))
|
||||
self.assertTrue(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[bot_mention]), sender_id, "")
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[other_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "open"}, clear=True)
|
||||
def test_group_message_matches_bot_name_when_only_name_available(self):
|
||||
@@ -1048,8 +1075,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
id=SimpleNamespace(open_id=None, user_id=None),
|
||||
)
|
||||
|
||||
self.assertTrue(adapter._should_accept_group_message(SimpleNamespace(mentions=[name_only_mention]), sender_id, ""))
|
||||
self.assertFalse(adapter._should_accept_group_message(SimpleNamespace(mentions=[different_mention]), sender_id, ""))
|
||||
self.assertTrue(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[name_only_mention]), sender_id, "")
|
||||
)
|
||||
self.assertFalse(
|
||||
_admits_group(adapter, SimpleNamespace(mentions=[different_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
# Case 2: bot's open_id IS known — a same-name human with different
|
||||
# open_id must NOT admit (IDs override names).
|
||||
@@ -1066,8 +1097,17 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
id=SimpleNamespace(open_id="ou_bot", user_id=None),
|
||||
)
|
||||
|
||||
self.assertFalse(adapter2._should_accept_group_message(SimpleNamespace(mentions=[same_name_other_id_mention]), sender_id, ""))
|
||||
self.assertTrue(adapter2._should_accept_group_message(SimpleNamespace(mentions=[bot_mention]), sender_id, ""))
|
||||
self.assertFalse(
|
||||
_admits_group(
|
||||
adapter2,
|
||||
SimpleNamespace(mentions=[same_name_other_id_mention]),
|
||||
sender_id,
|
||||
"",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
_admits_group(adapter2, SimpleNamespace(mentions=[bot_mention]), sender_id, "")
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_extract_post_message_as_text(self):
|
||||
@@ -1411,6 +1451,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
data=SimpleNamespace(event=SimpleNamespace(message=message)),
|
||||
message=message,
|
||||
sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
|
||||
is_bot=False,
|
||||
chat_type="p2p",
|
||||
message_id="om_command",
|
||||
)
|
||||
@@ -1522,13 +1563,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
user_id="u_user",
|
||||
union_id="on_union",
|
||||
)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message, sender=SimpleNamespace(sender_id=sender_id)))
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message, sender=sender))
|
||||
|
||||
asyncio.run(
|
||||
adapter._process_inbound_message(
|
||||
data=data,
|
||||
message=message,
|
||||
sender_id=sender_id,
|
||||
sender_id=sender.sender_id,
|
||||
chat_type="p2p",
|
||||
message_id="om_text",
|
||||
)
|
||||
@@ -1761,13 +1803,14 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
message_id="om_group_text",
|
||||
)
|
||||
sender_id = SimpleNamespace(open_id="ou_user", user_id=None, union_id=None)
|
||||
sender = SimpleNamespace(sender_type="user", sender_id=sender_id)
|
||||
data = SimpleNamespace(event=SimpleNamespace(message=message))
|
||||
|
||||
asyncio.run(
|
||||
adapter._process_inbound_message(
|
||||
data=data,
|
||||
message=message,
|
||||
sender_id=sender_id,
|
||||
sender_id=sender.sender_id,
|
||||
chat_type="group",
|
||||
message_id="om_group_text",
|
||||
)
|
||||
@@ -1805,6 +1848,7 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
data=SimpleNamespace(event=SimpleNamespace(message=message)),
|
||||
message=message,
|
||||
sender_id=SimpleNamespace(open_id="ou_user", user_id=None, union_id=None),
|
||||
is_bot=False,
|
||||
chat_type="p2p",
|
||||
message_id="om_reply",
|
||||
)
|
||||
@@ -2667,11 +2711,12 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestHydrateBotIdentity(unittest.TestCase):
|
||||
"""Hydration of bot identity via /open-apis/bot/v3/info and application info.
|
||||
"""Hydration of bot identity via ``/open-apis/bot/v3/info``.
|
||||
|
||||
Covers the manual-setup path where FEISHU_BOT_OPEN_ID / FEISHU_BOT_USER_ID
|
||||
are not configured. Hydration must populate _bot_open_id so that
|
||||
_is_self_sent_bot_message() can filter the adapter's own outbound echoes.
|
||||
Covers the manual-setup path where ``FEISHU_BOT_OPEN_ID`` /
|
||||
``FEISHU_BOT_NAME`` are not configured — hydration populates them so
|
||||
self-echo protection and group @mention gating both have something to
|
||||
match against.
|
||||
"""
|
||||
|
||||
def _make_adapter(self):
|
||||
@@ -2700,11 +2745,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
||||
|
||||
self.assertEqual(adapter._bot_open_id, "ou_hermes_hydrated")
|
||||
self.assertEqual(adapter._bot_name, "Hermes Bot")
|
||||
# Application-info fallback must NOT run when bot_name is already set.
|
||||
self.assertFalse(
|
||||
adapter._client.application.v6.application.get.called
|
||||
if hasattr(adapter._client, "application") else False
|
||||
)
|
||||
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
@@ -2721,7 +2761,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
# Neither probe should run — both fields are already populated.
|
||||
adapter._client.request.assert_not_called()
|
||||
self.assertEqual(adapter._bot_open_id, "ou_env")
|
||||
self.assertEqual(adapter._bot_name, "Env Hermes")
|
||||
@@ -2766,33 +2805,6 @@ class TestHydrateBotIdentity(unittest.TestCase):
|
||||
self.assertEqual(adapter._bot_open_id, "")
|
||||
self.assertEqual(adapter._bot_name, "Fallback Bot")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_hydrated_open_id_enables_self_send_filter(self):
|
||||
"""E2E: after hydration, _is_self_sent_bot_message() rejects adapter's own id."""
|
||||
adapter = self._make_adapter()
|
||||
adapter._client = Mock()
|
||||
payload = json.dumps(
|
||||
{"code": 0, "bot": {"bot_name": "Hermes", "open_id": "ou_hermes"}}
|
||||
).encode("utf-8")
|
||||
adapter._client.request = Mock(return_value=SimpleNamespace(raw=SimpleNamespace(content=payload)))
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
self_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_hermes", user_id=""),
|
||||
)
|
||||
)
|
||||
peer_event = SimpleNamespace(
|
||||
sender=SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(open_id="ou_peer_bot", user_id=""),
|
||||
)
|
||||
)
|
||||
self.assertTrue(adapter._is_self_sent_bot_message(self_event))
|
||||
self.assertFalse(adapter._is_self_sent_bot_message(peer_event))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestPendingInboundQueue(unittest.TestCase):
|
||||
@@ -3137,7 +3149,7 @@ class TestGroupMentionAtAll(unittest.TestCase):
|
||||
mentions=[],
|
||||
)
|
||||
sender_id = SimpleNamespace(open_id="ou_any", user_id=None)
|
||||
self.assertTrue(adapter._should_accept_group_message(message, sender_id, ""))
|
||||
self.assertTrue(_admits_group(adapter, message, sender_id, ""))
|
||||
|
||||
@patch.dict(os.environ, {"FEISHU_GROUP_POLICY": "allowlist", "FEISHU_ALLOWED_USERS": "ou_allowed"}, clear=True)
|
||||
def test_at_all_still_requires_policy_gate(self):
|
||||
@@ -3149,15 +3161,15 @@ class TestGroupMentionAtAll(unittest.TestCase):
|
||||
message = SimpleNamespace(content='{"text":"@_all attention"}', mentions=[])
|
||||
# Non-allowlisted user — should be blocked even with @_all.
|
||||
blocked_sender = SimpleNamespace(open_id="ou_blocked", user_id=None)
|
||||
self.assertFalse(adapter._should_accept_group_message(message, blocked_sender, ""))
|
||||
self.assertFalse(_admits_group(adapter, message, blocked_sender, ""))
|
||||
# Allowlisted user — should pass.
|
||||
allowed_sender = SimpleNamespace(open_id="ou_allowed", user_id=None)
|
||||
self.assertTrue(adapter._should_accept_group_message(message, allowed_sender, ""))
|
||||
self.assertTrue(_admits_group(adapter, message, allowed_sender, ""))
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestSenderNameResolution(unittest.TestCase):
|
||||
"""Tests for _resolve_sender_name_from_api."""
|
||||
"""Tests for _resolve_sender_name_from_api (contact API + cache)."""
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_returns_none_when_client_is_none(self):
|
||||
@@ -3261,6 +3273,137 @@ class TestSenderNameResolution(unittest.TestCase):
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestBotNameResolution(unittest.TestCase):
|
||||
"""Tests for the bot branch of _resolve_sender_name_from_api (basic_batch API + shared cache)."""
|
||||
|
||||
@staticmethod
|
||||
def _batch_payload(bots: Dict[str, str]):
|
||||
import json as _json
|
||||
body = {
|
||||
oid: {"bot_id": oid, "name": name, "i18n_names": {"en_us": name}}
|
||||
for oid, name in bots.items()
|
||||
}
|
||||
return _json.dumps({"code": 0, "msg": "", "data": {"bots": body, "failed_bots": {}}}).encode()
|
||||
|
||||
def _build_adapter_with_bots(self, bots: Dict[str, str]):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
calls = []
|
||||
|
||||
def _fake_request(request):
|
||||
calls.append(request)
|
||||
return SimpleNamespace(raw=SimpleNamespace(content=self._batch_payload(bots)))
|
||||
|
||||
adapter._client = SimpleNamespace(request=_fake_request)
|
||||
return adapter, calls
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_returns_cached_bot_name_without_api_call(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
adapter._sender_name_cache["ou_peer"] = ("Peer Bot", time.time() + 600)
|
||||
adapter._client = SimpleNamespace(
|
||||
request=lambda _r: (_ for _ in ()).throw(RuntimeError("should not fetch"))
|
||||
)
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
self.assertEqual(result, "Peer Bot")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_fetches_and_caches_bot_name(self):
|
||||
adapter, calls = self._build_adapter_with_bots({"ou_peer": "Peer Bot"})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertEqual(result, "Peer Bot")
|
||||
self.assertEqual(adapter._sender_name_cache["ou_peer"][0], "Peer Bot")
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertIn("/open-apis/bot/v3/bots/basic_batch", calls[0].uri)
|
||||
# Feishu expects repeated ?bot_ids= params, not comma-joined.
|
||||
self.assertEqual(calls[0].queries, [("bot_ids", "ou_peer")])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_api_failure_returns_none_and_does_not_poison_cache(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
|
||||
def _broken_request(_req):
|
||||
raise RuntimeError("API down")
|
||||
|
||||
adapter._client = SimpleNamespace(request=_broken_request)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_peer", adapter._sender_name_cache)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_bot_absent_from_response_is_not_cached(self):
|
||||
"""Bot not in ``data.bots`` (e.g. landed in ``failed_bots``) → no
|
||||
cache entry, next lookup re-fetches."""
|
||||
adapter, _ = self._build_adapter_with_bots({"ou_other": "Other Bot"})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_ghost", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_ghost", adapter._sender_name_cache)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_empty_name_in_response_is_negative_cached(self):
|
||||
"""API returns name="" → cache "" so repeat lookups short-circuit."""
|
||||
adapter, calls = self._build_adapter_with_bots({"ou_nameless": ""})
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
first = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True))
|
||||
second = asyncio.run(adapter._resolve_sender_name_from_api("ou_nameless", is_bot=True))
|
||||
|
||||
self.assertIsNone(first)
|
||||
self.assertIsNone(second)
|
||||
self.assertEqual(adapter._sender_name_cache["ou_nameless"][0], "")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_non_zero_code_returns_none(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
error_payload = b'{"code":99991663,"msg":"permission denied"}'
|
||||
adapter._client = SimpleNamespace(
|
||||
request=lambda _r: SimpleNamespace(raw=SimpleNamespace(content=error_payload))
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("gateway.platforms.feishu.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(adapter._resolve_sender_name_from_api("ou_peer", is_bot=True))
|
||||
|
||||
self.assertIsNone(result)
|
||||
self.assertNotIn("ou_peer", adapter._sender_name_cache)
|
||||
|
||||
|
||||
@unittest.skipUnless(_HAS_LARK_OAPI, "lark-oapi not installed")
|
||||
class TestProcessingReactions(unittest.TestCase):
|
||||
"""Typing on start → removed on SUCCESS, swapped for CrossMark on FAILURE,
|
||||
|
||||
@@ -0,0 +1,745 @@
|
||||
"""Adapter-layer tests for Feishu bot-sender admission (``FeishuAdapter._admit``)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.gateway.feishu_helpers import (
|
||||
install_dedup_state,
|
||||
make_adapter_skeleton,
|
||||
make_message,
|
||||
make_sender,
|
||||
stub_mention,
|
||||
)
|
||||
|
||||
|
||||
# --- FeishuAdapterSettings wiring ------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_value, expected",
|
||||
[
|
||||
("none", "none"),
|
||||
("mentions", "mentions"),
|
||||
("all", "all"),
|
||||
(" Mentions ", "mentions"),
|
||||
],
|
||||
)
|
||||
def test_feishu_load_settings_populates_allow_bots(monkeypatch, env_value, expected):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", env_value)
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra={})
|
||||
assert settings.allow_bots == expected
|
||||
|
||||
|
||||
def test_feishu_load_settings_allow_bots_defaults_to_none(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra={})
|
||||
assert settings.allow_bots == "none"
|
||||
|
||||
|
||||
def test_feishu_load_settings_ignores_extra_allow_bots(monkeypatch):
|
||||
# extra is ignored — env is single source of truth (yaml is bridged to env).
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.delenv("FEISHU_ALLOW_BOTS", raising=False)
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra={"allow_bots": "all"})
|
||||
assert settings.allow_bots == "none"
|
||||
|
||||
|
||||
def test_feishu_load_settings_falls_back_to_env_when_extra_missing(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "mentions")
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra={})
|
||||
assert settings.allow_bots == "mentions"
|
||||
|
||||
|
||||
def test_feishu_load_settings_warns_on_unknown_allow_bots(monkeypatch, caplog):
|
||||
import logging
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "menton") # typo
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="gateway.platforms.feishu"):
|
||||
settings = FeishuAdapter._load_settings(extra={})
|
||||
|
||||
assert settings.allow_bots == "none"
|
||||
assert any("allow_bots" in r.message and "menton" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"env_value, extra, expected",
|
||||
[
|
||||
(None, {}, True),
|
||||
("false", {}, False),
|
||||
("true", {}, True),
|
||||
("true", {"require_mention": False}, False),
|
||||
],
|
||||
)
|
||||
def test_feishu_load_settings_require_mention(monkeypatch, env_value, extra, expected):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
if env_value is None:
|
||||
monkeypatch.delenv("FEISHU_REQUIRE_MENTION", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("FEISHU_REQUIRE_MENTION", env_value)
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra=extra)
|
||||
assert settings.require_mention is expected
|
||||
|
||||
|
||||
def test_feishu_load_settings_parses_per_group_require_mention(monkeypatch):
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "cli_test")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret_test")
|
||||
|
||||
settings = FeishuAdapter._load_settings(extra={
|
||||
"group_rules": {
|
||||
"oc_free": {"policy": "open", "require_mention": False},
|
||||
"oc_strict": {"policy": "open", "require_mention": True},
|
||||
"oc_inherit": {"policy": "open"},
|
||||
},
|
||||
})
|
||||
assert settings.group_rules["oc_free"].require_mention is False
|
||||
assert settings.group_rules["oc_strict"].require_mention is True
|
||||
assert settings.group_rules["oc_inherit"].require_mention is None
|
||||
|
||||
|
||||
# --- Module-level helpers --------------------------------------------------
|
||||
|
||||
|
||||
def test_sender_identity_collects_every_non_empty_id_variant():
|
||||
from gateway.platforms.feishu import _sender_identity
|
||||
|
||||
sender = SimpleNamespace(
|
||||
sender_id=SimpleNamespace(open_id="ou_x", user_id="", union_id="un_x"),
|
||||
)
|
||||
assert _sender_identity(sender) == frozenset({"ou_x", "un_x"})
|
||||
|
||||
|
||||
def test_sender_identity_handles_missing_sender_id():
|
||||
from gateway.platforms.feishu import _sender_identity
|
||||
|
||||
assert _sender_identity(SimpleNamespace()) == frozenset()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sender_type", ["bot", "app"])
|
||||
def test_is_bot_sender_treats_bot_and_app_as_bot_origin(sender_type):
|
||||
from gateway.platforms.feishu import _is_bot_sender
|
||||
|
||||
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sender_type", ["user", "", None])
|
||||
def test_is_bot_sender_rejects_non_bot_origin(sender_type):
|
||||
from gateway.platforms.feishu import _is_bot_sender
|
||||
|
||||
assert _is_bot_sender(SimpleNamespace(sender_type=sender_type)) is False
|
||||
|
||||
|
||||
# --- _admit pipeline matrix ------------------------------------------------
|
||||
#
|
||||
# Covers the four-step admission pipeline (self_echo → bot_policy →
|
||||
# DM bypass → group_policy + mention) as a single result-only matrix.
|
||||
# Each row pins one decision in the pipeline; tests asserting call-count
|
||||
# semantics live below in their own functions.
|
||||
|
||||
|
||||
def _admit_case(
|
||||
*,
|
||||
adapter: dict | None = None,
|
||||
sender: dict | None = None,
|
||||
message: dict | None = None,
|
||||
mentions_self: bool | None = None,
|
||||
expected: str | None = None,
|
||||
):
|
||||
return {
|
||||
"adapter": adapter or {},
|
||||
"sender": sender or {},
|
||||
"message": message or {},
|
||||
"mentions_self": mentions_self,
|
||||
"expected": expected,
|
||||
}
|
||||
|
||||
|
||||
_ADMIT_CASES = [
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_me", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_me"},
|
||||
expected="self_echo",
|
||||
),
|
||||
id="self_echo:open_id_under_all_mode",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "", "bot_user_id": "u_me", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": None, "user_id": "u_me"},
|
||||
expected="self_echo",
|
||||
),
|
||||
id="self_echo:user_id_only",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_me", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_me", "user_id": "u_me", "union_id": "un_me"},
|
||||
expected="self_echo",
|
||||
),
|
||||
id="self_echo:mixed_ids",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "bot_user_id": "u_self", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": None, "user_id": "u_self"},
|
||||
expected="self_echo",
|
||||
),
|
||||
id="self_echo:user_id_when_bot_user_id_set",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "none"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
expected="bots_disabled",
|
||||
),
|
||||
id="bots_disabled:mode_none",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": ""},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
expected="bots_disabled",
|
||||
),
|
||||
id="bots_disabled:mode_empty",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "loose"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
expected="bots_disabled",
|
||||
),
|
||||
id="bots_disabled:mode_unknown_value",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "", "allow_bots": "none"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
expected="bots_disabled",
|
||||
),
|
||||
id="bots_disabled:wins_over_self_ids_unknown",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
expected="self_ids_unknown",
|
||||
),
|
||||
id="self_ids_unknown:bot_sender_no_self_ids",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "", "allow_bots": "all"},
|
||||
sender={"sender_type": "app", "open_id": "ou_peer"},
|
||||
expected="self_ids_unknown",
|
||||
),
|
||||
id="self_ids_unknown:app_sender_no_self_ids",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
|
||||
sender={"sender_type": "app", "open_id": None},
|
||||
expected="self_ids_unknown",
|
||||
),
|
||||
id="self_ids_unknown:no_sender_ids",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "mentions"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
mentions_self=False,
|
||||
expected="bot_not_mentioned",
|
||||
),
|
||||
id="mentions_mode:not_mentioned_dm",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "mentions"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
mentions_self=True,
|
||||
expected=None,
|
||||
),
|
||||
id="mentions_mode:mentioned_dm",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
mentions_self=False,
|
||||
expected=None,
|
||||
),
|
||||
id="all_mode:not_mentioned_dm",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "ou_self", "allow_bots": "all"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
mentions_self=True,
|
||||
expected=None,
|
||||
),
|
||||
id="all_mode:mentioned_dm",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"bot_open_id": "", "allow_bots": "none"},
|
||||
sender={"sender_type": "user", "open_id": "ou_human"},
|
||||
expected=None,
|
||||
),
|
||||
id="human:dm_admitted_regardless_of_allow_bots",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={"allow_bots": "all"},
|
||||
sender={"sender_type": "user", "open_id": "ou_human"},
|
||||
message={"message_id": "om_ok", "chat_type": "p2p"},
|
||||
expected=None,
|
||||
),
|
||||
id="human:p2p_admitted",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={
|
||||
"bot_open_id": "ou_self",
|
||||
"require_mention": False,
|
||||
"group_policy": "open",
|
||||
},
|
||||
sender={"sender_type": "user", "open_id": "ou_human"},
|
||||
message={"chat_type": "group"},
|
||||
mentions_self=False,
|
||||
expected=None,
|
||||
),
|
||||
id="require_mention_false:group_human_no_mention_admitted",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={
|
||||
"bot_open_id": "ou_self",
|
||||
"allow_bots": "all",
|
||||
"require_mention": False,
|
||||
"group_policy": "open",
|
||||
},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
message={"chat_type": "group"},
|
||||
mentions_self=False,
|
||||
expected=None,
|
||||
),
|
||||
id="require_mention_false:group_bot_all_mode_admitted",
|
||||
),
|
||||
pytest.param(
|
||||
_admit_case(
|
||||
adapter={
|
||||
"bot_open_id": "ou_self",
|
||||
"allow_bots": "mentions",
|
||||
"require_mention": False,
|
||||
"group_policy": "open",
|
||||
},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
message={"chat_type": "group"},
|
||||
mentions_self=False,
|
||||
expected="bot_not_mentioned",
|
||||
),
|
||||
id="require_mention_false:group_bot_mentions_mode_still_gated",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _ADMIT_CASES)
|
||||
def test_admit_pipeline(case):
|
||||
adapter = make_adapter_skeleton(**case["adapter"])
|
||||
if case["mentions_self"] is not None:
|
||||
stub_mention(adapter, case["mentions_self"])
|
||||
sender = make_sender(**case["sender"])
|
||||
message = make_message(**case["message"])
|
||||
assert adapter._admit(sender, message) == case["expected"]
|
||||
|
||||
|
||||
# --- Mention call-count semantics ------------------------------------------
|
||||
|
||||
|
||||
def test_admit_skips_mention_check_under_all_mode():
|
||||
# Tripwire: under allow_bots=all the mention path must not be probed.
|
||||
adapter = make_adapter_skeleton(bot_open_id="ou_self", allow_bots="all")
|
||||
calls = 0
|
||||
|
||||
def _tripwire(_message):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return False
|
||||
|
||||
adapter._mentions_self = _tripwire
|
||||
|
||||
sender = make_sender(sender_type="bot", open_id="ou_peer")
|
||||
assert adapter._admit(sender, make_message()) is None
|
||||
assert calls == 0
|
||||
|
||||
|
||||
def test_admit_group_mention_checked_once_per_call():
|
||||
# Stage 2 (mentions mode) and stage 4 (group require_mention) must not
|
||||
# double-evaluate _mentions_self for the same admit call.
|
||||
adapter = make_adapter_skeleton(
|
||||
bot_open_id="ou_self", allow_bots="mentions", require_mention=True,
|
||||
group_policy="open",
|
||||
)
|
||||
calls = 0
|
||||
|
||||
def _counting(_message):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return True
|
||||
|
||||
adapter._mentions_self = _counting
|
||||
|
||||
sender = make_sender(sender_type="bot", open_id="ou_peer")
|
||||
assert adapter._admit(sender, make_message(chat_type="group")) is None
|
||||
assert calls == 1
|
||||
|
||||
|
||||
# --- Per-group require_mention override ------------------------------------
|
||||
|
||||
|
||||
def test_admit_per_group_require_mention_overrides_global():
|
||||
from gateway.platforms.feishu import FeishuGroupRule
|
||||
|
||||
adapter = make_adapter_skeleton(
|
||||
bot_open_id="ou_self", require_mention=True, group_policy="open",
|
||||
)
|
||||
adapter._group_rules = {
|
||||
"oc_free": FeishuGroupRule(policy="open", require_mention=False),
|
||||
}
|
||||
stub_mention(adapter, False)
|
||||
|
||||
sender = make_sender(sender_type="user", open_id="ou_human")
|
||||
assert adapter._admit(sender, make_message(chat_id="oc_free", chat_type="group")) is None
|
||||
assert (
|
||||
adapter._admit(sender, make_message(chat_id="oc_other", chat_type="group"))
|
||||
== "group_policy_rejected"
|
||||
)
|
||||
|
||||
|
||||
# --- Hydration -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hydrate_bot_identity_populates_self_ids_from_bot_v3_info(monkeypatch):
|
||||
import asyncio
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._bot_open_id = ""
|
||||
adapter._bot_user_id = ""
|
||||
adapter._bot_name = ""
|
||||
adapter._allow_bots = "all"
|
||||
|
||||
captured = {}
|
||||
|
||||
def _fake_request(request):
|
||||
captured["uri"] = getattr(request, "uri", None)
|
||||
captured["http_method"] = getattr(request, "http_method", None)
|
||||
return SimpleNamespace(raw=SimpleNamespace(
|
||||
content=b'{"code":0,"bot":{"app_name":"Hermes","open_id":"ou_hydrated"}}'
|
||||
))
|
||||
|
||||
adapter._client = SimpleNamespace(request=_fake_request)
|
||||
|
||||
asyncio.run(adapter._hydrate_bot_identity())
|
||||
|
||||
assert captured["uri"] == "/open-apis/bot/v3/info"
|
||||
assert str(captured["http_method"]).endswith("GET")
|
||||
assert adapter._bot_open_id == "ou_hydrated"
|
||||
assert adapter._bot_name == "Hermes"
|
||||
# /bot/v3/info doesn't surface user_id, so _bot_user_id stays empty.
|
||||
assert adapter._bot_user_id == ""
|
||||
|
||||
|
||||
def test_resolve_sender_profile_uses_open_id_for_bot_name_lookup():
|
||||
import asyncio
|
||||
|
||||
from gateway.platforms.feishu import FeishuAdapter
|
||||
|
||||
adapter = object.__new__(FeishuAdapter)
|
||||
adapter._client = object()
|
||||
adapter._sender_name_cache = {}
|
||||
seen_ids = []
|
||||
|
||||
async def _fake_fetch_bot_names(bot_ids):
|
||||
seen_ids.extend(bot_ids)
|
||||
return {"ou_peer": "Peer Bot"}
|
||||
|
||||
adapter._fetch_bot_names = _fake_fetch_bot_names
|
||||
|
||||
profile = asyncio.run(
|
||||
adapter._resolve_sender_profile(
|
||||
SimpleNamespace(open_id="ou_peer", user_id="u_peer", union_id="on_peer"),
|
||||
is_bot=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert seen_ids == ["ou_peer"]
|
||||
assert profile["user_id"] == "u_peer"
|
||||
assert profile["user_name"] == "Peer Bot"
|
||||
|
||||
|
||||
# --- _allow_group_message matrix -------------------------------------------
|
||||
#
|
||||
# Bot-bypass semantics: admitted bots skip allowlist/blacklist (parallel
|
||||
# human-scope filters), but channel-level locks (disabled, admin_only) and
|
||||
# admin short-circuits still apply.
|
||||
|
||||
|
||||
def _group_case(
|
||||
*,
|
||||
adapter: dict | None = None,
|
||||
admins: set | None = None,
|
||||
group_rules: dict | None = None,
|
||||
sender: dict | None = None,
|
||||
chat_id: str = "oc_1",
|
||||
is_bot: bool = False,
|
||||
expected: bool = False,
|
||||
):
|
||||
return {
|
||||
"adapter": adapter or {},
|
||||
"admins": admins or set(),
|
||||
"group_rules": group_rules or {},
|
||||
"sender": sender or {},
|
||||
"chat_id": chat_id,
|
||||
"is_bot": is_bot,
|
||||
"expected": expected,
|
||||
}
|
||||
|
||||
|
||||
def _group_rule(policy: str, **kwargs):
|
||||
from gateway.platforms.feishu import FeishuGroupRule
|
||||
return FeishuGroupRule(policy=policy, **kwargs)
|
||||
|
||||
|
||||
_GROUP_CASES = [
|
||||
pytest.param(
|
||||
_group_case(
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
is_bot=True,
|
||||
expected=True,
|
||||
),
|
||||
id="bot:bypasses_default_allowlist",
|
||||
),
|
||||
pytest.param(
|
||||
_group_case(
|
||||
sender={"sender_type": "user", "open_id": "ou_stranger"},
|
||||
is_bot=False,
|
||||
expected=False,
|
||||
),
|
||||
id="human:gated_by_default_allowlist",
|
||||
),
|
||||
pytest.param(
|
||||
_group_case(
|
||||
admins={"ou_peer"},
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
is_bot=True,
|
||||
expected=True,
|
||||
),
|
||||
id="bot:admin_short_circuit",
|
||||
),
|
||||
pytest.param(
|
||||
_group_case(
|
||||
admins={"u_admin"},
|
||||
sender={"sender_type": "user", "open_id": None, "user_id": "u_admin"},
|
||||
is_bot=False,
|
||||
expected=True,
|
||||
),
|
||||
id="human:admin_via_user_id",
|
||||
),
|
||||
pytest.param(
|
||||
_group_case(
|
||||
sender={"sender_type": "bot", "open_id": "ou_peer"},
|
||||
is_bot=True,
|
||||
expected=True,
|
||||
),
|
||||
id="bot:allowlist_skipped",
|
||||
),
|
||||
pytest.param(
|
||||
_group_case(
|
||||
sender={"sender_type": "app", "open_id": "ou_peer"},
|
||||
is_bot=True,
|
||||
expected=True,
|
||||
),
|
||||
id="app:allowlist_skipped",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# Channel-lock cases need group_rules construction; keep them in a separate
|
||||
# parametrize so we can use _group_rule() (FeishuGroupRule import).
|
||||
_GROUP_RULE_CASES = [
|
||||
pytest.param(
|
||||
"disabled", "bot", False,
|
||||
id="bot:disabled_policy_blocks_even_with_bypass",
|
||||
),
|
||||
pytest.param(
|
||||
"disabled", "app", False,
|
||||
id="app:disabled_policy_blocks_even_with_bypass",
|
||||
),
|
||||
pytest.param(
|
||||
"admin_only", "bot", False,
|
||||
id="bot:admin_only_policy_blocks_non_admin",
|
||||
),
|
||||
pytest.param(
|
||||
"admin_only", "app", False,
|
||||
id="app:admin_only_policy_blocks_non_admin",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _GROUP_CASES)
|
||||
def test_allow_group_message_matrix(case):
|
||||
adapter = make_adapter_skeleton(**case["adapter"])
|
||||
adapter._admins = case["admins"]
|
||||
adapter._group_rules = case["group_rules"]
|
||||
sender = make_sender(**case["sender"])
|
||||
assert adapter._allow_group_message(
|
||||
sender_id=sender.sender_id,
|
||||
chat_id=case["chat_id"],
|
||||
is_bot=case["is_bot"],
|
||||
) is case["expected"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy, sender_type, expected", _GROUP_RULE_CASES)
|
||||
def test_allow_group_message_channel_locks_apply_to_bots(policy, sender_type, expected):
|
||||
adapter = make_adapter_skeleton()
|
||||
adapter._group_rules = {"oc_locked": _group_rule(policy)}
|
||||
sender = make_sender(sender_type=sender_type, open_id="ou_peer")
|
||||
assert adapter._allow_group_message(
|
||||
sender_id=sender.sender_id,
|
||||
chat_id="oc_locked",
|
||||
is_bot=True,
|
||||
) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sender_type", ["bot", "app"])
|
||||
def test_allow_group_message_blacklist_is_human_scope_only(sender_type):
|
||||
# blacklist is parallel to allowlist (human-scope); admitted bots bypass
|
||||
# it. To block a specific bot, gate upstream via FEISHU_ALLOW_BOTS.
|
||||
adapter = make_adapter_skeleton()
|
||||
adapter._group_rules = {
|
||||
"oc_1": _group_rule("blacklist", blacklist={"ou_peer"})
|
||||
}
|
||||
sender = make_sender(sender_type=sender_type, open_id="ou_peer")
|
||||
assert adapter._allow_group_message(
|
||||
sender_id=sender.sender_id,
|
||||
chat_id="oc_1",
|
||||
is_bot=True,
|
||||
) is True
|
||||
|
||||
|
||||
# --- Realistic payload smoke -----------------------------------------------
|
||||
|
||||
|
||||
def test_admit_accepts_realistic_bot_at_bot_group_event():
|
||||
# Locks in the real im.message.receive_v1 payload shape under mode=mentions.
|
||||
adapter = make_adapter_skeleton(bot_open_id="ou_self", allow_bots="mentions")
|
||||
|
||||
mention = SimpleNamespace(
|
||||
key="@_user_1",
|
||||
id=SimpleNamespace(union_id="on_mentionUnion", user_id="", open_id="ou_self"),
|
||||
name="Hermes",
|
||||
mentioned_type="bot",
|
||||
tenant_key="tenant_ab",
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
message_id="om_realistic_bot_at_bot",
|
||||
chat_id="oc_real",
|
||||
chat_type="group",
|
||||
message_type="text",
|
||||
content='{"text":"@_user_1 hello"}',
|
||||
mentions=[mention],
|
||||
)
|
||||
sender = SimpleNamespace(
|
||||
sender_type="bot",
|
||||
sender_id=SimpleNamespace(union_id="on_peerUnion", user_id="u_peer", open_id="ou_peer_bot"),
|
||||
tenant_key="tenant_ab",
|
||||
)
|
||||
|
||||
assert adapter._admit(sender, message) is None
|
||||
|
||||
|
||||
# --- Event-dispatch plumbing -----------------------------------------------
|
||||
|
||||
|
||||
def test_handle_message_event_data_drops_bot_sender_by_default():
|
||||
import asyncio
|
||||
|
||||
adapter = make_adapter_skeleton()
|
||||
install_dedup_state(adapter)
|
||||
processed = []
|
||||
|
||||
async def _fake_process_inbound_message(**kwargs):
|
||||
processed.append(kwargs)
|
||||
|
||||
adapter._process_inbound_message = _fake_process_inbound_message
|
||||
|
||||
data = SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
sender=make_sender(sender_type="bot", open_id="ou_peer"),
|
||||
message=make_message(message_id="om_bot_default", chat_type="p2p"),
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(adapter._handle_message_event_data(data))
|
||||
assert processed == []
|
||||
|
||||
|
||||
def test_handle_message_event_data_forwards_sender_when_admitted():
|
||||
import asyncio
|
||||
|
||||
adapter = make_adapter_skeleton(allow_bots="all")
|
||||
install_dedup_state(adapter)
|
||||
captured = {}
|
||||
|
||||
async def _fake_process_inbound_message(**kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
adapter._process_inbound_message = _fake_process_inbound_message
|
||||
|
||||
sender = make_sender(sender_type="bot", open_id="ou_peer")
|
||||
data = SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
sender=sender,
|
||||
message=make_message(message_id="om_bot_ok", chat_type="p2p"),
|
||||
)
|
||||
)
|
||||
|
||||
asyncio.run(adapter._handle_message_event_data(data))
|
||||
assert captured.get("sender_id") is sender.sender_id
|
||||
assert captured.get("is_bot") is True
|
||||
assert captured.get("message_id") == "om_bot_ok"
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Regression guard for Feishu bot-sender authorization bypass.
|
||||
|
||||
Mirrors tests/gateway/test_discord_bot_auth_bypass.py for Platform.FEISHU.
|
||||
Without the bypass in gateway/run.py, Feishu bot senders admitted by the
|
||||
adapter would be rejected at _is_user_authorized with "Unauthorized user"
|
||||
— same class of bug as Discord #4466.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.session import Platform, SessionSource
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_feishu_env(monkeypatch):
|
||||
for var in (
|
||||
"FEISHU_ALLOW_BOTS",
|
||||
"FEISHU_ALLOWED_USERS",
|
||||
"FEISHU_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOW_ALL_USERS",
|
||||
"GATEWAY_ALLOWED_USERS",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
|
||||
def _make_bare_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)
|
||||
return runner
|
||||
|
||||
|
||||
def _make_feishu_bot_source(open_id: str = "ou_peer"):
|
||||
return SessionSource(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id="oc_1",
|
||||
chat_type="group",
|
||||
user_id=open_id,
|
||||
user_name="PeerBot",
|
||||
is_bot=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_feishu_human_source(open_id: str = "ou_human"):
|
||||
return SessionSource(
|
||||
platform=Platform.FEISHU,
|
||||
chat_id="oc_1",
|
||||
chat_type="group",
|
||||
user_id=open_id,
|
||||
user_name="Human",
|
||||
is_bot=False,
|
||||
)
|
||||
|
||||
|
||||
def test_feishu_bot_authorized_when_allow_bots_mentions(monkeypatch):
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "mentions")
|
||||
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
|
||||
|
||||
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is True
|
||||
|
||||
|
||||
def test_feishu_bot_authorized_when_allow_bots_all(monkeypatch):
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
|
||||
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
|
||||
|
||||
assert runner._is_user_authorized(_make_feishu_bot_source()) is True
|
||||
|
||||
|
||||
def test_feishu_bot_NOT_authorized_when_allow_bots_none(monkeypatch):
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "none")
|
||||
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
|
||||
|
||||
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is False
|
||||
|
||||
|
||||
def test_feishu_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
|
||||
|
||||
assert runner._is_user_authorized(_make_feishu_bot_source("ou_peer")) is False
|
||||
|
||||
|
||||
def test_feishu_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):
|
||||
"""FEISHU_ALLOW_BOTS=all must NOT open the gate for humans."""
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
|
||||
monkeypatch.setenv("FEISHU_ALLOWED_USERS", "ou_human")
|
||||
|
||||
assert runner._is_user_authorized(_make_feishu_human_source("ou_stranger")) is False
|
||||
assert runner._is_user_authorized(_make_feishu_human_source("ou_human")) is True
|
||||
|
||||
|
||||
def test_feishu_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):
|
||||
"""FEISHU_ALLOW_BOTS=all must not authorize Telegram/Discord bot sources."""
|
||||
runner = _make_bare_runner()
|
||||
monkeypatch.setenv("FEISHU_ALLOW_BOTS", "all")
|
||||
|
||||
telegram_bot = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="channel",
|
||||
user_id="999",
|
||||
is_bot=True,
|
||||
)
|
||||
assert runner._is_user_authorized(telegram_bot) is False
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Regression tests for topic/channel skill auto-injection after /new or /reset.
|
||||
|
||||
Covers the fix for issue #6508.
|
||||
|
||||
Before the fix:
|
||||
1. User sends ``/new`` — ``reset_session`` creates a fresh SessionEntry
|
||||
with ``created_at == updated_at``.
|
||||
2. User sends the next message.
|
||||
3. ``get_or_create_session`` finds the entry and bumps
|
||||
``entry.updated_at = now`` (microseconds after ``created_at``).
|
||||
4. ``_handle_message_with_agent`` checks
|
||||
``_is_new_session = (created_at == updated_at) or was_auto_reset``.
|
||||
Both are False → ``_is_new_session = False`` → topic/channel skills
|
||||
are silently skipped for the first message of a manually reset session.
|
||||
|
||||
After the fix:
|
||||
``reset_session`` stamps the new entry with ``is_fresh_reset=True``.
|
||||
``_handle_message_with_agent`` ORs this into ``_is_new_session`` and
|
||||
consumes the flag immediately after the check, so subsequent messages
|
||||
are treated as continuing the session and the flag does not leak.
|
||||
|
||||
We use ``was_auto_reset`` for surprise resets (idle/daily/suspended) and
|
||||
``is_fresh_reset`` for user-initiated resets because the former also drives
|
||||
a "Session automatically reset due to inactivity" user-facing notice and
|
||||
a context-note prepend into the agent's prompt — both wrong for an explicit
|
||||
/new or /reset.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.session import SessionEntry, SessionSource, SessionStore
|
||||
|
||||
|
||||
def _make_store(tmp_path):
|
||||
return SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
|
||||
|
||||
|
||||
def _make_source(chat_id="123", user_id="u1"):
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def _is_new_session(entry) -> bool:
|
||||
"""Mirror of the predicate in ``_handle_message_with_agent``.
|
||||
|
||||
Kept in-sync with the production check so this test fails loudly if the
|
||||
upstream logic regresses.
|
||||
"""
|
||||
return (
|
||||
entry.created_at == entry.updated_at
|
||||
or getattr(entry, "was_auto_reset", False)
|
||||
or getattr(entry, "is_fresh_reset", False)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# reset_session stamps is_fresh_reset=True
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestResetSessionStampsFreshReset:
|
||||
def test_reset_session_sets_is_fresh_reset_true(self, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
store.get_or_create_session(source)
|
||||
session_key = store._generate_session_key(source)
|
||||
|
||||
new_entry = store.reset_session(session_key)
|
||||
|
||||
assert new_entry is not None
|
||||
assert new_entry.is_fresh_reset is True
|
||||
|
||||
def test_reset_session_unknown_key_returns_none(self, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
assert store.reset_session("unknown:key") is None
|
||||
|
||||
def test_fresh_session_does_not_have_is_fresh_reset(self, tmp_path):
|
||||
"""A vanilla first-time session should not carry the flag."""
|
||||
store = _make_store(tmp_path)
|
||||
entry = store.get_or_create_session(_make_source())
|
||||
assert entry.is_fresh_reset is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core regression: _is_new_session stays True after updated_at bump
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIsNewSessionSurvivesUpdatedAtBump:
|
||||
def test_is_new_session_true_after_reset_then_next_message(self, tmp_path):
|
||||
"""The actual bug: _is_new_session was False on message after /reset."""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
store.get_or_create_session(source)
|
||||
session_key = store._generate_session_key(source)
|
||||
|
||||
# User sends /reset
|
||||
store.reset_session(session_key)
|
||||
|
||||
# Next inbound message — get_or_create_session bumps updated_at
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
# Before the fix: created_at != updated_at, was_auto_reset=False → False
|
||||
# After the fix: is_fresh_reset=True carries the signal through the bump
|
||||
assert _is_new_session(entry) is True
|
||||
|
||||
def test_flag_consumed_after_first_read(self, tmp_path):
|
||||
"""After the message handler consumes is_fresh_reset, the NEXT
|
||||
message should not be treated as a new session (skill re-injection
|
||||
must not fire a second time).
|
||||
"""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
store.get_or_create_session(source)
|
||||
session_key = store._generate_session_key(source)
|
||||
store.reset_session(session_key)
|
||||
|
||||
# First message — handler consumes the flag
|
||||
entry = store.get_or_create_session(source)
|
||||
assert _is_new_session(entry) is True
|
||||
entry.is_fresh_reset = False # what _handle_message_with_agent does
|
||||
|
||||
# Second message — must not be treated as new
|
||||
entry = store.get_or_create_session(source)
|
||||
assert _is_new_session(entry) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vanilla-session behavior is unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVanillaBehaviorUnaffected:
|
||||
def test_ongoing_session_not_flagged_as_new(self, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
store.get_or_create_session(source)
|
||||
|
||||
# Second message on the same session — updated_at bumps,
|
||||
# is_fresh_reset was never set
|
||||
entry = store.get_or_create_session(source)
|
||||
assert entry.is_fresh_reset is False
|
||||
assert _is_new_session(entry) is False
|
||||
|
||||
def test_idle_auto_reset_does_not_set_is_fresh_reset(self, tmp_path):
|
||||
"""Idle/daily auto-resets use was_auto_reset — confirm they do NOT
|
||||
also set is_fresh_reset (which would double-fire the skill path and
|
||||
not leak through the auto-reset guard).
|
||||
"""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
# Simulate the auto-reset code path: get_or_create_session's internal
|
||||
# branch that sets was_auto_reset does NOT touch is_fresh_reset.
|
||||
# Construct a fresh entry the same way that branch does.
|
||||
store._entries.pop(store._generate_session_key(source))
|
||||
fresh = SessionEntry(
|
||||
session_key=entry.session_key,
|
||||
session_id="new_id",
|
||||
created_at=entry.created_at,
|
||||
updated_at=entry.created_at,
|
||||
origin=source,
|
||||
was_auto_reset=True,
|
||||
auto_reset_reason="idle",
|
||||
)
|
||||
assert fresh.is_fresh_reset is False
|
||||
assert fresh.was_auto_reset is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence through sessions.json round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPersistence:
|
||||
def test_is_fresh_reset_survives_to_dict_from_dict(self, tmp_path):
|
||||
"""Protect against the gateway restarting between /reset and the
|
||||
next message — the flag must be persisted in sessions.json.
|
||||
"""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
store.get_or_create_session(source)
|
||||
session_key = store._generate_session_key(source)
|
||||
new_entry = store.reset_session(session_key)
|
||||
|
||||
assert new_entry.is_fresh_reset is True
|
||||
restored = SessionEntry.from_dict(new_entry.to_dict())
|
||||
assert restored.is_fresh_reset is True
|
||||
|
||||
def test_default_false_when_missing_from_dict(self, tmp_path):
|
||||
"""Older sessions.json files written before this field existed must
|
||||
load cleanly with is_fresh_reset defaulting to False.
|
||||
"""
|
||||
data = {
|
||||
"session_key": "telegram:1:123",
|
||||
"session_id": "sess1",
|
||||
"created_at": "2026-01-01T00:00:00",
|
||||
"updated_at": "2026-01-01T00:00:00",
|
||||
}
|
||||
entry = SessionEntry.from_dict(data)
|
||||
assert entry.is_fresh_reset is False
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Regression tests for /sethome env-var resolution.
|
||||
|
||||
The `/sethome` command writes to a platform's home-target env var. Two platforms
|
||||
don't follow the `{PLATFORM}_HOME_CHANNEL` convention: matrix uses
|
||||
`MATRIX_HOME_ROOM` and email uses `EMAIL_HOME_ADDRESS`. Before PR #12698
|
||||
`/sethome` hardcoded the `_HOME_CHANNEL` suffix, so Matrix and Email saves went
|
||||
to env vars nothing read on startup — the home channel appeared to set
|
||||
successfully but was lost on every new gateway session.
|
||||
"""
|
||||
|
||||
from gateway.run import _home_target_env_var
|
||||
|
||||
|
||||
def test_matrix_home_target_env_var_uses_home_room():
|
||||
assert _home_target_env_var("matrix") == "MATRIX_HOME_ROOM"
|
||||
|
||||
|
||||
def test_email_home_target_env_var_uses_home_address():
|
||||
assert _home_target_env_var("email") == "EMAIL_HOME_ADDRESS"
|
||||
|
||||
|
||||
def test_telegram_home_target_env_var_uses_home_channel():
|
||||
assert _home_target_env_var("telegram") == "TELEGRAM_HOME_CHANNEL"
|
||||
|
||||
|
||||
def test_discord_home_target_env_var_uses_home_channel():
|
||||
assert _home_target_env_var("discord") == "DISCORD_HOME_CHANNEL"
|
||||
|
||||
|
||||
def test_unknown_platform_home_target_env_var_falls_back_to_home_channel():
|
||||
assert _home_target_env_var("custom") == "CUSTOM_HOME_CHANNEL"
|
||||
|
||||
|
||||
def test_case_insensitive_platform_name():
|
||||
assert _home_target_env_var("MATRIX") == "MATRIX_HOME_ROOM"
|
||||
assert _home_target_env_var("Email") == "EMAIL_HOME_ADDRESS"
|
||||
@@ -0,0 +1,79 @@
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
def _make_runner() -> GatewayRunner:
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="fake")},
|
||||
)
|
||||
runner.adapters = {}
|
||||
runner._model = "openai/gpt-4.1-mini"
|
||||
runner._base_url = None
|
||||
runner._decide_image_input_mode = lambda: "native"
|
||||
return runner
|
||||
|
||||
|
||||
def _source(chat_id: str) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
chat_type="private",
|
||||
user_name=f"user-{chat_id}",
|
||||
)
|
||||
|
||||
|
||||
def _image_event(source: SessionSource, path: str) -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text="see image",
|
||||
message_type=MessageType.PHOTO,
|
||||
source=source,
|
||||
media_urls=[path],
|
||||
media_types=["image/png"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_image_buffer_isolated_per_session():
|
||||
runner = _make_runner()
|
||||
source_a = _source("chat-a")
|
||||
source_b = _source("chat-b")
|
||||
|
||||
await runner._prepare_inbound_message_text(
|
||||
event=_image_event(source_a, "/tmp/a.png"),
|
||||
source=source_a,
|
||||
history=[],
|
||||
)
|
||||
await runner._prepare_inbound_message_text(
|
||||
event=_image_event(source_b, "/tmp/b.png"),
|
||||
source=source_b,
|
||||
history=[],
|
||||
)
|
||||
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_a)) == ["/tmp/a.png"]
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_b)) == ["/tmp/b.png"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_image_buffer_not_cleared_by_other_sessions_without_images():
|
||||
runner = _make_runner()
|
||||
source_a = _source("chat-a")
|
||||
source_b = _source("chat-b")
|
||||
|
||||
await runner._prepare_inbound_message_text(
|
||||
event=_image_event(source_a, "/tmp/a.png"),
|
||||
source=source_a,
|
||||
history=[],
|
||||
)
|
||||
await runner._prepare_inbound_message_text(
|
||||
event=MessageEvent(text="plain text", source=source_b),
|
||||
source=source_b,
|
||||
history=[],
|
||||
)
|
||||
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_a)) == ["/tmp/a.png"]
|
||||
assert runner._consume_pending_native_image_paths(build_session_key(source_b)) == []
|
||||
@@ -0,0 +1,67 @@
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_source() -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.SLACK,
|
||||
chat_id="C123",
|
||||
chat_type="channel",
|
||||
user_id="U123",
|
||||
thread_id="111.222",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(extra=None):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.SLACK: PlatformConfig(enabled=True, token="***", extra=extra or {})
|
||||
}
|
||||
)
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="public-1"))
|
||||
adapter.send_private_notice = AsyncMock(return_value=SendResult(success=True, message_id="private-1"))
|
||||
runner.adapters = {Platform.SLACK: adapter}
|
||||
return runner, adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_uses_private_delivery_when_configured():
|
||||
runner, adapter = _make_runner(extra={"notice_delivery": "private"})
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send_private_notice.assert_awaited_once_with(
|
||||
"C123",
|
||||
"U123",
|
||||
"hello",
|
||||
metadata={"thread_id": "111.222"},
|
||||
)
|
||||
adapter.send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_falls_back_to_public_when_private_fails():
|
||||
runner, adapter = _make_runner(extra={"notice_delivery": "private"})
|
||||
adapter.send_private_notice = AsyncMock(return_value=SendResult(success=False, error="nope"))
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send.assert_awaited_once_with("C123", "hello", metadata={"thread_id": "111.222"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_platform_notice_uses_public_delivery_by_default():
|
||||
runner, adapter = _make_runner()
|
||||
|
||||
await runner._deliver_platform_notice(_make_source(), "hello")
|
||||
|
||||
adapter.send.assert_awaited_once_with("C123", "hello", metadata={"thread_id": "111.222"})
|
||||
adapter.send_private_notice.assert_not_awaited()
|
||||
@@ -407,3 +407,44 @@ class TestReasoningCommand:
|
||||
assert result["final_response"] == "ok"
|
||||
assert _CapturingAgent.last_init is not None
|
||||
assert "homeassistant" in set(_CapturingAgent.last_init["enabled_toolsets"])
|
||||
|
||||
|
||||
class TestLoadShowReasoningCoercion:
|
||||
"""Regression: display.show_reasoning must be coerced, not bool()'d."""
|
||||
|
||||
def _load_with_config(self, tmp_path, monkeypatch, yaml_body: str) -> bool:
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "config.yaml").write_text(yaml_body, encoding="utf-8")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
return gateway_run.GatewayRunner._load_show_reasoning()
|
||||
|
||||
def test_quoted_false_is_false(self, tmp_path, monkeypatch):
|
||||
assert self._load_with_config(
|
||||
tmp_path, monkeypatch,
|
||||
'display:\n show_reasoning: "false"\n',
|
||||
) is False
|
||||
|
||||
def test_quoted_off_is_false(self, tmp_path, monkeypatch):
|
||||
assert self._load_with_config(
|
||||
tmp_path, monkeypatch,
|
||||
'display:\n show_reasoning: "off"\n',
|
||||
) is False
|
||||
|
||||
def test_quoted_true_is_true(self, tmp_path, monkeypatch):
|
||||
assert self._load_with_config(
|
||||
tmp_path, monkeypatch,
|
||||
'display:\n show_reasoning: "true"\n',
|
||||
) is True
|
||||
|
||||
def test_bare_true_is_true(self, tmp_path, monkeypatch):
|
||||
assert self._load_with_config(
|
||||
tmp_path, monkeypatch,
|
||||
'display:\n show_reasoning: true\n',
|
||||
) is True
|
||||
|
||||
def test_missing_is_false(self, tmp_path, monkeypatch):
|
||||
assert self._load_with_config(
|
||||
tmp_path, monkeypatch,
|
||||
'display: {}\n',
|
||||
) is False
|
||||
|
||||
@@ -113,6 +113,36 @@ async def test_restart_command_preserves_thread_id(tmp_path, monkeypatch):
|
||||
assert data["thread_id"] == "topic_7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_command_uses_atomic_json_writes_for_marker_files(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
|
||||
calls = []
|
||||
|
||||
def _fake_atomic_json_write(path, payload, **kwargs):
|
||||
calls.append((Path(path).name, payload, kwargs))
|
||||
|
||||
monkeypatch.setattr(gateway_run, "atomic_json_write", _fake_atomic_json_write)
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
runner.request_restart = MagicMock(return_value=True)
|
||||
|
||||
source = make_restart_source(chat_id="42")
|
||||
event = MessageEvent(
|
||||
text="/restart",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="m1",
|
||||
)
|
||||
|
||||
await runner._handle_restart_command(event)
|
||||
|
||||
names = [name for name, _payload, _kwargs in calls]
|
||||
assert names == [".restart_notify.json", ".restart_last_processed.json"]
|
||||
assert calls[0][1]["chat_id"] == "42"
|
||||
assert calls[1][1]["platform"] == "telegram"
|
||||
|
||||
|
||||
# ── _send_restart_notification ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -999,3 +999,65 @@ class TestStuckLoopEscalation:
|
||||
|
||||
assert store._entries[entry.session_key].resume_pending is False
|
||||
assert not counts_file.exists()
|
||||
|
||||
def test_increment_restart_failure_counts_uses_atomic_json_write(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
source = _make_source()
|
||||
session_key = _make_store(tmp_path).get_or_create_session(source).session_key
|
||||
|
||||
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
|
||||
calls = []
|
||||
|
||||
def _fake_atomic_json_write(path, payload, **kwargs):
|
||||
calls.append((path, payload, kwargs))
|
||||
|
||||
monkeypatch.setattr("gateway.run.atomic_json_write", _fake_atomic_json_write)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._increment_restart_failure_counts({session_key})
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
tmp_path / ".restart_failure_counts",
|
||||
{session_key: 1},
|
||||
{"indent": None},
|
||||
)
|
||||
]
|
||||
|
||||
def test_clear_restart_failure_count_uses_atomic_json_write_when_entries_remain(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
import json
|
||||
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
source = _make_source()
|
||||
session_key = _make_store(tmp_path).get_or_create_session(source).session_key
|
||||
other_key = "agent:main:telegram:dm:other"
|
||||
counts_file = tmp_path / ".restart_failure_counts"
|
||||
counts_file.write_text(
|
||||
json.dumps({session_key: 2, other_key: 1}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("gateway.run._hermes_home", tmp_path)
|
||||
calls = []
|
||||
|
||||
def _fake_atomic_json_write(path, payload, **kwargs):
|
||||
calls.append((path, payload, kwargs))
|
||||
|
||||
monkeypatch.setattr("gateway.run.atomic_json_write", _fake_atomic_json_write)
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._clear_restart_failure_count(session_key)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
tmp_path / ".restart_failure_counts",
|
||||
{other_key: 1},
|
||||
{"indent": None},
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1243,7 +1243,7 @@ class TestRewriteTranscriptPreservesReasoning:
|
||||
assert after[0].get("reasoning_details") == [{"type": "summary", "text": "step by step"}]
|
||||
assert after[0].get("codex_reasoning_items") == [{"id": "r1", "type": "reasoning"}]
|
||||
|
||||
def test_db_rewrite_is_atomic_on_insert_failure(self, tmp_path):
|
||||
def test_db_rewrite_is_atomic_on_insert_failure(self, tmp_path, monkeypatch):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=tmp_path / "test.db")
|
||||
@@ -1258,16 +1258,27 @@ class TestRewriteTranscriptPreservesReasoning:
|
||||
store._db = db
|
||||
store._loaded = True
|
||||
|
||||
# Force the second insert inside replace_messages to fail, simulating
|
||||
# any storage-layer error that might abort a multi-row rewrite.
|
||||
real_encode = SessionDB._encode_content
|
||||
calls = {"n": 0}
|
||||
|
||||
def flaky_encode(cls, content):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 2:
|
||||
raise RuntimeError("simulated storage failure")
|
||||
return real_encode.__func__(cls, content)
|
||||
|
||||
monkeypatch.setattr(SessionDB, "_encode_content", classmethod(flaky_encode))
|
||||
|
||||
replacement = [
|
||||
{"role": "user", "content": "after user"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": {"not": "sqlite-bindable but JSONL-safe"},
|
||||
},
|
||||
{"role": "assistant", "content": "after assistant"},
|
||||
]
|
||||
|
||||
store.rewrite_transcript(session_id, replacement)
|
||||
|
||||
# The rewrite must roll back atomically — original messages preserved.
|
||||
after = db.get_messages_as_conversation(session_id)
|
||||
assert [msg["content"] for msg in after] == [
|
||||
"before user",
|
||||
|
||||
@@ -10,6 +10,7 @@ from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||
from tools import approval as approval_mod
|
||||
from tools.approval import (
|
||||
_ApprovalEntry,
|
||||
approve_session,
|
||||
enable_session_yolo,
|
||||
is_approved,
|
||||
@@ -172,6 +173,38 @@ async def test_branch_clears_session_scoped_approval_and_yolo_state():
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branch_preserves_persisted_assistant_metadata():
|
||||
runner, _session_key = _make_branch_runner()
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "world",
|
||||
"finish_reason": "stop",
|
||||
"reasoning": "thinking",
|
||||
"reasoning_content": "provider scratchpad",
|
||||
"reasoning_details": [{"type": "summary", "text": "step"}],
|
||||
"codex_reasoning_items": [{"id": "r1", "type": "reasoning"}],
|
||||
"codex_message_items": [{"id": "m1", "type": "message"}],
|
||||
},
|
||||
]
|
||||
|
||||
result = await runner._handle_branch_command(_make_event("/branch"))
|
||||
|
||||
assert "Branched to" in result
|
||||
append_calls = runner._session_db.append_message.call_args_list
|
||||
assert len(append_calls) == 2
|
||||
assistant_kwargs = append_calls[1].kwargs
|
||||
assert assistant_kwargs["role"] == "assistant"
|
||||
assert assistant_kwargs["finish_reason"] == "stop"
|
||||
assert assistant_kwargs["reasoning"] == "thinking"
|
||||
assert assistant_kwargs["reasoning_content"] == "provider scratchpad"
|
||||
assert assistant_kwargs["reasoning_details"] == [{"type": "summary", "text": "step"}]
|
||||
assert assistant_kwargs["codex_reasoning_items"] == [{"id": "r1", "type": "reasoning"}]
|
||||
assert assistant_kwargs["codex_message_items"] == [{"id": "m1", "type": "message"}]
|
||||
|
||||
|
||||
def test_clear_session_boundary_security_state_is_scoped():
|
||||
"""The helper must wipe only the target session's approval/yolo state.
|
||||
|
||||
@@ -214,3 +247,30 @@ def test_clear_session_boundary_security_state_is_scoped():
|
||||
runner._clear_session_boundary_security_state("")
|
||||
assert is_approved(other_key, "recursive delete") is True
|
||||
assert other_key in runner._update_prompt_pending
|
||||
|
||||
|
||||
def test_clear_session_boundary_security_state_wakes_blocked_approvals():
|
||||
"""Boundary cleanup must cancel blocked approval waiters immediately."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._pending_approvals = {}
|
||||
runner._update_prompt_pending = {}
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
other_key = "agent:main:telegram:dm:other-chat"
|
||||
|
||||
target_entry = _ApprovalEntry({"command": "rm -rf /tmp/demo"})
|
||||
other_entry = _ApprovalEntry({"command": "rm -rf /tmp/other"})
|
||||
approval_mod._gateway_queues[session_key] = [target_entry]
|
||||
approval_mod._gateway_queues[other_key] = [other_entry]
|
||||
|
||||
runner._clear_session_boundary_security_state(session_key)
|
||||
|
||||
assert target_entry.event.is_set()
|
||||
assert target_entry.result == "deny"
|
||||
assert other_entry.event.is_set() is False
|
||||
assert other_entry.result is None
|
||||
assert session_key not in approval_mod._gateway_queues
|
||||
assert other_key in approval_mod._gateway_queues
|
||||
|
||||
@@ -226,6 +226,39 @@ def test_merge_pending_message_event_merges_text_and_photo_followups():
|
||||
assert merged.media_types == ["image/png"]
|
||||
|
||||
|
||||
def test_merge_pending_message_event_promotes_document_followups_over_text():
|
||||
pending = {}
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
user_id="u1",
|
||||
)
|
||||
session_key = build_session_key(source)
|
||||
|
||||
text_event = MessageEvent(
|
||||
text="please review this",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
)
|
||||
document_event = MessageEvent(
|
||||
text="",
|
||||
message_type=MessageType.DOCUMENT,
|
||||
source=source,
|
||||
media_urls=["/tmp/report.pdf"],
|
||||
media_types=["application/pdf"],
|
||||
)
|
||||
|
||||
merge_pending_message_event(pending, session_key, text_event, merge_text=True)
|
||||
merge_pending_message_event(pending, session_key, document_event, merge_text=True)
|
||||
|
||||
merged = pending[session_key]
|
||||
assert merged.message_type == MessageType.DOCUMENT
|
||||
assert merged.text == "please review this"
|
||||
assert merged.media_urls == ["/tmp/report.pdf"]
|
||||
assert merged.media_types == ["application/pdf"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recent_telegram_text_followup_is_queued_without_interrupt():
|
||||
runner = _make_runner()
|
||||
|
||||
@@ -1649,3 +1649,148 @@ class TestSignalSendTimeout:
|
||||
# 32 attachments × 5s = 160s; ought to comfortably outlast a
|
||||
# serial upload of an attachment-heavy batch.
|
||||
assert _signal_send_timeout(32) == 160.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contentless Envelope Filtering (profile key updates, empty messages)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignalContentlessEnvelope:
|
||||
"""Verify that profile key updates and empty Signal messages are skipped."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_profile_key_update_no_message_field(self, monkeypatch):
|
||||
"""Profile key updates may carry a dataMessage without 'message' field.
|
||||
Must be skipped to avoid triggering agent turns for metadata."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
async def fake_handle(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = fake_handle
|
||||
|
||||
# Profile key update: dataMessage exists but has no "message" field
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+155****9999",
|
||||
"sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475",
|
||||
"sourceName": "Elliott McManis",
|
||||
"timestamp": 1777600696077,
|
||||
"dataMessage": {
|
||||
# No "message" field — profile key update metadata only
|
||||
"profileKey": "some-profile-key-data",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
assert "event" not in captured, "Profile key update should be skipped"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_empty_message(self, monkeypatch):
|
||||
"""Empty text messages (message='') should be skipped."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
async def fake_handle(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = fake_handle
|
||||
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+155****9999",
|
||||
"sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475",
|
||||
"sourceName": "Elliott McManis",
|
||||
"timestamp": 1777600696077,
|
||||
"dataMessage": {
|
||||
"message": "",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
assert "event" not in captured, "Empty message should be skipped"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_whitespace_only_message(self, monkeypatch):
|
||||
"""Whitespace-only messages (' ') should be skipped."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
async def fake_handle(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = fake_handle
|
||||
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+155****9999",
|
||||
"sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475",
|
||||
"sourceName": "Elliott McManis",
|
||||
"timestamp": 1777600696077,
|
||||
"dataMessage": {
|
||||
"message": " \n\t ",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
assert "event" not in captured, "Whitespace-only message should be skipped"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_message_with_attachment_no_text(self, monkeypatch):
|
||||
"""Messages with attachments but no text should still be processed."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
async def fake_handle(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = fake_handle
|
||||
|
||||
# Mock attachment fetch to return a cached image
|
||||
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
||||
b64_data = base64.b64encode(png_data).decode()
|
||||
adapter._rpc, _ = _stub_rpc({"data": b64_data})
|
||||
|
||||
with patch("gateway.platforms.signal.cache_image_from_bytes", return_value="/tmp/img.png"):
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+155****9999",
|
||||
"sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475",
|
||||
"sourceName": "Elliott McManis",
|
||||
"timestamp": 1777600696077,
|
||||
"dataMessage": {
|
||||
"message": "", # No text
|
||||
"attachments": [{"id": "att-123", "size": 200}],
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
assert "event" in captured, "Message with attachment should NOT be skipped"
|
||||
assert captured["event"].media_urls == ["/tmp/img.png"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_allows_normal_text_message(self, monkeypatch):
|
||||
"""Normal text messages should still flow through."""
|
||||
adapter = _make_signal_adapter(monkeypatch)
|
||||
captured = {}
|
||||
|
||||
async def fake_handle(event):
|
||||
captured["event"] = event
|
||||
|
||||
adapter.handle_message = fake_handle
|
||||
|
||||
await adapter._handle_envelope({
|
||||
"envelope": {
|
||||
"sourceNumber": "+155****9999",
|
||||
"sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475",
|
||||
"sourceName": "Elliott McManis",
|
||||
"timestamp": 1777600696077,
|
||||
"dataMessage": {
|
||||
"message": "hello world",
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
assert "event" in captured, "Normal message should NOT be skipped"
|
||||
assert captured["event"].text == "hello world"
|
||||
|
||||
@@ -53,6 +53,9 @@ def _ensure_slack_mock():
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
# aiohttp is imported alongside slack-bolt; mock it if missing
|
||||
sys.modules.setdefault("aiohttp", MagicMock())
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
@@ -89,6 +92,46 @@ def _redirect_cache(tmp_path, monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSlashCommandSessionIsolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSlashCommandSessionIsolation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_slash_command_uses_group_session_semantics(self, adapter):
|
||||
command = {
|
||||
"text": "hello",
|
||||
"user_id": "U123",
|
||||
"channel_id": "C123",
|
||||
"team_id": "T123",
|
||||
}
|
||||
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.source.chat_type == "group"
|
||||
assert event.source.chat_id == "C123"
|
||||
assert event.source.user_id == "U123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter):
|
||||
command = {
|
||||
"text": "hello",
|
||||
"user_id": "U123",
|
||||
"channel_id": "D123",
|
||||
"team_id": "T123",
|
||||
}
|
||||
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.source.chat_type == "dm"
|
||||
assert event.source.chat_id == "D123"
|
||||
assert event.source.user_id == "U123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAppMentionHandler
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -515,6 +558,28 @@ class TestSendDocument:
|
||||
sleep_mock.assert_awaited_once()
|
||||
|
||||
|
||||
class TestSendPrivateNotice:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_private_notice_uses_ephemeral_api(self, adapter):
|
||||
adapter._app.client.chat_postEphemeral = AsyncMock(return_value={"message_ts": "123.456"})
|
||||
|
||||
result = await adapter.send_private_notice(
|
||||
chat_id="C123",
|
||||
user_id="U123",
|
||||
content="private hello",
|
||||
metadata={"thread_id": "1234567890.123456"},
|
||||
)
|
||||
|
||||
assert result.success
|
||||
adapter._app.client.chat_postEphemeral.assert_called_once_with(
|
||||
channel="C123",
|
||||
user="U123",
|
||||
text="private hello",
|
||||
mrkdwn=True,
|
||||
thread_ts="1234567890.123456",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestSendVideo
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1088,6 +1153,104 @@ class TestSendTyping:
|
||||
status="is thinking...",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_clears_tracked_thread(self, adapter):
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"})
|
||||
|
||||
await adapter.stop_typing("C123", metadata={"thread_id": "parent_ts"})
|
||||
|
||||
assert adapter._app.client.assistant_threads_setStatus.call_args_list[1] == call(
|
||||
channel_id="C123",
|
||||
thread_ts="parent_ts",
|
||||
status="",
|
||||
)
|
||||
assert "C123" not in adapter._active_status_threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_noop_without_tracked_thread(self, adapter):
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
|
||||
await adapter.stop_typing("C123")
|
||||
|
||||
adapter._app.client.assistant_threads_setStatus.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_typing_handles_api_error_gracefully(self, adapter):
|
||||
adapter._active_status_threads["C123"] = "parent_ts"
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock(
|
||||
side_effect=Exception("missing_scope")
|
||||
)
|
||||
|
||||
await adapter.stop_typing("C123")
|
||||
|
||||
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
|
||||
channel_id="C123",
|
||||
thread_ts="parent_ts",
|
||||
status="",
|
||||
)
|
||||
assert "C123" not in adapter._active_status_threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_clears_status_after_final_post(self, adapter):
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"})
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
adapter._active_status_threads["C123"] = "parent_ts"
|
||||
|
||||
result = await adapter.send("C123", "done", metadata={"thread_id": "parent_ts"})
|
||||
|
||||
assert result.success
|
||||
adapter._app.client.chat_postMessage.assert_called_once()
|
||||
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
|
||||
channel_id="C123",
|
||||
thread_ts="parent_ts",
|
||||
status="",
|
||||
)
|
||||
assert "C123" not in adapter._active_status_threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_final_edit_clears_status(self, adapter):
|
||||
adapter._app.client.chat_update = AsyncMock()
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
adapter._active_status_threads["C123"] = "parent_ts"
|
||||
|
||||
result = await adapter.edit_message(
|
||||
"C123",
|
||||
"reply_ts",
|
||||
"done",
|
||||
finalize=True,
|
||||
)
|
||||
|
||||
assert result.success
|
||||
adapter._app.client.chat_update.assert_called_once_with(
|
||||
channel="C123",
|
||||
ts="reply_ts",
|
||||
text="done",
|
||||
)
|
||||
adapter._app.client.assistant_threads_setStatus.assert_called_once_with(
|
||||
channel_id="C123",
|
||||
thread_ts="parent_ts",
|
||||
status="",
|
||||
)
|
||||
assert "C123" not in adapter._active_status_threads
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_streaming_intermediate_edit_keeps_status(self, adapter):
|
||||
adapter._app.client.chat_update = AsyncMock()
|
||||
adapter._app.client.assistant_threads_setStatus = AsyncMock()
|
||||
adapter._active_status_threads["C123"] = "parent_ts"
|
||||
|
||||
result = await adapter.edit_message(
|
||||
"C123",
|
||||
"reply_ts",
|
||||
"partial",
|
||||
finalize=False,
|
||||
)
|
||||
|
||||
assert result.success
|
||||
adapter._app.client.assistant_threads_setStatus.assert_not_called()
|
||||
assert adapter._active_status_threads["C123"] == "parent_ts"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFormatMessage — Markdown → mrkdwn conversion
|
||||
@@ -1312,6 +1475,16 @@ class TestFormatMessage:
|
||||
result = adapter.format_message("[link](https://x.com?a=1&b=2)")
|
||||
assert result == "<https://x.com?a=1&b=2|link>"
|
||||
|
||||
def test_markdown_image_does_not_create_broken_slack_link(self, adapter):
|
||||
"""Markdown image syntax should not become '!<url|alt>' in Slack."""
|
||||
result = adapter.format_message("")
|
||||
assert result == ""
|
||||
|
||||
def test_literal_asterisks_with_spaces_are_not_treated_as_italic(self, adapter):
|
||||
"""Asterisks used as plain delimiters should stay literal."""
|
||||
result = adapter.format_message("a * b * c")
|
||||
assert result == "a * b * c"
|
||||
|
||||
def test_emoji_shortcodes_passthrough(self, adapter):
|
||||
"""Emoji shortcodes like :smile: pass through unchanged."""
|
||||
assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:"
|
||||
@@ -2586,3 +2759,284 @@ class TestSlackReplyToText:
|
||||
assert msg_event.reply_to_text is None
|
||||
# Top-level message: reply_to_message_id must be falsy (None or empty).
|
||||
assert not msg_event.reply_to_message_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slash-command ephemeral ack and routing (#18182)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSlashEphemeralAck:
|
||||
"""Slash commands should produce an ephemeral ack and route replies ephemerally."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_stashes_response_url(self, adapter):
|
||||
"""_handle_slash_command stashes response_url for later ephemeral routing."""
|
||||
command = {
|
||||
"command": "/q",
|
||||
"text": "follow-up question",
|
||||
"user_id": "U_SLASH",
|
||||
"channel_id": "C_SLASH",
|
||||
"response_url": "https://hooks.slack.com/commands/T123/456/abc",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
# The context should be stashed under (channel_id, user_id).
|
||||
key = ("C_SLASH", "U_SLASH")
|
||||
assert key in adapter._slash_command_contexts
|
||||
ctx = adapter._slash_command_contexts[key]
|
||||
assert ctx["response_url"] == "https://hooks.slack.com/commands/T123/456/abc"
|
||||
assert "ts" in ctx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_without_response_url_does_not_stash(self, adapter):
|
||||
"""Commands without a response_url should not create a context."""
|
||||
command = {
|
||||
"command": "/stop",
|
||||
"text": "",
|
||||
"user_id": "U1",
|
||||
"channel_id": "C1",
|
||||
# no response_url
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pop_slash_context_returns_and_removes(self, adapter):
|
||||
"""_pop_slash_context returns the context and removes it."""
|
||||
import time
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/test",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
ctx = adapter._pop_slash_context("C1")
|
||||
assert ctx is not None
|
||||
assert ctx["response_url"] == "https://hooks.slack.com/test"
|
||||
# Must be removed after pop
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pop_slash_context_returns_none_for_no_match(self, adapter):
|
||||
"""_pop_slash_context returns None when no context exists."""
|
||||
ctx = adapter._pop_slash_context("C_NONEXISTENT")
|
||||
assert ctx is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pop_slash_context_discards_stale_entries(self, adapter):
|
||||
"""Stale contexts older than TTL are cleaned up."""
|
||||
import time
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/stale",
|
||||
"ts": time.monotonic() - adapter._SLASH_CTX_TTL - 1,
|
||||
}
|
||||
|
||||
ctx = adapter._pop_slash_context("C1")
|
||||
assert ctx is None
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_uses_response_url_when_context_exists(self, adapter):
|
||||
"""send() should POST to response_url for slash command replies."""
|
||||
import time
|
||||
adapter._slash_command_contexts[("C_SLASH", "U_SLASH")] = {
|
||||
"response_url": "https://hooks.slack.com/commands/T123/456/abc",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session):
|
||||
result = await adapter.send("C_SLASH", "Queued for the next turn.")
|
||||
|
||||
assert result.success is True
|
||||
# Verify response_url was POSTed to
|
||||
mock_session.post.assert_called_once()
|
||||
call_args = mock_session.post.call_args
|
||||
assert call_args[0][0] == "https://hooks.slack.com/commands/T123/456/abc"
|
||||
payload = call_args[1]["json"]
|
||||
assert payload["response_type"] == "ephemeral"
|
||||
assert payload["replace_original"] is True
|
||||
assert "Queued for the next turn" in payload["text"]
|
||||
|
||||
# Context must be consumed
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_falls_through_without_context(self, adapter):
|
||||
"""send() should use normal chat_postMessage when no slash context exists."""
|
||||
mock_result = {"ts": "1234.5678", "ok": True}
|
||||
adapter._app.client.chat_postMessage = AsyncMock(return_value=mock_result)
|
||||
|
||||
result = await adapter.send("C_NORMAL", "Hello world")
|
||||
|
||||
assert result.success is True
|
||||
adapter._app.client.chat_postMessage.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_slash_ephemeral_fallback_on_post_failure(self, adapter):
|
||||
"""_send_slash_ephemeral returns success=True even if POST fails."""
|
||||
import time
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/commands/bad",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
mock_resp = AsyncMock()
|
||||
mock_resp.status = 500
|
||||
mock_resp.text = AsyncMock(return_value="Internal Server Error")
|
||||
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(return_value=mock_resp)
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session):
|
||||
result = await adapter.send("C1", "Some response")
|
||||
|
||||
# Still success — the user saw the initial ack already
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_slash_ephemeral_fallback_on_exception(self, adapter):
|
||||
"""_send_slash_ephemeral returns success=True even if aiohttp raises."""
|
||||
import time
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/commands/timeout",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_session.post = MagicMock(side_effect=Exception("connection timeout"))
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch("gateway.platforms.slack.aiohttp.ClientSession", return_value=mock_session):
|
||||
result = await adapter.send("C1", "Some response")
|
||||
|
||||
assert result.success is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_slash_stashes_context_and_dispatches(self, adapter):
|
||||
"""Full flow: native /q slash → stash + handle_message dispatch."""
|
||||
command = {
|
||||
"command": "/q",
|
||||
"text": "do something",
|
||||
"user_id": "U_Q",
|
||||
"channel_id": "C_Q",
|
||||
"response_url": "https://hooks.slack.com/commands/T1/2/q",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
# 1. handle_message was called with the right event
|
||||
adapter.handle_message.assert_called_once()
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
assert event.text == "/q do something"
|
||||
assert event.message_type == MessageType.COMMAND
|
||||
|
||||
# 2. Context stashed for ephemeral routing
|
||||
assert ("C_Q", "U_Q") in adapter._slash_command_contexts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_hermes_slash_stashes_context(self, adapter):
|
||||
"""Legacy /hermes <subcommand> also stashes context."""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "help",
|
||||
"user_id": "U_H",
|
||||
"channel_id": "C_H",
|
||||
"response_url": "https://hooks.slack.com/commands/T1/3/h",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
assert ("C_H", "U_H") in adapter._slash_command_contexts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_freeform_hermes_question_does_not_stash_context(self, adapter):
|
||||
"""Free-form /hermes <question> must NOT route agent reply ephemeral."""
|
||||
command = {
|
||||
"command": "/hermes",
|
||||
"text": "what's the weather",
|
||||
"user_id": "U_FREE",
|
||||
"channel_id": "C_FREE",
|
||||
"response_url": "https://hooks.slack.com/commands/T1/4/free",
|
||||
}
|
||||
await adapter._handle_slash_command(command)
|
||||
|
||||
adapter.handle_message.assert_called_once()
|
||||
event = adapter.handle_message.call_args[0][0]
|
||||
# Free-form text — not a command
|
||||
assert event.message_type == MessageType.TEXT
|
||||
assert event.text == "what's the weather"
|
||||
# Context must NOT be stashed — agent reply should be public
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_users_same_channel_isolates_contexts(self, adapter):
|
||||
"""Two users slash on the same channel — each gets their own context."""
|
||||
import time
|
||||
from gateway.platforms.slack import _slash_user_id
|
||||
|
||||
# Simulate two users stashing contexts on the same channel.
|
||||
adapter._slash_command_contexts[("C_SHARED", "U_ALICE")] = {
|
||||
"response_url": "https://hooks.slack.com/alice",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
adapter._slash_command_contexts[("C_SHARED", "U_BOB")] = {
|
||||
"response_url": "https://hooks.slack.com/bob",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
# Alice's send() — ContextVar set to Alice's user_id.
|
||||
token = _slash_user_id.set("U_ALICE")
|
||||
try:
|
||||
ctx = adapter._pop_slash_context("C_SHARED")
|
||||
finally:
|
||||
_slash_user_id.reset(token)
|
||||
|
||||
assert ctx is not None
|
||||
assert ctx["response_url"] == "https://hooks.slack.com/alice"
|
||||
# Bob's context must still be there.
|
||||
assert ("C_SHARED", "U_BOB") in adapter._slash_command_contexts
|
||||
assert len(adapter._slash_command_contexts) == 1
|
||||
|
||||
# Bob's send() — ContextVar set to Bob's user_id.
|
||||
token = _slash_user_id.set("U_BOB")
|
||||
try:
|
||||
ctx = adapter._pop_slash_context("C_SHARED")
|
||||
finally:
|
||||
_slash_user_id.reset(token)
|
||||
|
||||
assert ctx is not None
|
||||
assert ctx["response_url"] == "https://hooks.slack.com/bob"
|
||||
assert len(adapter._slash_command_contexts) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_contextvar_does_not_match_any_context(self, adapter):
|
||||
"""send() without ContextVar (non-slash path) must not steal contexts."""
|
||||
import time
|
||||
from gateway.platforms.slack import _slash_user_id
|
||||
|
||||
adapter._slash_command_contexts[("C1", "U1")] = {
|
||||
"response_url": "https://hooks.slack.com/test",
|
||||
"ts": time.monotonic(),
|
||||
}
|
||||
|
||||
# ContextVar is unset (default=None) — simulates a normal message send.
|
||||
assert _slash_user_id.get() is None
|
||||
ctx = adapter._pop_slash_context("C1")
|
||||
# Fallback scan still finds it (channel-only) — this is fine for
|
||||
# the normal single-user case; the ContextVar path is the precise one.
|
||||
# The key invariant is: when the ContextVar IS set, it matches exactly.
|
||||
assert ctx is not None # fallback path finds the entry
|
||||
|
||||
@@ -215,6 +215,23 @@ def test_free_response_channels_env_var_fallback(monkeypatch):
|
||||
assert OTHER_CHANNEL_ID in result
|
||||
|
||||
|
||||
def test_free_response_channels_bare_int():
|
||||
# YAML `free_response_channels: 1491973769726791812` (single bare integer)
|
||||
# is loaded as an int and would previously fall through the isinstance(str)
|
||||
# branch to return an empty set. Coerce scalar → str so single-channel
|
||||
# config without quoting works as users expect.
|
||||
adapter = _make_adapter(free_response_channels=1491973769726791812)
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert result == {"1491973769726791812"}
|
||||
|
||||
|
||||
def test_free_response_channels_int_list():
|
||||
# YAML list form with bare numeric entries — each element should be coerced.
|
||||
adapter = _make_adapter(free_response_channels=[1491973769726791812, 99999])
|
||||
result = adapter._slack_free_response_channels()
|
||||
assert result == {"1491973769726791812", "99999"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: mention gating integration (simulating _handle_slack_message logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for the gateway stale-code self-check (Issue #17648).
|
||||
|
||||
A gateway that survives ``hermes update`` keeps pre-update modules cached
|
||||
in ``sys.modules``. Later imports of names added post-update (e.g.
|
||||
``cfg_get`` from PR #17304) raise ImportError against the stale module
|
||||
object. The self-check in ``GatewayRunner._detect_stale_code()`` detects
|
||||
this by comparing boot-time sentinel-file mtimes against current ones,
|
||||
and ``_trigger_stale_code_restart()`` triggers a graceful restart.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.run import (
|
||||
GatewayRunner,
|
||||
_compute_repo_mtime,
|
||||
_STALE_CODE_SENTINELS,
|
||||
)
|
||||
|
||||
|
||||
def _make_tmp_repo(tmp_path: Path) -> Path:
|
||||
"""Create a fake repo with all stale-code sentinel files."""
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
p = tmp_path / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text("# test sentinel\n")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _make_runner(repo_root: Path, *, boot_mtime: float, boot_wall: float):
|
||||
"""Bare GatewayRunner with just the stale-check attributes set."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._repo_root_for_staleness = repo_root
|
||||
runner._boot_wall_time = boot_wall
|
||||
runner._boot_repo_mtime = boot_mtime
|
||||
runner._stale_code_notified = set()
|
||||
runner._stale_code_restart_triggered = False
|
||||
return runner
|
||||
|
||||
|
||||
def test_compute_repo_mtime_returns_newest(tmp_path):
|
||||
"""_compute_repo_mtime returns the newest mtime across sentinel files."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
|
||||
# Stamp a baseline mtime across all sentinels
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
# Touch one file forward
|
||||
newer = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (newer, newer))
|
||||
|
||||
result = _compute_repo_mtime(repo)
|
||||
assert abs(result - newer) < 1.0 # within 1s (filesystem mtime resolution)
|
||||
|
||||
|
||||
def test_compute_repo_mtime_missing_files_returns_zero(tmp_path):
|
||||
"""Missing sentinel files return 0.0 (treated as 'can't tell' upstream)."""
|
||||
# tmp_path has none of the sentinels
|
||||
assert _compute_repo_mtime(tmp_path) == 0.0
|
||||
|
||||
|
||||
def test_compute_repo_mtime_partial_files_still_works(tmp_path):
|
||||
"""Partial sentinel presence still returns newest of the readable ones."""
|
||||
(tmp_path / "hermes_cli").mkdir()
|
||||
target = tmp_path / "hermes_cli" / "config.py"
|
||||
target.write_text("# partial\n")
|
||||
target_mtime = time.time() - 50
|
||||
os.utime(target, (target_mtime, target_mtime))
|
||||
|
||||
result = _compute_repo_mtime(tmp_path)
|
||||
assert abs(result - target_mtime) < 1.0
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_no_boot_snapshot(tmp_path):
|
||||
"""No boot snapshot → can't tell → not stale (no restart loop)."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=0.0, boot_wall=0.0)
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_false_when_files_unchanged(tmp_path):
|
||||
"""Source files at boot mtime → not stale."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
# Freeze all sentinels to the same mtime
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_detect_stale_code_true_after_update(tmp_path):
|
||||
"""Sentinel files newer than boot snapshot → stale."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Simulate hermes update touching config.py
|
||||
new_mtime = time.time()
|
||||
os.utime(repo / "hermes_cli/config.py", (new_mtime, new_mtime))
|
||||
|
||||
assert runner._detect_stale_code() is True
|
||||
|
||||
|
||||
def test_detect_stale_code_ignores_subsecond_drift(tmp_path):
|
||||
"""2-second slack prevents false positives on coarse-mtime filesystems."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Touch config.py 1s newer — within the 2s slack → not stale
|
||||
os.utime(repo / "hermes_cli/config.py", (baseline + 1.0, baseline + 1.0))
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
# Touch 5s newer → stale
|
||||
os.utime(repo / "hermes_cli/config.py", (baseline + 5.0, baseline + 5.0))
|
||||
assert runner._detect_stale_code() is True
|
||||
|
||||
|
||||
def test_trigger_stale_code_restart_is_idempotent(tmp_path):
|
||||
"""Calling _trigger_stale_code_restart twice only requests restart once."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0)
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_request_restart(*, detached=False, via_service=False):
|
||||
calls.append((detached, via_service))
|
||||
return True
|
||||
|
||||
runner.request_restart = fake_request_restart
|
||||
|
||||
runner._trigger_stale_code_restart()
|
||||
runner._trigger_stale_code_restart()
|
||||
runner._trigger_stale_code_restart()
|
||||
|
||||
assert len(calls) == 1
|
||||
assert runner._stale_code_restart_triggered is True
|
||||
|
||||
|
||||
def test_trigger_stale_code_restart_survives_request_failure(tmp_path):
|
||||
"""If request_restart raises, we swallow and mark as triggered anyway."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
runner = _make_runner(repo, boot_mtime=1.0, boot_wall=1.0)
|
||||
|
||||
def boom(*, detached=False, via_service=False):
|
||||
raise RuntimeError("no event loop")
|
||||
|
||||
runner.request_restart = boom
|
||||
|
||||
# Should not raise
|
||||
runner._trigger_stale_code_restart()
|
||||
|
||||
# Marked triggered so we don't retry on every subsequent message
|
||||
assert runner._stale_code_restart_triggered is True
|
||||
|
||||
|
||||
def test_detect_stale_code_handles_disappearing_repo_root(tmp_path):
|
||||
"""If the repo root vanishes after boot, return False (don't loop)."""
|
||||
repo = _make_tmp_repo(tmp_path)
|
||||
baseline = time.time() - 100
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
os.utime(repo / rel, (baseline, baseline))
|
||||
|
||||
runner = _make_runner(repo, boot_mtime=baseline, boot_wall=baseline)
|
||||
|
||||
# Remove all sentinel files — _compute_repo_mtime returns 0.0
|
||||
for rel in _STALE_CODE_SENTINELS:
|
||||
(repo / rel).unlink(missing_ok=True)
|
||||
|
||||
assert runner._detect_stale_code() is False
|
||||
|
||||
|
||||
def test_class_level_defaults_prevent_uninitialized_access():
|
||||
"""Partial construction via object.__new__ must not crash _detect_stale_code."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
# Don't set any instance attrs — class-level defaults should kick in
|
||||
runner._repo_root_for_staleness = Path(".")
|
||||
# _boot_wall_time / _boot_repo_mtime fall through to class defaults (0.0)
|
||||
assert runner._detect_stale_code() is False
|
||||
# _stale_code_restart_triggered falls through to class default (False)
|
||||
assert runner._stale_code_restart_triggered is False
|
||||
|
||||
|
||||
def test_init_captures_boot_snapshot(monkeypatch, tmp_path):
|
||||
"""GatewayRunner.__init__ captures a usable stale-code baseline."""
|
||||
# Stub out the heavy parts of __init__ we don't need. We only want
|
||||
# to prove the stale-code snapshot is captured before anything else.
|
||||
from gateway import run as run_mod
|
||||
|
||||
calls = {}
|
||||
|
||||
def fake_compute(repo_root):
|
||||
calls["repo_root"] = repo_root
|
||||
return 1234567890.0
|
||||
|
||||
monkeypatch.setattr(run_mod, "_compute_repo_mtime", fake_compute)
|
||||
|
||||
# Build a runner without running the full __init__ — then manually
|
||||
# exercise the stale-check init block that __init__ contains.
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._boot_wall_time = time.time()
|
||||
runner._repo_root_for_staleness = Path(run_mod.__file__).resolve().parent.parent
|
||||
runner._boot_repo_mtime = run_mod._compute_repo_mtime(runner._repo_root_for_staleness)
|
||||
runner._stale_code_notified = set()
|
||||
runner._stale_code_restart_triggered = False
|
||||
|
||||
assert runner._boot_repo_mtime == 1234567890.0
|
||||
assert calls["repo_root"] == runner._repo_root_for_staleness
|
||||
assert runner._boot_wall_time > 0
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway import status
|
||||
@@ -245,6 +246,27 @@ class TestGatewayPidState:
|
||||
|
||||
|
||||
class TestGatewayRuntimeStatus:
|
||||
def test_write_json_file_uses_atomic_json_write(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
calls = []
|
||||
|
||||
def _fake_atomic_json_write(path, payload, **kwargs):
|
||||
calls.append((Path(path), payload, kwargs))
|
||||
|
||||
monkeypatch.setattr(status, "atomic_json_write", _fake_atomic_json_write)
|
||||
|
||||
payload = {"gateway_state": "running"}
|
||||
target = tmp_path / "gateway_state.json"
|
||||
status._write_json_file(target, payload)
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
target,
|
||||
payload,
|
||||
{"indent": None, "separators": (",", ":")},
|
||||
)
|
||||
]
|
||||
|
||||
def test_write_runtime_status_overwrites_stale_pid_on_restart(self, tmp_path, monkeypatch):
|
||||
"""Regression: setdefault() preserved stale PID from previous process (#1631)."""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -349,6 +371,35 @@ class TestTerminatePid:
|
||||
|
||||
|
||||
class TestScopedLocks:
|
||||
def test_windows_file_lock_uses_high_offset(self, tmp_path, monkeypatch):
|
||||
lock_path = tmp_path / "gateway.lock"
|
||||
handle = open(lock_path, "a+", encoding="utf-8")
|
||||
fd = handle.fileno()
|
||||
calls = []
|
||||
|
||||
def fake_locking(fd, mode, size):
|
||||
calls.append((fd, mode, size, handle.tell()))
|
||||
|
||||
monkeypatch.setattr(status, "_IS_WINDOWS", True)
|
||||
monkeypatch.setattr(
|
||||
status,
|
||||
"msvcrt",
|
||||
SimpleNamespace(LK_NBLCK=1, LK_UNLCK=2, locking=fake_locking),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
try:
|
||||
assert status._try_acquire_file_lock(handle) is True
|
||||
status._release_file_lock(handle)
|
||||
finally:
|
||||
handle.close()
|
||||
|
||||
assert calls == [
|
||||
(fd, 1, 1, status._WINDOWS_LOCK_OFFSET),
|
||||
(fd, 2, 1, status._WINDOWS_LOCK_OFFSET),
|
||||
]
|
||||
assert lock_path.read_text(encoding="utf-8") == "\n"
|
||||
|
||||
def test_acquire_scoped_lock_rejects_live_other_process(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
lock_path = tmp_path / "locks" / "telegram-bot-token-2bb80d537b1da3e3.lock"
|
||||
|
||||
@@ -55,6 +55,9 @@ def _make_runner(session_entry: SessionEntry, *, platform: Platform = Platform.T
|
||||
runner._pending_approvals = {}
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_session_title.return_value = None
|
||||
# Default: no DB row → /status reports 0 tokens. Tests that exercise
|
||||
# the populated path override this.
|
||||
runner._session_db.get_session.return_value = None
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
@@ -80,6 +83,14 @@ async def test_status_command_reports_running_agent_without_interrupt(monkeypatc
|
||||
total_tokens=321,
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
# Token total comes from the SQLite SessionDB, not SessionEntry.
|
||||
runner._session_db.get_session.return_value = {
|
||||
"input_tokens": 200,
|
||||
"output_tokens": 121,
|
||||
"cache_read_tokens": 0,
|
||||
"cache_write_tokens": 0,
|
||||
"reasoning_tokens": 0,
|
||||
}
|
||||
running_agent = MagicMock()
|
||||
runner._running_agents[build_session_key(_make_source())] = running_agent
|
||||
|
||||
@@ -113,6 +124,56 @@ async def test_status_command_includes_session_title_when_present():
|
||||
assert "**Title:** My titled session" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_reads_token_totals_from_session_db():
|
||||
"""Regression test for #17158: /status must source token totals from the
|
||||
SQLite SessionDB (where run_agent.py persists them) and sum all component
|
||||
counts, not from SessionEntry (which the agent never writes)."""
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
total_tokens=0, # SessionEntry never gets written to — always 0.
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
runner._session_db.get_session.return_value = {
|
||||
"input_tokens": 1000,
|
||||
"output_tokens": 250,
|
||||
"cache_read_tokens": 500,
|
||||
"cache_write_tokens": 100,
|
||||
"reasoning_tokens": 50,
|
||||
}
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
# 1000 + 250 + 500 + 100 + 50 = 1,900
|
||||
assert "**Tokens:** 1,900" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_command_tokens_zero_when_session_db_row_missing():
|
||||
"""When the SessionDB has no row for the current session yet (fresh
|
||||
session, no agent calls), /status reports 0 without raising."""
|
||||
session_entry = SessionEntry(
|
||||
session_key=build_session_key(_make_source()),
|
||||
session_id="sess-1",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
total_tokens=999, # This should be ignored.
|
||||
)
|
||||
runner = _make_runner(session_entry)
|
||||
runner._session_db.get_session.return_value = None
|
||||
|
||||
result = await runner._handle_message(_make_event("/status"))
|
||||
|
||||
assert "**Tokens:** 0" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agents_command_reports_active_agents_and_processes(monkeypatch):
|
||||
session_key = build_session_key(_make_source())
|
||||
@@ -507,3 +568,68 @@ async def test_profile_command_reports_custom_root_profile(monkeypatch, tmp_path
|
||||
|
||||
assert "**Profile:** `coder`" in result
|
||||
assert f"**Home:** `{profile_home}`" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_delivery_callback_generation_snapshot_happens_after_bind():
|
||||
"""Regression: the callback_generation snapshot in _process_message_background
|
||||
must happen AFTER the handler runs, not before.
|
||||
|
||||
_hermes_run_generation is set on the interrupt event by
|
||||
GatewayRunner._bind_adapter_run_generation during _handle_message_with_agent.
|
||||
The earlier snapshot-at-task-start always captured None, which bypassed the
|
||||
generation-ownership check in pop_post_delivery_callback and let stale runs
|
||||
fire a fresher run's callbacks.
|
||||
"""
|
||||
import asyncio
|
||||
from gateway.platforms.base import BasePlatformAdapter
|
||||
|
||||
source = _make_source()
|
||||
session_key = build_session_key(source)
|
||||
fired = []
|
||||
|
||||
class _ConcreteAdapter(BasePlatformAdapter):
|
||||
platform = Platform.TELEGRAM
|
||||
|
||||
async def connect(self): pass
|
||||
async def disconnect(self): pass
|
||||
async def send(self, chat_id, content, **kwargs): pass
|
||||
async def get_chat_info(self, chat_id): return {}
|
||||
|
||||
adapter = _ConcreteAdapter(
|
||||
PlatformConfig(enabled=True, token="***"), Platform.TELEGRAM
|
||||
)
|
||||
|
||||
async def fake_handler(event):
|
||||
# Simulate what _bind_adapter_run_generation does mid-run.
|
||||
interrupt_event = adapter._active_sessions.get(session_key)
|
||||
setattr(interrupt_event, "_hermes_run_generation", 1)
|
||||
# Stale run registers its callback at generation=1.
|
||||
adapter.register_post_delivery_callback(
|
||||
session_key,
|
||||
lambda: fired.append("older"),
|
||||
generation=1,
|
||||
)
|
||||
# A fresher run overwrites with generation=2 (different dict entry).
|
||||
adapter.register_post_delivery_callback(
|
||||
session_key,
|
||||
lambda: fired.append("newer"),
|
||||
generation=2,
|
||||
)
|
||||
return None
|
||||
|
||||
adapter.set_message_handler(fake_handler)
|
||||
event = MessageEvent(text="hello", source=source, message_id="m1")
|
||||
|
||||
await adapter.handle_message(event)
|
||||
tasks = list(adapter._background_tasks)
|
||||
assert tasks, "expected background task to be created"
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# The stale run (generation=1) must NOT fire the fresher run's callback
|
||||
# (generation=2). With the pre-fix code, callback_generation was snapshotted
|
||||
# as None before the handler ran, bypassing the ownership check and firing
|
||||
# "newer" anyway.
|
||||
assert fired == []
|
||||
assert session_key in adapter._post_delivery_callbacks
|
||||
assert adapter._post_delivery_callbacks[session_key][0] == 2
|
||||
|
||||
@@ -59,6 +59,21 @@ def _make_adapter(extra=None):
|
||||
return adapter
|
||||
|
||||
|
||||
class _AuthRunner:
|
||||
"""Minimal runner shim for callback auth tests."""
|
||||
|
||||
def __init__(self, authorized: bool):
|
||||
self.authorized = authorized
|
||||
self.last_source = None
|
||||
|
||||
async def _handle_message(self, event):
|
||||
return None
|
||||
|
||||
def _is_user_authorized(self, source):
|
||||
self.last_source = source
|
||||
return self.authorized
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# send_exec_approval — inline keyboard buttons
|
||||
# ===========================================================================
|
||||
@@ -230,6 +245,41 @@ class TestTelegramApprovalCallback:
|
||||
edit_kwargs = query.edit_message_text.call_args[1]
|
||||
assert "Denied" in edit_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_callback_rejects_user_blocked_by_global_allowlist(self):
|
||||
adapter = _make_adapter()
|
||||
adapter._approval_state[7] = "agent:main:telegram:group:12345:99"
|
||||
runner = _AuthRunner(authorized=False)
|
||||
adapter._message_handler = runner._handle_message
|
||||
|
||||
query = AsyncMock()
|
||||
query.data = "ea:once:7"
|
||||
query.message = MagicMock()
|
||||
query.message.chat_id = 12345
|
||||
query.message.chat.type = "private"
|
||||
query.from_user = MagicMock()
|
||||
query.from_user.id = 222
|
||||
query.from_user.first_name = "Mallory"
|
||||
query.answer = AsyncMock()
|
||||
query.edit_message_text = AsyncMock()
|
||||
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||
await adapter._handle_callback_query(update, context)
|
||||
|
||||
mock_resolve.assert_not_called()
|
||||
query.answer.assert_called_once()
|
||||
assert "not authorized" in query.answer.call_args[1]["text"].lower()
|
||||
query.edit_message_text.assert_not_called()
|
||||
assert adapter._approval_state[7] == "agent:main:telegram:group:12345:99"
|
||||
assert runner.last_source is not None
|
||||
assert runner.last_source.platform == Platform.TELEGRAM
|
||||
assert runner.last_source.user_id == "222"
|
||||
assert runner.last_source.chat_id == "12345"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_resolved(self):
|
||||
adapter = _make_adapter()
|
||||
@@ -333,6 +383,39 @@ class TestTelegramApprovalCallback:
|
||||
query.edit_message_text.assert_not_called()
|
||||
assert not (tmp_path / ".update_response").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_prompt_callback_rejects_user_blocked_by_global_allowlist(self, tmp_path):
|
||||
adapter = _make_adapter()
|
||||
runner = _AuthRunner(authorized=False)
|
||||
adapter._message_handler = runner._handle_message
|
||||
|
||||
query = AsyncMock()
|
||||
query.data = "update_prompt:y"
|
||||
query.message = MagicMock()
|
||||
query.message.chat_id = 12345
|
||||
query.message.chat.type = "private"
|
||||
query.from_user = MagicMock()
|
||||
query.from_user.id = 222
|
||||
query.from_user.first_name = "Mallory"
|
||||
query.answer = AsyncMock()
|
||||
query.edit_message_text = AsyncMock()
|
||||
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
|
||||
with patch("hermes_constants.get_hermes_home", return_value=tmp_path):
|
||||
with patch.dict(os.environ, {"TELEGRAM_ALLOWED_USERS": ""}):
|
||||
await adapter._handle_callback_query(update, context)
|
||||
|
||||
query.answer.assert_called_once()
|
||||
assert "not authorized" in query.answer.call_args[1]["text"].lower()
|
||||
query.edit_message_text.assert_not_called()
|
||||
assert not (tmp_path / ".update_response").exists()
|
||||
assert runner.last_source is not None
|
||||
assert runner.last_source.platform == Platform.TELEGRAM
|
||||
assert runner.last_source.user_id == "222"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_prompt_callback_allows_authorized_user(self, tmp_path):
|
||||
"""Allowed Telegram users can still answer update prompt buttons."""
|
||||
|
||||
@@ -17,13 +17,14 @@ from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_event(text="/update", platform=Platform.TELEGRAM,
|
||||
user_id="12345", chat_id="67890"):
|
||||
user_id="12345", chat_id="67890", thread_id=None):
|
||||
"""Build a MessageEvent for testing."""
|
||||
source = SessionSource(
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
chat_id=chat_id,
|
||||
user_name="testuser",
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return MessageEvent(text=text, source=source)
|
||||
|
||||
@@ -214,6 +215,34 @@ class TestHandleUpdateCommand:
|
||||
assert "timestamp" in data
|
||||
assert not (hermes_home / ".update_exit_code").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_writes_pending_marker_with_thread_id(self, tmp_path):
|
||||
"""Persists thread_id so update notifications can route back to the thread."""
|
||||
runner = _make_runner()
|
||||
event = _make_event(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="99999",
|
||||
thread_id="777",
|
||||
)
|
||||
|
||||
fake_root = tmp_path / "project"
|
||||
fake_root.mkdir()
|
||||
(fake_root / ".git").mkdir()
|
||||
(fake_root / "gateway").mkdir()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home), \
|
||||
patch("gateway.run.__file__", fake_file), \
|
||||
patch("shutil.which", side_effect=lambda x: "/usr/bin/hermes" if x == "hermes" else "/usr/bin/setsid"), \
|
||||
patch("subprocess.Popen"):
|
||||
await runner._handle_update_command(event)
|
||||
|
||||
data = json.loads((hermes_home / ".update_pending.json").read_text())
|
||||
assert data["thread_id"] == "777"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spawns_setsid(self, tmp_path):
|
||||
"""Uses setsid when available."""
|
||||
@@ -432,6 +461,31 @@ class TestSendUpdateNotification:
|
||||
assert call_args[0][0] == "67890" # chat_id
|
||||
assert "Update complete" in call_args[0][1] or "update finished" in call_args[0][1].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_notification_with_thread_metadata(self, tmp_path):
|
||||
"""Final update notification preserves thread metadata when present."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {
|
||||
"platform": "telegram",
|
||||
"chat_id": "67890",
|
||||
"thread_id": "777",
|
||||
"user_id": "12345",
|
||||
}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("done")
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
await runner._send_update_notification()
|
||||
|
||||
assert mock_adapter.send.call_args.kwargs["metadata"] == {"thread_id": "777"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_ansi_codes(self, tmp_path):
|
||||
"""ANSI escape codes are removed from output."""
|
||||
|
||||
@@ -321,6 +321,58 @@ class TestWatchUpdateProgress:
|
||||
# Check session was marked as having pending prompt
|
||||
# (may be cleared by the time we check since update finished)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_forwarding_preserves_thread_metadata(self, tmp_path):
|
||||
"""Forwarded update prompts keep the originating thread/topic metadata."""
|
||||
runner = _make_runner()
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
|
||||
pending = {
|
||||
"platform": "telegram",
|
||||
"chat_id": "111",
|
||||
"thread_id": "777",
|
||||
"user_id": "222",
|
||||
"session_key": "agent:main:telegram:group:111:777",
|
||||
}
|
||||
(hermes_home / ".update_pending.json").write_text(json.dumps(pending))
|
||||
(hermes_home / ".update_output.txt").write_text("")
|
||||
(hermes_home / ".update_prompt.json").write_text(json.dumps({
|
||||
"prompt": "Restore local changes? [Y/n]",
|
||||
"default": "y",
|
||||
"id": "threaded-prompt",
|
||||
}))
|
||||
|
||||
class _PromptCapableAdapter:
|
||||
def __init__(self):
|
||||
self.send = AsyncMock()
|
||||
self.prompt_calls = AsyncMock()
|
||||
|
||||
async def send_update_prompt(self, **kwargs):
|
||||
return await self.prompt_calls(**kwargs)
|
||||
|
||||
mock_adapter = _PromptCapableAdapter()
|
||||
runner.adapters = {Platform.TELEGRAM: mock_adapter}
|
||||
|
||||
async def finish_after_prompt():
|
||||
await asyncio.sleep(0.3)
|
||||
(hermes_home / ".update_response").write_text("y")
|
||||
await asyncio.sleep(0.2)
|
||||
(hermes_home / ".update_exit_code").write_text("0")
|
||||
|
||||
with patch("gateway.run._hermes_home", hermes_home):
|
||||
task = asyncio.create_task(finish_after_prompt())
|
||||
await runner._watch_update_progress(
|
||||
poll_interval=0.1,
|
||||
stream_interval=0.2,
|
||||
timeout=5.0,
|
||||
)
|
||||
await task
|
||||
|
||||
assert mock_adapter.prompt_calls.call_args.kwargs["metadata"] == {
|
||||
"thread_id": "777"
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleans_up_on_completion(self, tmp_path):
|
||||
"""All marker files are cleaned up when update finishes."""
|
||||
|
||||
@@ -85,6 +85,25 @@ class TestVerboseCommand:
|
||||
saved = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
assert saved["display"]["platforms"]["telegram"]["tool_progress"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quoted_false_keeps_command_disabled(self, tmp_path, monkeypatch):
|
||||
"""Quoted false must not enable the /verbose gateway command."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
'display:\n tool_progress_command: "false"\n tool_progress: all\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
|
||||
runner = _make_runner()
|
||||
result = await runner._handle_verbose_command(_make_event())
|
||||
|
||||
assert "not enabled" in result.lower()
|
||||
assert "tool_progress_command" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cycles_through_all_modes(self, tmp_path, monkeypatch):
|
||||
"""Calling /verbose repeatedly cycles through all four modes."""
|
||||
|
||||
Reference in New Issue
Block a user