fix: resolve CI test failures — add missing functions, fix stale tests (#9483)

Production fixes:
- Add clear_session_context() to hermes_logging.py (fixes 48 teardown errors)
- Add clear_session() to tools/approval.py (fixes 9 setup errors)
- Add SyncError M_UNKNOWN_TOKEN check to Matrix _sync_loop (bug fix)
- Fall back to inline api_key in named custom providers when key_env
  is absent (runtime_provider.py)

Test fixes:
- test_memory_user_id: use builtin+external provider pair, fix honcho
  peer_name override test to match production behavior
- test_display_config: remove TestHelpers for non-existent functions
- test_auxiliary_client: fix OAuth tokens to match _is_oauth_token
  patterns, replace get_vision_auxiliary_client with resolve_vision_provider_client
- test_cli_interrupt_subagent: add missing _execution_thread_id attr
- test_compress_focus: add model/provider/api_key/base_url/api_mode
  to mock compressor
- test_auth_provider_gate: add autouse fixture to clean Anthropic env
  vars that leak from CI secrets
- test_opencode_go_in_model_list: accept both 'built-in' and 'hermes'
  source (models.dev API unavailable in CI)
- test_email: verify email Platform enum membership instead of source
  inspection (build_channel_directory now uses dynamic enum loop)
- test_feishu: add bot_added/bot_deleted handler mocks to _Builder
- test_ws_auth_retry: add AsyncMock for sync_store.get_next_batch,
  add _pending_megolm and _joined_rooms to Matrix adapter mocks
- test_restart_drain: monkeypatch-delete INVOCATION_ID (systemd sets
  this in CI, changing the restart call signature)
- test_session_hygiene: add user_id to SessionSource
- test_session_env: use relative baseline for contextvar clear check
  (pytest-xdist workers share context)
This commit is contained in:
Teknium
2026-04-14 01:43:45 -07:00
committed by GitHub
parent 2cfd2dafc6
commit 2558d28a9b
17 changed files with 100 additions and 61 deletions

View File

@@ -958,6 +958,16 @@ class MatrixAdapter(BasePlatformAdapter):
sync_data = await client.sync(
since=next_batch, timeout=30000,
)
# nio returns SyncError objects (not exceptions) for auth
# failures like M_UNKNOWN_TOKEN. Detect and stop immediately.
_sync_msg = getattr(sync_data, "message", None)
if _sync_msg and isinstance(_sync_msg, str):
_lower = _sync_msg.lower()
if "m_unknown_token" in _lower or "unknown_token" in _lower:
logger.error("Matrix: permanent auth error from sync: %s — stopping", _sync_msg)
return
if isinstance(sync_data, dict):
# Update joined rooms from sync response.
rooms_join = sync_data.get("rooms", {}).get("join", {})

View File

@@ -287,6 +287,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
# Resolve the API key from the env var name stored in key_env
key_env = str(entry.get("key_env", "") or "").strip()
resolved_api_key = os.getenv(key_env, "").strip() if key_env else ""
# Fall back to inline api_key when key_env is absent or unresolvable
if not resolved_api_key:
resolved_api_key = str(entry.get("api_key", "") or "").strip()
if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}:
# Found match by provider key

View File

@@ -78,6 +78,10 @@ def set_session_context(session_id: str) -> None:
_session_context.session_id = session_id
def clear_session_context() -> None:
"""Clear the session ID for the current thread."""
_session_context.session_id = None
# ---------------------------------------------------------------------------
# Record factory — injects session_tag into every LogRecord at creation

View File

@@ -365,7 +365,7 @@ class TestExpiredCodexFallback:
def test_hermes_oauth_file_sets_oauth_flag(self, monkeypatch):
"""OAuth-style tokens should get is_oauth=*** (token is not sk-ant-api-*)."""
# Mock resolve_anthropic_token to return an OAuth-style token
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="hermes-oauth-jwt-token"), \
with patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="sk-ant-oat-hermes-token"), \
patch("agent.anthropic_adapter.build_anthropic_client") as mock_build, \
patch("agent.auxiliary_client._select_pool_entry", return_value=(False, None)):
mock_build.return_value = MagicMock()
@@ -420,7 +420,7 @@ class TestExpiredCodexFallback:
def test_claude_code_oauth_env_sets_flag(self, monkeypatch):
"""CLAUDE_CODE_OAUTH_TOKEN env var should get is_oauth=True."""
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "cc-oauth-token-test")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat-cc-test-token")
monkeypatch.delenv("ANTHROPIC_TOKEN", raising=False)
with patch("agent.anthropic_adapter.build_anthropic_client") as mock_build:
mock_build.return_value = MagicMock()
@@ -786,7 +786,7 @@ class TestAuxiliaryPoolAwareness:
patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()),
patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"),
):
client, model = get_vision_auxiliary_client()
provider, client, model = resolve_vision_provider_client()
assert client is not None
assert client.__class__.__name__ == "AnthropicAuxiliaryClient"

View File

@@ -25,6 +25,11 @@ def _make_compressor():
compressor._previous_summary = None
compressor._summary_failure_cooldown_until = 0.0
compressor.summary_model = None
compressor.model = "test-model"
compressor.provider = "test"
compressor.base_url = "http://localhost"
compressor.api_key = "test-key"
compressor.api_mode = "chat_completions"
return compressor

View File

@@ -109,14 +109,12 @@ class TestMemoryManagerUserIdThreading:
assert "user_id" not in p._init_kwargs
def test_multiple_providers_all_receive_user_id(self):
from agent.builtin_memory_provider import BuiltinMemoryProvider
mgr = MemoryManager()
# Use builtin + one external (MemoryManager only allows one external)
builtin = BuiltinMemoryProvider()
ext = RecordingProvider("external")
mgr.add_provider(builtin)
mgr.add_provider(ext)
# Use one provider named "builtin" (always accepted) and one external
p1 = RecordingProvider("builtin")
p2 = RecordingProvider("external")
mgr.add_provider(p1)
mgr.add_provider(p2)
mgr.initialize_all(
session_id="sess-multi",
@@ -124,8 +122,10 @@ class TestMemoryManagerUserIdThreading:
user_id="slack_U12345",
)
assert ext._init_kwargs.get("user_id") == "slack_U12345"
assert ext._init_kwargs.get("platform") == "slack"
assert p1._init_kwargs.get("user_id") == "slack_U12345"
assert p1._init_kwargs.get("platform") == "slack"
assert p2._init_kwargs.get("user_id") == "slack_U12345"
assert p2._init_kwargs.get("platform") == "slack"
# ---------------------------------------------------------------------------
@@ -211,17 +211,17 @@ class TestHonchoUserIdScoping:
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
def test_gateway_user_id_overrides_peer_name(self):
"""When user_id is in kwargs, cfg.peer_name should be overridden."""
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
from plugins.memory.honcho import HonchoMemoryProvider
provider = HonchoMemoryProvider()
# Create a mock config with a static peer_name
# Create a mock config with NO explicit peer_name
mock_cfg = MagicMock()
mock_cfg.enabled = True
mock_cfg.api_key = "test-key"
mock_cfg.base_url = None
mock_cfg.peer_name = "static-user"
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
with patch(

View File

@@ -63,6 +63,7 @@ class TestCLISubagentInterrupt(unittest.TestCase):
parent._delegate_depth = 0
parent._delegate_spinner = None
parent.tool_progress_callback = None
parent._execution_thread_id = None
# We'll track what happens with _active_children
original_children = parent._active_children

View File

@@ -220,41 +220,6 @@ class TestPlatformDefaults:
assert resolve_display_setting({}, "telegram", "streaming") is None
# ---------------------------------------------------------------------------
# get_effective_display / get_platform_defaults
# ---------------------------------------------------------------------------
class TestHelpers:
"""Helper functions return correct composite results."""
def test_get_effective_display_merges_correctly(self):
from gateway.display_config import get_effective_display
config = {
"display": {
"tool_progress": "new",
"show_reasoning": True,
"platforms": {
"telegram": {"tool_progress": "verbose"},
},
}
}
eff = get_effective_display(config, "telegram")
assert eff["tool_progress"] == "verbose" # platform override
assert eff["show_reasoning"] is True # global
assert "tool_preview_length" in eff # default filled in
def test_get_platform_defaults_returns_dict(self):
from gateway.display_config import get_platform_defaults
defaults = get_platform_defaults("telegram")
assert "tool_progress" in defaults
assert "show_reasoning" in defaults
# Returns a new dict (not the shared tier dict)
defaults["tool_progress"] = "changed"
assert get_platform_defaults("telegram")["tool_progress"] != "changed"
# ---------------------------------------------------------------------------
# Config migration: tool_progress_overrides → display.platforms
# ---------------------------------------------------------------------------

View File

@@ -334,10 +334,12 @@ class TestChannelDirectory(unittest.TestCase):
"""Verify email in channel directory session-based discovery."""
def test_email_in_session_discovery(self):
import gateway.channel_directory
import inspect
source = inspect.getsource(gateway.channel_directory.build_channel_directory)
self.assertIn('"email"', source)
from gateway.config import Platform
# Verify email is a Platform enum member — the dynamic loop in
# build_channel_directory iterates all Platform members, so email
# is included automatically as long as it's in the enum.
email_values = [p.value for p in Platform]
self.assertIn("email", email_values)
class TestGatewaySetup(unittest.TestCase):

View File

@@ -631,6 +631,14 @@ class TestAdapterBehavior(unittest.TestCase):
calls.append("card_action")
return self
def register_p2_im_chat_member_bot_added_v1(self, _handler):
calls.append("bot_added")
return self
def register_p2_im_chat_member_bot_deleted_v1(self, _handler):
calls.append("bot_deleted")
return self
def build(self):
calls.append("build")
return "handler"
@@ -654,6 +662,8 @@ class TestAdapterBehavior(unittest.TestCase):
"reaction_created",
"reaction_deleted",
"card_action",
"bot_added",
"bot_deleted",
"build",
],
)

View File

@@ -13,7 +13,10 @@ from tests.gateway.restart_test_helpers import make_restart_runner, make_restart
@pytest.mark.asyncio
async def test_restart_command_while_busy_requests_drain_without_interrupt():
async def test_restart_command_while_busy_requests_drain_without_interrupt(monkeypatch):
# Ensure INVOCATION_ID is NOT set — systemd sets this in service mode,
# which changes the restart call signature.
monkeypatch.delenv("INVOCATION_ID", raising=False)
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
event = MessageEvent(

View File

@@ -186,10 +186,13 @@ def test_set_session_env_includes_session_key():
session_key="tg:-1001:17585",
)
# Capture baseline value before setting (may be non-empty from another
# test in the same pytest-xdist worker sharing the context).
baseline = get_session_env("HERMES_SESSION_KEY")
tokens = runner._set_session_env(context)
assert get_session_env("HERMES_SESSION_KEY") == "tg:-1001:17585"
runner._clear_session_env(tokens)
assert get_session_env("HERMES_SESSION_KEY") == ""
assert get_session_env("HERMES_SESSION_KEY") == baseline
def test_session_key_no_race_condition_with_contextvars(monkeypatch):

View File

@@ -374,6 +374,7 @@ async def test_session_hygiene_messages_stay_in_originating_topic(monkeypatch, t
chat_id="-1001",
chat_type="group",
thread_id="17585",
user_id="12345",
),
message_id="1",
)

View File

@@ -130,13 +130,17 @@ class TestMatrixSyncAuthRetry:
sync_count = 0
async def fake_sync(timeout=30000):
async def fake_sync(timeout=30000, since=None):
nonlocal sync_count
sync_count += 1
return SyncError("M_UNKNOWN_TOKEN: Invalid access token")
adapter._client = MagicMock()
adapter._client.sync = fake_sync
adapter._client.sync_store = MagicMock()
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
adapter._pending_megolm = []
adapter._joined_rooms = set()
async def run():
import sys
@@ -157,13 +161,17 @@ class TestMatrixSyncAuthRetry:
call_count = 0
async def fake_sync(timeout=30000):
async def fake_sync(timeout=30000, since=None):
nonlocal call_count
call_count += 1
raise RuntimeError("HTTP 401 Unauthorized")
adapter._client = MagicMock()
adapter._client.sync = fake_sync
adapter._client.sync_store = MagicMock()
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
adapter._pending_megolm = []
adapter._joined_rooms = set()
async def run():
import types
@@ -188,7 +196,7 @@ class TestMatrixSyncAuthRetry:
call_count = 0
async def fake_sync(timeout=30000):
async def fake_sync(timeout=30000, since=None):
nonlocal call_count
call_count += 1
if call_count >= 2:
@@ -198,6 +206,10 @@ class TestMatrixSyncAuthRetry:
adapter._client = MagicMock()
adapter._client.sync = fake_sync
adapter._client.sync_store = MagicMock()
adapter._client.sync_store.get_next_batch = AsyncMock(return_value=None)
adapter._pending_megolm = []
adapter._joined_rooms = set()
async def run():
import types

View File

@@ -18,6 +18,13 @@ def _write_auth_store(tmp_path, payload: dict) -> None:
(hermes_home / "auth.json").write_text(json.dumps(payload, indent=2))
@pytest.fixture(autouse=True)
def _clean_anthropic_env(monkeypatch):
"""Strip Anthropic env vars so CI secrets don't leak into tests."""
for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
monkeypatch.delenv(key, raising=False)
def test_returns_false_when_no_config(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
(tmp_path / "hermes").mkdir(parents=True, exist_ok=True)

View File

@@ -16,8 +16,10 @@ def test_opencode_go_appears_when_api_key_set():
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
# opencode-go is in PROVIDER_TO_MODELS_DEV, so it appears as "built-in" (Part 1)
assert opencode_go["source"] == "built-in"
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
# the API is unavailable, e.g. in CI).
assert opencode_go["source"] in ("built-in", "hermes")
def test_opencode_go_not_appears_when_no_creds():

View File

@@ -313,6 +313,17 @@ def disable_session_yolo(session_key: str) -> None:
_session_yolo.discard(session_key)
def clear_session(session_key: str) -> None:
"""Remove all approval and yolo state for a given session."""
if not session_key:
return
with _lock:
_session_approved.pop(session_key, None)
_session_yolo.discard(session_key)
_pending.pop(session_key, None)
_gateway_queues.pop(session_key, None)
def is_session_yolo_enabled(session_key: str) -> bool:
"""Return True when YOLO bypass is enabled for a specific session."""
if not session_key: