chore: uptick

This commit is contained in:
Brooklyn Nicholson
2026-05-02 03:19:39 -05:00
parent 420f68e4e2
commit db884f4646
240 changed files with 25206 additions and 3155 deletions
+65
View File
@@ -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
+30
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -4
View File
@@ -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
+18 -10
View File
@@ -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())
+96
View File
@@ -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(
+58
View File
@@ -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")
+336
View File
@@ -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
View File
@@ -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,
+745
View File
@@ -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
+36
View File
@@ -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)) == []
+67
View File
@@ -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()
+41
View File
@@ -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},
)
]
+16 -5
View File
@@ -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
+33
View File
@@ -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()
+145
View File
@@ -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"
+454
View File
@@ -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("![alt](https://img.example.com/cat.png)")
assert result == "![alt](https://img.example.com/cat.png)"
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
+17
View File
@@ -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)
# ---------------------------------------------------------------------------
+223
View File
@@ -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
+51
View File
@@ -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"
+126
View File
@@ -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."""
+55 -1
View File
@@ -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."""
+52
View File
@@ -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."""
+19
View File
@@ -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."""