diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a828c3..c290bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ --- +## [v0.50.13] Fix session_search in WebUI sessions — inject SessionDB into AIAgent (PR #356) + +- **`session_search` now works in WebUI sessions** (`api/streaming.py`): The agent's `session_search` tool returned "Session database not available" for all WebUI sessions. The CLI and gateway code paths both initialize a `SessionDB` instance and pass it via `session_db=` to `AIAgent.__init__()`, but the WebUI streaming path was missing this step. `_run_agent_streaming` now initializes `SessionDB()` before constructing the agent and passes it in. A `try/except` wrapper makes the init non-fatal — if `hermes_state` is unavailable (older installs, test environments), a `WARNING` is printed and `session_db=None` is passed instead, preserving the prior behavior gracefully. + - 7 new tests in `tests/test_sprint42.py`; 822 tests total (up from 815) + ## [v0.50.12] Profile .env isolation — prevent API key leakage on profile switch (fixes #351) - **API keys no longer leak between profiles on switch** (`api/profiles.py`): `_reload_dotenv()` now tracks which env vars were loaded from the active profile's `.env` and clears them before loading the next profile. Previously, switching from a profile with `OPENAI_API_KEY=X` to a profile without that key left `X` in `os.environ` for the duration of the process — effectively leaking credentials across the profile boundary. A module-level `_loaded_profile_env_keys: set[str]` tracks loaded keys; it is cleared and repopulated on every `_reload_dotenv()` call. diff --git a/api/streaming.py b/api/streaming.py index c080794..620edc9 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -186,6 +186,14 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta _AIAgent = _get_ai_agent() if _AIAgent is None: raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path") + + # Initialize SessionDB so session_search works in WebUI sessions + _session_db = None + try: + from hermes_state import SessionDB + _session_db = SessionDB() + except Exception as _db_err: + print(f"[webui] WARNING: SessionDB init failed — session_search will be unavailable: {_db_err}", flush=True) resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model) # Resolve API key via Hermes runtime provider (matches gateway behaviour). @@ -235,6 +243,7 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta enabled_toolsets=_toolsets, fallback_model=_fallback_resolved, session_id=session_id, + session_db=_session_db, stream_delta_callback=on_token, tool_progress_callback=on_tool, ) diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py new file mode 100644 index 0000000..b618084 --- /dev/null +++ b/tests/test_sprint42.py @@ -0,0 +1,107 @@ +""" +Sprint 42 Tests: SessionDB injection into AIAgent for WebUI sessions (PR #356). + +Covers: +- streaming.py: SessionDB is initialized inside _run_agent_streaming (import present) +- streaming.py: try/except guards SessionDB init so failures are non-fatal +- streaming.py: session_db= kwarg is passed to AIAgent constructor +- streaming.py: SessionDB init failure prints a WARNING (not silently swallowed) +- streaming.py: SessionDB init is placed before AIAgent construction +""" +import ast +import pathlib +import re +import unittest + +REPO_ROOT = pathlib.Path(__file__).parent.parent +STREAMING_PY = (REPO_ROOT / "api" / "streaming.py").read_text() + + +class TestSessionDBInjection(unittest.TestCase): + """Verify SessionDB is initialized and passed to AIAgent in streaming.py.""" + + def test_hermes_state_import_present(self): + """SessionDB must be imported from hermes_state inside _run_agent_streaming.""" + self.assertIn( + "from hermes_state import SessionDB", + STREAMING_PY, + "SessionDB import missing from streaming.py (PR #356)", + ) + + def test_session_db_kwarg_passed_to_agent(self): + """session_db= must be passed to the AIAgent constructor call.""" + self.assertIn( + "session_db=_session_db", + STREAMING_PY, + "session_db kwarg not passed to AIAgent (PR #356)", + ) + + def test_sessiondb_init_in_try_except(self): + """SessionDB() init must be wrapped in try/except for non-fatal failure handling.""" + # Check that the try/except pattern surrounding SessionDB() is present + pattern = r"try:\s*\n\s*from hermes_state import SessionDB\s*\n\s*_session_db\s*=\s*SessionDB\(\)" + self.assertRegex( + STREAMING_PY, + pattern, + "SessionDB() init must be inside a try block for non-fatal error handling (PR #356)", + ) + + def test_sessiondb_failure_logs_warning(self): + """A failure initializing SessionDB must print a WARNING (not silently drop the error).""" + self.assertIn( + "WARNING: SessionDB init failed", + STREAMING_PY, + "SessionDB init failure must log a WARNING message (PR #356)", + ) + + def test_session_db_initialized_before_agent_construction(self): + """SessionDB initialization must appear before the AIAgent(...) constructor call.""" + db_pos = STREAMING_PY.find("from hermes_state import SessionDB") + agent_pos = STREAMING_PY.find("session_db=_session_db") + self.assertGreater( + agent_pos, + db_pos, + "SessionDB init must appear before AIAgent construction (PR #356)", + ) + + def test_session_db_default_is_none(self): + """_session_db must be initialized to None before the try block (safe default).""" + # Pattern: _session_db = None followed (eventually) by the try/SessionDB block + pattern = r"_session_db\s*=\s*None\s*\n\s*try:" + self.assertRegex( + STREAMING_PY, + pattern, + "_session_db must default to None before try/except block (PR #356)", + ) + + +class TestSessionDBAST(unittest.TestCase): + """AST-level checks: verify the try/except is not inside _ENV_LOCK (deadlock guard).""" + + def setUp(self): + self.tree = ast.parse(STREAMING_PY) + + def test_sessiondb_try_not_inside_env_lock(self): + """The try block that wraps SessionDB init must NOT be inside a 'with _ENV_LOCK:' block. + + Putting a try/except inside _ENV_LOCK is the deadlock pattern caught by test_sprint34. + The SessionDB try/except is outside the lock scope, which is correct. + """ + # Find all 'with _ENV_LOCK:' nodes; check none of their bodies contain + # a Try node that also contains 'from hermes_state import SessionDB' + for node in ast.walk(self.tree): + if not isinstance(node, ast.With): + continue + names = [getattr(item.context_expr, "id", "") for item in node.items] + if "_ENV_LOCK" not in names: + continue + # Walk the with-body for Try nodes + for stmt in node.body: + if isinstance(stmt, ast.Try): + # Check if this try imports hermes_state + src = ast.unparse(stmt) + self.assertNotIn( + "hermes_state", + src, + "SessionDB try/except must NOT be inside _ENV_LOCK body (deadlock risk)", + )