opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine
hermes --tui launches the native OpenTUI engine (Bun) when HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config); Ink stays the default and the shipping path is untouched. - _resolve_tui_engine() (env > config > ink); refuses opentui on Windows/Termux (no Bun) -> falls back to ink with a notice. - _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step). - _bun_bin() with HERMES_BUN override. - Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host must not bootstrap Node). - Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun is JSC; the V8 flag errors/ignores). Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI -> real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
@@ -66,7 +66,6 @@ def make_restart_runner(
|
||||
runner._background_tasks = set()
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._signal_initiated_shutdown = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Tests for #42039 — user messages stored twice in state.db.
|
||||
|
||||
When the agent has its own SessionDB reference (``_session_db is not None``),
|
||||
``_flush_messages_to_session_db()`` persists messages to SQLite during the
|
||||
agent run. The gateway's ``append_to_transcript()`` must then use
|
||||
``skip_db=True`` on all fallback paths to prevent writing a second copy
|
||||
to the same SQLite file.
|
||||
|
||||
This test covers the two fallback paths that previously lacked
|
||||
``skip_db=agent_persisted``:
|
||||
|
||||
1. ``agent_failed_early`` path — transient 429/timeout failures
|
||||
2. ``not new_messages`` path — edge case where ``history_offset`` exceeds
|
||||
the actual message count
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionEntry, SessionSource
|
||||
|
||||
|
||||
def _bootstrap(monkeypatch, tmp_path):
|
||||
"""Minimal GatewayRunner setup shared by all tests in this module."""
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
config = GatewayConfig()
|
||||
runner = gateway_run.GatewayRunner(config)
|
||||
runner.adapters = {}
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner._set_session_env = lambda _context: None
|
||||
runner._handle_active_session_busy_message = AsyncMock(return_value=False)
|
||||
runner._session_db = MagicMock()
|
||||
runner._recover_telegram_topic_thread_id = lambda _source: None
|
||||
runner._cache_session_source = lambda _key, _source: None
|
||||
runner._is_session_run_current = lambda _key, _gen: True
|
||||
runner._begin_session_run_generation = lambda _key: 1
|
||||
runner._reply_anchor_for_event = lambda _event: None
|
||||
runner._get_guild_id = lambda _event: None
|
||||
runner._should_send_voice_reply = lambda *_a, **_kw: False
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.get_or_create_session.return_value = SessionEntry(
|
||||
session_key="agent:main:telegram:group:-1001:12345",
|
||||
session_id="sess-dedup",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="group",
|
||||
)
|
||||
runner.session_store.load_transcript.return_value = []
|
||||
runner.session_store.append_to_transcript = MagicMock()
|
||||
runner.session_store.update_session = MagicMock()
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.model_metadata.get_model_context_length",
|
||||
lambda *_args, **_kwargs: 100_000,
|
||||
)
|
||||
return runner
|
||||
|
||||
|
||||
def _event():
|
||||
return MessageEvent(
|
||||
text="hello world",
|
||||
source=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
user_id="12345",
|
||||
),
|
||||
message_id="msg-42",
|
||||
)
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
user_id="12345",
|
||||
)
|
||||
|
||||
|
||||
def _assert_user_call_has_skip_db(calls, expected_skip_db: bool):
|
||||
"""Find append_to_transcript calls with role='user' and check skip_db."""
|
||||
user_calls = []
|
||||
for call in calls:
|
||||
args = call.args
|
||||
if len(args) >= 2 and isinstance(args[1], dict):
|
||||
if args[1].get("role") == "user":
|
||||
user_calls.append(call)
|
||||
assert len(user_calls) >= 1, (
|
||||
f"Expected at least one user-role append_to_transcript call, "
|
||||
f"got calls: {[c.args for c in calls if len(c.args)>=2]}"
|
||||
)
|
||||
for call in user_calls:
|
||||
actual = call.kwargs.get("skip_db", False)
|
||||
assert actual == expected_skip_db, (
|
||||
f"Expected skip_db={expected_skip_db} for user-role call, "
|
||||
f"got skip_db={actual}. kwargs={call.kwargs}"
|
||||
)
|
||||
|
||||
|
||||
# ── Test 1: agent_failed_early path uses skip_db=True ─────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_failed_early_skip_db_when_agent_has_session_db(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
runner = _bootstrap(monkeypatch, tmp_path)
|
||||
|
||||
# Agent fails with transient 429
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"failed": True,
|
||||
"final_response": None,
|
||||
"error": "429 Too Many Requests — rate limit exceeded",
|
||||
"messages": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
await runner._handle_message_with_agent(
|
||||
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
|
||||
)
|
||||
|
||||
_assert_user_call_has_skip_db(
|
||||
runner.session_store.append_to_transcript.call_args_list, True
|
||||
)
|
||||
|
||||
|
||||
# ── Test 2: agent_failed_early with no _session_db → skip_db not True ─
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_failed_early_no_skip_db_when_no_session_db(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
runner = _bootstrap(monkeypatch, tmp_path)
|
||||
runner._session_db = None # No agent DB → agent_persisted=False
|
||||
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"failed": True,
|
||||
"final_response": None,
|
||||
"error": "ReadTimeout: timed out",
|
||||
"messages": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
await runner._handle_message_with_agent(
|
||||
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
|
||||
)
|
||||
|
||||
_assert_user_call_has_skip_db(
|
||||
runner.session_store.append_to_transcript.call_args_list, False
|
||||
)
|
||||
|
||||
|
||||
# ── Test 3: not-new-messages path uses skip_db=True ───────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_new_messages_skip_db_when_agent_has_session_db(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
runner = _bootstrap(monkeypatch, tmp_path)
|
||||
|
||||
# Agent succeeds but history_offset equals messages length → no new messages
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"final_response": "Hello!",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [],
|
||||
"history_offset": 1, # equals len(messages) → new_messages=[]
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
await runner._handle_message_with_agent(
|
||||
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
|
||||
)
|
||||
|
||||
_assert_user_call_has_skip_db(
|
||||
runner.session_store.append_to_transcript.call_args_list, True
|
||||
)
|
||||
|
||||
|
||||
# ── Test 4: normal path (new_messages found) uses skip_db=True ────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normal_path_skip_db_when_agent_has_session_db(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
runner = _bootstrap(monkeypatch, tmp_path)
|
||||
|
||||
# Agent succeeds with new messages
|
||||
runner._run_agent = AsyncMock(
|
||||
return_value={
|
||||
"final_response": "Hello!",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "Hello!"},
|
||||
],
|
||||
"tools": [],
|
||||
"history_offset": 0,
|
||||
"last_prompt_tokens": 0,
|
||||
}
|
||||
)
|
||||
|
||||
await runner._handle_message_with_agent(
|
||||
_event(), _source(), "agent:main:telegram:group:-1001:12345", 1
|
||||
)
|
||||
|
||||
_assert_user_call_has_skip_db(
|
||||
runner.session_store.append_to_transcript.call_args_list, True
|
||||
)
|
||||
@@ -27,7 +27,6 @@ sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
|
||||
from gateway.platforms.base import (
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
Platform,
|
||||
SessionSource,
|
||||
build_session_key,
|
||||
)
|
||||
@@ -67,8 +66,6 @@ def _make_runner():
|
||||
runner._busy_text_mode = "interrupt"
|
||||
runner.adapters = {}
|
||||
runner.config = MagicMock()
|
||||
runner.config.group_sessions_per_user = True
|
||||
runner.config.thread_sessions_per_user = False
|
||||
runner.session_store = None
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
@@ -122,55 +119,6 @@ class TestBusySessionAck:
|
||||
assert sk not in runner._pending_messages
|
||||
running_agent.interrupt.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_grace_followups_respect_queue_fifo(self, monkeypatch):
|
||||
"""Rapid Telegram text follow-ups in queue mode must not merge."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
monkeypatch.setenv("HERMES_TELEGRAM_FOLLOWUP_GRACE_SECONDS", "3.0")
|
||||
|
||||
runner, _sentinel = _make_runner()
|
||||
runner._busy_input_mode = "queue"
|
||||
runner._queued_events = {}
|
||||
adapter = _make_adapter()
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="123",
|
||||
chat_type="dm",
|
||||
user_id="user1",
|
||||
)
|
||||
sk = build_session_key(source)
|
||||
runner.adapters[source.platform] = adapter
|
||||
|
||||
agent = MagicMock()
|
||||
agent.get_activity_summary.return_value = {
|
||||
"seconds_since_activity": 0.0,
|
||||
}
|
||||
runner._running_agents[sk] = agent
|
||||
runner._running_agents_ts[sk] = time.time()
|
||||
|
||||
events = [
|
||||
MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=f"m-{idx}",
|
||||
)
|
||||
for idx, text in enumerate(("first", "second", "third"), start=1)
|
||||
]
|
||||
|
||||
for event in events:
|
||||
result = await GatewayRunner._handle_message(runner, event)
|
||||
assert result is None
|
||||
|
||||
assert adapter._pending_messages[sk].text == "first"
|
||||
assert [event.text for event in runner._queued_events[sk]] == [
|
||||
"second",
|
||||
"third",
|
||||
]
|
||||
agent.interrupt.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_ack_when_agent_running(self):
|
||||
"""First message during busy session should get a status ack."""
|
||||
|
||||
@@ -146,30 +146,6 @@ def test_concurrent_compressions_same_session_serialize(tmp_path: Path) -> None:
|
||||
agent_a = _build_agent_with_db(db, shared_sid)
|
||||
agent_b = _build_agent_with_db(db, shared_sid)
|
||||
|
||||
# Force genuine simultaneous lock contention instead of relying on a
|
||||
# ``time.sleep`` inside the compressor stub to make the threads overlap.
|
||||
# Under CI CPU starvation that sleep is not enough: one thread could
|
||||
# acquire → compress → rotate → RELEASE the lock before the other even
|
||||
# reaches ``try_acquire``, so both would acquire on the shared id and
|
||||
# both would compress (the historical "got 2" flake). A two-party
|
||||
# barrier in front of the real acquire guarantees both threads are
|
||||
# contending for the lock at the same instant, which is exactly the
|
||||
# condition this test means to assert — with zero timing dependency.
|
||||
barrier = threading.Barrier(2, timeout=15)
|
||||
_real_acquire = db.try_acquire_compression_lock
|
||||
|
||||
def _barriered_acquire(*args, **kwargs):
|
||||
# Rendezvous both callers, then let the real (atomic) acquire decide
|
||||
# the single winner. Tolerate a broken barrier so a test-side timeout
|
||||
# never masquerades as a lock-logic failure.
|
||||
try:
|
||||
barrier.wait()
|
||||
except threading.BrokenBarrierError:
|
||||
pass
|
||||
return _real_acquire(*args, **kwargs)
|
||||
|
||||
db.try_acquire_compression_lock = _barriered_acquire
|
||||
|
||||
results: dict[str, list | None] = {"a": None, "b": None}
|
||||
errors: list[Exception] = []
|
||||
|
||||
@@ -187,10 +163,6 @@ def test_concurrent_compressions_same_session_serialize(tmp_path: Path) -> None:
|
||||
t_a.join(timeout=15)
|
||||
t_b.join(timeout=15)
|
||||
|
||||
# Restore the real method so the post-join lock-leak assertion below
|
||||
# (and any future call) hits the unwrapped implementation.
|
||||
db.try_acquire_compression_lock = _real_acquire
|
||||
|
||||
assert not errors, f"Compression raised exceptions: {errors}"
|
||||
|
||||
# Count which agents actually compressed (returned fewer messages than input)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for gateway configuration management."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -214,43 +213,6 @@ class TestGatewayConfigRoundtrip:
|
||||
assert restored.group_sessions_per_user is False
|
||||
assert restored.thread_sessions_per_user is True
|
||||
|
||||
def test_max_concurrent_sessions_from_dict_normalizes_disabled_values(self):
|
||||
assert GatewayConfig.from_dict({}).max_concurrent_sessions is None
|
||||
assert GatewayConfig.from_dict({"max_concurrent_sessions": None}).max_concurrent_sessions is None
|
||||
assert GatewayConfig.from_dict({"max_concurrent_sessions": 0}).max_concurrent_sessions is None
|
||||
assert GatewayConfig.from_dict({"max_concurrent_sessions": -1}).max_concurrent_sessions is None
|
||||
|
||||
def test_max_concurrent_sessions_from_dict_accepts_positive_integer(self):
|
||||
config = GatewayConfig.from_dict({"max_concurrent_sessions": "3"})
|
||||
|
||||
assert config.max_concurrent_sessions == 3
|
||||
|
||||
def test_max_concurrent_sessions_from_dict_ignores_invalid_values(self, caplog):
|
||||
caplog.set_level(logging.WARNING, logger="gateway.config")
|
||||
|
||||
config = GatewayConfig.from_dict({"max_concurrent_sessions": "many"})
|
||||
|
||||
assert config.max_concurrent_sessions is None
|
||||
assert any(
|
||||
"Ignoring invalid max_concurrent_sessions='many'" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
def test_max_concurrent_sessions_from_dict_accepts_nested_fallback(self):
|
||||
config = GatewayConfig.from_dict({"gateway": {"max_concurrent_sessions": 4}})
|
||||
|
||||
assert config.max_concurrent_sessions == 4
|
||||
|
||||
def test_max_concurrent_sessions_top_level_overrides_nested(self):
|
||||
config = GatewayConfig.from_dict(
|
||||
{
|
||||
"gateway": {"max_concurrent_sessions": 4},
|
||||
"max_concurrent_sessions": 2,
|
||||
}
|
||||
)
|
||||
|
||||
assert config.max_concurrent_sessions == 2
|
||||
|
||||
def test_roundtrip_preserves_unauthorized_dm_behavior(self):
|
||||
config = GatewayConfig(
|
||||
unauthorized_dm_behavior="ignore",
|
||||
@@ -347,51 +309,6 @@ class TestLoadGatewayConfig:
|
||||
|
||||
assert config.thread_sessions_per_user is False
|
||||
|
||||
def test_bridges_top_level_max_concurrent_sessions_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("max_concurrent_sessions: 2\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.max_concurrent_sessions == 2
|
||||
|
||||
def test_bridges_nested_max_concurrent_sessions_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(
|
||||
"gateway:\n"
|
||||
" max_concurrent_sessions: 3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.max_concurrent_sessions == 3
|
||||
|
||||
def test_top_level_max_concurrent_sessions_overrides_nested_config_yaml(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
config_path = hermes_home / "config.yaml"
|
||||
config_path.write_text(
|
||||
"max_concurrent_sessions: 2\n"
|
||||
"gateway:\n"
|
||||
" max_concurrent_sessions: 3\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
config = load_gateway_config()
|
||||
|
||||
assert config.max_concurrent_sessions == 2
|
||||
|
||||
def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch):
|
||||
"""discord.thread_require_mention in config.yaml should reach the runtime env var."""
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
|
||||
@@ -266,12 +266,11 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_wait_for_ready(ready_event, bot_task, timeout):
|
||||
async def fake_wait_for(awaitable, timeout):
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(
|
||||
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
|
||||
)
|
||||
monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for)
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
@@ -280,89 +279,6 @@ async def test_connect_releases_token_lock_on_timeout(monkeypatch):
|
||||
assert adapter._platform_lock_identity is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_timeout_cancels_bot_task(monkeypatch):
|
||||
"""Regression: connect() timeout must cancel _bot_task so the zombie
|
||||
Discord client cannot fire on_message after the adapter is discarded.
|
||||
|
||||
Without this fix, the orphaned task eventually completes its WebSocket
|
||||
handshake and a subsequent successful reconnect leaves two live clients
|
||||
that each process every message, producing duplicate threads.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
|
||||
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
|
||||
|
||||
intents = SimpleNamespace(
|
||||
message_content=False, dm_messages=False, guild_messages=False,
|
||||
members=False, voice_states=False,
|
||||
)
|
||||
monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents)
|
||||
|
||||
class NeverReadyBot(FakeBot):
|
||||
"""Bot whose start() never fires on_ready — simulates a slow gateway handshake."""
|
||||
async def start(self, token):
|
||||
await asyncio.Event().wait() # hang forever
|
||||
|
||||
monkeypatch.setattr(
|
||||
discord_platform.commands,
|
||||
"Bot",
|
||||
lambda **kwargs: NeverReadyBot(
|
||||
intents=kwargs["intents"],
|
||||
proxy=kwargs.get("proxy"),
|
||||
allowed_mentions=kwargs.get("allowed_mentions"),
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_wait_for_ready(ready_event, bot_task, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
monkeypatch.setattr(
|
||||
discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready
|
||||
)
|
||||
|
||||
ok = await adapter.connect()
|
||||
|
||||
assert ok is False
|
||||
assert adapter._bot_task is None, (
|
||||
"_bot_task must be cancelled and cleared on connect() timeout; "
|
||||
"leaving it alive creates a zombie Discord client that produces duplicate threads"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_cancels_running_bot_task(monkeypatch):
|
||||
"""Regression: disconnect() must cancel _bot_task even when connect() timed out.
|
||||
|
||||
_dispose_unused_adapter calls disconnect() on adapters whose connect() returned
|
||||
False. If _bot_task was still running (zombie), disconnect() must cancel it.
|
||||
"""
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None))
|
||||
monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None)
|
||||
|
||||
# Simulate a zombie bot_task that never finishes (as if discord.py is mid-handshake)
|
||||
async def _forever():
|
||||
await asyncio.Event().wait() # hang forever
|
||||
|
||||
zombie_task = asyncio.create_task(_forever())
|
||||
adapter._bot_task = zombie_task
|
||||
adapter._client = AsyncMock()
|
||||
adapter._post_connect_task = None
|
||||
adapter._voice_clients = {}
|
||||
adapter._running = True
|
||||
adapter._ready_event = asyncio.Event()
|
||||
|
||||
await adapter.disconnect()
|
||||
|
||||
# The task must have been cancelled (done + cancelled) and cleared from the adapter.
|
||||
assert adapter._bot_task is None, "disconnect() must clear _bot_task"
|
||||
assert zombie_task.done(), "disconnect() must have awaited the bot task to completion"
|
||||
assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_does_not_wait_for_slash_sync(monkeypatch):
|
||||
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))
|
||||
|
||||
@@ -80,91 +80,3 @@ async def test_model_picker_clears_controls_before_running_switch_callback():
|
||||
interaction.response.edit_message.assert_awaited_once()
|
||||
interaction.response.defer.assert_not_called()
|
||||
interaction.edit_original_response.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expensive_model_requires_confirmation(monkeypatch):
|
||||
events: list[object] = []
|
||||
|
||||
async def on_model_selected(chat_id: str, model_id: str, provider_slug: str) -> str:
|
||||
events.append(("switch", chat_id, model_id, provider_slug))
|
||||
return "Model switched"
|
||||
|
||||
async def edit_message(**kwargs):
|
||||
events.append(
|
||||
(
|
||||
"edit",
|
||||
kwargs["embed"].title,
|
||||
kwargs["embed"].description,
|
||||
kwargs["view"],
|
||||
)
|
||||
)
|
||||
|
||||
async def edit_original_response(**kwargs):
|
||||
events.append((
|
||||
"final-edit",
|
||||
kwargs["embed"].title,
|
||||
kwargs["embed"].description,
|
||||
kwargs["view"],
|
||||
))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(
|
||||
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
|
||||
),
|
||||
)
|
||||
|
||||
view = ModelPickerView(
|
||||
providers=[
|
||||
{
|
||||
"slug": "openrouter",
|
||||
"name": "OpenRouter",
|
||||
"models": ["openai/gpt-5.5-pro"],
|
||||
"total_models": 1,
|
||||
"is_current": True,
|
||||
}
|
||||
],
|
||||
current_model="openai/gpt-5.5",
|
||||
current_provider="openrouter",
|
||||
session_key="session-1",
|
||||
on_model_selected=on_model_selected,
|
||||
allowed_user_ids={"123"}, # matches the interaction user; empty = fail-closed
|
||||
)
|
||||
view._selected_provider = "openrouter"
|
||||
|
||||
interaction = SimpleNamespace(
|
||||
user=SimpleNamespace(id=123),
|
||||
channel_id=456,
|
||||
data={"values": ["openai/gpt-5.5-pro"]},
|
||||
response=SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
edit_message=AsyncMock(side_effect=edit_message),
|
||||
),
|
||||
edit_original_response=AsyncMock(side_effect=edit_original_response),
|
||||
)
|
||||
|
||||
await view._on_model_selected(interaction)
|
||||
|
||||
assert events == [
|
||||
(
|
||||
"edit",
|
||||
"⚠ Expensive Model Warning",
|
||||
"!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?",
|
||||
view,
|
||||
),
|
||||
]
|
||||
assert view.resolved is False
|
||||
|
||||
await view._on_expensive_confirm(interaction)
|
||||
|
||||
assert events[1:] == [
|
||||
(
|
||||
"edit",
|
||||
"⚙ Switching Model",
|
||||
"Switching to `openai/gpt-5.5-pro`...",
|
||||
None,
|
||||
),
|
||||
("switch", "456", "openai/gpt-5.5-pro", "openrouter"),
|
||||
("final-edit", "⚙ Model Switched", "Model switched", None),
|
||||
]
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Tests for the document context note prepended to user turns with attachments.
|
||||
|
||||
A user who attaches a PDF / DOCX in chat used to see the agent treat it as
|
||||
"unreadable" because the context note told the model to "Ask the user what
|
||||
they'd like you to do with it" — steering it away from extracting the text it
|
||||
is perfectly capable of reading. These tests pin the contract:
|
||||
|
||||
- text documents: note confirms the (adapter-)inlined content + records path.
|
||||
- binary documents (PDF/DOCX/…): note tells the agent to extract the text
|
||||
itself and never tells it to punt back to the user.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
_build_document_context_note = gateway_run._build_document_context_note
|
||||
|
||||
|
||||
class TestTextDocumentNote:
|
||||
@pytest.mark.parametrize("mtype", ["text/plain", "text/markdown", "text/csv"])
|
||||
def test_text_note_mentions_included_content_and_path(self, mtype):
|
||||
note = _build_document_context_note("notes.txt", "/cache/doc_notes.txt", mtype)
|
||||
assert "text document" in note
|
||||
assert "notes.txt" in note
|
||||
assert "/cache/doc_notes.txt" in note
|
||||
assert "included below" in note
|
||||
|
||||
|
||||
class TestBinaryDocumentNote:
|
||||
@pytest.mark.parametrize(
|
||||
"mtype",
|
||||
[
|
||||
"application/pdf",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"application/octet-stream",
|
||||
],
|
||||
)
|
||||
def test_binary_note_guides_extraction(self, mtype):
|
||||
note = _build_document_context_note("contract.pdf", "/cache/doc_contract.pdf", mtype)
|
||||
# Records the path so the agent can open it.
|
||||
assert "/cache/doc_contract.pdf" in note
|
||||
# Tells the agent to read it by extracting the text...
|
||||
assert "extract" in note.lower()
|
||||
# ...and does NOT steer it into punting back to the user (the bug).
|
||||
assert "ask the user" not in note.lower()
|
||||
assert "paste" in note.lower()
|
||||
|
||||
def test_binary_note_distinct_from_text_note(self):
|
||||
text_note = _build_document_context_note("a.txt", "/c/a.txt", "text/plain")
|
||||
pdf_note = _build_document_context_note("a.pdf", "/c/a.pdf", "application/pdf")
|
||||
assert text_note != pdf_note
|
||||
# The text path claims content is inlined; the binary path must not.
|
||||
assert "included below" in text_note
|
||||
assert "included below" not in pdf_note
|
||||
@@ -358,90 +358,3 @@ async def test_gateway_stop_kills_tool_subprocesses_on_graceful_path(monkeypatch
|
||||
|
||||
# Only the final catch-all fires on the graceful path.
|
||||
assert kill_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gateway_state persistence on shutdown (issue #42675)
|
||||
#
|
||||
# On Docker/s6, container_boot.py only auto-starts gateways whose last
|
||||
# persisted gateway_state was "running". An unexpected external signal
|
||||
# (the SIGTERM s6/Docker sends on `docker compose up --force-recreate`,
|
||||
# OOM, bare kill) must NOT persist "stopped" — otherwise the gateway
|
||||
# stays down after every container restart. An operator-initiated stop
|
||||
# writes a planned-stop marker first, so it is NOT signal-initiated and
|
||||
# DOES persist "stopped", respecting the explicit intent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _persisted_states(runner) -> list:
|
||||
"""All gateway_state values passed to _update_runtime_status, in order."""
|
||||
states = []
|
||||
for call in runner._update_runtime_status.call_args_list:
|
||||
args, kwargs = call
|
||||
state = kwargs.get("gateway_state", args[0] if args else None)
|
||||
states.append(state)
|
||||
return states
|
||||
|
||||
|
||||
def _stopped_state_persisted(runner) -> bool:
|
||||
"""True iff _update_runtime_status was called with gateway_state='stopped'."""
|
||||
return "stopped" in _persisted_states(runner)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_initiated_shutdown_persists_running_not_stopped(tmp_path, monkeypatch):
|
||||
"""Unexpected SIGTERM (container restart / OOM / kill) must persist
|
||||
gateway_state=running — NOT stopped, and NOT leave the mid-shutdown
|
||||
'draining' marker — so container_boot auto-starts on next boot (#42675)."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.disconnect = AsyncMock()
|
||||
runner._signal_initiated_shutdown = True # set by handler on unmarked signal
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop()
|
||||
|
||||
assert not _stopped_state_persisted(runner), (
|
||||
"signal-initiated shutdown must NOT persist gateway_state=stopped"
|
||||
)
|
||||
# The FINAL terminal write must be 'running' so container_boot's
|
||||
# _AUTOSTART_STATES check passes (it only auto-starts 'running').
|
||||
assert _persisted_states(runner)[-1] == "running", (
|
||||
f"final state must be 'running', got: {_persisted_states(runner)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_initiated_stop_persists_stopped(tmp_path, monkeypatch):
|
||||
"""A planned stop (marker written → not signal-initiated) must persist
|
||||
gateway_state=stopped so an explicit `hermes gateway stop` stays down."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.disconnect = AsyncMock()
|
||||
runner._signal_initiated_shutdown = False # planned stop classification
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop()
|
||||
|
||||
assert _stopped_state_persisted(runner), (
|
||||
"operator-initiated stop must persist gateway_state=stopped"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signal_initiated_restart_still_persists_stopped(tmp_path, monkeypatch):
|
||||
"""A restart is not a 'stay down' — it persists normally (the new
|
||||
process/container brings the gateway back up itself). The suppression
|
||||
only applies to a terminal signal-initiated stop, not a restart."""
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
runner, adapter = make_restart_runner()
|
||||
adapter.disconnect = AsyncMock()
|
||||
runner._signal_initiated_shutdown = True
|
||||
runner._launch_systemd_restart_shortcut = MagicMock()
|
||||
|
||||
with patch("gateway.status.remove_pid_file"), patch("gateway.status.write_runtime_status"):
|
||||
await runner.stop(restart=True, service_restart=True)
|
||||
|
||||
assert _stopped_state_persisted(runner), (
|
||||
"a restart must persist gateway_state=stopped via the normal path"
|
||||
)
|
||||
|
||||
+24
-1506
File diff suppressed because it is too large
Load Diff
@@ -28,38 +28,13 @@ def _stub_mautrix():
|
||||
sys.modules.setdefault(sub, types.ModuleType(sub))
|
||||
sys.modules.setdefault("mautrix", stub)
|
||||
m = sys.modules["mautrix.types"]
|
||||
|
||||
class EventType:
|
||||
ROOM_MESSAGE = "m.room.message"
|
||||
REACTION = "m.reaction"
|
||||
ROOM_ENCRYPTED = "m.room.encrypted"
|
||||
ROOM_NAME = "m.room.name"
|
||||
|
||||
class PaginationDirection:
|
||||
BACKWARD = "b"
|
||||
FORWARD = "f"
|
||||
|
||||
class PresenceState:
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
UNAVAILABLE = "unavailable"
|
||||
|
||||
class RoomCreatePreset:
|
||||
PRIVATE = "private_chat"
|
||||
PUBLIC = "public_chat"
|
||||
TRUSTED_PRIVATE = "trusted_private_chat"
|
||||
|
||||
class TrustState:
|
||||
UNVERIFIED = 0
|
||||
VERIFIED = 1
|
||||
|
||||
for attr in ("ContentURI", "EventID", "RoomID", "SyncToken", "UserID"):
|
||||
setattr(m, attr, str)
|
||||
m.EventType = EventType
|
||||
m.PaginationDirection = PaginationDirection
|
||||
m.PresenceState = PresenceState
|
||||
m.RoomCreatePreset = RoomCreatePreset
|
||||
m.TrustState = TrustState
|
||||
for attr in (
|
||||
"ContentURI", "EventID", "EventType", "PaginationDirection",
|
||||
"PresenceState", "RoomCreatePreset", "RoomID", "SyncToken",
|
||||
"TrustState", "UserID",
|
||||
):
|
||||
if not hasattr(m, attr):
|
||||
setattr(m, attr, str)
|
||||
|
||||
|
||||
_stub_mautrix()
|
||||
|
||||
@@ -27,9 +27,9 @@ class TestMatrixExecApprovalReactions:
|
||||
assert result.success is True
|
||||
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||
assert adapter._send_reaction.await_count == 3
|
||||
assert adapter._send_reaction.await_count == 2
|
||||
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||
assert emojis == ["✅", "♾️", "❌"]
|
||||
assert emojis == ["✅", "❎"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
|
||||
@@ -1,510 +0,0 @@
|
||||
"""Matrix Project A / Project B context-isolation regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import (
|
||||
SessionContext,
|
||||
SessionEntry,
|
||||
SessionSource,
|
||||
build_session_context_prompt,
|
||||
build_session_key,
|
||||
)
|
||||
|
||||
PROJECT_A_ROOM_ID = "!projectA:example.org"
|
||||
PROJECT_B_ROOM_ID = "!projectB:example.org"
|
||||
PROJECT_A_NAME = "Project - Project A"
|
||||
PROJECT_B_NAME = "Project - Project B"
|
||||
PROJECT_A_TOPIC = "Architecture and deploy plan for Project A"
|
||||
PROJECT_B_TOPIC = "Migration and branch plan for Project B"
|
||||
PROJECT_A_ALIAS = "#project-a:example.org"
|
||||
PROJECT_B_ALIAS = "#project-b:example.org"
|
||||
SENDER = "@alice:example.org"
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
from gateway.platforms.matrix import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(
|
||||
PlatformConfig(
|
||||
enabled=True,
|
||||
token="test-token",
|
||||
extra={"homeserver": "https://matrix.example.org", "user_id": "@bot:example.org"},
|
||||
)
|
||||
)
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._require_mention = False
|
||||
adapter._auto_thread = False
|
||||
adapter._matrix_session_scope = "room"
|
||||
adapter._text_batch_delay_seconds = 0
|
||||
adapter._background_read_receipt = MagicMock()
|
||||
adapter._get_display_name = AsyncMock(return_value="Alice")
|
||||
adapter._client = _FakeMatrixClient()
|
||||
return adapter
|
||||
|
||||
|
||||
class _FakeMatrixClient:
|
||||
def __init__(self):
|
||||
self.state_store = MagicMock()
|
||||
self.state_store.get_members = AsyncMock(return_value=["@bot:example.org", SENDER])
|
||||
|
||||
async def get_state_event(self, room_id, event_type):
|
||||
rid = str(room_id)
|
||||
state = {
|
||||
PROJECT_A_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_A_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_A_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_A_ALIAS}},
|
||||
},
|
||||
PROJECT_B_ROOM_ID: {
|
||||
"m.room.name": {"content": {"name": PROJECT_B_NAME}},
|
||||
"m.room.topic": {"content": {"topic": PROJECT_B_TOPIC}},
|
||||
"m.room.canonical_alias": {"content": {"alias": PROJECT_B_ALIAS}},
|
||||
},
|
||||
}
|
||||
value = state.get(rid, {}).get(str(event_type))
|
||||
if value is None:
|
||||
raise KeyError((rid, event_type))
|
||||
return value
|
||||
|
||||
|
||||
async def _source_for(adapter, room_id: str, event_id: str = "$event"):
|
||||
ctx = await adapter._resolve_message_context(
|
||||
room_id=room_id,
|
||||
sender=SENDER,
|
||||
event_id=event_id,
|
||||
body="What is next?",
|
||||
source_content={"body": "What is next?"},
|
||||
relates_to={},
|
||||
)
|
||||
assert ctx is not None
|
||||
return ctx[-1]
|
||||
|
||||
|
||||
def _matrix_event(room_id: str, event_id: str, body: str = "What is next?"):
|
||||
event = MagicMock()
|
||||
event.room_id = room_id
|
||||
event.sender = SENDER
|
||||
event.event_id = event_id
|
||||
event.timestamp = int(time.time() * 1000)
|
||||
event.server_timestamp = event.timestamp
|
||||
event.content = {"msgtype": "m.text", "body": body}
|
||||
return event
|
||||
|
||||
|
||||
def _context_for(source: SessionSource) -> SessionContext:
|
||||
return SessionContext(
|
||||
source=source,
|
||||
connected_platforms=[Platform.MATRIX],
|
||||
home_channels={},
|
||||
session_key=build_session_key(source),
|
||||
session_id="session-test",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_source_includes_room_name_topic_and_message_id():
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$project-b-msg")
|
||||
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.guild_id == "example.org"
|
||||
assert source.message_id == "$project-b-msg"
|
||||
assert source.parent_chat_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_a_and_project_b_have_distinct_session_keys():
|
||||
adapter = _make_adapter()
|
||||
source_a = await _source_for(adapter, PROJECT_A_ROOM_ID, "$a")
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
assert source_a.chat_id != source_b.chat_id
|
||||
assert source_a.chat_name == PROJECT_A_NAME
|
||||
assert source_b.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(source_a) != build_session_key(source_b)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_b_prompt_contains_project_b_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
source_b = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b")
|
||||
|
||||
prompt = build_session_context_prompt(_context_for(source_b))
|
||||
|
||||
assert PROJECT_B_NAME in prompt
|
||||
assert PROJECT_B_TOPIC in prompt
|
||||
assert PROJECT_B_ROOM_ID in prompt
|
||||
assert "Matrix room boundary" in prompt
|
||||
assert PROJECT_A_NAME not in prompt
|
||||
assert PROJECT_A_TOPIC not in prompt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_sequential_messages():
|
||||
adapter = _make_adapter()
|
||||
adapter._matrix_session_scope = "room"
|
||||
first = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b1")
|
||||
second = await _source_for(adapter, PROJECT_B_ROOM_ID, "$b2")
|
||||
|
||||
assert first.thread_id is None
|
||||
assert second.thread_id is None
|
||||
assert first.chat_name == PROJECT_B_NAME
|
||||
assert second.chat_name == PROJECT_B_NAME
|
||||
assert build_session_key(first) == build_session_key(second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_session_scope_auto_and_thread_preserve_synthetic_threads():
|
||||
adapter = _make_adapter()
|
||||
adapter._auto_thread = True
|
||||
adapter._matrix_session_scope = "auto"
|
||||
auto_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$auto")
|
||||
assert auto_source.thread_id == "$auto"
|
||||
|
||||
adapter._matrix_session_scope = "thread"
|
||||
thread_source = await _source_for(adapter, PROJECT_B_ROOM_ID, "$thread")
|
||||
assert thread_source.thread_id == "$thread"
|
||||
|
||||
real_thread = await adapter._resolve_message_context(
|
||||
room_id=PROJECT_B_ROOM_ID,
|
||||
sender=SENDER,
|
||||
event_id="$reply",
|
||||
body="thread reply",
|
||||
source_content={"body": "thread reply"},
|
||||
relates_to={"rel_type": "m.thread", "event_id": "$root"},
|
||||
)
|
||||
assert real_thread is not None
|
||||
assert real_thread[-1].thread_id == "$root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_project_context_survives_concurrent_messages():
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session_context import get_session_env
|
||||
|
||||
async def observe(room_id: str):
|
||||
adapter = _make_adapter()
|
||||
source = await _source_for(adapter, room_id, f"${room_id}")
|
||||
context = _context_for(source)
|
||||
runner = object.__new__(GatewayRunner)
|
||||
tokens = runner._set_session_env(context)
|
||||
try:
|
||||
await asyncio.sleep(0)
|
||||
return SimpleNamespace(
|
||||
chat_id=get_session_env("HERMES_SESSION_CHAT_ID"),
|
||||
chat_name=get_session_env("HERMES_SESSION_CHAT_NAME"),
|
||||
session_key=get_session_env("HERMES_SESSION_KEY"),
|
||||
)
|
||||
finally:
|
||||
runner._clear_session_env(tokens)
|
||||
|
||||
observed_a, observed_b = await asyncio.gather(
|
||||
observe(PROJECT_A_ROOM_ID),
|
||||
observe(PROJECT_B_ROOM_ID),
|
||||
)
|
||||
|
||||
assert observed_a.chat_id == PROJECT_A_ROOM_ID
|
||||
assert observed_b.chat_id == PROJECT_B_ROOM_ID
|
||||
assert observed_a.chat_name == PROJECT_A_NAME
|
||||
assert observed_b.chat_name == PROJECT_B_NAME
|
||||
assert observed_a.session_key != observed_b.session_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_emits_project_b_metadata_not_project_a():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b"))
|
||||
|
||||
assert len(captured) == 1
|
||||
source = captured[0].source
|
||||
assert source.chat_id == PROJECT_B_ROOM_ID
|
||||
assert source.chat_name == PROJECT_B_NAME
|
||||
assert source.chat_topic == PROJECT_B_TOPIC
|
||||
assert source.message_id == "$project-b"
|
||||
assert PROJECT_A_NAME not in repr(source.to_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_inbound_handler_keeps_project_a_and_b_distinct():
|
||||
adapter = _make_adapter()
|
||||
captured = []
|
||||
|
||||
async def capture(event):
|
||||
captured.append(event)
|
||||
|
||||
adapter.handle_message = capture
|
||||
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_A_ROOM_ID, "$project-a", "A"))
|
||||
await adapter._on_room_message(_matrix_event(PROJECT_B_ROOM_ID, "$project-b", "B"))
|
||||
|
||||
assert [event.source.chat_id for event in captured] == [
|
||||
PROJECT_A_ROOM_ID,
|
||||
PROJECT_B_ROOM_ID,
|
||||
]
|
||||
assert [event.source.chat_name for event in captured] == [
|
||||
PROJECT_A_NAME,
|
||||
PROJECT_B_NAME,
|
||||
]
|
||||
assert build_session_key(captured[0].source) != build_session_key(captured[1].source)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_true_separates_users():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=True) != build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=True,
|
||||
)
|
||||
|
||||
|
||||
def test_matrix_room_scope_group_sessions_per_user_false_shares_room():
|
||||
alice = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
bob.user_id = "@bob:example.org"
|
||||
alice.thread_id = None
|
||||
bob.thread_id = None
|
||||
|
||||
assert build_session_key(alice, group_sessions_per_user=False) == build_session_key(
|
||||
bob,
|
||||
group_sessions_per_user=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_matrix_source(room_id: str, room_name: str, topic: str) -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id=room_id,
|
||||
chat_name=room_name,
|
||||
chat_type="group",
|
||||
user_id=SENDER,
|
||||
user_name="Alice",
|
||||
chat_topic=topic,
|
||||
)
|
||||
|
||||
|
||||
def _entry(source: SessionSource, session_id: str, title: str | None = None) -> SessionEntry:
|
||||
return SessionEntry(
|
||||
session_key=build_session_key(source),
|
||||
session_id=session_id,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
display_name=title or source.chat_name,
|
||||
platform=Platform.MATRIX,
|
||||
chat_type="group",
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(current_source: SessionSource, entries: list[SessionEntry]):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(platforms={Platform.MATRIX: PlatformConfig(enabled=True)})
|
||||
adapter = MagicMock()
|
||||
adapter._matrix_session_scope = "room"
|
||||
runner.adapters = {Platform.MATRIX: adapter}
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store._entries = {entry.session_key: entry for entry in entries}
|
||||
current = next((e for e in entries if e.origin and e.origin.chat_id == current_source.chat_id), entries[0])
|
||||
runner.session_store.get_or_create_session.return_value = current
|
||||
runner.session_store.switch_session.return_value = current
|
||||
runner.session_store.load_transcript.return_value = [{"role": "user", "content": "hello"}]
|
||||
runner._running_agents = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._release_running_agent_state = MagicMock()
|
||||
runner._clear_session_boundary_security_state = MagicMock()
|
||||
runner._evict_cached_agent = MagicMock()
|
||||
runner._queue_depth = MagicMock(return_value=0)
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.list_sessions_rich.return_value = [
|
||||
{"id": entry.session_id, "title": entry.display_name, "preview": ""}
|
||||
for entry in entries
|
||||
]
|
||||
runner._session_db.resolve_resume_session_id.side_effect = lambda sid: sid
|
||||
runner._session_db.get_session_title.side_effect = lambda sid: {
|
||||
entry.session_id: entry.display_name for entry in entries
|
||||
}.get(sid)
|
||||
runner._session_db.get_session.return_value = None
|
||||
return runner
|
||||
|
||||
|
||||
def _event(text: str, source: SessionSource) -> MessageEvent:
|
||||
return MessageEvent(text=text, source=source, message_id="$cmd")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_status_reports_current_matrix_room_scope():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [_entry(source_a, "session-a", "Project A Plan"), entry_b])
|
||||
|
||||
result = await runner._handle_status_command(_event("/status", source_b))
|
||||
|
||||
assert "Matrix scope:" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
assert PROJECT_B_ROOM_ID in result
|
||||
assert "session_scope: room" in result
|
||||
session_key = build_session_key(source_b)
|
||||
assert session_key not in result
|
||||
assert session_key[:8] not in result
|
||||
assert "session_key: sha256:" in result
|
||||
assert PROJECT_A_NAME not in result
|
||||
assert PROJECT_A_ROOM_ID not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_does_not_cross_rooms_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project A Plan", source_b))
|
||||
|
||||
assert "blocked" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_allows_same_room_session():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume Project B Plan", source_b))
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_same_room():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_b = _entry(source_b, "session-b-old", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_b])
|
||||
runner.session_store.get_or_create_session.return_value = _entry(
|
||||
source_b, "session-b-current", "Current Project B"
|
||||
)
|
||||
runner.session_store.switch_session.return_value = entry_b
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-b-old"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "Resumed session" in result
|
||||
runner._session_db.resolve_session_by_title.assert_called_once_with("Project B Plan")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_quoted_title_cross_room_blocked():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project A Plan"', source_b)
|
||||
)
|
||||
|
||||
assert "blocked" in result
|
||||
runner.session_store.switch_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_malformed_quote_returns_helpful_error():
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(source_b, [_entry(source_b, "session-b", "Project B Plan")])
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event('/resume "Project B Plan', source_b)
|
||||
)
|
||||
|
||||
assert "Could not parse" in result
|
||||
assert "quotes" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_cross_room_requires_explicit_flag_and_warns():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
entry_a = _entry(source_a, "session-a", "Project A Plan")
|
||||
entry_b = _entry(source_b, "session-b", "Project B Plan")
|
||||
runner = _make_runner(source_b, [entry_a, entry_b])
|
||||
runner.session_store.switch_session.return_value = entry_a
|
||||
runner._session_db.resolve_session_by_title.return_value = "session-a"
|
||||
|
||||
result = await runner._handle_resume_command(
|
||||
_event("/resume --cross-room Project A Plan", source_b)
|
||||
)
|
||||
|
||||
assert "Cross-room resume" in result
|
||||
assert PROJECT_B_NAME in result
|
||||
runner.session_store.switch_session.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_lists_only_current_room_by_default():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume", source_b))
|
||||
|
||||
assert "Project B Plan" in result
|
||||
assert "Project A Plan" not in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matrix_resume_all_lists_room_names():
|
||||
source_a = _make_matrix_source(PROJECT_A_ROOM_ID, PROJECT_A_NAME, PROJECT_A_TOPIC)
|
||||
source_b = _make_matrix_source(PROJECT_B_ROOM_ID, PROJECT_B_NAME, PROJECT_B_TOPIC)
|
||||
runner = _make_runner(
|
||||
source_b,
|
||||
[_entry(source_a, "session-a", "Project A Plan"), _entry(source_b, "session-b", "Project B Plan")],
|
||||
)
|
||||
|
||||
result = await runner._handle_resume_command(_event("/resume --all", source_b))
|
||||
|
||||
assert "Project A Plan" in result
|
||||
assert PROJECT_A_NAME in result
|
||||
assert "Project B Plan" in result
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Tests for the gateway max_concurrent_sessions active-session cap."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_active_session_registry(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
def __init__(self):
|
||||
self._pending_messages = {}
|
||||
self._active_sessions = {}
|
||||
|
||||
async def send(self, chat_id, text, **kwargs):
|
||||
return None
|
||||
|
||||
async def interrupt_session_activity(self, session_key, chat_id):
|
||||
event = self._active_sessions.get(session_key)
|
||||
if event is not None:
|
||||
event.set()
|
||||
|
||||
|
||||
def _make_source(chat_id: str = "chat-1") -> SessionSource:
|
||||
return SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id=chat_id,
|
||||
chat_type="dm",
|
||||
user_id=f"user-{chat_id}",
|
||||
)
|
||||
|
||||
|
||||
def _make_event(text: str = "hello", chat_id: str = "chat-1") -> MessageEvent:
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=_make_source(chat_id),
|
||||
)
|
||||
|
||||
|
||||
def _make_runner(max_concurrent_sessions: int | None = None) -> GatewayRunner:
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(
|
||||
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")},
|
||||
max_concurrent_sessions=max_concurrent_sessions,
|
||||
)
|
||||
runner.adapters = {Platform.TELEGRAM: _FakeAdapter()}
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._active_session_leases = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner._voice_mode = {}
|
||||
runner._background_tasks = set()
|
||||
runner._draining = False
|
||||
runner._restart_requested = False
|
||||
runner._restart_task_started = False
|
||||
runner._restart_detached = False
|
||||
runner._restart_via_service = False
|
||||
runner._restart_drain_timeout = 0.0
|
||||
runner._stop_task = None
|
||||
runner._exit_code = None
|
||||
runner._busy_ack_ts = {}
|
||||
runner._busy_input_mode = "interrupt"
|
||||
runner._busy_text_mode = "interrupt"
|
||||
runner._queued_events = {}
|
||||
runner._update_runtime_status = MagicMock()
|
||||
runner._is_user_authorized = lambda _source: True
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
runner.session_store = MagicMock()
|
||||
runner.delivery_router = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
def _occupy_session(runner: GatewayRunner, chat_id: str = "busy"):
|
||||
source = _make_source(chat_id)
|
||||
session_key = build_session_key(source)
|
||||
runner._running_agents[session_key] = MagicMock()
|
||||
runner._running_agents_ts[session_key] = time.time()
|
||||
return session_key
|
||||
|
||||
|
||||
def _silence_global_gateway_hooks(monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *args, **kwargs: [])
|
||||
monkeypatch.setattr("tools.slash_confirm.get_pending", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("tools.slash_confirm.clear_if_stale", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr("tools.approval.has_blocking_approval", lambda *args, **kwargs: False)
|
||||
|
||||
|
||||
def test_new_session_gets_clean_error_at_active_session_limit(monkeypatch):
|
||||
_silence_global_gateway_hooks(monkeypatch)
|
||||
runner = _make_runner(max_concurrent_sessions=1)
|
||||
_occupy_session(runner, "busy")
|
||||
event = _make_event(chat_id="new")
|
||||
new_key = build_session_key(event.source)
|
||||
|
||||
async def fail_if_agent_runs(self_inner, ev, src, qk, generation):
|
||||
raise AssertionError("_handle_message_with_agent should not run at capacity")
|
||||
|
||||
with patch.object(GatewayRunner, "_handle_message_with_agent", fail_if_agent_runs):
|
||||
result = asyncio.run(runner._handle_message(event))
|
||||
|
||||
assert result == (
|
||||
"Hermes is at the active session limit (1/1). "
|
||||
"Try again when another session finishes."
|
||||
)
|
||||
assert new_key not in runner._running_agents
|
||||
runner.session_store.get_or_create_session.assert_not_called()
|
||||
|
||||
|
||||
def test_existing_active_session_uses_busy_handling_at_limit(monkeypatch):
|
||||
_silence_global_gateway_hooks(monkeypatch)
|
||||
runner = _make_runner(max_concurrent_sessions=1)
|
||||
runner._busy_input_mode = "queue"
|
||||
event = _make_event(chat_id="busy")
|
||||
session_key = build_session_key(event.source)
|
||||
runner._running_agents[session_key] = MagicMock()
|
||||
runner._running_agents_ts[session_key] = 0
|
||||
|
||||
async def fail_if_agent_runs(self_inner, ev, src, qk, generation):
|
||||
raise AssertionError("_handle_message_with_agent should not run for busy follow-up")
|
||||
|
||||
with patch.object(GatewayRunner, "_handle_message_with_agent", fail_if_agent_runs):
|
||||
result = asyncio.run(runner._handle_message(event))
|
||||
|
||||
assert result is None
|
||||
assert runner.adapters[Platform.TELEGRAM]._pending_messages[session_key] is event
|
||||
|
||||
|
||||
def test_new_session_can_start_after_active_session_released(monkeypatch):
|
||||
_silence_global_gateway_hooks(monkeypatch)
|
||||
runner = _make_runner(max_concurrent_sessions=1)
|
||||
busy_key = _occupy_session(runner, "busy")
|
||||
runner._release_running_agent_state(busy_key)
|
||||
event = _make_event(chat_id="new")
|
||||
|
||||
sentinel_seen = False
|
||||
|
||||
async def mock_agent_run(self_inner, ev, src, qk, generation):
|
||||
nonlocal sentinel_seen
|
||||
sentinel_seen = runner._running_agents.get(qk) is _AGENT_PENDING_SENTINEL
|
||||
return "ok"
|
||||
|
||||
with patch.object(GatewayRunner, "_handle_message_with_agent", mock_agent_run):
|
||||
result = asyncio.run(runner._handle_message(event))
|
||||
|
||||
assert result == "ok"
|
||||
assert sentinel_seen is True
|
||||
|
||||
|
||||
def test_status_command_bypasses_active_session_limit(monkeypatch):
|
||||
_silence_global_gateway_hooks(monkeypatch)
|
||||
runner = _make_runner(max_concurrent_sessions=1)
|
||||
_occupy_session(runner, "busy")
|
||||
runner._handle_status_command = AsyncMock(return_value="status ok")
|
||||
|
||||
result = asyncio.run(runner._handle_message(_make_event("/status", chat_id="new")))
|
||||
|
||||
assert result == "status ok"
|
||||
runner._handle_status_command.assert_awaited_once()
|
||||
|
||||
|
||||
def test_skill_command_that_would_start_agent_is_blocked_at_limit(monkeypatch):
|
||||
_silence_global_gateway_hooks(monkeypatch)
|
||||
runner = _make_runner(max_concurrent_sessions=1)
|
||||
_occupy_session(runner, "busy")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.skill_commands.get_skill_commands",
|
||||
lambda: {"demo": {"name": "demo-skill"}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.skill_commands.resolve_skill_command_key",
|
||||
lambda command: "demo" if command == "demo" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.skill_commands.build_skill_invocation_message",
|
||||
lambda *args, **kwargs: "invoke demo skill",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.skill_utils.get_disabled_skill_names",
|
||||
lambda *args, **kwargs: [],
|
||||
)
|
||||
|
||||
async def fail_if_agent_runs(self_inner, ev, src, qk, generation):
|
||||
raise AssertionError("_handle_message_with_agent should not run at capacity")
|
||||
|
||||
with patch.object(GatewayRunner, "_handle_message_with_agent", fail_if_agent_runs):
|
||||
result = asyncio.run(
|
||||
runner._handle_message(_make_event("/demo please", chat_id="new"))
|
||||
)
|
||||
|
||||
assert result == (
|
||||
"Hermes is at the active session limit (1/1). "
|
||||
"Try again when another session finishes."
|
||||
)
|
||||
@@ -159,106 +159,7 @@ caption
|
||||
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
|
||||
assert tags == ["MEDIA:/tmp/voice.ogg"]
|
||||
assert voice is True
|
||||
|
||||
def test_gateway_auto_append_image_generate_json_path(self):
|
||||
"""image_generate returns a local path in JSON (no MEDIA: tag); it is
|
||||
auto-appended so delivery doesn't depend on the model restating it."""
|
||||
from gateway.run import _collect_auto_append_media_tags
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Make me a cat"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_img", "function": {"name": "image_generate"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_img",
|
||||
"content": '{"success": true, "image": "/tmp/gen/cat.png", "agent_visible_image": "/tmp/gen/cat.png"}',
|
||||
},
|
||||
{"role": "assistant", "content": "Here's your cat."},
|
||||
]
|
||||
|
||||
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
|
||||
assert tags == ["MEDIA:/tmp/gen/cat.png"]
|
||||
assert voice is False
|
||||
|
||||
def test_gateway_auto_append_image_generate_prefers_host_path(self):
|
||||
"""When host and sandbox paths differ, the host-deliverable path wins."""
|
||||
from gateway.run import _collect_auto_append_media_tags
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Make me a dog"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "call_img", "function": {"name": "image_generate"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "call_img",
|
||||
"content": '{"success": true, "host_image": "/host/dog.jpg", "image": "/host/dog.jpg", "agent_visible_image": "/sandbox/dog.jpg"}',
|
||||
},
|
||||
]
|
||||
|
||||
tags, _ = _collect_auto_append_media_tags(messages, history_offset=0)
|
||||
assert tags == ["MEDIA:/host/dog.jpg"]
|
||||
|
||||
def test_gateway_auto_append_image_generate_failure_and_url_ignored(self):
|
||||
"""Failed generations and remote URLs are not auto-delivered."""
|
||||
from gateway.run import _collect_auto_append_media_tags
|
||||
|
||||
def _img_msgs(content):
|
||||
return [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "c", "function": {"name": "image_generate"}}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "c", "content": content},
|
||||
]
|
||||
|
||||
# Failed generation
|
||||
tags, _ = _collect_auto_append_media_tags(
|
||||
_img_msgs('{"success": false, "image": null, "error": "boom"}'),
|
||||
history_offset=0,
|
||||
)
|
||||
assert tags == []
|
||||
|
||||
# Remote URL is not a local file path
|
||||
tags, _ = _collect_auto_append_media_tags(
|
||||
_img_msgs('{"success": true, "image": "https://fal.media/x/cat.png"}'),
|
||||
history_offset=0,
|
||||
)
|
||||
assert tags == []
|
||||
|
||||
def test_gateway_auto_append_image_generate_dedupes_history(self):
|
||||
"""A generated image path already in history is not re-sent."""
|
||||
from gateway.run import _collect_auto_append_media_tags
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [
|
||||
{"id": "c", "function": {"name": "image_generate"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c",
|
||||
"content": '{"success": true, "image": "/tmp/gen/cat.png"}',
|
||||
},
|
||||
]
|
||||
|
||||
tags, _ = _collect_auto_append_media_tags(
|
||||
messages, history_offset=0, history_media_paths={"/tmp/gen/cat.png"}
|
||||
)
|
||||
assert tags == []
|
||||
|
||||
|
||||
def test_media_tags_not_extracted_from_history(self):
|
||||
"""MEDIA tags from previous turns should NOT be extracted again."""
|
||||
# Simulate conversation history with a TTS call from a previous turn
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Gateway typed ``/model <name>`` must route through the expensive-model
|
||||
confirmation gate.
|
||||
|
||||
The pickers (Telegram/Discord inline keyboards, TUI, dashboard) confirm
|
||||
expensive models via their own UI affordances; the typed text command
|
||||
previously bypassed the guard entirely — a user typing
|
||||
``/model openai/gpt-5.5-pro`` switched silently while the picker warned.
|
||||
These tests pin the typed path:
|
||||
|
||||
- warning fires → handler returns the slash-confirm prompt, switch NOT applied
|
||||
- confirm ("once") → switch applies (session override set)
|
||||
- cancel → switch not applied, current model unchanged
|
||||
- no warning (cheap model) → switch applies immediately, no prompt
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner():
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.adapters = {}
|
||||
runner._voice_mode = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._running_agents = {}
|
||||
return runner
|
||||
|
||||
|
||||
def _make_event(text):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
def _fake_switch_result():
|
||||
from hermes_cli.model_switch import ModelSwitchResult
|
||||
|
||||
return ModelSwitchResult(
|
||||
success=True,
|
||||
new_model="openai/gpt-5.5-pro",
|
||||
target_provider="openrouter",
|
||||
provider_changed=False,
|
||||
api_key="sk-test",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
provider_label="OpenRouter",
|
||||
)
|
||||
|
||||
|
||||
def _fake_warning():
|
||||
return SimpleNamespace(
|
||||
message=(
|
||||
"!!! EXPENSIVE MODEL WARNING !!!\n"
|
||||
"openai/gpt-5.5-pro has known pricing above Hermes' safety threshold.\n"
|
||||
"did you mean to select openai/gpt-5.5?"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _setup_isolated_home(tmp_path, monkeypatch, *, warn):
|
||||
import gateway.run as gateway_run
|
||||
|
||||
hermes_home = tmp_path / ".hermes"
|
||||
hermes_home.mkdir()
|
||||
cfg_path = hermes_home / "config.yaml"
|
||||
cfg_path.write_text(
|
||||
yaml.safe_dump({"model": {"default": "old-model", "provider": "openrouter"}, "providers": {}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_switch.switch_model",
|
||||
lambda **kw: _fake_switch_result(),
|
||||
)
|
||||
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
(lambda *a, **kw: _fake_warning()) if warn else (lambda *a, **kw: None),
|
||||
)
|
||||
return cfg_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_model_expensive_prompts_instead_of_switching(tmp_path, monkeypatch):
|
||||
"""Expensive model typed directly → confirm prompt, no switch applied."""
|
||||
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
|
||||
runner = _make_runner()
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _fake_request_slash_confirm(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return kwargs["message"]
|
||||
|
||||
runner._request_slash_confirm = _fake_request_slash_confirm
|
||||
|
||||
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
|
||||
|
||||
assert result is not None
|
||||
assert "EXPENSIVE MODEL WARNING" in result
|
||||
# The switch must NOT have been applied yet.
|
||||
assert runner._session_model_overrides == {}
|
||||
assert captured["command"] == "model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_model_expensive_confirm_once_applies_switch(tmp_path, monkeypatch):
|
||||
"""Resolving the confirm with "once" applies the switch."""
|
||||
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
|
||||
runner = _make_runner()
|
||||
runner._evict_cached_agent = lambda session_key: None
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _fake_request_slash_confirm(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return None # buttons rendered
|
||||
|
||||
runner._request_slash_confirm = _fake_request_slash_confirm
|
||||
|
||||
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
|
||||
assert runner._session_model_overrides == {}
|
||||
|
||||
reply = await captured["handler"]("once")
|
||||
|
||||
assert "gpt-5.5-pro" in reply
|
||||
overrides = list(runner._session_model_overrides.values())
|
||||
assert len(overrides) == 1
|
||||
assert overrides[0]["model"] == "openai/gpt-5.5-pro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_model_expensive_cancel_keeps_current_model(tmp_path, monkeypatch):
|
||||
"""Resolving the confirm with "cancel" leaves everything unchanged."""
|
||||
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, warn=True)
|
||||
runner = _make_runner()
|
||||
|
||||
captured = {}
|
||||
|
||||
async def _fake_request_slash_confirm(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return None
|
||||
|
||||
runner._request_slash_confirm = _fake_request_slash_confirm
|
||||
|
||||
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro --global"))
|
||||
|
||||
reply = await captured["handler"]("cancel")
|
||||
|
||||
assert "cancelled" in reply.lower()
|
||||
assert runner._session_model_overrides == {}
|
||||
# --global must not have persisted the cancelled switch.
|
||||
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert written["model"]["default"] == "old-model"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typed_model_cheap_switches_without_prompt(tmp_path, monkeypatch):
|
||||
"""No warning → switch applies immediately; confirm primitive never invoked."""
|
||||
_setup_isolated_home(tmp_path, monkeypatch, warn=False)
|
||||
runner = _make_runner()
|
||||
runner._evict_cached_agent = lambda session_key: None
|
||||
|
||||
async def _fail_request_slash_confirm(**kwargs): # pragma: no cover
|
||||
raise AssertionError("confirm should not be requested for cheap models")
|
||||
|
||||
runner._request_slash_confirm = _fail_request_slash_confirm
|
||||
|
||||
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
|
||||
|
||||
assert result is not None
|
||||
assert "gpt-5.5-pro" in result
|
||||
overrides = list(runner._session_model_overrides.values())
|
||||
assert len(overrides) == 1
|
||||
@@ -360,95 +360,3 @@ class TestQueueConsumptionAfterCompletion:
|
||||
e.text for e in runner._queued_events[session_key]
|
||||
]
|
||||
assert collected == texts
|
||||
|
||||
|
||||
class TestBusyInputModeQueueFifo:
|
||||
"""Regression coverage for issue #28503.
|
||||
|
||||
``busy_input_mode: queue`` rapid follow-ups used to silently overwrite
|
||||
a single pending slot, losing every message except the last. The
|
||||
runner's busy/queue/steer-fallback entry point now routes through
|
||||
the same FIFO infrastructure as ``/queue``, so each follow-up gets
|
||||
its own turn in arrival order.
|
||||
"""
|
||||
|
||||
def _make_runner_and_adapter(self):
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner._queued_events = {}
|
||||
adapter = _StubAdapter()
|
||||
runner.adapters = {Platform.TELEGRAM: adapter}
|
||||
return runner, adapter
|
||||
|
||||
def _text_event(self, text: str) -> MessageEvent:
|
||||
source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM)
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id=f"m-{text}",
|
||||
)
|
||||
|
||||
def test_rapid_text_followups_are_queued_in_fifo_order(self):
|
||||
"""Five rapid texts in queue mode must all survive (none silently dropped)."""
|
||||
runner, adapter = self._make_runner_and_adapter()
|
||||
session_key = "telegram:user:fifo"
|
||||
|
||||
texts = ["one", "two", "three", "four", "five"]
|
||||
for text in texts:
|
||||
runner._queue_or_replace_pending_event(session_key, self._text_event(text))
|
||||
|
||||
# Head slot keeps the first; overflow keeps the rest in order.
|
||||
assert adapter._pending_messages[session_key].text == "one"
|
||||
assert [e.text for e in runner._queued_events[session_key]] == [
|
||||
"two",
|
||||
"three",
|
||||
"four",
|
||||
"five",
|
||||
]
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == len(texts)
|
||||
|
||||
def test_queue_respects_bounded_cap(self):
|
||||
"""Beyond the per-session cap, follow-ups are dropped (with a warning)."""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner, adapter = self._make_runner_and_adapter()
|
||||
session_key = "telegram:user:cap"
|
||||
|
||||
cap = GatewayRunner._BUSY_QUEUE_MAX_PENDING
|
||||
for i in range(cap + 5):
|
||||
runner._queue_or_replace_pending_event(
|
||||
session_key, self._text_event(f"msg-{i:03d}")
|
||||
)
|
||||
|
||||
# Exactly ``cap`` follow-ups retained (head + cap-1 in overflow).
|
||||
assert runner._queue_depth(session_key, adapter=adapter) == cap
|
||||
assert adapter._pending_messages[session_key].text == "msg-000"
|
||||
# The last accepted overflow item is msg-{cap-1}.
|
||||
assert runner._queued_events[session_key][-1].text == f"msg-{cap - 1:03d}"
|
||||
|
||||
def test_photo_burst_still_merges_in_head_slot(self):
|
||||
"""Photo bursts must keep album-merge semantics, not split into N turns."""
|
||||
runner, adapter = self._make_runner_and_adapter()
|
||||
session_key = "telegram:user:burst"
|
||||
|
||||
source = MagicMock(chat_id="c1", platform=Platform.TELEGRAM)
|
||||
for i in range(3):
|
||||
runner._queue_or_replace_pending_event(
|
||||
session_key,
|
||||
MessageEvent(
|
||||
text="",
|
||||
message_type=MessageType.PHOTO,
|
||||
source=source,
|
||||
message_id=f"p-{i}",
|
||||
media_urls=[f"http://example.com/{i}.jpg"],
|
||||
media_types=["image/jpeg"],
|
||||
),
|
||||
)
|
||||
|
||||
# Single merged head event with all three media URLs.
|
||||
assert session_key not in runner._queued_events or not runner._queued_events[session_key]
|
||||
head = adapter._pending_messages[session_key]
|
||||
assert head.message_type == MessageType.PHOTO
|
||||
assert len(head.media_urls) == 3
|
||||
|
||||
@@ -197,10 +197,8 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "linux")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["/usr/bin/hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/setsid" if cmd == "setsid" else None)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
@@ -219,72 +217,6 @@ async def test_launch_detached_restart_command_uses_setsid(monkeypatch):
|
||||
assert kwargs["start_new_session"] is True
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
# The watcher must NOT inherit the gateway marker, or the CLI's
|
||||
# self-restart loop guard refuses to run `hermes gateway restart`.
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
|
||||
|
||||
def test_windows_gateway_venv_imports_add_site_packages(monkeypatch, tmp_path):
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
pth_extra = tmp_path / "pywin32_system32"
|
||||
site_packages.mkdir(parents=True)
|
||||
pth_extra.mkdir()
|
||||
(site_packages / "pywin32.pth").write_text(str(pth_extra), encoding="utf-8")
|
||||
project_root = str(gateway_run.Path(gateway_run.__file__).resolve().parent.parent)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run.sys, "path", ["existing"])
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
monkeypatch.setenv("PYTHONPATH", "already-there")
|
||||
|
||||
gateway_run._ensure_windows_gateway_venv_imports()
|
||||
|
||||
assert gateway_run.sys.path[:2] == [project_root, str(site_packages)]
|
||||
assert str(pth_extra) in gateway_run.sys.path
|
||||
assert gateway_run.os.environ["VIRTUAL_ENV"] == str(venv_dir.resolve())
|
||||
pythonpath = gateway_run.os.environ["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert pythonpath[:3] == [project_root, str(site_packages), "already-there"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path):
|
||||
runner, _adapter = make_restart_runner()
|
||||
popen_calls = []
|
||||
venv_dir = tmp_path / "venv"
|
||||
site_packages = venv_dir / "Lib" / "site-packages"
|
||||
site_packages.mkdir(parents=True)
|
||||
|
||||
monkeypatch.setattr(gateway_run.sys, "platform", "win32")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
|
||||
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
|
||||
monkeypatch.setenv("_HERMES_GATEWAY", "1")
|
||||
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
|
||||
|
||||
import hermes_cli._subprocess_compat as subprocess_compat
|
||||
|
||||
monkeypatch.setattr(
|
||||
subprocess_compat,
|
||||
"windows_detach_popen_kwargs",
|
||||
lambda: {},
|
||||
)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
await runner._launch_detached_restart_command()
|
||||
|
||||
assert len(popen_calls) == 1
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd[-3:] == ["hermes", "gateway", "restart"]
|
||||
assert kwargs["env"].get("_HERMES_GATEWAY") is None
|
||||
assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir)
|
||||
assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep)
|
||||
assert kwargs["stdout"] is subprocess.DEVNULL
|
||||
assert kwargs["stderr"] is subprocess.DEVNULL
|
||||
|
||||
|
||||
# ── Shutdown notification tests ──────────────────────────────────────
|
||||
|
||||
@@ -153,10 +153,6 @@ async def test_restart_command_uses_atomic_json_writes_for_marker_files(tmp_path
|
||||
def _fake_atomic_json_write(path, payload, **kwargs):
|
||||
calls.append((Path(path).name, payload, kwargs))
|
||||
|
||||
# _handle_restart_command lives in gateway/slash_commands.py (extracted from
|
||||
# run.py); it uses that module's top-level atomic_json_write import.
|
||||
import gateway.slash_commands as gateway_slash
|
||||
monkeypatch.setattr(gateway_slash, "atomic_json_write", _fake_atomic_json_write)
|
||||
monkeypatch.setattr(gateway_run, "atomic_json_write", _fake_atomic_json_write)
|
||||
|
||||
runner, _adapter = make_restart_runner()
|
||||
|
||||
@@ -9,7 +9,6 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import gateway.platforms.base as base_platform
|
||||
from gateway.config import Platform, PlatformConfig, StreamingConfig
|
||||
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
||||
from gateway.session import SessionSource
|
||||
@@ -1077,54 +1076,6 @@ async def test_base_processing_releases_post_delivery_callback_after_main_send()
|
||||
assert released == [True]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_processing_stops_typing_before_hung_post_delivery_callback(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A stuck post-delivery callback must not keep the typing task alive."""
|
||||
monkeypatch.setattr(base_platform, "_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS", 0.01)
|
||||
adapter = ProgressCaptureAdapter()
|
||||
events = []
|
||||
|
||||
async def _handler(event):
|
||||
return "done"
|
||||
|
||||
async def _post_delivery_cb():
|
||||
events.append("callback-start")
|
||||
await asyncio.Event().wait()
|
||||
|
||||
async def _stop_typing(chat_id):
|
||||
events.append("typing-stopped")
|
||||
await ProgressCaptureAdapter.stop_typing(adapter, chat_id)
|
||||
|
||||
adapter.set_message_handler(_handler)
|
||||
adapter.stop_typing = _stop_typing
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="-1001",
|
||||
chat_type="group",
|
||||
thread_id="17585",
|
||||
)
|
||||
event = MessageEvent(
|
||||
text="hello",
|
||||
message_type=MessageType.TEXT,
|
||||
source=source,
|
||||
message_id="msg-1",
|
||||
)
|
||||
session_key = "agent:main:telegram:group:-1001:17585"
|
||||
adapter._active_sessions[session_key] = asyncio.Event()
|
||||
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
|
||||
|
||||
await asyncio.wait_for(
|
||||
adapter._process_message_background(event, session_key), timeout=1.0
|
||||
)
|
||||
|
||||
assert [call["content"] for call in adapter.sent] == ["done"]
|
||||
assert events[:2] == ["typing-stopped", "callback-start"]
|
||||
assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path):
|
||||
import yaml
|
||||
@@ -1313,247 +1264,3 @@ async def test_verbose_mode_respects_explicit_tool_preview_length(monkeypatch, t
|
||||
assert VerboseAgent.LONG_CODE not in all_content
|
||||
# But should still contain the truncated portion with "..."
|
||||
assert "..." in all_content
|
||||
|
||||
|
||||
class CodeBlockProgressAdapter(ProgressCaptureAdapter):
|
||||
"""A markdown-capable progress adapter (declares supports_code_blocks)."""
|
||||
|
||||
supports_code_blocks = True
|
||||
|
||||
|
||||
class TerminalCommandAgent:
|
||||
"""Emits a terminal tool.started with a real, multi-line command arg."""
|
||||
|
||||
CMD = (
|
||||
"set -euo pipefail\n"
|
||||
"printf 'node: '; node --version\n"
|
||||
"npm install -g hyperframes@latest"
|
||||
)
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
self.tool_progress_callback(
|
||||
"tool.started", "terminal", self.CMD, {"command": self.CMD}
|
||||
)
|
||||
# Let the async progress task drain the queue and send before returning.
|
||||
time.sleep(0.35)
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
|
||||
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
|
||||
renders a bare fenced code block — no language tag (Slack mrkdwn would print
|
||||
'bash' as a literal first code line). In non-verbose ("all"/"new") mode the
|
||||
command is collapsed to a single line capped at tool_preview_length so a long
|
||||
or multi-line command doesn't render as a huge block (#42634)."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = TerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-code-block",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
# Bare fenced block, no language tag (no '```bash').
|
||||
assert "```" in all_content
|
||||
assert "```bash" not in all_content
|
||||
# Non-verbose collapses to the first line + truncation marker — the later
|
||||
# command lines must NOT appear (this was the "huge block" regression).
|
||||
assert "set -euo pipefail" in all_content
|
||||
assert "npm install -g hyperframes@latest" not in all_content
|
||||
assert "node --version" not in all_content
|
||||
# No truncated quoted preview for the terminal command.
|
||||
assert 'terminal: "' not in all_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):
|
||||
"""Verbose mode on a markdown-capable gateway renders the FULL multi-line
|
||||
command in a bare fenced block (no truncation, no 'bash' tag). This is the
|
||||
parity guarantee for #42634: verbose keeps full detail, non-verbose caps."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = TerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-code-block-verbose",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert "```" in all_content
|
||||
assert "```bash" not in all_content
|
||||
# Full command body present — verbose is uncapped.
|
||||
assert "npm install -g hyperframes@latest" in all_content
|
||||
assert "node --version" in all_content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_progress_no_bash_block_in_verbose_mode(monkeypatch, tmp_path):
|
||||
"""#41215 also rendered the bash block in verbose mode. The revert removed it
|
||||
from both branches, so verbose progress must not emit a fenced ```bash block
|
||||
either (verbose still shows args by opt-in, just not as a code block)."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = TerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-verbose-no-bash",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
all_content = " ".join(call["content"] for call in adapter.sent)
|
||||
all_content += " ".join(call["content"] for call in adapter.edits)
|
||||
assert "```bash" not in all_content
|
||||
|
||||
class MultiTerminalCommandAgent:
|
||||
"""Emits several consecutive terminal tool.started events, then a
|
||||
different tool, then terminal again — to exercise header collapsing."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.tool_progress_callback = kwargs.get("tool_progress_callback")
|
||||
self.tools = []
|
||||
|
||||
def run_conversation(self, message, conversation_history=None, task_id=None):
|
||||
cb = self.tool_progress_callback
|
||||
cb("tool.started", "terminal", "echo one", {"command": "echo one"})
|
||||
cb("tool.started", "terminal", "echo two", {"command": "echo two"})
|
||||
cb("tool.started", "terminal", "echo three", {"command": "echo three"})
|
||||
cb("tool.started", "web_search", "query stuff", {"query": "query stuff"})
|
||||
cb("tool.started", "terminal", "echo four", {"command": "echo four"})
|
||||
time.sleep(0.35)
|
||||
return {"final_response": "done", "messages": [], "api_calls": 1}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consecutive_terminal_progress_collapses_headers(monkeypatch, tmp_path):
|
||||
"""Back-to-back terminal calls render ONE "terminal" header followed by
|
||||
adjacent code blocks; a different tool in between resets the header so the
|
||||
next terminal call gets a fresh one."""
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
|
||||
|
||||
fake_dotenv = types.ModuleType("dotenv")
|
||||
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
|
||||
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
|
||||
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = MultiTerminalCommandAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
import tools.terminal_tool # noqa: F401 - register terminal emoji
|
||||
|
||||
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
|
||||
runner = _make_runner(adapter)
|
||||
gateway_run = importlib.import_module("gateway.run")
|
||||
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
|
||||
|
||||
source = SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="12345",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
result = await runner._run_agent(
|
||||
message="hello",
|
||||
context_prompt="",
|
||||
history=[],
|
||||
source=source,
|
||||
session_id="sess-terminal-consecutive",
|
||||
session_key="agent:main:telegram:dm:12345",
|
||||
)
|
||||
|
||||
assert result["final_response"] == "done"
|
||||
contents = [call["content"] for call in adapter.sent] + [
|
||||
call["content"] for call in adapter.edits
|
||||
]
|
||||
final = max(contents, key=len) if contents else ""
|
||||
# All four commands present as code blocks.
|
||||
for cmd in ("echo one", "echo two", "echo three", "echo four"):
|
||||
assert cmd in final
|
||||
# Exactly TWO terminal headers: one for the first run of three calls,
|
||||
# one for the terminal call after web_search broke the streak.
|
||||
assert final.count("terminal\n```") == 2
|
||||
|
||||
@@ -611,30 +611,6 @@ class TestSessionStoreSwitchSession:
|
||||
db.close()
|
||||
|
||||
|
||||
class TestSessionStoreLookupBySessionId:
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
config = GatewayConfig()
|
||||
with patch("gateway.session.SessionStore._ensure_loaded"):
|
||||
s = SessionStore(sessions_dir=tmp_path, config=config)
|
||||
s._db = None
|
||||
s._loaded = True
|
||||
return s
|
||||
|
||||
def test_returns_active_entry_for_persisted_session_id(self, store):
|
||||
source = SessionSource(
|
||||
platform=Platform.MATRIX,
|
||||
chat_id="!room:example.org",
|
||||
chat_type="group",
|
||||
user_id="@alice:example.org",
|
||||
)
|
||||
entry = store.get_or_create_session(source)
|
||||
|
||||
assert store.lookup_by_session_id(entry.session_id) is entry
|
||||
assert store.lookup_by_session_id("missing") is None
|
||||
assert store.lookup_by_session_id("") is None
|
||||
|
||||
|
||||
class TestWhatsAppSessionKeyConsistency:
|
||||
"""Regression: WhatsApp session keys must collapse JID/LID aliases to a
|
||||
single stable identity for both DM chat_ids and group participant_ids."""
|
||||
|
||||
@@ -75,54 +75,6 @@ async def test_capabilities_advertises_session_control_surface(adapter):
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_agent_binds_api_session_context_for_tool_env(adapter, monkeypatch):
|
||||
"""API-server request sessions should reach tools and terminal subprocess env."""
|
||||
monkeypatch.setenv("HERMES_SESSION_ID", "stale-session")
|
||||
observed = {}
|
||||
|
||||
class FakeAgent:
|
||||
session_prompt_tokens = 0
|
||||
session_completion_tokens = 0
|
||||
session_total_tokens = 0
|
||||
|
||||
def __init__(self, session_id: str):
|
||||
self.session_id = session_id
|
||||
|
||||
def run_conversation(self, user_message, conversation_history, task_id):
|
||||
from gateway.session_context import get_session_env
|
||||
from tools.environments.local import _make_run_env
|
||||
|
||||
observed["task_id"] = task_id
|
||||
observed["context_session_id"] = get_session_env("HERMES_SESSION_ID")
|
||||
observed["context_platform"] = get_session_env("HERMES_SESSION_PLATFORM")
|
||||
observed["context_session_key"] = get_session_env("HERMES_SESSION_KEY")
|
||||
observed["child_session_id"] = _make_run_env({}).get("HERMES_SESSION_ID")
|
||||
return {"final_response": "ok"}
|
||||
|
||||
def fake_create_agent(**kwargs):
|
||||
return FakeAgent(kwargs["session_id"])
|
||||
|
||||
monkeypatch.setattr(adapter, "_create_agent", fake_create_agent)
|
||||
|
||||
result, usage = await adapter._run_agent(
|
||||
user_message="hello",
|
||||
conversation_history=[],
|
||||
session_id="request-session",
|
||||
gateway_session_key="request-key",
|
||||
)
|
||||
|
||||
assert result["session_id"] == "request-session"
|
||||
assert usage == {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||
assert observed == {
|
||||
"task_id": "request-session",
|
||||
"context_session_id": "request-session",
|
||||
"context_platform": "api_server",
|
||||
"context_session_key": "request-key",
|
||||
"child_session_id": "request-session",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_crud_and_message_history(adapter, session_db):
|
||||
app = _create_session_app(adapter)
|
||||
|
||||
@@ -190,17 +190,6 @@ def test_session_key_falls_back_to_os_environ(monkeypatch):
|
||||
assert get_session_env("HERMES_SESSION_KEY") == ""
|
||||
|
||||
|
||||
def test_session_id_set_via_contextvars(monkeypatch):
|
||||
"""set_session_vars should set HERMES_SESSION_ID via contextvars."""
|
||||
monkeypatch.setenv("HERMES_SESSION_ID", "stale-env-session")
|
||||
|
||||
tokens = set_session_vars(session_id="ctx-session-456")
|
||||
assert get_session_env("HERMES_SESSION_ID") == "ctx-session-456"
|
||||
|
||||
clear_session_vars(tokens)
|
||||
assert get_session_env("HERMES_SESSION_ID") == ""
|
||||
|
||||
|
||||
def test_set_session_env_includes_session_key():
|
||||
"""_set_session_env should propagate session_key from SessionContext."""
|
||||
runner = object.__new__(GatewayRunner)
|
||||
|
||||
@@ -84,12 +84,6 @@ class _FakeGateway:
|
||||
def _evict_cached_agent(self, key):
|
||||
pass
|
||||
|
||||
def _release_running_agent_state(self, session_key, **_kwargs):
|
||||
agent = self._running_agents.pop(session_key, None)
|
||||
self._running_agents_ts.pop(session_key, None)
|
||||
self._cleanup_agent_resources(agent)
|
||||
return agent is not None
|
||||
|
||||
|
||||
def _make_mock_agent():
|
||||
a = MagicMock()
|
||||
|
||||
@@ -205,13 +205,6 @@ def test_corr_id_pending_set_self_trims():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm():
|
||||
"""DMs use the bare ``@<id> text`` chat-command form.
|
||||
|
||||
The bracketed form ``@[<id>] text`` is what the daemon's man page
|
||||
documents, but in practice both addressing styles route through
|
||||
the same chat-command parser; bare ``@<id>`` matches what every
|
||||
Hermes deployment has been using in production for months.
|
||||
"""
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
|
||||
adapter = SimplexAdapter(cfg)
|
||||
@@ -229,14 +222,6 @@ async def test_send_dm():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_group():
|
||||
"""Groups use the structured ``/_send #<id> json [...]`` form.
|
||||
|
||||
The bracket chat-command form ``#[<id>] text`` *looks* like an exact
|
||||
ID match in the daemon docs but is parsed as a display-name lookup
|
||||
— so messages to groups whose display name isn't literally the ID
|
||||
silently drop. The structured ``/_send`` form addresses by numeric
|
||||
ID and survives newlines/quoting through ``json.dumps``.
|
||||
"""
|
||||
from gateway.config import PlatformConfig
|
||||
cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
|
||||
adapter = SimplexAdapter(cfg)
|
||||
@@ -246,11 +231,7 @@ async def test_send_group():
|
||||
|
||||
result = await adapter.send("group:grp-99", "Hello, group!")
|
||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||
assert payload["cmd"].startswith("/_send #grp-99 json ")
|
||||
msg_content = json.loads(payload["cmd"].split(" json ", 1)[1])[0][
|
||||
"msgContent"
|
||||
]
|
||||
assert msg_content == {"type": "text", "text": "Hello, group!"}
|
||||
assert payload["cmd"] == "#[grp-99] Hello, group!"
|
||||
assert result.success is True
|
||||
|
||||
|
||||
|
||||
@@ -794,11 +794,9 @@ class TestSegmentBreakOnToolBoundary:
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_deletes_partial_after_full_resend(self):
|
||||
"""After fallback re-sends the COMPLETE response, the frozen partial
|
||||
must be deleted so the user sees only the complete response (#16668).
|
||||
Full resend happens when the visible prefix doesn't match the final
|
||||
text (e.g. post-segment-break content, #10807)."""
|
||||
async def test_fallback_final_deletes_partial_after_chunks_succeed(self):
|
||||
"""After fallback chunks land, the frozen partial must be deleted so
|
||||
the user sees only the complete response (#16668)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
@@ -812,49 +810,14 @@ class TestSegmentBreakOnToolBoundary:
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# The stale partial shows pre-tool text that is NOT a prefix of the
|
||||
# final response — fallback re-sends the complete final text.
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Let me check that for you…"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial")
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_keeps_partial_after_tail_only_send(self):
|
||||
"""When the fallback sends only the missing TAIL (visible prefix
|
||||
matches the final text), the partial message IS the head of the
|
||||
answer — deleting it would leave the user with only the last part
|
||||
of the response (the 'model sent only the second half' bug)."""
|
||||
adapter = MagicMock()
|
||||
adapter.send = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message_id="msg_new"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True),
|
||||
)
|
||||
adapter.delete_message = AsyncMock(return_value=None)
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Visible partial is a true prefix of the final response — the
|
||||
# fallback dedup sends only the tail.
|
||||
# Seed the consumer as if it already edited a partial message that
|
||||
# later got stuck (flood control etc.) — _message_id is the stale id.
|
||||
consumer._message_id = "msg_partial"
|
||||
consumer._last_sent_text = "Working on i"
|
||||
|
||||
await consumer._send_fallback_final("Working on it. Done!")
|
||||
|
||||
# Tail was sent...
|
||||
sent_contents = [
|
||||
c.kwargs.get("content", "") for c in adapter.send.call_args_list
|
||||
]
|
||||
assert any("Done!" in s and "Working on i" not in s for s in sent_contents)
|
||||
# ...and the head-bearing partial was NOT deleted.
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
adapter.delete_message.assert_awaited_once_with("chat_123", "msg_partial")
|
||||
assert consumer._final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -347,200 +347,6 @@ class TestSegmentBreakDoesNotMarkFinalSent:
|
||||
assert any("answer is 42" in t for t in self._delivered_texts(adapter))
|
||||
|
||||
|
||||
class TestCancelledBestEffortDeliveryFinalizes:
|
||||
"""Cancel-path best-effort delivery must go through the finalize path.
|
||||
|
||||
The gateway cancels the consumer shortly after finish(). The
|
||||
CancelledError handler re-delivers the accumulated text; previously it
|
||||
did so with finalize=False, so REQUIRES_EDIT_FINALIZE platforms
|
||||
(Telegram) kept the plain streaming preview — the whole final reply
|
||||
rendered with raw markdown markers — while the success flags still
|
||||
suppressed the gateway's formatted re-send.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_best_effort_edit_is_finalized(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05) # preview lands; message_id set
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert finalize_edits, (
|
||||
"cancel best-effort delivery must use finalize=True so "
|
||||
"REQUIRES_EDIT_FINALIZE platforms apply final formatting"
|
||||
)
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_best_effort_failure_keeps_gateway_resend_possible(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
# Best-effort delivery at cancel time fails.
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=False, error="boom",
|
||||
))
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_without_preview_makes_no_delivery_attempt(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.02)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
adapter.edit_message.assert_not_called()
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_with_fresh_final_enabled_delivers_and_flags_via_handler(self):
|
||||
"""With fresh_final_after_seconds enabled and an aged preview, the
|
||||
finalized cancel-path delivery is eligible for fresh-final
|
||||
(delete + fresh send). is_turn_final=False keeps _try_fresh_final
|
||||
from setting the flags itself; the cancel handler sets them after
|
||||
the successful delivery."""
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.send.side_effect = [
|
||||
SimpleNamespace(success=True, message_id="initial_preview"),
|
||||
SimpleNamespace(success=True, message_id="fresh_final"),
|
||||
]
|
||||
consumer = GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=0.01, buffer_threshold=5, cursor=" ▉",
|
||||
fresh_final_after_seconds=0.001,
|
||||
),
|
||||
)
|
||||
consumer.on_delta("Reply with **bold** and `code` markers.")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer._message_created_ts = 0.0 # force the preview stale
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
# Fresh-final engaged: a second send replaced the stale preview.
|
||||
assert adapter.send.call_count == 2
|
||||
adapter.delete_message.assert_awaited_once_with("chat", "initial_preview")
|
||||
# Flags were set by the cancel handler after successful delivery.
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
|
||||
class TestGotDoneOverflowSplitNotRefinalized:
|
||||
"""A got_done finalize edit that split-and-delivered across continuation
|
||||
messages must not be followed by the redundant requires-finalize edit.
|
||||
|
||||
After a split, the consumer adopts the last continuation as the live
|
||||
message and the redundant finalize edit re-submits the FULL accumulated
|
||||
text against it; the adapter pre-flights that into another overflow
|
||||
split, editing chunk 1 over the continuation and re-sending the rest,
|
||||
so the user sees duplicated chunks. The finalize signal was already
|
||||
carried by the split edit itself.
|
||||
"""
|
||||
|
||||
def _consumer(self, adapter):
|
||||
# High interval/threshold so the only edit is the got_done finalize.
|
||||
return GatewayStreamConsumer(
|
||||
adapter=adapter,
|
||||
chat_id="chat",
|
||||
config=StreamConsumerConfig(
|
||||
edit_interval=10.0, buffer_threshold=10_000, cursor=" ▉",
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_finalize_edit_is_not_refinalized(self):
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True,
|
||||
message_id="cont_2",
|
||||
continuation_message_ids=("cont_2",),
|
||||
))
|
||||
consumer = self._consumer(adapter)
|
||||
consumer.on_delta("oversize **markdown** final reply")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05) # preview send lands; no interval edits
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert len(finalize_edits) == 1, (
|
||||
"split finalize edit must not be re-finalized; the redundant "
|
||||
"edit re-splits the full text into the adopted continuation "
|
||||
"and duplicates chunks on screen"
|
||||
)
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_split_finalize_edit_still_gets_explicit_refinalize(self):
|
||||
"""The narrow fix must not regress the requires-finalize contract:
|
||||
a normal (non-split) got_done edit is still followed by the
|
||||
explicit finalize edit (#25010 semantics unchanged)."""
|
||||
adapter = _make_adapter()
|
||||
adapter.REQUIRES_EDIT_FINALIZE = True
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(
|
||||
success=True, message_id="initial_preview",
|
||||
))
|
||||
consumer = self._consumer(adapter)
|
||||
consumer.on_delta("short final reply")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
finalize_edits = [
|
||||
c for c in adapter.edit_message.call_args_list
|
||||
if c.kwargs.get("finalize")
|
||||
]
|
||||
assert len(finalize_edits) == 2
|
||||
assert consumer.final_response_sent is True
|
||||
|
||||
|
||||
class TestStreamConsumerConfigFreshFinalField:
|
||||
"""The dataclass field must exist and default to 0 (disabled)."""
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ async def test_enrich_message_with_transcription_surfaces_path_when_stt_disabled
|
||||
"gateway.run._probe_audio_duration",
|
||||
new=AsyncMock(return_value="0:12"),
|
||||
):
|
||||
result, transcripts = await runner._enrich_message_with_transcription(
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
["/tmp/voice.ogg"],
|
||||
)
|
||||
@@ -56,7 +56,6 @@ async def test_enrich_message_with_transcription_surfaces_path_when_stt_disabled
|
||||
assert "voice message" in result.lower()
|
||||
assert "(duration: 0:12)" in result
|
||||
assert "caption" in result
|
||||
assert transcripts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -70,14 +69,13 @@ async def test_enrich_message_with_transcription_omits_duration_on_probe_failure
|
||||
"gateway.run._probe_audio_duration",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
result, transcripts = await runner._enrich_message_with_transcription(
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"",
|
||||
["/tmp/voice.ogg"],
|
||||
)
|
||||
|
||||
assert "/tmp/voice.ogg" in result
|
||||
assert "duration" not in result.lower()
|
||||
assert transcripts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -91,7 +89,7 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
return_value={"success": False, "error": "VOICE_TOOLS_OPENAI_KEY not set"},
|
||||
):
|
||||
result, transcripts = await runner._enrich_message_with_transcription(
|
||||
result = await runner._enrich_message_with_transcription(
|
||||
"caption",
|
||||
["/tmp/voice.ogg"],
|
||||
)
|
||||
@@ -99,46 +97,6 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
|
||||
assert "No STT provider is configured" not in result
|
||||
assert "trouble transcribing" in result
|
||||
assert "caption" in result
|
||||
assert transcripts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder():
|
||||
"""A successful transcription whose caption is the empty-content placeholder
|
||||
must still return the ``(text, transcripts)`` tuple.
|
||||
|
||||
The Discord adapter delivers a captionless voice note as the literal
|
||||
``"(The user sent a message with no text content)"`` placeholder. When STT
|
||||
succeeds we strip that redundant placeholder and return just the transcript
|
||||
prefix — but the method's contract (and every caller, which unpacks the
|
||||
result as ``text, transcripts = ...``) requires a 2-tuple. Returning a bare
|
||||
string here raised ``ValueError: too many values to unpack`` and dropped the
|
||||
whole voice message on the floor.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = GatewayConfig(stt_enabled=True)
|
||||
runner._has_setup_skill = lambda: False
|
||||
|
||||
with patch(
|
||||
"tools.transcription_tools.transcribe_audio",
|
||||
return_value={
|
||||
"success": True,
|
||||
"transcript": "hello from a captionless voice note",
|
||||
"provider": "local_command",
|
||||
},
|
||||
):
|
||||
result, transcripts = await runner._enrich_message_with_transcription(
|
||||
"(The user sent a message with no text content)",
|
||||
["/tmp/voice.ogg"],
|
||||
)
|
||||
|
||||
# The redundant placeholder is stripped, leaving only the transcript prefix.
|
||||
assert "hello from a captionless voice note" in result
|
||||
assert "(The user sent a message with no text content)" not in result
|
||||
# Crucially, the transcripts are still surfaced so callers can echo them.
|
||||
assert transcripts == ["hello from a captionless voice note"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -134,10 +134,6 @@ async def test_audio_attachment_context_note_format():
|
||||
assert "audio file attachment" in result.lower()
|
||||
# Should NOT contain the voice-message transcription wrapper text
|
||||
assert "voice message" not in result.lower()
|
||||
# Guides the agent to transcribe/process the file itself rather than
|
||||
# punting back to the user (same bug class as the PDF/DOCX note).
|
||||
assert "transcri" in result.lower()
|
||||
assert "ask the user what they'd like" not in result.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -835,7 +835,7 @@ class TestEditMessageStreamingSafety:
|
||||
assert second_call == {
|
||||
"chat_id": 123,
|
||||
"message_id": 456,
|
||||
"text": "final bold",
|
||||
"text": "final **bold**",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -91,6 +91,10 @@ class TestTelegramModelPicker:
|
||||
query.answer = AsyncMock()
|
||||
query.edit_message_text = AsyncMock()
|
||||
|
||||
update = MagicMock()
|
||||
update.callback_query = query
|
||||
context = MagicMock()
|
||||
|
||||
await adapter._handle_model_picker_callback(query, "mb", "12345")
|
||||
|
||||
edit_kwargs = query.edit_message_text.call_args[1]
|
||||
@@ -129,11 +133,17 @@ class TestTelegramModelPicker:
|
||||
|
||||
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
|
||||
|
||||
# The callback was invoked with the selected model
|
||||
callback.assert_awaited_once()
|
||||
# edit_message_text MUST be called on the success path (this is the
|
||||
# regression we're guarding).
|
||||
query.edit_message_text.assert_awaited()
|
||||
edit_kwargs = query.edit_message_text.call_args[1]
|
||||
assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"])
|
||||
# The dynamic result text was routed through format_message
|
||||
# (backtick code blocks survive escaping).
|
||||
assert "`gpt-5`" in edit_kwargs["text"]
|
||||
# State is cleaned up after a successful switch.
|
||||
assert "12345" not in adapter._model_picker_state
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -174,7 +184,7 @@ class TestTelegramModelPicker:
|
||||
providers = [
|
||||
{"slug": "minimax", "name": "MiniMax", "total_models": 2},
|
||||
{"slug": "minimax-cn", "name": "MiniMax (China)", "total_models": 3},
|
||||
{"slug": "xai", "name": "xAI", "total_models": 1},
|
||||
{"slug": "xai", "name": "xAI", "total_models": 1}, # lone group member
|
||||
]
|
||||
|
||||
await adapter.send_model_picker(
|
||||
@@ -187,11 +197,14 @@ class TestTelegramModelPicker:
|
||||
metadata=None,
|
||||
)
|
||||
|
||||
# Top-level keyboard: MiniMax family folded into one group button;
|
||||
# xai (lone member) degraded to a direct provider button.
|
||||
assert "mpg:minimax" in built
|
||||
assert "mp:xai" in built
|
||||
assert "mp:minimax" not in built
|
||||
assert "mp:minimax-cn" not in built
|
||||
|
||||
# Drill into the MiniMax group → members appear as mp: buttons + back.
|
||||
built.clear()
|
||||
query = AsyncMock()
|
||||
query.message = MagicMock()
|
||||
@@ -203,49 +216,7 @@ class TestTelegramModelPicker:
|
||||
|
||||
assert "mp:minimax" in built
|
||||
assert "mp:minimax-cn" in built
|
||||
assert "mb" in built
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expensive_model_requires_confirmation(self, monkeypatch):
|
||||
adapter = _make_adapter()
|
||||
callback = AsyncMock(return_value="Switched to `openai/gpt-5.5-pro`")
|
||||
adapter._model_picker_state["12345"] = {
|
||||
"providers": [
|
||||
{"slug": "openrouter", "name": "OpenRouter", "total_models": 1, "is_current": True}
|
||||
],
|
||||
"current_model": "model_1",
|
||||
"current_provider": "openrouter",
|
||||
"session_key": "s",
|
||||
"on_model_selected": callback,
|
||||
"selected_provider": "openrouter",
|
||||
"model_list": ["openai/gpt-5.5-pro"],
|
||||
"msg_id": 42,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.model_cost_guard.expensive_model_warning",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(
|
||||
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
|
||||
),
|
||||
)
|
||||
|
||||
query = AsyncMock()
|
||||
query.message = MagicMock()
|
||||
query.message.chat_id = 12345
|
||||
query.answer = AsyncMock()
|
||||
query.edit_message_text = AsyncMock()
|
||||
|
||||
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
|
||||
|
||||
callback.assert_not_awaited()
|
||||
assert "12345" in adapter._model_picker_state
|
||||
first_edit = query.edit_message_text.call_args[1]
|
||||
assert "EXPENSIVE MODEL WARNING" in first_edit["text"]
|
||||
assert first_edit["reply_markup"] is not None
|
||||
|
||||
await adapter._handle_model_picker_callback(query, "mc:0", "12345")
|
||||
|
||||
callback.assert_awaited_once_with("12345", "openai/gpt-5.5-pro", "openrouter")
|
||||
assert "12345" not in adapter._model_picker_state
|
||||
assert "mb" in built # back-to-providers button present
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_without_thread_when_thread_not_found(self):
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
"""Regression coverage for partial Telegram overflow delivery."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.base import SendResult
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.stream_consumer import GatewayStreamConsumer
|
||||
|
||||
|
||||
def _message(message_id: int | str) -> SimpleNamespace:
|
||||
return SimpleNamespace(message_id=message_id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telegram_adapter() -> TelegramAdapter:
|
||||
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
|
||||
adapter._bot = MagicMock()
|
||||
object.__setattr__(adapter, "MAX_MESSAGE_LENGTH", 160)
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_success_when_all_continuations_land(telegram_adapter):
|
||||
"""Complete overflow delivery keeps the existing successful contract."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[_message(202), _message(203), _message(204), _message(205)]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == result.continuation_message_ids[-1]
|
||||
assert result.raw_response is None
|
||||
assert telegram_adapter._bot.edit_message_text.await_count == 1
|
||||
assert telegram_adapter._bot.send_message.await_count == len(result.continuation_message_ids)
|
||||
for call in telegram_adapter._bot.send_message.await_args_list:
|
||||
assert call.kwargs["message_thread_id"] == 77
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_later_partial_failure_after_some_continuations_land(telegram_adapter):
|
||||
"""Partial metadata tracks the last delivered continuation before failure."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[
|
||||
_message(202),
|
||||
RuntimeError("telegram send failed"),
|
||||
RuntimeError("telegram send failed"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message_id == "202"
|
||||
assert result.raw_response["partial_overflow"] is True
|
||||
assert result.raw_response["delivered_chunks"] == 2
|
||||
assert result.raw_response["last_message_id"] == "202"
|
||||
assert result.continuation_message_ids == ("202",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_overflow_split_reports_partial_failure_when_continuation_fails(telegram_adapter):
|
||||
"""A failed continuation must not be reported as final delivery."""
|
||||
content = "word " * 120
|
||||
telegram_adapter._bot.edit_message_text = AsyncMock(return_value=True)
|
||||
telegram_adapter._bot.send_message = AsyncMock(
|
||||
side_effect=[RuntimeError("telegram send failed"), RuntimeError("telegram send failed")]
|
||||
)
|
||||
|
||||
result = await telegram_adapter._edit_overflow_split(
|
||||
"12345", "201", content, finalize=False, metadata={"thread_id": "77"}
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.retryable is True
|
||||
assert result.error == "overflow_continuation_failed"
|
||||
assert result.message_id == "201"
|
||||
assert result.raw_response["partial_overflow"] is True
|
||||
assert result.raw_response["delivered_chunks"] == 1
|
||||
assert result.raw_response["total_chunks"] > 1
|
||||
assert result.raw_response["last_message_id"] == "201"
|
||||
assert result.raw_response["delivered_prefix"]
|
||||
assert result.continuation_message_ids == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_consumer_fallback_sends_tail_after_partial_overflow():
|
||||
"""A partial overflow edit enters fallback instead of marking final delivered."""
|
||||
adapter = MagicMock()
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
adapter.edit_message = AsyncMock(
|
||||
return_value=SendResult(
|
||||
success=False,
|
||||
message_id="preview-1",
|
||||
error="overflow_continuation_failed",
|
||||
retryable=True,
|
||||
raw_response={
|
||||
"partial_overflow": True,
|
||||
"delivered_chunks": 1,
|
||||
"total_chunks": 2,
|
||||
"last_message_id": "preview-1",
|
||||
"delivered_prefix": "hello ",
|
||||
},
|
||||
)
|
||||
)
|
||||
adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="tail-1"))
|
||||
adapter.delete_message = AsyncMock(return_value=True)
|
||||
|
||||
consumer = GatewayStreamConsumer(adapter, "chat-1", metadata={"thread_id": "77"})
|
||||
consumer._message_id = "preview-1"
|
||||
consumer._last_sent_text = "hello "
|
||||
|
||||
ok = await consumer._send_or_edit("hello world", finalize=True)
|
||||
|
||||
assert ok is False
|
||||
assert consumer.final_response_sent is False
|
||||
assert consumer.final_content_delivered is False
|
||||
assert consumer._fallback_final_send is True
|
||||
assert consumer._fallback_prefix == "hello "
|
||||
|
||||
await consumer._send_fallback_final("hello world")
|
||||
|
||||
adapter.send.assert_awaited_once()
|
||||
assert adapter.send.await_args.kwargs["content"] == "world"
|
||||
assert adapter.send.await_args.kwargs["metadata"] == {"thread_id": "77"}
|
||||
adapter.delete_message.assert_not_awaited()
|
||||
assert consumer.final_response_sent is True
|
||||
assert consumer.final_content_delivered is True
|
||||
@@ -1,71 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.telegram import TelegramAdapter
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _source():
|
||||
return SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm")
|
||||
|
||||
|
||||
def _runner(adapter=None):
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner.config = SimpleNamespace(
|
||||
stt_enabled=True,
|
||||
group_sessions_per_user=True,
|
||||
thread_sessions_per_user=False,
|
||||
)
|
||||
runner.adapters = {Platform.TELEGRAM: adapter} if adapter else {}
|
||||
runner._consume_pending_native_image_paths = lambda _key: []
|
||||
runner._session_key_for_source = lambda _source: "telegram:dm:12345"
|
||||
runner._thread_metadata_for_source = lambda *_args, **_kwargs: {}
|
||||
runner._reply_anchor_for_event = lambda _event: None
|
||||
return runner
|
||||
|
||||
|
||||
def test_telegram_audio_size_gate_rejects_oversized_media_before_download():
|
||||
adapter = object.__new__(TelegramAdapter)
|
||||
adapter._max_doc_bytes = 1024
|
||||
|
||||
allowed, note = adapter._telegram_media_size_allowed(
|
||||
SimpleNamespace(file_size=2048),
|
||||
"voice message",
|
||||
)
|
||||
|
||||
assert allowed is False
|
||||
assert "exceeds" in note
|
||||
assert "voice message" in note
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_tts_is_explicit_audio_reply_opt_in():
|
||||
adapter = SimpleNamespace(
|
||||
_auto_tts_disabled_chats=set(),
|
||||
_auto_tts_enabled_chats=set(),
|
||||
)
|
||||
runner = _runner(adapter)
|
||||
runner._voice_mode = {}
|
||||
runner._voice_provider_mode = {}
|
||||
runner._save_voice_modes = lambda: None
|
||||
runner._save_voice_provider_modes = lambda: None
|
||||
|
||||
event = SimpleNamespace(
|
||||
source=_source(),
|
||||
get_command_args=lambda: "tts",
|
||||
)
|
||||
result = await GatewayRunner._handle_voice_command(runner, event)
|
||||
|
||||
assert runner._voice_mode["telegram:12345"] == "all"
|
||||
assert "12345" in adapter._auto_tts_enabled_chats
|
||||
assert result
|
||||
@@ -86,15 +86,12 @@ class TestHandleUpdateCommand:
|
||||
class FakePath(type(Path())):
|
||||
pass
|
||||
|
||||
# Actually, simplest: just patch the specific file attr.
|
||||
# The _handle_update_command handler lives in gateway/slash_commands.py
|
||||
# (extracted from run.py in the god-file decomposition); it resolves
|
||||
# project_root via Path(__file__).parent.parent, so fake that file.
|
||||
fake_file = str(fake_root / "gateway" / "slash_commands.py")
|
||||
# Actually, simplest: just patch the specific file attr
|
||||
fake_file = str(fake_root / "gateway" / "run.py")
|
||||
(fake_root / "gateway").mkdir(parents=True)
|
||||
(fake_root / "gateway" / "slash_commands.py").touch()
|
||||
(fake_root / "gateway" / "run.py").touch()
|
||||
|
||||
with patch("gateway.slash_commands.__file__", fake_file):
|
||||
with patch("gateway.run.__file__", fake_file):
|
||||
result = await runner._handle_update_command(event)
|
||||
|
||||
assert "Not a git repository" in result
|
||||
|
||||
@@ -188,11 +188,11 @@ class TestUsageAccountSection:
|
||||
event = MagicMock()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gateway.slash_commands.fetch_account_usage",
|
||||
"gateway.run.fetch_account_usage",
|
||||
lambda provider, base_url=None, api_key=None: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.slash_commands.render_account_usage_lines",
|
||||
"gateway.run.render_account_usage_lines",
|
||||
lambda snapshot, markdown=False: [
|
||||
"📈 **Account limits**",
|
||||
"Provider: openai-codex (Pro)",
|
||||
@@ -235,11 +235,11 @@ class TestUsageAccountSection:
|
||||
|
||||
monkeypatch.setattr("gateway.run.asyncio.to_thread", _fake_to_thread)
|
||||
monkeypatch.setattr(
|
||||
"gateway.slash_commands.fetch_account_usage",
|
||||
"gateway.run.fetch_account_usage",
|
||||
lambda provider, base_url=None, api_key=None: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"gateway.slash_commands.render_account_usage_lines",
|
||||
"gateway.run.render_account_usage_lines",
|
||||
lambda snapshot, markdown=False: [
|
||||
"📈 **Account limits**",
|
||||
"Provider: openai-codex (Pro)",
|
||||
|
||||
@@ -415,17 +415,14 @@ class TestSendVoiceReply:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_tts_and_send_voice(self, runner):
|
||||
from gateway.config import Platform
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send_voice = AsyncMock()
|
||||
event = _make_event()
|
||||
event.source.platform = Platform.TELEGRAM
|
||||
runner.adapters[event.source.platform] = mock_adapter
|
||||
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
@@ -433,32 +430,9 @@ class TestSendVoiceReply:
|
||||
await runner._send_voice_reply(event, "Hello world")
|
||||
|
||||
mock_adapter.send_voice.assert_called_once()
|
||||
assert mock_tts.call_args.kwargs["output_path"].endswith(".ogg")
|
||||
call_args = mock_adapter.send_voice.call_args
|
||||
assert call_args.kwargs.get("chat_id") == "123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_telegram_auto_voice_reply_uses_mp3(self, runner):
|
||||
from gateway.config import Platform
|
||||
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter.send_voice = AsyncMock()
|
||||
event = _make_event()
|
||||
event.source.platform = Platform.SLACK
|
||||
runner.adapters[event.source.platform] = mock_adapter
|
||||
|
||||
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
|
||||
|
||||
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
|
||||
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
|
||||
patch("os.path.isfile", return_value=True), \
|
||||
patch("os.unlink"), \
|
||||
patch("os.makedirs"):
|
||||
await runner._send_voice_reply(event, "Hello world")
|
||||
|
||||
mock_adapter.send_voice.assert_called_once()
|
||||
assert mock_tts.call_args.kwargs["output_path"].endswith(".mp3")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
|
||||
from gateway.config import Platform
|
||||
@@ -1955,49 +1929,6 @@ class TestVoiceTimeoutCleansRunnerState:
|
||||
|
||||
assert 111 not in adapter._voice_clients
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_skips_disconnect_when_voice_mode_off(self, adapter):
|
||||
"""Voice-off is deliberate text-only mode, not idle neglect — the
|
||||
inactivity timer must NOT disconnect or spam the channel (#PanBartosz)."""
|
||||
disconnect_calls = []
|
||||
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
|
||||
adapter._voice_mode_getter = lambda chat_id: "off"
|
||||
|
||||
mock_vc = MagicMock()
|
||||
mock_vc.is_connected.return_value = True
|
||||
mock_vc.disconnect = AsyncMock()
|
||||
adapter._voice_clients[111] = mock_vc
|
||||
adapter._voice_text_channels[111] = 999
|
||||
adapter._voice_timeout_tasks[111] = MagicMock()
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
await adapter._voice_timeout_handler(111)
|
||||
|
||||
# Still connected, no disconnect callback, no "inactivity timeout" spam.
|
||||
assert 111 in adapter._voice_clients
|
||||
assert disconnect_calls == []
|
||||
mock_vc.disconnect.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_still_disconnects_when_voice_mode_active(self, adapter):
|
||||
"""A non-off mode still auto-disconnects on genuine inactivity."""
|
||||
disconnect_calls = []
|
||||
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
|
||||
adapter._voice_mode_getter = lambda chat_id: "all"
|
||||
|
||||
mock_vc = MagicMock()
|
||||
mock_vc.is_connected.return_value = True
|
||||
mock_vc.disconnect = AsyncMock()
|
||||
adapter._voice_clients[111] = mock_vc
|
||||
adapter._voice_text_channels[111] = 999
|
||||
adapter._voice_timeout_tasks[111] = MagicMock()
|
||||
|
||||
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||
await adapter._voice_timeout_handler(111)
|
||||
|
||||
assert 111 not in adapter._voice_clients
|
||||
assert disconnect_calls == ["999"]
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Bug 6: play_in_voice_channel has playback timeout
|
||||
|
||||
@@ -1,341 +0,0 @@
|
||||
"""Tests for the WhatsApp stale-bridge staleness handshake.
|
||||
|
||||
Regression tests for the stale-bridge trap: ``connect()`` reused any
|
||||
already-running bridge with ``status: connected`` unconditionally, and
|
||||
``disconnect()`` only kills bridges the adapter spawned itself. A
|
||||
long-lived bridge process therefore survived gateway restarts AND
|
||||
``hermes update``, serving pre-update bridge.js behavior forever (e.g.
|
||||
no inbound media download → images/voice notes arrive as placeholders).
|
||||
|
||||
The fix: bridge.js reports a hash of its own source in ``/health``
|
||||
(``scriptHash``); the adapter compares it against the bridge.js on disk
|
||||
and restarts the bridge on mismatch. Bridges that predate the handshake
|
||||
report no hash and are treated as stale by definition.
|
||||
|
||||
Also covers the npm dependency-refresh stamp: deps are reinstalled when
|
||||
package.json changes, not only when node_modules is missing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
|
||||
|
||||
class _AsyncCM:
|
||||
"""Minimal async context manager returning a fixed value."""
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
async def __aenter__(self):
|
||||
return self.value
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
def _make_adapter(bridge_script: str = "/tmp/test-bridge.js",
|
||||
session_path: Path = Path("/tmp/test-wa-session")):
|
||||
"""Create a WhatsAppAdapter with test attributes (bypass __init__)."""
|
||||
from gateway.platforms.whatsapp import WhatsAppAdapter
|
||||
|
||||
adapter = WhatsAppAdapter.__new__(WhatsAppAdapter)
|
||||
adapter.platform = Platform.WHATSAPP
|
||||
adapter.config = MagicMock()
|
||||
adapter._bridge_port = 19876
|
||||
adapter._bridge_script = bridge_script
|
||||
adapter._session_path = session_path
|
||||
adapter._bridge_log_fh = None
|
||||
adapter._bridge_log = None
|
||||
adapter._bridge_process = None
|
||||
adapter._reply_prefix = None
|
||||
adapter._running = False
|
||||
adapter._message_handler = None
|
||||
adapter._fatal_error_code = None
|
||||
adapter._fatal_error_message = None
|
||||
adapter._fatal_error_retryable = True
|
||||
adapter._fatal_error_handler = None
|
||||
adapter._active_sessions = {}
|
||||
adapter._pending_messages = {}
|
||||
adapter._background_tasks = set()
|
||||
adapter._auto_tts_disabled_chats = set()
|
||||
adapter._message_queue = asyncio.Queue()
|
||||
adapter._http_session = None
|
||||
return adapter
|
||||
|
||||
|
||||
def _mock_health(json_data):
|
||||
"""Mock aiohttp.ClientSession whose GET returns 200 + *json_data*."""
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status = 200
|
||||
mock_resp.json = AsyncMock(return_value=json_data)
|
||||
mock_session = MagicMock()
|
||||
mock_session.get = MagicMock(return_value=_AsyncCM(mock_resp))
|
||||
mock_session.close = AsyncMock()
|
||||
return MagicMock(return_value=_AsyncCM(mock_session))
|
||||
|
||||
|
||||
def _setup_bridge_dir(tmp_path: Path) -> Path:
|
||||
"""Create a real bridge dir with bridge.js + package.json + creds."""
|
||||
bridge_dir = tmp_path / "whatsapp-bridge"
|
||||
bridge_dir.mkdir()
|
||||
(bridge_dir / "bridge.js").write_text("// current bridge code\n")
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge"}\n')
|
||||
session_path = tmp_path / "session"
|
||||
session_path.mkdir()
|
||||
(session_path / "creds.json").write_text("{}")
|
||||
return bridge_dir
|
||||
|
||||
|
||||
def _fresh_node_modules(bridge_dir: Path) -> None:
|
||||
"""Create node_modules with a stamp matching the current package.json."""
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
nm = bridge_dir / "node_modules"
|
||||
nm.mkdir()
|
||||
(nm / ".hermes-pkg-hash").write_text(
|
||||
_file_content_hash(bridge_dir / "package.json")
|
||||
)
|
||||
|
||||
|
||||
class TestFileContentHash:
|
||||
def test_hashes_file(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h = _file_content_hash(f)
|
||||
assert len(h) == 16
|
||||
assert h == _file_content_hash(f) # deterministic
|
||||
|
||||
def test_changes_with_content(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "x.js"
|
||||
f.write_text("abc")
|
||||
h1 = _file_content_hash(f)
|
||||
f.write_text("def")
|
||||
assert _file_content_hash(f) != h1
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
assert _file_content_hash(tmp_path / "nope.js") == ""
|
||||
|
||||
def test_matches_bridge_js_self_hash_algorithm(self, tmp_path):
|
||||
"""Python and Node must compute the same hash for the same bytes."""
|
||||
import hashlib
|
||||
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
f = tmp_path / "bridge.js"
|
||||
f.write_bytes(b"const x = 1;\n")
|
||||
# Node side: createHash('sha256').update(bytes).digest('hex').slice(0, 16)
|
||||
expected = hashlib.sha256(b"const x = 1;\n").hexdigest()[:16]
|
||||
assert _file_content_hash(f) == expected
|
||||
|
||||
|
||||
class TestStaleBridgeHandshake:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reuses_bridge_when_hash_matches(self, tmp_path):
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
disk_hash = _file_content_hash(bridge_dir / "bridge.js")
|
||||
mock_client = _mock_health({"status": "connected", "scriptHash": disk_hash})
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.create_task") as mock_task, \
|
||||
patch("subprocess.Popen") as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True), \
|
||||
patch.object(adapter, "_mark_connected", create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is True
|
||||
mock_popen.assert_not_called() # reused, never spawned
|
||||
mock_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_bridge_on_hash_mismatch(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_client = _mock_health(
|
||||
{"status": "connected", "scriptHash": "deadbeefdeadbeef"}
|
||||
)
|
||||
# Spawned bridge dies immediately → connect() returns False, but the
|
||||
# assertion that matters is that the stale bridge was NOT reused and
|
||||
# a new process spawn was attempted.
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process") as mock_kill_port, \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
result = await adapter.connect()
|
||||
|
||||
assert result is False # mock proc died; not the point of the test
|
||||
mock_popen.assert_called_once() # stale bridge replaced, not reused
|
||||
mock_kill_port.assert_called_once_with(adapter._bridge_port)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restarts_unversioned_bridge(self, tmp_path):
|
||||
"""Bridges predating the handshake report no scriptHash → stale."""
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
# Old bridge /health payload: no scriptHash key at all
|
||||
mock_client = _mock_health({"status": "connected"})
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", mock_client), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
|
||||
class TestDepRefreshStamp:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_install_when_stamp_fresh(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run") as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reinstalls_when_package_json_changed(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
# Simulate `hermes update` bumping the Baileys pin
|
||||
(bridge_dir / "package.json").write_text('{"name": "bridge", "v": 2}\n')
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", return_value=MagicMock(returncode=0)) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
assert "install" in mock_run.call_args[0][0]
|
||||
# Stamp updated to the new package.json hash
|
||||
from gateway.platforms.whatsapp import _file_content_hash
|
||||
stamp = (bridge_dir / "node_modules" / ".hermes-pkg-hash").read_text().strip()
|
||||
assert stamp == _file_content_hash(bridge_dir / "package.json")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_installs_when_node_modules_missing(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path) # no node_modules
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
def _npm_install(*args, **kwargs):
|
||||
# npm creates node_modules as a side effect
|
||||
(bridge_dir / "node_modules").mkdir(exist_ok=True)
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.run", side_effect=_npm_install) as mock_run, \
|
||||
patch("subprocess.Popen", return_value=mock_proc), \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestCacheDirEnvPassthrough:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bridge_spawn_env_has_cache_dirs(self, tmp_path):
|
||||
bridge_dir = _setup_bridge_dir(tmp_path)
|
||||
_fresh_node_modules(bridge_dir)
|
||||
adapter = _make_adapter(
|
||||
bridge_script=str(bridge_dir / "bridge.js"),
|
||||
session_path=tmp_path / "session",
|
||||
)
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.poll.return_value = 1
|
||||
mock_proc.returncode = 1
|
||||
|
||||
with patch("gateway.platforms.whatsapp.check_whatsapp_requirements", return_value=True), \
|
||||
patch("aiohttp.ClientSession", _mock_health({"status": "disconnected"})), \
|
||||
patch("gateway.platforms.whatsapp.asyncio.sleep", new_callable=AsyncMock), \
|
||||
patch("gateway.platforms.whatsapp._kill_stale_bridge_by_pidfile"), \
|
||||
patch("gateway.platforms.whatsapp._kill_port_process"), \
|
||||
patch("subprocess.Popen", return_value=mock_proc) as mock_popen, \
|
||||
patch.object(adapter, "_acquire_platform_lock", return_value=True, create=True):
|
||||
await adapter.connect()
|
||||
|
||||
env = mock_popen.call_args.kwargs["env"]
|
||||
from gateway.platforms.base import (
|
||||
get_audio_cache_dir,
|
||||
get_document_cache_dir,
|
||||
get_image_cache_dir,
|
||||
)
|
||||
assert env["HERMES_IMAGE_CACHE_DIR"] == str(get_image_cache_dir())
|
||||
assert env["HERMES_AUDIO_CACHE_DIR"] == str(get_audio_cache_dir())
|
||||
assert env["HERMES_DOCUMENT_CACHE_DIR"] == str(get_document_cache_dir())
|
||||
Reference in New Issue
Block a user