From a7983d5ad768551508667e8c708e13def7ee28ab Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:06:38 -0600 Subject: [PATCH] fix(dashboard): hide sidecar sessions from history (#49269) * fix(dashboard): hide sidecar sessions from history * test(dashboard): allow sidecar source in session payload --- gateway/session_context.py | 5 +++ run_agent.py | 17 +++++++-- tests/gateway/test_session_env.py | 15 ++++++++ tests/run_agent/test_session_source.py | 35 +++++++++++++++++++ ...t_dashboard_sidecar_close_on_disconnect.py | 12 +++---- tests/test_tui_gateway_server.py | 31 ++++++++++++++++ tui_gateway/server.py | 24 +++++++++++-- web/src/components/ChatSidebar.tsx | 1 + 8 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 tests/run_agent/test_session_source.py diff --git a/gateway/session_context.py b/gateway/session_context.py index c8c5cf438c..f6e6ab6dce 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -49,6 +49,7 @@ _UNSET: Any = object() # --------------------------------------------------------------------------- _SESSION_PLATFORM: ContextVar = ContextVar("HERMES_SESSION_PLATFORM", default=_UNSET) +_SESSION_SOURCE: ContextVar = ContextVar("HERMES_SESSION_SOURCE", default=_UNSET) _SESSION_CHAT_ID: ContextVar = ContextVar("HERMES_SESSION_CHAT_ID", default=_UNSET) _SESSION_CHAT_NAME: ContextVar = ContextVar("HERMES_SESSION_CHAT_NAME", default=_UNSET) _SESSION_THREAD_ID: ContextVar = ContextVar("HERMES_SESSION_THREAD_ID", default=_UNSET) @@ -69,6 +70,7 @@ _CRON_AUTO_DELIVER_THREAD_ID: ContextVar = ContextVar("HERMES_CRON_AUTO_DELIVER_ _VAR_MAP = { "HERMES_SESSION_PLATFORM": _SESSION_PLATFORM, + "HERMES_SESSION_SOURCE": _SESSION_SOURCE, "HERMES_SESSION_CHAT_ID": _SESSION_CHAT_ID, "HERMES_SESSION_CHAT_NAME": _SESSION_CHAT_NAME, "HERMES_SESSION_THREAD_ID": _SESSION_THREAD_ID, @@ -100,6 +102,7 @@ def set_current_session_id(session_id: str) -> None: def set_session_vars( platform: str = "", + source: str = "", chat_id: str = "", chat_name: str = "", thread_id: str = "", @@ -122,6 +125,7 @@ def set_session_vars( """ tokens = [ _SESSION_PLATFORM.set(platform), + _SESSION_SOURCE.set(source), _SESSION_CHAT_ID.set(chat_id), _SESSION_CHAT_NAME.set(chat_name), _SESSION_THREAD_ID.set(thread_id), @@ -153,6 +157,7 @@ def clear_session_vars(tokens: list) -> None: """ for var in ( _SESSION_PLATFORM, + _SESSION_SOURCE, _SESSION_CHAT_ID, _SESSION_CHAT_NAME, _SESSION_THREAD_ID, diff --git a/run_agent.py b/run_agent.py index 7c195b35ca..104d1f9289 100644 --- a/run_agent.py +++ b/run_agent.py @@ -89,6 +89,19 @@ def _launch_cwd_for_session(source: str) -> Optional[str]: return None +def _session_source_for_agent(platform: Optional[str]) -> str: + try: + from gateway.session_context import get_session_env + + source = get_session_env("HERMES_SESSION_SOURCE", "") + except Exception: + source = os.environ.get("HERMES_SESSION_SOURCE", "") + source = str(source or "").strip() + if source: + return source + return platform or "cli" + + # OpenAI lazy proxy + safe stdio + proxy URL helpers — see agent/process_bootstrap.py. # `OpenAI` is re-exported here so `patch("run_agent.OpenAI", ...)` in tests works. # The other `# noqa: F401` re-exports below cover names accessed via @@ -512,7 +525,7 @@ class AIAgent: """Create session DB row on first use. Disables _session_db on failure.""" if self._session_db_created or not self._session_db: return - source = self.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli") + source = _session_source_for_agent(self.platform) try: self._session_db.create_session( session_id=self.session_id, @@ -578,7 +591,7 @@ class AIAgent: start_context = { "old_session_id": old_session_id, "carry_over_context": carry_over_context, - "platform": getattr(self, "platform", None) or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + "platform": _session_source_for_agent(getattr(self, "platform", None)), "model": getattr(self, "model", ""), "context_length": getattr(engine, "context_length", None), "conversation_id": getattr(self, "_gateway_session_key", None), diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index 1da1e2a3b8..b0797467d4 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -45,6 +45,7 @@ def test_set_session_env_sets_contextvars(monkeypatch): context = SessionContext(source=source, connected_platforms=[], home_channels={}) monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False) monkeypatch.delenv("HERMES_SESSION_CHAT_NAME", raising=False) monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False) @@ -55,6 +56,7 @@ def test_set_session_env_sets_contextvars(monkeypatch): # Values should be readable via get_session_env (contextvar path) assert get_session_env("HERMES_SESSION_PLATFORM") == "telegram" + assert get_session_env("HERMES_SESSION_SOURCE") == "" assert get_session_env("HERMES_SESSION_CHAT_ID") == "-1001" assert get_session_env("HERMES_SESSION_CHAT_NAME") == "Group" assert get_session_env("HERMES_SESSION_USER_ID") == "123456" @@ -63,12 +65,25 @@ def test_set_session_env_sets_contextvars(monkeypatch): # os.environ should NOT be touched assert os.getenv("HERMES_SESSION_PLATFORM") is None + assert os.getenv("HERMES_SESSION_SOURCE") is None assert os.getenv("HERMES_SESSION_THREAD_ID") is None # Clean up runner._clear_session_env(tokens) +def test_session_source_uses_contextvars(monkeypatch): + monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False) + + tokens = set_session_vars(source="tool") + + assert get_session_env("HERMES_SESSION_SOURCE") == "tool" + + clear_session_vars(tokens) + + assert get_session_env("HERMES_SESSION_SOURCE") == "" + + def test_clear_session_env_restores_previous_state(monkeypatch): """_clear_session_env should restore contextvars to their pre-handler values.""" runner = object.__new__(GatewayRunner) diff --git a/tests/run_agent/test_session_source.py b/tests/run_agent/test_session_source.py new file mode 100644 index 0000000000..e582b94162 --- /dev/null +++ b/tests/run_agent/test_session_source.py @@ -0,0 +1,35 @@ +import pytest + +from gateway.session_context import _UNSET, _VAR_MAP, clear_session_vars, set_session_vars +from run_agent import _session_source_for_agent + + +@pytest.fixture(autouse=True) +def _reset_contextvars(): + for var in _VAR_MAP.values(): + var.set(_UNSET) + yield + for var in _VAR_MAP.values(): + var.set(_UNSET) + + +def test_session_source_context_overrides_platform(monkeypatch): + monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False) + + tokens = set_session_vars(source="tool") + try: + assert _session_source_for_agent("tui") == "tool" + finally: + clear_session_vars(tokens) + + +def test_session_source_falls_back_to_platform(monkeypatch): + monkeypatch.delenv("HERMES_SESSION_SOURCE", raising=False) + + assert _session_source_for_agent("tui") == "tui" + + +def test_session_source_falls_back_to_env(monkeypatch): + monkeypatch.setenv("HERMES_SESSION_SOURCE", "webhook") + + assert _session_source_for_agent(None) == "webhook" diff --git a/tests/test_dashboard_sidecar_close_on_disconnect.py b/tests/test_dashboard_sidecar_close_on_disconnect.py index b3490900d4..b2eb33645f 100644 --- a/tests/test_dashboard_sidecar_close_on_disconnect.py +++ b/tests/test_dashboard_sidecar_close_on_disconnect.py @@ -17,9 +17,9 @@ def test_sidecar_session_create_scopes_profile(): """The sidecar must pass the dashboard's selected profile so model/credential info matches the PTY child under profile-scoped chat.""" source = CHAT_SIDEBAR.read_text(encoding="utf-8") - assert '"session.create"' in source - assert re.search( - r"close_on_disconnect:\s*true,\s*\.\.\.\(profile\s*\?\s*\{\s*profile\s*\}\s*:\s*\{\}\)", - source, - re.DOTALL, - ) + call = re.search(r'"session\.create",\s*\{(.*?)\}\);', source, re.DOTALL) + assert call, "sidecar session.create call not found" + body = call.group(1) + assert re.search(r"close_on_disconnect:\s*true", body) + assert re.search(r'source:\s*"tool"', body) + assert re.search(r"\.\.\.\(profile\s*\?\s*\{\s*profile\s*\}\s*:\s*\{\}\)", body) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index d2057c634c..432e028467 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2016,6 +2016,25 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): ] +def test_ensure_session_db_row_persists_session_source(monkeypatch): + created = [] + + class _FakeDB: + def create_session(self, key, source=None, model=None, model_config=None, cwd=None): + created.append( + {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} + ) + + monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row({"session_key": "k1", "source": "tool"}) + + assert created == [ + {"key": "k1", "source": "tool", "model": "test-model", "model_config": None, "cwd": None} + ] + + def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): """Without an explicit workspace, cwd is left null so the session groups under "No workspace" rather than the gateway's launch directory.""" @@ -7686,6 +7705,18 @@ def test_session_create_records_close_on_disconnect_flag(monkeypatch): server._sessions.clear() +def test_session_create_records_source(monkeypatch): + monkeypatch.setattr(server, "_start_agent_build", lambda sid, session: None) + server._sessions.clear() + try: + sid = server.handle_request( + {"id": "1", "method": "session.create", "params": {"source": "tool"}} + )["result"]["session_id"] + assert server._sessions[sid]["source"] == "tool" + finally: + server._sessions.clear() + + def test_shutdown_sessions_closes_every_session_via_helper(monkeypatch): seen = [] monkeypatch.setattr( diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f43ea707c8..7f7109d101 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1148,6 +1148,14 @@ def _session_cwd(session: dict | None) -> str: return _completion_cwd() +def _session_source(session: dict | None) -> str: + if session: + source = str(session.get("source") or "").strip() + if source: + return source + return "tui" + + def _register_session_cwd(session: dict | None) -> None: if not session: return @@ -1247,7 +1255,7 @@ def _ensure_session_db_row(session: dict) -> None: try: db.create_session( key, - source="tui", + source=_session_source(session), model=row_model, model_config=model_config or None, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, @@ -1416,7 +1424,13 @@ def _set_session_context(session_key: str, cwd: str | None = None) -> list: # know the parent workspace pass it explicitly so spawned agents inherit # it instead of falling back to the gateway launch dir. resolved = cwd if cwd is not None else _cwd_for_session_key(session_key) - return set_session_vars(session_key=session_key, cwd=resolved) + source = "tui" + with _sessions_lock: + for sess in list(_sessions.values()): + if sess.get("session_key") == session_key: + source = _session_source(sess) + break + return set_session_vars(session_key=session_key, source=source, cwd=resolved) except Exception: return [] @@ -4183,6 +4197,7 @@ def _(rid, params: dict) -> dict: except Exception: explicit_cwd = False resolved_cwd = _completion_cwd(params) + source = str(params.get("source") or "tui").strip() or "tui" _enable_gateway_prompts() # ``profile`` (app-global remote mode): a new chat started under a non-launch @@ -4248,6 +4263,7 @@ def _(rid, params: dict) -> dict: "running": False, "session_key": key, "show_reasoning": _load_show_reasoning(), + "source": source, "slash_worker": None, "tool_progress_mode": _load_tool_progress_mode(), "tool_started_at": {}, @@ -4521,6 +4537,7 @@ def _(rid, params: dict) -> dict: # report its liveness from the relay registry so the window paints a # busy indicator instead of a dead idle transcript. child_running = _child_run_active(target) + source = str(params.get("source") or "tui").strip() or "tui" with _session_resume_lock: live = _find_live_session_by_key(target) if live is not None: @@ -4556,6 +4573,7 @@ def _(rid, params: dict) -> dict: "running": False, "session_key": target, "show_reasoning": _load_show_reasoning(), + "source": source, "slash_worker": None, "tool_progress_mode": _load_tool_progress_mode(), "tool_started_at": {}, @@ -5753,7 +5771,7 @@ def _(rid, params: dict) -> dict: ) db.create_session( new_key, - source="tui", + source=_session_source(session), model=_resolve_model(), # Stable _branched_from marker so list_sessions_rich() keeps the # branch visible in /resume and /sessions. The TUI branch leaves diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 8bb0f3a60d..c70f74d65b 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -199,6 +199,7 @@ export function ChatSidebar({ // slash_worker subprocess) when the WS drops, instead of leaking it. return gw.request<{ session_id: string }>("session.create", { close_on_disconnect: true, + source: "tool", ...(profile ? { profile } : {}), }); })