Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
View File
@@ -0,0 +1,73 @@
"""Auto-compaction status re-tagging for the desktop "Summarizing…" indicator.
Auto-compaction reaches the gateway as a generic ``lifecycle`` status. The
gateway re-tags it as ``kind="compacting"`` so drivers (the desktop app) can
show an explicit summarizing indicator instead of the transcript appearing to
silently reset mid-turn.
"""
from __future__ import annotations
import importlib
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def server():
with patch.dict(
"sys.modules",
{
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value="/tmp/hermes_test_compaction")
),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
},
):
yield importlib.import_module("tui_gateway.server")
def _capture(server, monkeypatch):
events: list[dict] = []
monkeypatch.setattr(
server, "_emit", lambda event, sid, payload=None: events.append(payload or {})
)
return events
def test_compaction_lifecycle_is_retagged(server, monkeypatch):
from agent.conversation_compression import COMPACTION_STATUS
events = _capture(server, monkeypatch)
server._status_update("sid", "lifecycle", COMPACTION_STATUS)
assert events == [{"kind": "compacting", "text": COMPACTION_STATUS}]
def test_other_lifecycle_status_stays_lifecycle(server, monkeypatch):
events = _capture(server, monkeypatch)
server._status_update("sid", "lifecycle", "❌ Rate limited after 5 retries")
assert events[0]["kind"] == "lifecycle"
def test_manual_compressing_kind_is_preserved(server, monkeypatch):
events = _capture(server, monkeypatch)
server._status_update("sid", "compressing", "⠋ compressing 40 messages…")
assert events[0]["kind"] == "compressing"
def test_compaction_status_contains_marker():
# Contract: the gateway matches COMPACTION_STATUS_MARKER inside the emitted
# status text. If the message is reworded, the marker must survive.
from agent.conversation_compression import (
COMPACTION_STATUS,
COMPACTION_STATUS_MARKER,
)
assert COMPACTION_STATUS_MARKER in COMPACTION_STATUS
@@ -0,0 +1,198 @@
"""Session persistence must not strip a custom provider's identity.
``_runtime_model_config`` persists the live agent's RESOLVED provider into
the session row's ``model_config`` JSON. For any named ``providers:`` /
``custom_providers:`` entry (e.g. one called "mimo-v2.5-pro"),
``agent.provider`` is the literal string "custom", so the entry name was
lost — and the api_key is deliberately never persisted. On ``session.resume``
or ``_reset_session_agent``, ``_stored_session_runtime_overrides`` fed
provider="custom" back into ``_make_agent`` →
``resolve_runtime_provider(requested="custom")``, which cannot match an entry
named "mimo-v2.5-pro". Depending on config the rebuild either raised
"No LLM provider configured. Run `hermes model`..." (resume failed) or
silently resolved placeholder credentials ("no-key-required") against the
patched-back base_url.
Fix: persist the REQUESTED/entry identity — ``_runtime_model_config`` maps
the agent's base_url back to the canonical ``custom:<name>`` menu key via
``find_custom_provider_identity``; ``_make_agent`` performs the same
recovery for rows persisted before the fix (and falls back to handing the
stored base_url to the direct-alias branch when no entry matches).
Related investigation: GH #44070 / PR #44099 (credential-pool base_url
pinning); same family of resolved-vs-requested identity loss.
"""
import json
import types
from unittest.mock import MagicMock, patch
import hermes_cli.runtime_provider as rp
MIMO_URL = "https://token-plan-cn.xiaomimimo.com/v1"
MIMO_KEY = "sk-mimo-entry-key"
LEGACY_LIST_CONFIG = {
"custom_providers": [
{
"name": "mimo-v2.5-pro",
"base_url": MIMO_URL,
"api_key": MIMO_KEY,
"api_mode": "chat_completions",
}
]
}
PROVIDERS_DICT_CONFIG = {
"providers": {
"mimo-v2.5-pro": {
"api": MIMO_URL,
"api_key": MIMO_KEY,
}
}
}
def _custom_agent(base_url=MIMO_URL):
return types.SimpleNamespace(
model="mimo-v2.5-pro",
provider="custom",
base_url=base_url,
api_mode="chat_completions",
reasoning_config=None,
service_tier=None,
)
class TestRuntimeModelConfigPersistsEntryIdentity:
def test_persists_menu_key_instead_of_resolved_custom(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom:mimo-v2.5-pro"
assert config["base_url"] == MIMO_URL
# Credentials must keep coming from config/provider resolution,
# never from the session DB.
assert "api_key" not in config
def test_persists_menu_key_for_providers_dict_entry(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: PROVIDERS_DICT_CONFIG)
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom:mimo-v2.5-pro"
def test_keeps_bare_custom_when_no_entry_matches(self, monkeypatch):
monkeypatch.setattr(rp, "load_config", lambda: {})
from tui_gateway.server import _runtime_model_config
config = _runtime_model_config(_custom_agent())
assert config["provider"] == "custom"
def test_non_custom_provider_untouched(self, monkeypatch):
def _boom():
raise AssertionError("identity lookup must not run for built-ins")
monkeypatch.setattr(rp, "load_config", _boom)
from tui_gateway.server import _runtime_model_config
agent = _custom_agent()
agent.provider = "anthropic"
agent.base_url = "https://api.anthropic.com"
assert _runtime_model_config(agent)["provider"] == "anthropic"
def _make_agent_with_override(override, monkeypatch, config):
"""Run _make_agent through the REAL resolve_runtime_provider against a
patched config, returning the kwargs AIAgent was constructed with."""
monkeypatch.setattr(rp, "load_config", lambda: config)
monkeypatch.setattr(rp, "_get_model_config", lambda: {})
# Keep credential-pool resolution off the developer's real HERMES home.
monkeypatch.setattr(rp, "_try_resolve_from_custom_pool", lambda *a, **k: None)
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "unused"}}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-custom", "key-custom", model_override=override)
return mock_agent.call_args.kwargs
class TestResumeRoundTrip:
def test_round_trip_restores_entry_credentials(self, monkeypatch):
"""persist → stored-overrides → _make_agent resolves the entry's
api_key again (the exact path that raised "No LLM provider
configured" before the fix)."""
monkeypatch.setattr(rp, "load_config", lambda: LEGACY_LIST_CONFIG)
from tui_gateway.server import (
_runtime_model_config,
_stored_session_runtime_overrides,
)
model_config = _runtime_model_config(_custom_agent())
row = {
"model": "mimo-v2.5-pro",
"model_config": json.dumps(model_config),
}
overrides = _stored_session_runtime_overrides(row)
assert overrides["model_override"]["provider"] == "custom:mimo-v2.5-pro"
kwargs = _make_agent_with_override(
overrides["model_override"], monkeypatch, LEGACY_LIST_CONFIG
)
assert kwargs["provider"] == "custom"
assert kwargs["base_url"] == MIMO_URL
assert kwargs["api_key"] == MIMO_KEY
def test_legacy_row_with_bare_custom_heals_via_base_url(self, monkeypatch):
"""Rows persisted BEFORE the fix stored provider="custom"; the
rebuild must recover the entry identity from the stored base_url."""
override = {
"model": "mimo-v2.5-pro",
"provider": "custom",
"base_url": MIMO_URL,
"api_mode": "chat_completions",
}
kwargs = _make_agent_with_override(override, monkeypatch, LEGACY_LIST_CONFIG)
assert kwargs["base_url"] == MIMO_URL
assert kwargs["api_key"] == MIMO_KEY
def test_legacy_row_without_matching_entry_keeps_endpoint(self, monkeypatch):
"""No config entry owns the stored URL: the direct-alias branch must
still receive the base_url so resolution targets the session's
endpoint instead of raising auth_unavailable."""
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
override = {
"model": "local-model",
"provider": "custom",
"base_url": "http://127.0.0.1:8000/v1",
"api_mode": "chat_completions",
}
kwargs = _make_agent_with_override(override, monkeypatch, {})
assert kwargs["provider"] == "custom"
assert kwargs["base_url"] == "http://127.0.0.1:8000/v1"
assert kwargs["api_key"] == "no-key-required"
+100
View File
@@ -0,0 +1,100 @@
"""Tests for tui_gateway/entry.py sys.path hardening (issue #15989).
When the TUI backend is spawned by Node.js, the Python interpreter may have
'' or '.' at the front of sys.path, allowing a local utils/ directory in CWD
to shadow the installed utils module. entry.py must sanitize sys.path before
any non-stdlib import is resolved.
"""
import os
import sys
from unittest.mock import patch
def _reload_entry_with_env(env_overrides: dict) -> None:
"""Re-execute entry.py's module-level path setup under a controlled env."""
# We only want to exercise the sys.path fixup block, not the signal/import
# machinery that follows. We do this by running the fixup code verbatim in
# a fresh copy of sys.path rather than importing the real module (which
# would trigger tui_gateway.server imports requiring heavy mocks).
original_path = sys.path[:]
original_env = {k: os.environ.get(k) for k in env_overrides}
try:
with patch.dict(os.environ, env_overrides, clear=False):
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in {"", "."}]
return sys.path[:]
finally:
sys.path = original_path
for k, v in original_env.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
def test_empty_string_and_dot_removed_from_sys_path():
original = sys.path[:]
try:
sys.path.insert(0, "")
sys.path.insert(0, ".")
assert "" in sys.path
assert "." in sys.path
# Run the entry.py fixup logic directly
sys.path = [p for p in sys.path if p not in {"", "."}]
assert "" not in sys.path
assert "." not in sys.path
finally:
sys.path = original
def test_hermes_src_root_inserted_at_front():
original = sys.path[:]
try:
fake_root = "/fake/hermes/src"
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in {"", "."}]
assert sys.path[0] == fake_root
finally:
sys.path = original
def test_src_root_not_duplicated_if_already_present():
original = sys.path[:]
try:
fake_root = "/already/present"
sys.path.insert(0, fake_root)
count_before = sys.path.count(fake_root)
with patch.dict(os.environ, {"HERMES_PYTHON_SRC_ROOT": fake_root}):
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in {"", "."}]
assert sys.path.count(fake_root) == count_before
finally:
sys.path = original
def test_no_src_root_env_does_not_crash():
original = sys.path[:]
try:
env = {k: v for k, v in os.environ.items() if k != "HERMES_PYTHON_SRC_ROOT"}
with patch.dict(os.environ, {}, clear=True):
os.environ.update(env)
_src_root = os.environ.get("HERMES_PYTHON_SRC_ROOT", "")
if _src_root and _src_root not in sys.path:
sys.path.insert(0, _src_root)
sys.path = [p for p in sys.path if p not in {"", "."}]
# No exception raised
finally:
sys.path = original
+202
View File
@@ -0,0 +1,202 @@
"""Tests for /goal handling in tui_gateway.
The TUI routes ``/goal`` through ``command.dispatch`` (not ``slash.exec``)
because the CLI's ``_handle_goal_command`` queues the kickoff message onto
``_pending_input``, which the slash-worker subprocess has no reader for.
Instead we handle ``/goal`` directly in the server and return a
``{"type": "send", "notice": ..., "message": ...}`` payload the TUI client
uses to render a system line and fire the kickoff prompt.
"""
from __future__ import annotations
import importlib
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
# Bust the goal-module DB cache so it re-resolves HERMES_HOME.
from hermes_cli import goals
goals._DB_CACHE.clear()
yield home
goals._DB_CACHE.clear()
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
@pytest.fixture()
def session(server):
sid = "sid-test"
session_key = "tui-goal-session-1"
s = {
"session_key": session_key,
"history": [],
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"attached_images": [],
"cols": 120,
}
server._sessions[sid] = s
return sid, session_key, s
def _call(server, method, **params):
handler = server._methods[method]
return handler(1, params)
# ── command.dispatch /goal ────────────────────────────────────────────
def test_goal_bare_shows_status_when_none_set(server, session):
sid, _, _ = session
r = _call(server, "command.dispatch", name="goal", arg="", session_id=sid)
assert r["result"]["type"] == "exec"
assert "No active goal" in r["result"]["output"]
def test_goal_whitespace_only_shows_status(server, session):
sid, _, _ = session
r = _call(server, "command.dispatch", name="goal", arg=" ", session_id=sid)
assert r["result"]["type"] == "exec"
assert "No active goal" in r["result"]["output"]
def test_goal_status_alias_shows_status(server, session):
sid, _, _ = session
r = _call(server, "command.dispatch", name="goal", arg="status", session_id=sid)
assert r["result"]["type"] == "exec"
assert "No active goal" in r["result"]["output"]
def test_goal_set_returns_send_with_notice(server, session):
sid, session_key, _ = session
r = _call(server, "command.dispatch", name="goal", arg="build a rocket", session_id=sid)
result = r["result"]
assert result["type"] == "send"
assert result["message"] == "build a rocket"
assert "notice" in result
assert "Goal set" in result["notice"]
assert "20-turn budget" in result["notice"]
# Persisted in SessionDB
from hermes_cli.goals import GoalManager
mgr = GoalManager(session_key)
assert mgr.state is not None
assert mgr.state.goal == "build a rocket"
assert mgr.state.status == "active"
def test_goal_pause_after_set(server, session):
sid, session_key, _ = session
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
r = _call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
assert r["result"]["type"] == "exec"
assert "paused" in r["result"]["output"].lower()
from hermes_cli.goals import GoalManager
assert GoalManager(session_key).state.status == "paused"
def test_goal_resume_reactivates(server, session):
sid, session_key, _ = session
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
_call(server, "command.dispatch", name="goal", arg="pause", session_id=sid)
r = _call(server, "command.dispatch", name="goal", arg="resume", session_id=sid)
assert r["result"]["type"] == "exec"
assert "resumed" in r["result"]["output"].lower()
from hermes_cli.goals import GoalManager
assert GoalManager(session_key).state.status == "active"
def test_goal_clear_removes_active_goal(server, session):
sid, session_key, _ = session
_call(server, "command.dispatch", name="goal", arg="write a story", session_id=sid)
r = _call(server, "command.dispatch", name="goal", arg="clear", session_id=sid)
assert r["result"]["type"] == "exec"
assert "cleared" in r["result"]["output"].lower()
from hermes_cli.goals import GoalManager
# After clear the row is marked status=cleared (kept for audit);
# ``has_goal()`` / ``is_active()`` return False so the goal loop
# stays off and ``status`` reports "No active goal".
mgr = GoalManager(session_key)
assert not mgr.has_goal()
assert not mgr.is_active()
assert "No active goal" in mgr.status_line()
def test_goal_stop_and_done_are_clear_aliases(server, session):
sid, _, _ = session
_call(server, "command.dispatch", name="goal", arg="first goal", session_id=sid)
r = _call(server, "command.dispatch", name="goal", arg="stop", session_id=sid)
assert "cleared" in r["result"]["output"].lower()
_call(server, "command.dispatch", name="goal", arg="second goal", session_id=sid)
r = _call(server, "command.dispatch", name="goal", arg="done", session_id=sid)
assert "cleared" in r["result"]["output"].lower()
def test_goal_requires_session(server):
r = _call(server, "command.dispatch", name="goal", arg="nope", session_id="unknown")
assert "error" in r
assert r["error"]["code"] == 4001
# ── slash.exec /goal routing ──────────────────────────────────────────
def test_slash_exec_rejects_goal_routes_to_command_dispatch(server, session):
"""slash.exec must reject /goal with 4018 so the TUI client falls through
to command.dispatch. Without this, the HermesCLI slash-worker subprocess
would set the goal but silently drop the kickoff — the queue is in-proc."""
sid, _, _ = session
r = _call(server, "slash.exec", command="goal status", session_id=sid)
assert "error" in r
assert r["error"]["code"] == 4018
assert "command.dispatch" in r["error"]["message"]
def test_pending_input_commands_includes_goal(server):
"""Guard: _PENDING_INPUT_COMMANDS must list 'goal' — removing it would
silently re-break the TUI."""
assert "goal" in server._PENDING_INPUT_COMMANDS
@@ -0,0 +1,469 @@
"""Regression test for #11884: _make_agent must resolve runtime provider.
Without resolve_runtime_provider(), bare-slug models in config
(e.g. ``claude-opus-4-6`` with ``model.provider: anthropic``) leave
provider/base_url/api_key empty in AIAgent, causing HTTP 404.
"""
import os
from unittest.mock import MagicMock, patch
def test_make_agent_passes_resolved_provider():
"""_make_agent forwards provider/base_url/api_key/api_mode from
resolve_runtime_provider to AIAgent."""
fake_runtime = {
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"api_key": "sk-test-key",
"api_mode": "anthropic_messages",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {
"model": {"default": "claude-opus-4-6", "provider": "anthropic"},
"agent": {"system_prompt": "test"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
) as mock_resolve,
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-1", "key-1")
# target_model comes from _resolve_startup_runtime() which reads
# _load_cfg(). Due to module-level caching in tui_gateway.server,
# the patched config may not take effect when the module was already
# imported by an earlier test. Assert the stable part of the call.
mock_resolve.assert_called_once()
assert mock_resolve.call_args.kwargs.get("requested") is None
call_kwargs = mock_agent.call_args
assert call_kwargs.kwargs["provider"] == "anthropic"
assert call_kwargs.kwargs["base_url"] == "https://api.anthropic.com"
assert call_kwargs.kwargs["api_key"] == "sk-test-key"
assert call_kwargs.kwargs["api_mode"] == "anthropic_messages"
def test_make_agent_forwards_provider_routing():
"""Parity with the messaging gateway + CLI: ``provider_routing`` in
config.yaml must reach AIAgent so OpenRouter honors the user's sort /
only / ignore / order / require_parameters / data_collection prefs.
Regression for the desktop report (LewisDB): Discord respected
provider_routing but the desktop app (tui_gateway backend) built agents
with no routing prefs, so OpenRouter selected providers at random.
"""
fake_runtime = {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {
"agent": {"system_prompt": ""},
"model": {"default": "openrouter/some-model"},
"provider_routing": {
"only": ["anthropic", "google"],
"ignore": ["deepinfra"],
"order": ["anthropic", "together"],
"sort": "throughput",
"require_parameters": True,
"data_collection": "deny",
},
}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-pr", "key-pr")
kwargs = mock_agent.call_args.kwargs
assert kwargs["providers_allowed"] == ["anthropic", "google"]
assert kwargs["providers_ignored"] == ["deepinfra"]
assert kwargs["providers_order"] == ["anthropic", "together"]
assert kwargs["provider_sort"] == "throughput"
assert kwargs["provider_require_parameters"] is True
assert kwargs["provider_data_collection"] == "deny"
def test_make_agent_provider_routing_defaults_when_unset():
"""No ``provider_routing`` section → no routing prefs forwarded (None /
False), so behavior is unchanged for users who never configured it."""
fake_runtime = {
"provider": "openrouter",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-pr-default", "key-pr-default")
kwargs = mock_agent.call_args.kwargs
assert kwargs["providers_allowed"] is None
assert kwargs["providers_ignored"] is None
assert kwargs["providers_order"] is None
assert kwargs["provider_sort"] is None
assert kwargs["provider_require_parameters"] is False
assert kwargs["provider_data_collection"] is None
def test_make_agent_ignores_display_personality_without_system_prompt():
"""The TUI matches the classic CLI: personality only becomes active once
it has been saved to agent.system_prompt."""
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {
"agent": {
"system_prompt": "",
"personalities": {"kawaii": "sparkle system prompt"},
},
"display": {"personality": "kawaii"},
"model": {"default": "glm-5"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-default-personality", "key-default-personality")
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
def test_make_agent_honors_tui_launch_env_flags():
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {"agent": {"system_prompt": ""}, "model": {"default": "glm-5"}}
with (
patch.dict(
os.environ,
{
"HERMES_TUI_MAX_TURNS": "7",
"HERMES_TUI_CHECKPOINTS": "1",
"HERMES_TUI_PASS_SESSION_ID": "1",
"HERMES_IGNORE_RULES": "1",
},
),
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-env", "key-env")
kwargs = mock_agent.call_args.kwargs
assert kwargs["max_iterations"] == 7
assert kwargs["checkpoints_enabled"] is True
assert kwargs["pass_session_id"] is True
assert kwargs["skip_context_files"] is True
assert kwargs["skip_memory"] is True
def test_probe_config_health_flags_null_sections():
"""Bare YAML keys (`agent:` with no value) parse as None and silently
drop nested settings; probe must surface them so users can fix."""
from tui_gateway.server import _probe_config_health
assert _probe_config_health({"agent": {"x": 1}}) == ""
assert _probe_config_health({}) == ""
msg = _probe_config_health({"agent": None, "display": None, "model": {}})
assert "agent" in msg and "display" in msg
assert "model" not in msg
def test_probe_config_health_flags_null_personalities_with_active_personality():
from tui_gateway.server import _probe_config_health
msg = _probe_config_health(
{
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {},
}
)
assert "display.personality" in msg
assert "agent.personalities" in msg
def test_make_agent_tolerates_null_config_sections():
"""Bare `agent:` / `display:` keys in ~/.hermes/config.yaml parse as
None. cfg.get("agent", {}) returns None (default only fires on missing
key), so downstream .get() chains must be guarded. Reported via Twitter
against the new TUI."""
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
null_cfg = {"agent": None, "display": None, "model": {"default": "glm-5"}}
with (
patch("tui_gateway.server._load_cfg", return_value=null_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-null", "key-null")
assert mock_agent.called
def test_make_agent_tolerates_null_personalities_with_active_personality():
fake_runtime = {
"provider": "openrouter",
"base_url": "https://api.synthetic.new/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
cfg = {
"agent": {"personalities": None},
"display": {"personality": "kawaii"},
"model": {"default": "glm-5"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("cli.load_cli_config", return_value={"agent": {"personalities": None}}),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent
_make_agent("sid-null-personality", "key-null-personality")
assert mock_agent.called
assert mock_agent.call_args.kwargs["ephemeral_system_prompt"] is None
def test_make_agent_honors_per_session_model_override():
"""Regression for cross-session model contamination: a per-session
``model_override`` (set by an in-session /model switch) must drive the
rebuilt agent's model/provider/base_url, NOT global config — and without
reading process-global env vars that a sibling session may have changed.
"""
# resolve_runtime_provider echoes the requested provider so we can prove
# the override's provider (not the global default) was passed through.
def echo_runtime(requested=None, target_model=None):
return {
"provider": requested or "GLOBAL_DEFAULT",
"base_url": "global-url",
"api_key": "global-key",
"api_mode": "chat_completions",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {
"agent": {"system_prompt": ""},
"model": {"default": "global/model", "provider": "globalprov"},
}
override = {
"model": "zai/glm-5.1",
"provider": "zai",
"base_url": "https://api.z.ai/v1",
"api_key": "sk-glm",
"api_mode": "chat_completions",
}
with (
# Ensure no leaked env biases _resolve_startup_runtime (it must not even
# be consulted when an override is present).
patch.dict(os.environ, {}, clear=False),
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
side_effect=echo_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
for var in (
"HERMES_MODEL",
"HERMES_INFERENCE_MODEL",
"HERMES_TUI_PROVIDER",
"HERMES_INFERENCE_PROVIDER",
):
os.environ.pop(var, None)
from tui_gateway.server import _make_agent
_make_agent(
"sid-override", "key-override", model_override=override
)
kwargs = mock_agent.call_args.kwargs
assert kwargs["model"] == "zai/glm-5.1"
assert kwargs["provider"] == "zai"
# Concrete credentials from the switch survive the rebuild.
assert kwargs["base_url"] == "https://api.z.ai/v1"
assert kwargs["api_key"] == "sk-glm"
def test_apply_model_switch_does_not_leak_process_env():
"""Core fix for cross-session contamination: an in-session /model switch
must mutate only the target session (record a per-session override + switch
that session's agent in place) and must NOT write process-global env vars,
which the single-process desktop backend shares across every live session.
"""
from tui_gateway import server
class _FakeResult:
success = True
error_message = ""
warning_message = ""
new_model = "zai/glm-5.1"
target_provider = "zai"
base_url = "https://api.z.ai/v1"
api_key = "sk-glm"
api_mode = "chat_completions"
class _FakeAgent:
def __init__(self):
self.model = "minimax/m3"
self.provider = "minimax"
self.base_url = ""
self.api_key = ""
def switch_model(self, **kw):
self.model = kw["new_model"]
self.provider = kw["new_provider"]
env_keys = (
"HERMES_MODEL",
"HERMES_INFERENCE_MODEL",
"HERMES_TUI_PROVIDER",
"HERMES_INFERENCE_PROVIDER",
)
sess_b = {"agent": _FakeAgent(), "session_key": "k-B", "model_override": None}
sess_a = {"agent": _FakeAgent(), "session_key": "k-A", "model_override": None}
with (
patch("hermes_cli.model_switch.parse_model_flags",
return_value=("glm-5.1", None, False, False)),
patch("hermes_cli.model_switch.switch_model", return_value=_FakeResult()),
patch("tui_gateway.server._emit"),
patch("tui_gateway.server._restart_slash_worker"),
patch("tui_gateway.server._session_info", return_value={}),
patch("tui_gateway.server._persist_model_switch") as mock_persist,
):
before = {k: os.environ.get(k) for k in env_keys}
result = server._apply_model_switch("sidB", sess_b, "glm-5.1")
after = {k: os.environ.get(k) for k in env_keys}
assert result["value"] == "zai/glm-5.1"
# No process-global env mutation (the contamination vector).
assert before == after
# persist_global was False → config untouched.
mock_persist.assert_not_called()
# Target session recorded a per-session override.
assert sess_b["model_override"]["model"] == "zai/glm-5.1"
assert sess_b["model_override"]["provider"] == "zai"
# The switched agent mutated in place.
assert sess_b["agent"].model == "zai/glm-5.1"
# Sibling session is completely untouched.
assert sess_a["model_override"] is None
assert sess_a["agent"].model == "minimax/m3"
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
"""Tests for tui_gateway.render — rendering bridge fallback behavior."""
from unittest.mock import MagicMock, patch
from tui_gateway.render import make_stream_renderer, render_diff, render_message
def _stub_rich(mock_mod):
return patch.dict("sys.modules", {"agent.rich_output": mock_mod})
def _no_rich():
return patch.dict("sys.modules", {"agent.rich_output": None})
# ── render_message ───────────────────────────────────────────────────
def test_render_message_none_without_module():
with _no_rich():
assert render_message("hello") is None
def test_render_message_formatted():
mod = MagicMock()
mod.format_response.return_value = "<b>hi</b>"
with _stub_rich(mod):
assert render_message("hi", 100) == "<b>hi</b>"
def test_render_message_type_error_fallback():
mod = MagicMock()
mod.format_response.side_effect = [TypeError, "fallback"]
with _stub_rich(mod):
assert render_message("hi") == "fallback"
def test_render_message_exception_returns_none():
mod = MagicMock()
mod.format_response.side_effect = RuntimeError
with _stub_rich(mod):
assert render_message("hi") is None
# ── render_diff / make_stream_renderer ───────────────────────────────
def test_render_diff_none_without_module():
with _no_rich():
assert render_diff("+line") is None
def test_stream_renderer_none_without_module():
with _no_rich():
assert make_stream_renderer() is None
def test_stream_renderer_returns_instance():
renderer = MagicMock()
mod = MagicMock()
mod.StreamingRenderer.return_value = renderer
with _stub_rich(mod):
assert make_stream_renderer(120) is renderer
@@ -0,0 +1,122 @@
"""Tests for tui_gateway background-review summary delivery.
When the self-improvement background review fires and saves a skill or
memory entry, it calls ``agent.background_review_callback(message)``. In
the CLI that routes through a prompt_toolkit-safe ``_cprint``; in the TUI
there is no print surface, so without a callback wired up the review
writes the change silently. ``_init_session`` attaches a callback that
emits a ``review.summary`` event which Ink renders as a persistent
transcript line.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def server():
with patch.dict(
"sys.modules",
{
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value="/tmp/hermes_test_review_summary")
),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
},
):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
# Reset module-level session state without re-importing. importlib.reload
# would re-register the module's atexit hooks (ThreadPoolExecutor
# shutdown, _shutdown_sessions); the duplicates race the stderr
# buffer at interpreter shutdown and surface as Fatal Python error:
# _enter_buffered_busy. Clearing the per-session dicts gives the
# next test a clean slate; _methods is NOT cleared because it's
# populated at module import time and re-registration only happens
# via reload (which we don't do).
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
def test_init_session_attaches_background_review_callback(server, monkeypatch):
"""After _init_session, agent.background_review_callback is set to a
function that emits 'review.summary' for the session's sid."""
# Neutralize side-effect calls inside _init_session so we're testing
# just the callback wiring.
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"})
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
captured_emits: list = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload=None: captured_emits.append(
(event, sid, payload)
),
)
class FakeAgent:
model = "fake/model"
# Presence of the attribute is all the Python side needs; the real
# AIAgent has it defaulted to None in __init__.
background_review_callback = None
agent = FakeAgent()
server._init_session("sid-abc", "session-key", agent, [], cols=80)
cb = getattr(agent, "background_review_callback", None)
assert callable(cb), (
"_init_session must attach a background_review_callback to the "
"agent so the self-improvement review is visible in the TUI."
)
# Clear the session.info emit captured during _init_session.
captured_emits.clear()
# Invoke the callback the way AIAgent._spawn_background_review would.
cb("💾 Self-improvement review: Skill 'hermes-release' patched")
# Exactly one review.summary event should have been emitted, bound to
# the session id we passed in, carrying the full message text.
matched = [e for e in captured_emits if e[0] == "review.summary"]
assert len(matched) == 1, captured_emits
event, sid, payload = matched[0]
assert sid == "sid-abc"
assert payload == {
"text": "💾 Self-improvement review: Skill 'hermes-release' patched"
}
def test_review_summary_callback_survives_agent_without_attribute(server, monkeypatch):
"""If the agent is a bare object that doesn't allow attribute
assignment (e.g. some stubbed test double), _init_session must not
raise — session startup stays robust."""
monkeypatch.setattr(server, "_SlashWorker", lambda *a, **kw: object())
monkeypatch.setattr(server, "_wire_callbacks", lambda sid: None)
monkeypatch.setattr(server, "_notify_session_boundary", lambda *a, **kw: None)
monkeypatch.setattr(server, "_session_info", lambda agent, session=None: {"model": "m"})
monkeypatch.setattr(server, "_load_show_reasoning", lambda: False)
monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "all")
monkeypatch.setattr(server, "_emit", lambda *a, **kw: None)
class LockedAgent:
__slots__ = ("model",)
def __init__(self):
self.model = "fake/model"
# LockedAgent's __slots__ blocks background_review_callback assignment.
server._init_session("sid-x", "key-x", LockedAgent(), [], cols=80)
# If we got here, _init_session swallowed the AttributeError gracefully.
@@ -0,0 +1,215 @@
"""Tests for the gateway's child-session live mirror.
A delegated child runs synchronously inside the parent's turn; its activity
reaches the gateway only as relayed ``subagent.*`` events on the PARENT sid
(tagged with ``child_session_id``). When a UI resumes the child's own session
(desktop open-in-new-window), ``_mirror_subagent_to_child`` translates those
relayed events into native stream events on the CHILD's live sid so the window
shows a real midstream turn instead of sitting silent until persistence.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def server():
with patch.dict(
"sys.modules",
{
"hermes_constants": MagicMock(
get_hermes_home=MagicMock(return_value="/tmp/hermes_test_child_mirror")
),
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
"hermes_state": MagicMock(),
},
):
import importlib
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._child_mirrors.clear()
mod._active_child_runs.clear()
@pytest.fixture()
def emits(server, monkeypatch):
captured: list = []
monkeypatch.setattr(
server,
"_emit",
lambda event, sid, payload=None: captured.append((event, sid, payload)),
)
monkeypatch.setattr(server, "_tool_progress_enabled", lambda sid: True)
return captured
def _relay(server, event_type, **payload):
"""Drive _on_tool_progress the way the delegate relay does."""
server._on_tool_progress(
"parent-sid",
event_type,
payload.pop("tool_name", None),
payload.pop("preview", None),
None,
goal="research X",
task_count=1,
task_index=0,
**payload,
)
def test_no_live_child_session_no_mirror(server, emits):
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
# Only the parent-sid relay event — nothing mirrored, no state retained.
assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")]
assert server._child_mirrors == {}
def test_live_child_session_gets_native_stream(server, emits):
# A window resumed the child session: live sid differs from the stored key.
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="terminal", preview="ls", child_session_id="child-1")
_relay(server, "subagent.thinking", preview="hmm", child_session_id="child-1")
_relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1")
_relay(
server,
"subagent.complete",
child_session_id="child-1",
status="completed",
summary="done deal",
)
child = [(e, p) for e, s, p in emits if s == "live-1"]
# Synthetic turn: start → tool → reasoning → tool rotation → close + summary.
assert [e for e, _ in child] == [
"message.start",
"tool.start",
"reasoning.delta",
"tool.complete",
"tool.start",
"tool.complete",
"message.complete",
]
first_tool = child[1][1]
assert first_tool["name"] == "terminal"
assert first_tool["tool_id"].startswith("submirror:child-1:")
assert child[2][1] == {"text": "hmm"}
# The rotated-out tool closes with the same id it opened with.
assert child[3][1]["tool_id"] == first_tool["tool_id"]
assert child[6][1] == {"text": "done deal"}
# Parent relay is untouched alongside the mirror.
assert [e for e, s, _ in emits if s == "parent-sid"] == [
"subagent.tool",
"subagent.thinking",
"subagent.tool",
"subagent.complete",
]
# Completion clears mirror state.
assert server._child_mirrors == {}
def test_window_closed_midrun_drops_state_then_fresh_turn_on_reopen(server, emits):
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert "child-1" in server._child_mirrors
# Window closes → live session gone → state dropped on the next event.
server._sessions.clear()
_relay(server, "subagent.tool", tool_name="read_file", child_session_id="child-1")
assert server._child_mirrors == {}
# Reopen under a new live sid → a fresh synthetic turn starts.
emits.clear()
server._sessions["live-2"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.tool", tool_name="web_search", child_session_id="child-1")
assert [(e, s) for e, s, _ in emits if s == "live-2"] == [
("message.start", "live-2"),
("tool.start", "live-2"),
]
def test_upgraded_child_session_not_mirrored(server, emits):
"""A watch window upgraded to a full session (agent built) owns a real
native stream — mirroring on top would interleave two turns on one sid."""
server._sessions["live-1"] = {"session_key": "child-1", "agent": object()}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert [(e, s) for e, s, _ in emits] == [("subagent.tool", "parent-sid")]
assert server._child_mirrors == {}
# Liveness registry still updates — it serves resume, not the mirror.
assert "child-1" in server._active_child_runs
def test_stale_child_run_not_reported_active(server, emits):
"""A leaked registry entry (lost completion event) must age out instead of
pinning running=true on every future lazy resume of that child."""
server._active_child_runs["child-1"] = 0.0 # epoch — ancient
assert server._child_run_active("child-1") is False
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert server._child_run_active("child-1") is True
def test_prompt_submit_rejected_while_child_run_active(server, emits):
"""Typing into a watch window mid-run must not build a second agent racing
the in-flight child on the same stored session — busy error instead."""
import threading
server._sessions["live-1"] = {
"agent": None,
"history_lock": threading.Lock(),
"lazy": True,
"running": False,
"session_key": "child-1",
}
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
result = server._methods["prompt.submit"]("rid-1", {"session_id": "live-1", "text": "hi"})
assert result["error"]["code"] == 4009
# Run completes → the same submit upgrades into a real conversation
# (passes the guard; fails later only because this test stubs no agent).
_relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok")
assert server._child_run_active("child-1") is False
def test_active_child_runs_registry_tracks_liveness(server, emits):
"""Every relayed event marks the child as in flight (even with no window
open), and completion clears it — lazy watch resumes read this registry to
report running=true while the child is silent inside a long tool call."""
_relay(server, "subagent.start", preview="go", child_session_id="child-1")
assert "child-1" in server._active_child_runs
_relay(server, "subagent.tool", tool_name="terminal", child_session_id="child-1")
assert "child-1" in server._active_child_runs
_relay(server, "subagent.complete", child_session_id="child-1", status="completed", summary="ok")
assert "child-1" not in server._active_child_runs
def test_start_and_progress_mirror_as_immediate_text_activity(server, emits):
server._sessions["live-1"] = {"session_key": "child-1", "agent": None}
_relay(server, "subagent.start", preview="starting child branch", child_session_id="child-1")
_relay(server, "subagent.progress", preview="step 1/3", child_session_id="child-1")
child = [(e, p) for e, s, p in emits if s == "live-1"]
assert child == [
("message.start", None),
("message.delta", {"text": "starting child branch\n"}),
("message.delta", {"text": "step 1/3\n"}),
]
+183
View File
@@ -0,0 +1,183 @@
"""Tests for /undo handling in tui_gateway.
The TUI routes ``/undo`` through ``command.dispatch`` (it's in
``_PENDING_INPUT_COMMANDS`` because the CLI handler queues input the
slash-worker subprocess can't read). The server handles it directly,
mutates SessionDB to soft-delete rows, refreshes the in-memory session
history, fires the memory-provider hook with ``rewound=True``, and
returns ``{"type": "prefill", "message": <text>, "notice": ...}`` so
the Ink client drops the message into the composer for editing.
``/undo N`` backs up N user turns at once (default 1). See issue #21910.
"""
from __future__ import annotations
import importlib
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from hermes_state import SessionDB
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
@pytest.fixture()
def server(hermes_home):
with patch.dict(
"sys.modules",
{
"hermes_cli.env_loader": MagicMock(),
"hermes_cli.banner": MagicMock(),
},
):
mod = importlib.import_module("tui_gateway.server")
yield mod
mod._sessions.clear()
mod._pending.clear()
mod._answers.clear()
mod._methods.clear()
importlib.reload(mod)
@pytest.fixture()
def db(hermes_home):
return SessionDB(db_path=hermes_home / "state.db")
@pytest.fixture()
def session_with_history(server, db):
"""Build a session with 3 user turns + assistant replies persisted in DB."""
sid = "sid-undo"
session_key = "tui-undo-1"
db.create_session(session_key, source="tui")
for i in range(1, 4):
db.append_message(session_key, "user", f"question {i}")
db.append_message(session_key, "assistant", f"answer {i}")
history = db.get_messages_as_conversation(session_key)
agent = MagicMock()
agent._memory_manager = MagicMock()
agent._last_flushed_db_idx = len(history)
s = {
"session_key": session_key,
"history": list(history),
"history_lock": threading.Lock(),
"history_version": 0,
"running": False,
"agent": agent,
"attached_images": [],
"cols": 120,
}
server._sessions[sid] = s
# Wire the DB cache so _get_db() returns our fixture.
server._db = db
return sid, session_key, s, agent
def _call(server, method, **params):
return server._methods[method](1, params)
def test_undo_returns_prefill_with_target_text(server, session_with_history):
sid, session_key, s, agent = session_with_history
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="")
result = resp["result"]
assert result["type"] == "prefill"
# Default /undo backs up one user turn — "question 3"
assert result["message"] == "question 3"
assert "Undid" in result["notice"]
def test_undo_truncates_in_memory_history(server, session_with_history, db):
sid, session_key, s, agent = session_with_history
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
# After undoing to "question 3", active history should be 4 rows:
# user q1, asst a1, user q2, asst a2
assert len(s["history"]) == 4
roles = [m["role"] for m in s["history"]]
assert roles == ["user", "assistant", "user", "assistant"]
# version bumped
assert s["history_version"] == 1
def test_undo_n_backs_up_multiple_turns(server, session_with_history, db):
"""/undo 2 backs up two user turns to "question 2"."""
sid, session_key, s, agent = session_with_history
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="2")
result = resp["result"]
assert result["type"] == "prefill"
assert result["message"] == "question 2"
assert "2 turns" in result["notice"]
# Active history truncated to user q1 + asst a1
assert len(s["history"]) == 2
assert [m["role"] for m in s["history"]] == ["user", "assistant"]
def test_undo_n_clamps_to_oldest_turn(server, session_with_history, db):
"""/undo with N larger than the number of user turns backs up to the oldest."""
sid, session_key, s, agent = session_with_history
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="99")
result = resp["result"]
assert result["message"] == "question 1"
assert len(s["history"]) == 0
def test_undo_rejects_invalid_count(server, session_with_history):
sid, _, _, _ = session_with_history
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="abc")
assert "error" in resp
assert "invalid count" in resp["error"]["message"].lower()
def test_undo_soft_deletes_rows_in_db(server, session_with_history, db):
sid, session_key, _, _ = session_with_history
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
# All rows still present
all_rows = db.get_messages(session_key, include_inactive=True)
assert len(all_rows) == 6
# 2 inactive (the "question 3" row + its trailing "answer 3").
active = [r for r in all_rows if r["active"] == 1]
assert len(active) == 4
# rewind_count bumped
sess = db.get_session(session_key)
assert sess["rewind_count"] == 1
def test_undo_notifies_memory_provider(server, session_with_history):
sid, session_key, _, agent = session_with_history
_call(server, "command.dispatch", session_id=sid, name="undo", arg="")
agent._memory_manager.on_session_switch.assert_called_once()
args, kwargs = agent._memory_manager.on_session_switch.call_args
assert args[0] == session_key
assert kwargs["rewound"] is True
assert kwargs["reset"] is False
def test_undo_refuses_when_session_busy(server, session_with_history):
sid, _, s, _ = session_with_history
s["running"] = True
resp = _call(server, "command.dispatch", session_id=sid, name="undo", arg="")
assert "error" in resp
assert "busy" in resp["error"]["message"].lower()
def test_undo_errors_when_no_active_session(server):
resp = _call(server, "command.dispatch", session_id="no-such-sid", name="undo", arg="")
assert "error" in resp
assert "no active session" in resp["error"]["message"].lower()
def test_undo_in_pending_input_commands(server):
"""Registry sanity: /undo must be in _PENDING_INPUT_COMMANDS so
slash.exec rejects it and the TUI falls through to command.dispatch."""
assert "undo" in server._PENDING_INPUT_COMMANDS
@@ -0,0 +1,78 @@
"""Tests for tui_gateway.entry.wait_for_mcp_discovery (PR #35245).
MCP tool discovery runs in a background daemon thread so a slow/dead server
can't freeze ``gateway.ready``. The agent snapshots its tool list once at
build time and never re-reads it, so ``_make_agent`` briefly joins the
discovery thread before building — bounded, so a dead server can't re-introduce
the startup hang, and a no-op once discovery has finished.
"""
import threading
import time
import tui_gateway.entry as entry
def _restore_thread_slot(saved):
entry._mcp_discovery_thread = saved
def test_no_thread_is_noop():
"""When no discovery thread was started (the common no-MCP case), the
helper returns immediately and never blocks."""
saved = entry._mcp_discovery_thread
try:
entry._mcp_discovery_thread = None
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=5.0)
assert time.monotonic() - start < 0.1
finally:
_restore_thread_slot(saved)
def test_already_finished_thread_is_noop():
"""A thread that has already finished is not joined-on (dead thread)."""
saved = entry._mcp_discovery_thread
try:
t = threading.Thread(target=lambda: None, daemon=True)
t.start()
t.join() # ensure it's finished
entry._mcp_discovery_thread = t
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=5.0)
assert time.monotonic() - start < 0.1
finally:
_restore_thread_slot(saved)
def test_fast_thread_is_joined():
"""A reachable-but-still-connecting (fast) server lands before the agent
snapshots tools — the helper waits for it to finish."""
saved = entry._mcp_discovery_thread
try:
t = threading.Thread(target=lambda: time.sleep(0.05), daemon=True)
t.start()
entry._mcp_discovery_thread = t
entry.wait_for_mcp_discovery(timeout=1.0)
assert not t.is_alive() # joined to completion
finally:
_restore_thread_slot(saved)
def test_hung_thread_is_bounded_by_timeout():
"""A slow/dead server must NOT re-introduce the startup hang — the join is
bounded by the timeout and returns even though the thread is still alive."""
saved = entry._mcp_discovery_thread
stop = threading.Event()
try:
t = threading.Thread(target=stop.wait, daemon=True) # blocks until set
t.start()
entry._mcp_discovery_thread = t
start = time.monotonic()
entry.wait_for_mcp_discovery(timeout=0.3)
elapsed = time.monotonic() - start
assert 0.25 <= elapsed < 1.0 # bounded near the timeout, not forever
assert t.is_alive() # thread still running; we did not block on it
finally:
stop.set()
_restore_thread_slot(saved)