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:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
-1
View File
@@ -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
)
-52
View File
@@ -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)
-83
View File
@@ -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"
+3 -87
View File
@@ -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
-87
View File
@@ -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"
)
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()
+2 -2
View File
@@ -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."
)
+1 -100
View File
@@ -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
-92
View File
@@ -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
-68
View File
@@ -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()
-293
View File
@@ -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
-24
View File
@@ -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."""
-48
View File
@@ -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)
-11
View File
@@ -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()
+1 -20
View File
@@ -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
+6 -43
View File
@@ -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)."""
+3 -45
View File
@@ -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()
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -835,7 +835,7 @@ class TestEditMessageStreamingSafety:
assert second_call == {
"chat_id": 123,
"message_id": 456,
"text": "final bold",
"text": "final **bold**",
}
@pytest.mark.asyncio
+15 -44
View File
@@ -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
+4 -7
View File
@@ -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
+4 -4
View File
@@ -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)",
+1 -70
View File
@@ -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
-341
View File
@@ -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())