159 lines
7.1 KiB
Python
159 lines
7.1 KiB
Python
"""Regression tests: profile HERMES_HOME override in ephemeral agent threads (#50233).
|
|
|
|
Why: normal prompt turns bind ``session['profile_home']`` via
|
|
``set_hermes_home_override`` before ``run_conversation`` so the turn runs against
|
|
the correct profile home. The two ephemeral RPC paths — ``prompt.background`` and
|
|
``preview.restart`` — spawn a fresh ``AIAgent`` on a NEW thread, and the
|
|
``HERMES_HOME`` ContextVar set on the session-create thread does NOT propagate to
|
|
those threads. Without an explicit re-bind, a background/preview-restart turn under
|
|
a non-default profile would run against the wrong home. This module locks in:
|
|
|
|
1. ``prompt.background`` re-binds ``profile_home`` for the ephemeral turn.
|
|
2. ``preview.restart`` re-binds ``profile_home`` for the ephemeral turn AND does
|
|
NOT close the ephemeral agent (a task-wide ``AIAgent.close()`` would kill the
|
|
background server the restart just started — maintainer problem #1).
|
|
3. Both paths RESTORE the override after the turn (reset token from set), exactly
|
|
like the normal prompt turn, and skip the bind entirely when no profile is set.
|
|
|
|
How to test: run this module with pytest; each test drives the real RPC handler
|
|
from ``tui_gateway.server._methods`` with ``threading.Thread`` patched to run the
|
|
target inline, then asserts on the recorded override set/reset calls and the agent.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tui_gateway import server as srv
|
|
|
|
|
|
PROFILE_HOME = "/home/user/.hermes/profiles/work"
|
|
|
|
|
|
class _InlineThread:
|
|
"""Drop-in for ``threading.Thread`` that runs the target synchronously.
|
|
|
|
Why: the ephemeral RPC handlers do their real work inside ``run()`` on a
|
|
spawned thread; running it inline makes the override set/reset observable
|
|
within the test without racing a real background thread.
|
|
"""
|
|
|
|
def __init__(self, target=None, daemon=None, **_kwargs):
|
|
self._target = target
|
|
|
|
def start(self):
|
|
if self._target is not None:
|
|
self._target()
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_session():
|
|
"""A minimal session carrying a non-default ``profile_home``."""
|
|
agent = MagicMock()
|
|
return {"agent": agent, "session_key": "sess_k", "profile_home": PROFILE_HOME}
|
|
|
|
|
|
@pytest.fixture
|
|
def override_calls():
|
|
"""Patch set/reset override + AIAgent + emit/context helpers; record calls.
|
|
|
|
Returns a dict with the mocks so each test can assert on set/reset ordering
|
|
and on whether the ephemeral agent was closed.
|
|
"""
|
|
agent_instance = MagicMock()
|
|
# run_conversation returns a plain dict like the real agent does.
|
|
agent_instance.run_conversation.return_value = {"final_response": "done"}
|
|
|
|
with patch("tui_gateway.server.threading.Thread", _InlineThread), \
|
|
patch("tui_gateway.server.set_hermes_home_override", return_value="TOK") as m_set, \
|
|
patch("tui_gateway.server.reset_hermes_home_override") as m_reset, \
|
|
patch("tui_gateway.server._background_agent_kwargs", return_value={}), \
|
|
patch("tui_gateway.server._ephemeral_preview_agent_kwargs", return_value={}), \
|
|
patch("tui_gateway.server._preview_restart_callbacks", return_value={}), \
|
|
patch("tui_gateway.server._preview_restart_history", return_value=[]), \
|
|
patch("tui_gateway.server._set_session_context", return_value=None), \
|
|
patch("tui_gateway.server._clear_session_context"), \
|
|
patch("tui_gateway.server._session_cwd", return_value="/tmp"), \
|
|
patch("tui_gateway.server._emit"), \
|
|
patch("run_agent.AIAgent", return_value=agent_instance) as m_agent:
|
|
yield {
|
|
"set": m_set,
|
|
"reset": m_reset,
|
|
"agent_cls": m_agent,
|
|
"agent": agent_instance,
|
|
}
|
|
|
|
|
|
def _run(method_name, params, session):
|
|
"""Invoke a registered RPC handler with ``_sess`` patched to our session."""
|
|
handler = srv._methods[method_name]
|
|
with patch("tui_gateway.server._sess", return_value=(session, None)):
|
|
return handler("rid1", params)
|
|
|
|
|
|
class TestBackgroundProfileOverride:
|
|
def test_background_binds_and_restores_profile_home(self, fake_session, override_calls):
|
|
"""prompt.background binds profile_home for the ephemeral turn and restores it."""
|
|
_run("prompt.background", {"text": "hi", "session_id": "ui1"}, fake_session)
|
|
|
|
override_calls["set"].assert_called_once_with(PROFILE_HOME)
|
|
override_calls["reset"].assert_called_once_with("TOK")
|
|
override_calls["agent"].run_conversation.assert_called_once()
|
|
|
|
def test_background_no_profile_skips_override(self, override_calls):
|
|
"""With no profile_home the background path never touches the override."""
|
|
session = {"agent": MagicMock(), "session_key": "sess_k", "profile_home": None}
|
|
_run("prompt.background", {"text": "hi", "session_id": "ui1"}, session)
|
|
|
|
override_calls["set"].assert_not_called()
|
|
override_calls["reset"].assert_not_called()
|
|
override_calls["agent"].run_conversation.assert_called_once()
|
|
|
|
def test_background_restores_override_on_error(self, fake_session, override_calls):
|
|
"""A failing turn must still restore the override (finally-block parity)."""
|
|
override_calls["agent"].run_conversation.side_effect = RuntimeError("boom")
|
|
_run("prompt.background", {"text": "hi", "session_id": "ui1"}, fake_session)
|
|
|
|
override_calls["set"].assert_called_once_with(PROFILE_HOME)
|
|
override_calls["reset"].assert_called_once_with("TOK")
|
|
|
|
|
|
class TestPreviewRestartProfileOverride:
|
|
def test_preview_binds_and_restores_profile_home(self, fake_session, override_calls):
|
|
"""preview.restart binds profile_home for the ephemeral turn and restores it."""
|
|
_run(
|
|
"preview.restart",
|
|
{"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"},
|
|
fake_session,
|
|
)
|
|
|
|
override_calls["set"].assert_called_once_with(PROFILE_HOME)
|
|
override_calls["reset"].assert_called_once_with("TOK")
|
|
override_calls["agent"].run_conversation.assert_called_once()
|
|
|
|
def test_preview_does_not_close_agent(self, fake_session, override_calls):
|
|
"""The restarted preview server must survive: the ephemeral agent is NOT
|
|
closed via task-wide process cleanup (maintainer problem #1)."""
|
|
_run(
|
|
"preview.restart",
|
|
{"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"},
|
|
fake_session,
|
|
)
|
|
|
|
# A task-wide AIAgent.close() would kill every process for this task_id,
|
|
# tearing down the very background server the restart just launched.
|
|
override_calls["agent"].close.assert_not_called()
|
|
|
|
def test_preview_no_profile_skips_override(self, override_calls):
|
|
"""With no profile_home the preview path never touches the override."""
|
|
session = {"agent": MagicMock(), "session_key": "sess_k", "profile_home": None}
|
|
_run(
|
|
"preview.restart",
|
|
{"url": "http://localhost:5173", "cwd": "", "session_id": "ui1"},
|
|
session,
|
|
)
|
|
|
|
override_calls["set"].assert_not_called()
|
|
override_calls["reset"].assert_not_called()
|
|
override_calls["agent"].close.assert_not_called()
|