From b9d2eb7f43d63701a1fa78dc87061247b73b1c16 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Fri, 17 Jul 2026 13:10:54 -0400 Subject: [PATCH 1/4] fix(tui_gateway): honor params.profile for session.* state.db access Closes #62503 Root cause: session.resume already opened the requested profile's state.db, but session.list / most_recent / delete / history / title / status, create's immediate info payload, teardown, session.branch, and post-turn pending_title still used the launch profile (_get_db / _current_profile_name). Fix: add _db_for_profile / _profile_db / _session_db helpers and route session.* methods through them; report requested profile on create/status; lifecycle uses session-owned profile stores with branch profile_home inherit. Rebase onto current main keeps _session_usage_snapshot in session info. --- tests/test_tui_gateway_server.py | 521 ++++++++++++++++++++++++ tui_gateway/server.py | 667 +++++++++++++++++++------------ 2 files changed, 925 insertions(+), 263 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index c1c794473c1f..4e75e0543d8b 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -8672,6 +8672,527 @@ def test_session_delete_success_returns_deleted_id(monkeypatch): assert str(captured["sessions_dir"]).endswith("sessions") + + +# -------------------------------------------------------------------------- +# session.* profile scoping (app-global remote mode) — #62503 +# -------------------------------------------------------------------------- + + +def test_session_list_honors_params_profile_opens_profile_db(monkeypatch, tmp_path): + """Issue #62503: session.list must read the profile's state.db, not launch.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + (profile_home / "state.db").write_bytes(b"") + seen: dict = {} + + class LaunchDB: + def list_sessions_rich(self, **kwargs): + seen["launch"] = True + return [{"id": "launch-1", "source": "tui", "title": "L"}] + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + + def list_sessions_rich(self, **kwargs): + seen["profile"] = True + return [ + { + "id": "ml-1", + "source": "tui", + "title": "M", + "preview": "", + "started_at": 1, + "message_count": 1, + } + ] + + def close(self): + seen["closed"] = True + + monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + + resp = server.handle_request( + { + "id": "1", + "method": "session.list", + "params": {"profile": "mlperf", "limit": 5}, + } + ) + assert "result" in resp, resp + assert resp["result"]["sessions"][0]["id"] == "ml-1" + assert seen.get("profile") is True + assert seen.get("launch") is None + assert str(seen.get("db_path")).endswith("state.db") + assert seen.get("closed") is True + + +def test_session_most_recent_honors_params_profile(monkeypatch, tmp_path): + """Issue #62503: session.most_recent must not return the launch profile tip.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + + class LaunchDB: + def list_sessions_rich(self, **kwargs): + return [{"id": "launch-tip", "source": "tui", "title": "L", "started_at": 9}] + + class ProfileDB2: + def __init__(self, db_path=None): + self.db_path = db_path + + def list_sessions_rich(self, **kwargs): + return [ + {"id": "tool-noise", "source": "tool", "title": "t", "started_at": 9}, + {"id": "ml-tip", "source": "desktop", "title": "M", "started_at": 3}, + ] + + def close(self): + pass + + monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB2) + + resp = server.handle_request( + { + "id": "1", + "method": "session.most_recent", + "params": {"profile": "mlperf"}, + } + ) + assert resp["result"]["session_id"] == "ml-tip" + + +def test_session_create_reports_requested_profile_name(monkeypatch, tmp_path): + """Issue #62503: session.create info.profile_name must not always be launch.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + + def _clear(): + for session in list(server._sessions.values()): + server._teardown_session(session) + server._sessions.clear() + + monkeypatch.setattr(server, "_start_agent_build", lambda *a, **k: None) + monkeypatch.setattr(server, "_schedule_agent_build", lambda *a, **k: None) + monkeypatch.setattr(server, "_schedule_session_cap_enforcement", lambda *a, **k: None) + monkeypatch.setattr(server, "_completion_cwd", lambda params=None: str(tmp_path)) + monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) + monkeypatch.setattr(server, "_current_profile_name", lambda: "default") + monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None)) + _clear() + try: + resp = server._methods["session.create"]("r1", {"profile": "mlperf", "cols": 80}) + assert "result" in resp, resp + assert resp["result"]["info"]["profile_name"] == "mlperf" + sid = resp["result"]["session_id"] + assert server._sessions[sid]["profile_home"] == str(profile_home) + finally: + _clear() + + +def test_session_delete_honors_params_profile_sessions_dir(monkeypatch, tmp_path): + """Issue #62503: delete must target the profile state.db + sessions dir.""" + profile_home = tmp_path / "profiles" / "mlperf" + (profile_home / "sessions").mkdir(parents=True) + captured: dict = {} + + class ProfileDB: + def __init__(self, db_path=None): + captured["db_path"] = db_path + + def delete_session(self, sid, sessions_dir=None): + captured["sid"] = sid + captured["sessions_dir"] = sessions_dir + return True + + def close(self): + captured["closed"] = True + + monkeypatch.setattr(server, "_profile_home", lambda p: profile_home if p == "mlperf" else None) + monkeypatch.setattr(server, "_get_db", lambda: None) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + + resp = server.handle_request( + { + "id": "1", + "method": "session.delete", + "params": {"session_id": "old-ml", "profile": "mlperf"}, + } + ) + assert "result" in resp, resp + assert resp["result"] == {"deleted": "old-ml"} + assert str(captured["db_path"]).endswith("state.db") + assert Path(captured["sessions_dir"]) == profile_home / "sessions" + assert captured.get("closed") is True + + +def test_session_title_uses_session_profile_db_not_launch(monkeypatch, tmp_path): + """session.title on a non-launch profile session must not touch launch DB.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {} + + class LaunchDB: + def get_session_title(self, _key): + seen["launch_read"] = True + return "from-launch" + + def set_session_title(self, _key, _title): + seen["launch_write"] = True + return True + + def get_session(self, _key): + return {"id": _key, "title": "from-launch"} + + class ProfileDB: + def __init__(self, db_path=None): + self.db_path = db_path + seen["db_path"] = db_path + + def get_session_title(self, _key): + return seen.get("title") + + def get_session(self, _key): + if "title" in seen: + return {"id": _key, "title": seen["title"]} + return None + + def set_session_title(self, _key, title): + seen["title"] = title + seen["profile_write"] = True + return True + + def close(self): + seen["closed"] = True + + server._sessions["sid"] = { + "session_key": "ml-sess", + "history": [], + "history_lock": __import__("threading").Lock(), + "running": False, + "pending_title": None, + "profile_home": str(profile_home), + "agent": None, + "created_at": 1.0, + "last_active": 1.0, + } + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + try: + set_resp = server.handle_request( + { + "id": "1", + "method": "session.title", + "params": {"session_id": "sid", "title": "profile-title"}, + } + ) + assert "result" in set_resp, set_resp + assert set_resp["result"]["title"] == "profile-title" + assert seen.get("profile_write") is True + assert seen.get("launch_write") is None + assert str(seen.get("db_path")).endswith("state.db") + + get_resp = server.handle_request( + {"id": "2", "method": "session.title", "params": {"session_id": "sid"}} + ) + assert get_resp["result"]["title"] == "profile-title" + assert seen.get("launch_read") is None + finally: + server._sessions.pop("sid", None) + + +def test_session_history_uses_session_profile_db(monkeypatch, tmp_path): + """session.history must read durable messages from the profile state.db.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {} + + class LaunchDB: + def get_messages_as_conversation(self, _key, include_ancestors=True): + seen["launch"] = True + return [{"role": "user", "content": "launch"}] + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + + def get_messages_as_conversation(self, _key, include_ancestors=True): + seen["profile"] = True + return [{"role": "user", "content": "from-profile"}] + + def close(self): + seen["closed"] = True + + server._sessions["sid"] = { + "session_key": "ml-sess", + "history": [{"role": "user", "content": "mem"}], + "history_lock": __import__("threading").Lock(), + "running": False, + "profile_home": str(profile_home), + "agent": None, + "created_at": 1.0, + "last_active": 1.0, + } + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + try: + resp = server.handle_request( + {"id": "1", "method": "session.history", "params": {"session_id": "sid"}} + ) + assert "result" in resp, resp + assert seen.get("profile") is True + assert seen.get("launch") is None + # Count comes from profile-backed conversation (1 msg), not bare mem list alone. + assert resp["result"]["count"] == 1 + texts = [] + for m in resp["result"]["messages"]: + texts.append(str(m)) + assert any("from-profile" in t for t in texts) or resp["result"]["count"] == 1 + finally: + server._sessions.pop("sid", None) + + +def test_session_status_uses_session_profile_db(monkeypatch, tmp_path): + """session.status must load meta from the session profile state.db.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {} + + class LaunchDB: + def get_session(self, _key): + seen["launch"] = True + return {"id": _key, "title": "launch-title", "started_at": 1} + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + + def get_session(self, _key): + seen["profile"] = True + return {"id": _key, "title": "profile-title", "started_at": 42} + + def close(self): + seen["closed"] = True + + server._sessions["sid"] = { + "session_key": "ml-sess", + "history": [], + "history_lock": __import__("threading").Lock(), + "running": False, + "profile_home": str(profile_home), + "agent": None, + "created_at": 1.0, + "last_active": 1.0, + } + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + try: + resp = server.handle_request( + {"id": "1", "method": "session.status", "params": {"session_id": "sid"}} + ) + assert "result" in resp, resp + assert "profile-title" in resp["result"]["output"] + assert seen.get("profile") is True + assert seen.get("launch") is None + finally: + server._sessions.pop("sid", None) + + +def test_teardown_ends_session_in_profile_db(monkeypatch, tmp_path): + """_teardown_session must end_session on the profile store, not launch.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {} + + class LaunchDB: + def get_session(self, _key): + seen["launch"] = True + return {"id": _key, "source": "tui"} + + def end_session(self, _key, _reason): + seen["launch_end"] = True + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + + def get_session(self, _key): + seen["profile"] = True + return {"id": _key, "source": "tui"} + + def end_session(self, key, reason): + seen["ended"] = (key, reason) + + def close(self): + seen["closed"] = True + + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + session = { + "session_key": "ml-sess", + "profile_home": str(profile_home), + "agent": None, + "history": [], + "source": "tui", + } + server._teardown_session(session, end_reason="closed") + assert seen.get("ended") == ("ml-sess", "closed") + assert seen.get("launch_end") is None + assert seen.get("launch") is None + assert str(seen.get("db_path")).endswith("state.db") + + +def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): + """session.branch must copy history into the parent's profile state.db.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {"msgs": []} + + class LaunchDB: + def get_session_title(self, _key): + seen["launch"] = True + return "L" + + def create_session(self, *a, **k): + seen["launch_create"] = True + + def append_message(self, **k): + seen["launch_msg"] = True + + def set_session_title(self, *a, **k): + return True + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + seen.setdefault("inits", 0) + seen["inits"] += 1 + + def get_session_title(self, _key): + return "parent" + + def get_next_title_in_lineage(self, current): + return f"{current} (branch)" + + def create_session(self, new_key, **kwargs): + seen["created"] = new_key + seen["parent"] = kwargs.get("parent_session_id") + + def append_message(self, **kwargs): + seen["msgs"].append(kwargs) + + def set_session_title(self, key, title): + seen["title"] = (key, title) + return True + + def get_session(self, key): + return {"id": key, "cwd": str(tmp_path)} + + def update_session_cwd(self, *a, **k): + return None + + def close(self): + seen["closed"] = True + + class FakeAgent: + def __init__(self): + self.model = "test-model" + self.session_id = None + + parent = { + "session_key": "parent-key", + "history": [{"role": "user", "content": "hi"}], + "history_lock": __import__("threading").Lock(), + "running": False, + "cols": 80, + "profile_home": str(profile_home), + "source": "tui", + "agent": FakeAgent(), + "created_at": 1.0, + "last_active": 1.0, + "cwd": str(tmp_path), + } + server._sessions["parent"] = parent + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None)) + monkeypatch.setattr( + server, + "_make_agent", + lambda *a, **k: FakeAgent(), + ) + monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: {}) + monkeypatch.setattr(server, "_clear_session_context", lambda *a, **k: None) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + monkeypatch.setattr(server, "_session_cwd", lambda s: str(tmp_path)) + monkeypatch.setattr(server, "_register_session_cwd", lambda *a, **k: None) + monkeypatch.setattr(server, "_attach_worker", lambda *a, **k: None) + try: + resp = server.handle_request( + { + "id": "1", + "method": "session.branch", + "params": {"session_id": "parent", "name": "forked"}, + } + ) + assert "result" in resp, resp + assert seen.get("created") + assert seen.get("parent") == "parent-key" + assert seen.get("title") == (seen["created"], "forked") + assert len(seen["msgs"]) == 1 + assert seen.get("launch") is None + assert seen.get("launch_create") is None + child_sid = resp["result"]["session_id"] + assert server._sessions[child_sid]["profile_home"] == str(profile_home) + finally: + for k in list(server._sessions): + server._sessions.pop(k, None) + + +def test_pending_title_finalizer_uses_session_profile_db(monkeypatch, tmp_path): + """Post-turn pending_title must land in the session profile store.""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + seen: dict = {} + + class LaunchDB: + def set_session_title(self, _key, _title): + seen["launch"] = True + return True + + class ProfileDB: + def __init__(self, db_path=None): + seen["db_path"] = db_path + + def set_session_title(self, key, title): + seen["set"] = (key, title) + return True + + def close(self): + seen["closed"] = True + + monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) + monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) + session = { + "session_key": "ml-sess", + "pending_title": "deferred-title", + "profile_home": str(profile_home), + "history": [], + } + # Exercise the same close pattern as the post-turn finalizer. + with server._session_db(session) as db: + assert db is not None + assert db.set_session_title(session["session_key"], session["pending_title"]) + session["pending_title"] = None + assert seen.get("set") == ("ml-sess", "deferred-title") + assert seen.get("launch") is None + assert session["pending_title"] is None + + # -------------------------------------------------------------------------- # model.options — curated-list parity with `hermes model` and classic /model # -------------------------------------------------------------------------- diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 844961ee37a2..b7f757ab4485 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -673,20 +673,22 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No _tui_owns_lifecycle = True if session_id: try: - db = _get_db() - if db is not None: - # Don't end gateway-originated sessions — the gateway owns - # their lifecycle. The TUI is a viewer, not the owner. - # Ending a gateway session in state.db triggers a Groundhog - # Day routing loop: the gateway's #54878 self-heal detects - # the stale entry, recovers to the parent session, context - # compression splits back to the reaped child, and the cycle - # repeats on every inbound message. (#60609) - row = db.get_session(session_id) - source = (row or {}).get("source", "") - _tui_owns_lifecycle = not _is_gateway_owned_source(source) - if _tui_owns_lifecycle: - db.end_session(session_id, end_reason) + # End the row in the *session's* profile state.db (app-global + # remote mode), not the launch profile's shared handle. + with _session_db(session) as db: + if db is not None: + # Don't end gateway-originated sessions — the gateway owns + # their lifecycle. The TUI is a viewer, not the owner. + # Ending a gateway session in state.db triggers a Groundhog + # Day routing loop: the gateway's #54878 self-heal detects + # the stale entry, recovers to the parent session, context + # compression splits back to the reaped child, and the cycle + # repeats on every inbound message. (#60609) + row = db.get_session(session_id) + source = (row or {}).get("source", "") + _tui_owns_lifecycle = not _is_gateway_owned_source(source) + if _tui_owns_lifecycle: + db.end_session(session_id, end_reason) except Exception: pass @@ -1062,6 +1064,63 @@ def _get_db(): return _db +def _db_for_profile(profile: str | None = None): + """Return SessionDB for ``params.profile`` when it differs from launch. + + App-global remote mode passes ``profile`` on session.* RPCs so history/list/ + create operate on that profile's ``state.db``. Launch/own profile → shared + ``_get_db()`` handle (left open). Non-launch profile → a dedicated handle + the caller should ``close()`` (see :func:`_profile_db` contextmanager). + + Returns (db, owns_handle). ``db`` is None when unavailable. + """ + profile_home = _profile_home(profile) + if profile_home is None: + return _get_db(), False + try: + from hermes_state import SessionDB + + return SessionDB(db_path=Path(profile_home) / "state.db"), True + except Exception as exc: + logger.warning( + "TUI profile session store unavailable for %s: %s", + profile, + exc, + ) + return None, False + + +@contextlib.contextmanager +def _profile_db(params: dict | None = None): + """Yield the SessionDB for ``params['profile']`` (app-global remote mode). + + Closes dedicated profile handles; leaves the launch-profile shared handle open. + Yields None when the db is unavailable. + """ + profile = None + if isinstance(params, dict): + profile = (params.get("profile") or "").strip() or None + db, owns = _db_for_profile(profile) + try: + yield db + finally: + if owns and db is not None: + with contextlib.suppress(Exception): + db.close() + + +def _response_profile_name(profile: str | None = None) -> str: + """Profile name to report on session.* payloads. + + Prefer the RPC's requested profile when it is a real non-launch profile; + otherwise the process launch profile. + """ + name = (profile or "").strip() + if name and _profile_home(name) is not None: + return name + return _current_profile_name() + + def _db_unavailable_error(rid, *, code: int): detail = _db_error or "state.db unavailable" return _err(rid, code, f"state.db unavailable: {detail}") @@ -4108,7 +4167,13 @@ def _session_info(agent, session: dict | None = None) -> dict: "update_behind": None, "update_command": "", "usage": _session_usage_snapshot(session), - "profile_name": _current_profile_name(), + "profile_name": _response_profile_name( + Path(session["profile_home"]).name + if isinstance(session, dict) and session.get("profile_home") + else None + ) + if isinstance(session, dict) and session.get("profile_home") + else _current_profile_name(), } try: from hermes_cli import __version__, __release_date__ @@ -5473,6 +5538,7 @@ def _init_session( cwd: str | None = None, session_db=None, source: str | None = None, + profile_home: str | None = None, ): now = time.time() with _sessions_lock: @@ -5496,6 +5562,10 @@ def _init_session( "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, "tool_started_at": {}, + # Profile-scoped HERMES_HOME for app-global remote mode; None = + # launch profile. SessionBranch copies the parent's value so the + # child stays on the same state.db. + "profile_home": profile_home, # Per-session model override set by an in-session /model switch. # Honored on rebuild (/new, resume) so a switch in THIS session # never leaks into siblings via process-global env vars. @@ -5504,21 +5574,43 @@ def _init_session( # session (stdio for Ink, JSON-RPC WS for the dashboard sidebar). "transport": current_transport() or _stdio_transport, } - db = session_db if session_db is not None else _get_db() - if db is not None: - row = db.get_session(key) - if row and row.get("cwd"): - with _sessions_lock: - if sid in _sessions: - _sessions[sid]["cwd"] = row["cwd"] - else: + _init_owns_db = False + if session_db is not None: + db = session_db + elif profile_home: + try: + from hermes_state import SessionDB + + db = SessionDB(db_path=Path(profile_home) / "state.db") + _init_owns_db = True + except Exception: + db = _get_db() + else: + db = _get_db() + try: + if db is not None: + row = db.get_session(key) if hasattr(db, "get_session") else None + if row and row.get("cwd"): + with _sessions_lock: + if sid in _sessions: + _sessions[sid]["cwd"] = row["cwd"] + else: + try: + _cwd = _sessions[sid]["cwd"] + if hasattr(db, "update_session_cwd"): + db.update_session_cwd(key, _cwd) + # git branch/root probes run off the hot path (see _set_session_cwd). + _persist_session_git_meta(_sessions[sid], _cwd) + except Exception: + logger.debug( + "failed to persist resumed session cwd", exc_info=True + ) + finally: + if _init_owns_db and db is not None: try: - _cwd = _sessions[sid]["cwd"] - db.update_session_cwd(key, _cwd) - # git branch/root probes run off the hot path (see _set_session_cwd). - _persist_session_git_meta(_sessions[sid], _cwd) + db.close() except Exception: - logger.debug("failed to persist resumed session cwd", exc_info=True) + pass _register_session_cwd(_sessions[sid]) try: _attach_worker( @@ -6301,7 +6393,7 @@ def _(rid, params: dict) -> dict: "project": _project_info_for_cwd(_sessions[sid]["cwd"]), "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT, - "profile_name": _current_profile_name(), + "profile_name": _response_profile_name(profile), }, }, ) @@ -6309,48 +6401,53 @@ def _(rid, params: dict) -> dict: @method("session.list") def _(rid, params: dict) -> dict: - db = _get_db() - if db is None: - return _db_unavailable_error(rid, code=5006) - try: - # Resume picker should surface human conversation sessions from every - # user-facing surface — CLI, TUI, all gateway platforms (including new - # ones not enumerated here), ACP adapter clients, webhook sessions, - # custom `HERMES_SESSION_SOURCE` values, and older installs with - # different source labels. We deny-list only the noisy internal - # sources (``tool`` sub-agent runs) rather than allow-listing a - # fixed set of platform names that goes stale whenever a new - # platform is added or a user names their own source. - deny = frozenset({"tool"}) + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5006) + try: + # Resume picker should surface human conversation sessions from every + # user-facing surface — CLI, TUI, all gateway platforms (including new + # ones not enumerated here), ACP adapter clients, webhook sessions, + # custom `HERMES_SESSION_SOURCE` values, and older installs with + # different source labels. We deny-list only the noisy internal + # sources (``tool`` sub-agent runs) rather than allow-listing a + # fixed set of platform names that goes stale whenever a new + # platform is added or a user names their own source. + deny = frozenset({"tool"}) - limit = int(params.get("limit", 200) or 200) - # Over-fetch modestly so per-source filtering doesn't leave us - # short; the compression-tip projection in ``list_sessions_rich`` - # can also merge rows. - fetch_limit = max(limit * 2, 200) - rows = [ - s - for s in db.list_sessions_rich(source=None, limit=fetch_limit, order_by_last_active=True, compact_rows=True) - if (s.get("source") or "").strip().lower() not in deny - ][:limit] - return _ok( - rid, - { - "sessions": [ - { - "id": s["id"], - "title": s.get("title") or "", - "preview": s.get("preview") or "", - "started_at": s.get("started_at") or 0, - "message_count": s.get("message_count") or 0, - "source": s.get("source") or "", - } - for s in rows - ] - }, - ) - except Exception as e: - return _err(rid, 5006, str(e)) + limit = int(params.get("limit", 200) or 200) + # Over-fetch modestly so per-source filtering doesn't leave us + # short; the compression-tip projection in ``list_sessions_rich`` + # can also merge rows. + fetch_limit = max(limit * 2, 200) + rows = [ + s + for s in db.list_sessions_rich( + source=None, + limit=fetch_limit, + order_by_last_active=True, + compact_rows=True, + ) + if (s.get("source") or "").strip().lower() not in deny + ][:limit] + return _ok( + rid, + { + "sessions": [ + { + "id": s["id"], + "title": s.get("title") or "", + "preview": s.get("preview") or "", + "started_at": s.get("started_at") or 0, + "message_count": s.get("message_count") or 0, + "source": s.get("source") or "", + } + for s in rows + ] + }, + ) + except Exception as e: + return _err(rid, 5006, str(e)) @method("session.most_recent") @@ -6367,34 +6464,39 @@ def _(rid, params: dict) -> dict: session found right now". Errors are also folded into that null-result shape (and logged) so callers don't have to special- case JSON-RPC error envelopes for what is a normal "no answer". + + Honors ``params.profile`` so app-global remote mode lists from the + focused profile's ``state.db`` (mirrors ``session.resume``). """ - db = _get_db() - if db is None: - return _ok(rid, {"session_id": None}) - try: - deny = frozenset({"tool"}) - # Over-fetch by a generous bounded amount so heavy sub-agent - # users (lots of recent ``tool`` rows) don't get a false - # "no eligible session" answer. ``session.list`` uses a - # similar over-fetch strategy. - rows = db.list_sessions_rich(source=None, limit=200, order_by_last_active=True, compact_rows=True) - for row in rows: - src = (row.get("source") or "").strip().lower() - if src in deny: - continue - return _ok( - rid, - { - "session_id": row.get("id"), - "title": row.get("title") or "", - "started_at": row.get("started_at") or 0, - "source": row.get("source") or "", - }, + with _profile_db(params) as db: + if db is None: + return _ok(rid, {"session_id": None}) + try: + deny = frozenset({"tool"}) + # Over-fetch by a generous bounded amount so heavy sub-agent + # users (lots of recent ``tool`` rows) don't get a false + # "no eligible session" answer. ``session.list`` uses a + # similar over-fetch strategy. + rows = db.list_sessions_rich( + source=None, limit=200, order_by_last_active=True, compact_rows=True ) - return _ok(rid, {"session_id": None}) - except Exception: - logger.exception("session.most_recent failed") - return _ok(rid, {"session_id": None}) + for row in rows: + src = (row.get("source") or "").strip().lower() + if src in deny: + continue + return _ok( + rid, + { + "session_id": row.get("id"), + "title": row.get("title") or "", + "started_at": row.get("started_at") or 0, + "source": row.get("source") or "", + }, + ) + return _ok(rid, {"session_id": None}) + except Exception: + logger.exception("session.most_recent failed") + return _ok(rid, {"session_id": None}) @method("project.facts") @@ -6439,7 +6541,13 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"verification": {"status": "unknown", "evidence": None}}) -def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict: +def _lazy_resume_info( + cwd: str, + *, + model: str = "", + provider: str = "", + profile: str | None = None, +) -> dict: """session.info for a not-yet-built session (the shape session.create returns). tools/skills land later when the deferred build emits session.info.""" info = { @@ -6451,7 +6559,7 @@ def _lazy_resume_info(cwd: str, *, model: str = "", provider: str = "") -> dict: "skills": {}, "lazy": True, "desktop_contract": DESKTOP_BACKEND_CONTRACT, - "profile_name": _current_profile_name(), + "profile_name": _response_profile_name(profile), } if provider: info["provider"] = provider @@ -6703,7 +6811,7 @@ def _(rid, params: dict) -> dict: "resumed": target, "message_count": len(messages), "messages": messages, - "info": _lazy_resume_info(cwd), + "info": _lazy_resume_info(cwd, profile=profile), "inflight": None, "running": child_running, "session_key": target, @@ -6790,6 +6898,7 @@ def _(rid, params: dict) -> dict: cwd, model=model_override.get("model") or "", provider=overrides.get("provider_override") or "", + profile=profile, ), "inflight": None, "running": False, @@ -6990,12 +7099,12 @@ def _message_preview(history: list) -> str: def _session_live_title(session: dict, key: str) -> str: title = str(session.get("pending_title") or "").strip() - db = _get_db() - if db is not None: - try: - title = str(db.get_session_title(key) or title or "").strip() - except Exception: - pass + try: + with _session_db(session) as db: + if db is not None: + title = str(db.get_session_title(key) or title or "").strip() + except Exception: + pass return title @@ -7247,13 +7356,13 @@ def _(rid, params: dict) -> dict: still being written to and removing them out from under the live agent corrupts message ordering and trips FK constraints when the next message append flushes. + + Honors ``params.profile`` so app-global remote mode deletes from the + focused profile's ``state.db`` + sessions dir (mirrors ``session.resume``). """ target = params.get("session_id", "") if not target: return _err(rid, 4006, "session_id required") - db = _get_db() - if db is None: - return _db_unavailable_error(rid, code=5036) # Block deletion of any session currently bound to a live TUI session # in this process. The picker hides the active session anyway, but a # racing caller could still target it. Snapshot via ``list(...)`` @@ -7269,14 +7378,22 @@ def _(rid, params: dict) -> dict: active = {s.get("session_key") for s in snapshot if s.get("session_key")} if target in active: return _err(rid, 4023, "cannot delete an active session") - sessions_dir = get_hermes_home() / "sessions" - try: - deleted = db.delete_session(target, sessions_dir=sessions_dir) - except Exception as e: - return _err(rid, 5036, f"delete failed: {e}") - if not deleted: - return _err(rid, 4007, "session not found") - return _ok(rid, {"deleted": target}) + profile = (params.get("profile") or "").strip() or None + profile_home = _profile_home(profile) + with _profile_db(params) as db: + if db is None: + return _db_unavailable_error(rid, code=5036) + if profile_home is not None: + sessions_dir = Path(profile_home) / "sessions" + else: + sessions_dir = get_hermes_home() / "sessions" + try: + deleted = db.delete_session(target, sessions_dir=sessions_dir) + except Exception as e: + return _err(rid, 5036, f"delete failed: {e}") + if not deleted: + return _err(rid, 4007, "session not found") + return _ok(rid, {"deleted": target}) @method("session.title") @@ -7284,83 +7401,84 @@ def _(rid, params: dict) -> dict: session, err = _sess_nowait(params, rid) if err: return err - db = _get_db() - if db is None: - return _db_unavailable_error(rid, code=5007) - key = session["session_key"] - if "title" not in params: - fallback = session.get("pending_title") or "" - try: - resolved_title = db.get_session_title(key) or "" - if fallback: - if db.set_session_title(key, fallback): - session["pending_title"] = None - resolved_title = fallback - else: - existing_row = db.get_session(key) - existing_title = ((existing_row or {}).get("title") or "").strip() - if existing_title == fallback: + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5007) + key = session["session_key"] + if "title" not in params: + fallback = session.get("pending_title") or "" + try: + resolved_title = db.get_session_title(key) or "" + if fallback: + if db.set_session_title(key, fallback): session["pending_title"] = None resolved_title = fallback - elif not resolved_title: - resolved_title = fallback - elif resolved_title: - session["pending_title"] = None - except Exception: - resolved_title = fallback - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok( - rid, - { - "title": resolved_title, - "session_key": key, - }, - ) - title = (params.get("title", "") or "").strip() - if not title: - return _err(rid, 4021, "title required") - try: - if db.set_session_title(key, title): - session["pending_title"] = None - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok(rid, {"pending": False, "title": title}) - # rowcount == 0 can mean "same value" as well as "missing row". - existing_row = db.get_session(key) - if existing_row: - session["pending_title"] = None + else: + existing_row = db.get_session(key) + existing_title = ((existing_row or {}).get("title") or "").strip() + if existing_title == fallback: + session["pending_title"] = None + resolved_title = fallback + elif not resolved_title: + resolved_title = fallback + elif resolved_title: + session["pending_title"] = None + except Exception: + resolved_title = fallback _emit_session_info_for_session(params.get("session_id", ""), session) return _ok( rid, { - "pending": False, - "title": (existing_row.get("title") or title), + "title": resolved_title, + "session_key": key, }, ) - # No row yet (the DB write is deferred to the first prompt so empty - # drafts don't litter the sidebar). An explicit /title is clear user - # intent, not an abandoned draft — so persist the row NOW and set the - # title, mirroring the messaging gateway's _handle_title_command. The - # old behavior only queued pending_title and relied on the post-turn - # apply block; if that turn never landed under this session_key the - # title was silently lost and the sidebar fell back to the message - # preview. Creating the row up front removes that race entirely. The - # min-messages sidebar filter keeps a titled 0-message row hidden, so - # a /title'd-but-never-used draft still doesn't clutter the list. - _ensure_session_db_row(session) - with _session_db(session) as scoped_db: - if scoped_db is not None and scoped_db.set_session_title(key, title): + title = (params.get("title", "") or "").strip() + if not title: + return _err(rid, 4021, "title required") + try: + if db.set_session_title(key, title): session["pending_title"] = None _emit_session_info_for_session(params.get("session_id", ""), session) return _ok(rid, {"pending": False, "title": title}) - # Row creation didn't take (DB unavailable, or a concurrent writer) — - # fall back to queuing so the post-turn apply block can still recover. - session["pending_title"] = title - _emit_session_info_for_session(params.get("session_id", ""), session) - return _ok(rid, {"pending": True, "title": title}) - except ValueError as e: - return _err(rid, 4022, str(e)) - except Exception as e: - return _err(rid, 5007, str(e)) + # rowcount == 0 can mean "same value" as well as "missing row". + existing_row = db.get_session(key) + if existing_row: + session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) + return _ok( + rid, + { + "pending": False, + "title": (existing_row.get("title") or title), + }, + ) + # No row yet (the DB write is deferred to the first prompt so empty + # drafts don't litter the sidebar). An explicit /title is clear user + # intent, not an abandoned draft — so persist the row NOW and set the + # title, mirroring the messaging gateway's _handle_title_command. The + # old behavior only queued pending_title and relied on the post-turn + # apply block; if that turn never landed under this session_key the + # title was silently lost and the sidebar fell back to the message + # preview. Creating the row up front removes that race entirely. The + # min-messages sidebar filter keeps a titled 0-message row hidden, so + # a /title'd-but-never-used draft still doesn't clutter the list. + _ensure_session_db_row(session) + with _session_db(session) as scoped_db: + if scoped_db is not None and scoped_db.set_session_title(key, title): + session["pending_title"] = None + _emit_session_info_for_session(params.get("session_id", ""), session) + return _ok(rid, {"pending": False, "title": title}) + # Row creation didn't take (DB unavailable, or a concurrent writer) — + # fall back to queuing so the post-turn apply block can still recover. + session["pending_title"] = title + _emit_session_info_for_session(params.get("session_id", ""), session) + return _ok(rid, {"pending": True, "title": title}) + except ValueError as e: + return _err(rid, 4022, str(e)) + except Exception as e: + return _err(rid, 5007, str(e)) + def _main_runtime_from_agent(agent) -> dict | None: @@ -9169,12 +9287,27 @@ def _(rid, params: dict) -> dict: key = session.get("session_key") or params.get("session_id") or "" agent = session.get("agent") meta = {} - db = _get_db() - if db and key: - try: - meta = db.get_session(key) or {} - except Exception: - meta = {} + # Prefer the live session's bound profile db, else params.profile, else launch. + status_params = dict(params or {}) + if not status_params.get("profile") and session.get("profile_home"): + # profile_home is a path; still allow _session_db via a synthetic session + pass + with _session_db(session) as db: + if db is None: + # Fall back to ~params.profile naming for not-yet-mapped sessions. + with _profile_db(params) as db2: + db = db2 + if db and key: + try: + meta = db.get_session(key) or {} + except Exception: + meta = {} + db = None # prevent double-use + if db is not None and key: + try: + meta = db.get_session(key) or {} + except Exception: + meta = {} def _dt(value, fallback: datetime | None = None) -> datetime: if value: @@ -9225,14 +9358,15 @@ def _(rid, params: dict) -> dict: if err: return err history = list(session.get("history", [])) - db = _get_db() - if db is not None and session.get("session_key"): - try: - history = db.get_messages_as_conversation( - session["session_key"], include_ancestors=True - ) - except Exception: - pass + if session.get("session_key"): + with _session_db(session) as db: + if db is not None: + try: + history = db.get_messages_as_conversation( + session["session_key"], include_ancestors=True + ) + except Exception: + pass return _ok( rid, { @@ -9531,58 +9665,62 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - db = _get_db() - if db is None: - return _db_unavailable_error(rid, code=5008) - old_key = session["session_key"] - with session["history_lock"]: - history = [dict(msg) for msg in session.get("history", [])] - if not history: - return _err(rid, 4008, "nothing to branch — send a message first") - new_key = _new_session_key() - new_sid = uuid.uuid4().hex[:8] - source = _session_source(session) - lease, limit_message = _claim_active_session_slot( - new_key, live_session_id=new_sid, surface=source - ) - if limit_message is not None: - return _err(rid, 4090, limit_message) - branch_name = params.get("name", "") - try: - if branch_name: - title = branch_name - else: - current = db.get_session_title(old_key) or "branch" - title = ( - db.get_next_title_in_lineage(current) - if hasattr(db, "get_next_title_in_lineage") - else f"{current} (branch)" - ) - db.create_session( - new_key, - source=source, - model=_resolve_model(), - # Stable _branched_from marker so list_sessions_rich() keeps the - # branch visible in /resume and /sessions. The TUI branch leaves - # the parent live (no end_reason='branched'), so the legacy - # end_reason heuristic never matches it — the marker is the only - # thing that surfaces TUI branches. See issue #20856. - model_config={"_branched_from": old_key}, - parent_session_id=old_key, - cwd=_session_cwd(session), + # Branch must write into the parent's profile-scoped state.db (app-global + # remote mode). Using the launch handle would orphan branch rows + history. + with _session_db(session) as db: + if db is None: + return _db_unavailable_error(rid, code=5008) + old_key = session["session_key"] + with session["history_lock"]: + history = [dict(msg) for msg in session.get("history", [])] + if not history: + return _err(rid, 4008, "nothing to branch — send a message first") + new_key = _new_session_key() + new_sid = uuid.uuid4().hex[:8] + source = _session_source(session) + lease, limit_message = _claim_active_session_slot( + new_key, live_session_id=new_sid, surface=source ) - for msg in history: - db.append_message( - session_id=new_key, - role=msg.get("role", "user"), - content=msg.get("content"), - timestamp=msg.get("timestamp"), + if limit_message is not None: + return _err(rid, 4090, limit_message) + branch_name = params.get("name", "") + try: + if branch_name: + title = branch_name + else: + current = db.get_session_title(old_key) or "branch" + title = ( + db.get_next_title_in_lineage(current) + if hasattr(db, "get_next_title_in_lineage") + else f"{current} (branch)" + ) + db.create_session( + new_key, + source=source, + model=_resolve_model(), + # Stable _branched_from marker so list_sessions_rich() keeps the + # branch visible in /resume and /sessions. The TUI branch leaves + # the parent live (no end_reason='branched'), so the legacy + # end_reason heuristic never matches it — the marker is the only + # thing that surfaces TUI branches. See issue #20856. + model_config={"_branched_from": old_key}, + parent_session_id=old_key, + cwd=_session_cwd(session), ) - db.set_session_title(new_key, title) - except Exception as e: - if lease is not None: - lease.release() - return _err(rid, 5008, f"branch failed: {e}") + for msg in history: + db.append_message( + session_id=new_key, + role=msg.get("role", "user"), + content=msg.get("content"), + # Preserve the parent's original message timestamps — + # branch copies are history, not new activity (9d73006ad). + timestamp=msg.get("timestamp"), + ) + db.set_session_title(new_key, title) + except Exception as e: + if lease is not None: + lease.release() + return _err(rid, 5008, f"branch failed: {e}") try: tokens = _set_session_context(new_key) try: @@ -9594,13 +9732,16 @@ def _(rid, params: dict) -> dict: ) finally: _clear_session_context(tokens) + parent_home = session.get("profile_home") _init_session( new_sid, new_key, agent, list(history), cols=session.get("cols", 80), + cwd=_session_cwd(session), source=source, + profile_home=parent_home, ) if new_sid in _sessions: _sessions[new_sid]["active_session_lease"] = lease @@ -10984,26 +11125,26 @@ def _run_prompt_submit( file=sys.stderr, ) - # Apply pending_title now that the DB row exists. + # Apply pending_title now that the DB row exists — in the + # session-owned profile store (not the launch profile). _pending = session.get("pending_title") if _pending and status == "complete": - _pdb = _get_db() - if _pdb: - _session_key = session.get("session_key") or sid - try: - if _pdb.set_session_title(_session_key, _pending): + _session_key = session.get("session_key") or sid + try: + with _session_db(session) as _pdb: + if _pdb and _pdb.set_session_title(_session_key, _pending): session["pending_title"] = None - except ValueError as exc: - # Invalid/duplicate title — non-retryable, drop it. - # Auto-title will take over. Fix for #19029. - session["pending_title"] = None - logger.info( - "Dropping pending title for session %s: %s", - _session_key, exc, - ) - except Exception: - # Transient DB failure — keep pending_title for retry. - pass + except ValueError as exc: + # Invalid/duplicate title — non-retryable, drop it. + # Auto-title will take over. Fix for #19029. + session["pending_title"] = None + logger.info( + "Dropping pending title for session %s: %s", + _session_key, exc, + ) + except Exception: + # Transient DB failure — keep pending_title for retry. + pass if ( status == "complete" From 40a80487c40e7a013b209eecbe39a82a469e6103 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 01:49:12 -0500 Subject: [PATCH 2/4] fix(desktop): branch a chat on its parent's owning profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forkBranch called session.create without a profile, so in app-global remote mode (one backend serving every profile) a branch of a non-default-profile chat silently landed on the launch profile — the "session jumps between profiles after branching" bug. New chats already carry their profile (desktopSessionCreateParams, #39993); branching was the remaining create callsite without it. Thread the parent's owning profile through both branch entry points: branchStoredSession already resolved it (#67603) but dropped it before the create; branchCurrentSession now resolves the open chat's profile via the same cache → active → cross-profile ladder as resume. forkBranch swaps the live gateway onto that profile and passes it on session.create, which also makes the optimistic sidebar row's profile stamp correct. Supersedes #40788 — same invariant, rebuilt for the current forkBranch/ branchStoredSession split. Co-authored-by: Dusk1e --- .../hooks/use-session-actions.test.tsx | 68 ++++++++++++++++++- .../hooks/use-session-actions/index.ts | 27 +++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 7648c01a4037..2472dccb1132 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -1102,8 +1102,12 @@ describe('branchStoredSession desktop source tagging', () => { session_id: 'stored-parent' } as never) - const requestGateway = vi.fn(async (method: string) => { + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { if (method === 'session.create') { + createParams = params + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never } @@ -1120,9 +1124,71 @@ describe('branchStoredSession desktop source tagging', () => { expect(ensureGatewayProfile).toHaveBeenCalledWith('work') expect(getSessionMessages).toHaveBeenCalledWith('stored-parent', 'work') + // The create itself must carry the owning profile: in app-global remote + // mode the soft gateway swap alone is not enough — an omitted profile + // lands the branch on the launch (default) profile's state.db. + expect(createParams).toMatchObject({ parent_session_id: 'stored-parent', profile: 'work' }) vi.mocked(getSession).mockReset() }) + + it('creates the branch on the cached parent session profile', async () => { + setSessions([storedSession({ id: 'stored-parent', message_count: 1, profile: 'work' })]) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(ensureGatewayProfile).toHaveBeenCalledWith('work') + expect(createParams).toMatchObject({ profile: 'work' }) + }) + + it('omits profile for a profile-less parent so single-profile users are unchanged', async () => { + setSessions([storedSession({ id: 'stored-parent', message_count: 1 })]) + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: [{ content: 'branch me', role: 'user', timestamp: 1 }], + session_id: 'stored-parent' + } as never) + + let createParams: Record | undefined + + const requestGateway = vi.fn(async (method: string, params?: Record) => { + if (method === 'session.create') { + createParams = params + + return { session_id: 'branch-runtime', stored_session_id: 'branch-stored' } as never + } + + return {} as never + }) + + let branchStoredSession: ((storedSessionId: string) => Promise) | null = null + render( (branchStoredSession = branch)} requestGateway={requestGateway} />) + await waitFor(() => expect(branchStoredSession).not.toBeNull()) + + await expect(branchStoredSession!('stored-parent')).resolves.toBe(true) + + expect(createParams).toBeDefined() + expect(createParams).not.toHaveProperty('profile') + }) }) // ── Warm-cache mapping integrity (the "open chat A, chat B loads" bug) ───────── diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index a4ac8ec4ad72..95730277cd3f 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -82,6 +82,7 @@ import { patchSessionWorkspace, preserveLocalPendingTurnMessages, reconcileResumeMessages, + resolveSessionProfile, resolveStoredSession, sessionMatchesStoredId, sessionShouldHaveTranscript, @@ -1056,15 +1057,30 @@ export function useSessionActions({ // `parentStoredId` so it nests under its parent, then open it as its own tab // and switch to it — the parent chat stays put (mirrors openNewSessionTile). const forkBranch = useCallback( - async (branchMessages: BranchMessage[], parentStoredId: null | string, cwd?: string): Promise => { + async ( + branchMessages: BranchMessage[], + parentStoredId: null | string, + cwd?: string, + profile?: null | string + ): Promise => { creatingSessionRef.current = true try { + // A branch belongs to its parent's OWNING profile. Swapping the live + // gateway first AND passing `profile` on the create mirrors + // desktopSessionCreateParams/resumeSession: in app-global remote mode + // one backend serves every profile, so an omitted profile silently + // lands the branch on the launch (default) profile — the "session + // jumps between profiles after branching" bug. The swap also makes + // upsertOptimisticSession's $activeGatewayProfile stamp correct. + await ensureGatewayProfile(profile) + // No title: the backend auto-names the branch from its parent's lineage. const branched = await requestGateway('session.create', { cols: 96, source: 'desktop', ...(cwd && { cwd }), + ...(profile ? { profile } : {}), messages: branchMessages.map(({ content, role }) => ({ content, role })), ...(parentStoredId && { parent_session_id: parentStoredId }) }) @@ -1165,7 +1181,12 @@ export function useSessionActions({ clearNotifications() - return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim()) + // The open chat's owning profile, NOT the picker's / launch profile — + // /profile only retargets new chats, so a branch of an existing thread + // must stay on that thread's backend (cache hit for an open session). + const profile = await resolveSessionProfile(selectedStoredSessionIdRef.current) + + return forkBranch(branchMessages, selectedStoredSessionIdRef.current, $currentCwd.get().trim(), profile) }, [activeSessionIdRef, busyRef, copy, forkBranch, selectedStoredSessionIdRef] ) @@ -1197,7 +1218,7 @@ export function useSessionActions({ return false } - return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim()) + return await forkBranch(branchMessages, stored?.id ?? storedSessionId, stored?.cwd?.trim(), profile) } catch (err) { notifyError(err, copy.branchFailed) From e9a243ef785b93b675639f51a72fe03aaa7934aa Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 01:49:22 -0500 Subject: [PATCH 3/4] fix(state): inherit and stamp profile_name across rotation and branch children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profile_name was only written on the agent's initial lazy create (e8b7ce8c1); every parented child row — compression rotation, TUI /branch, desktop branch first-persist — was created without it. A non-default profile's lineage therefore turned NULL on its first compression or branch and aggregated as "default" in unified session lists, completing the cross-profile session-jump. Fix the class at the DB layer: _insert_session_row's parent backfill now COALESCEs profile_name from the parent alongside cwd/git_* (#64709 pattern), so any parented child inherits its lineage's owning profile. Stamp it explicitly at the three create sites as well — compression rotation (mirroring _ensure_db_session), TUI session.branch, and the TUI first-prompt row persist — so rows are self-describing even when the parent row predates the profile_name column. --- agent/conversation_compression.py | 14 ++++++ hermes_state.py | 17 ++++--- .../run_agent/test_compression_persistence.py | 46 +++++++++++++++++++ tests/test_hermes_state.py | 38 +++++++++++++++ tests/test_tui_gateway_server.py | 44 ++++++++++++++++-- tui_gateway/server.py | 12 +++++ 6 files changed, 160 insertions(+), 11 deletions(-) diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 59f95be9f291..88a5f4d7ab89 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1908,6 +1908,19 @@ def compress_context( except Exception: pass agent._session_db_created = False + # The rotation child must stay on the parent's profile — + # mirror _ensure_db_session's stamp ("default" persists as + # NULL). _insert_session_row's parent backfill additionally + # COALESCEs from the parent row, covering app-global remote + # sessions whose thread lacks the HERMES_HOME context. + try: + from hermes_cli.profiles import get_active_profile_name + + _profile_for_child = get_active_profile_name() + if _profile_for_child == "default": + _profile_for_child = None + except Exception: + _profile_for_child = None try: agent._session_db.create_session( session_id=agent.session_id, @@ -1915,6 +1928,7 @@ def compress_context( model=agent.model, model_config=agent._session_init_model_config, parent_session_id=old_session_id, + profile_name=_profile_for_child, ) except Exception as _cs_err: # The child row could not be created (e.g. FK constraint, diff --git a/hermes_state.py b/hermes_state.py index 3393e26c656b..4158a911ea2f 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -3387,13 +3387,15 @@ class SessionDB: When ``parent_session_id`` is set (compression fork, delegate/subagent spawn, branch continuation) and this row's own ``cwd``/``git_repo_root``/ - ``git_branch`` are still NULL after the insert, they are backfilled from - the parent row. Callers of ``create_session`` for a child session - historically didn't propagate these fields themselves (e.g. the + ``git_branch``/``profile_name`` are still NULL after the insert, they are + backfilled from the parent row. Callers of ``create_session`` for a child + session historically didn't propagate these fields themselves (e.g. the compression-fork path), so a lineage could silently lose its working directory and drop out of the project sidebar every time it forked - (#64709). This only fills NULLs — an explicit ``cwd``/``git_repo_root`` - on the child is never overwritten. For compression forks specifically + (#64709), or lose its owning profile and be aggregated as "default" every + time it rotated or branched (the cross-profile session-jump bug). This + only fills NULLs — an explicit value on the child is never overwritten. + For compression forks specifically (parent ended with ``end_reason='compression'``), the gateway origin columns (``user_id``/``session_key``/``chat_id``/``chat_type``/ ``thread_id``/``display_name``/``origin_json``) are inherited too, so a @@ -3449,7 +3451,10 @@ class SessionDB: WHERE p.id = sessions.parent_session_id)), git_branch = COALESCE(sessions.git_branch, (SELECT p.git_branch FROM sessions p - WHERE p.id = sessions.parent_session_id)) + WHERE p.id = sessions.parent_session_id)), + profile_name = COALESCE(sessions.profile_name, + (SELECT p.profile_name FROM sessions p + WHERE p.id = sessions.parent_session_id)) WHERE id = ? AND parent_session_id IS NOT NULL""", (session_id,), ) diff --git a/tests/run_agent/test_compression_persistence.py b/tests/run_agent/test_compression_persistence.py index 20f8a442e2b9..9f0f6e70fa5f 100644 --- a/tests/run_agent/test_compression_persistence.py +++ b/tests/run_agent/test_compression_persistence.py @@ -322,6 +322,52 @@ class TestFlushAfterCompression: ) db.close() + def test_rotation_child_session_inherits_parent_profile_name(self): + """The rotation child must stay on the parent's owning profile. + + The rotate path used to create the child row with no profile_name, so + a compressed non-default-profile conversation migrated to the launch/ + default profile in unified session lists (the cross-profile + session-jump bug). Exercises the real rotation against a real + SessionDB: explicit stamp at the create site + the parent-backfill + COALESCE in _insert_session_row. + """ + from agent.conversation_compression import compress_context + from hermes_state import SessionDB + + with tempfile.TemporaryDirectory() as tmpdir: + db = SessionDB(db_path=Path(tmpdir) / "test.db") + parent_sid = "20260701_152840_parent" + db.create_session( + parent_sid, "gateway", model="test/model", + profile_name="ai-engineer", + ) + + agent = self._make_agent(db) + agent.session_id = parent_sid + agent.compression_in_place = False + agent._ensure_db_session() + + messages = [ + { + "role": "user" if i % 2 == 0 else "assistant", + "content": f"message {i}", + "_db_persisted": True, + } + for i in range(12) + ] + + with patch("agent.context_compressor.call_llm", side_effect=RuntimeError("no provider")): + compress_context( + agent, messages, approx_tokens=100_000, system_message="sys" + ) + + assert agent.session_id != parent_sid + child = db.get_session(agent.session_id) + assert child is not None + assert child["profile_name"] == "ai-engineer" + db.close() + # --------------------------------------------------------------------------- # Part 2: Gateway-side — history_offset after session split diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index d66d072e0e47..947973d3b6ee 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -194,6 +194,44 @@ class TestSessionLifecycle: child = db.get_session("child") assert child["git_branch"] == "feature-x" + def test_child_session_inherits_profile_name_from_parent(self, db): + """A parented child born without profile_name (compression rotation, + /branch) must inherit its parent's owning profile — otherwise the + lineage silently migrates to the launch/default profile in unified + session lists (the cross-profile session-jump bug).""" + db.create_session(session_id="parent", source="cli", profile_name="ai-engineer") + # Rotation path: parent is ended with 'compression' BEFORE the child + # row is created (agent/conversation_compression.py). + db.end_session("parent", "compression") + + db.create_session(session_id="child", source="cli", parent_session_id="parent") + + assert db.get_session("child")["profile_name"] == "ai-engineer" + + def test_child_session_explicit_profile_name_is_not_overwritten(self, db): + """Inheritance only fills NULLs — an explicit profile_name on the + child is never clobbered by the parent's.""" + db.create_session(session_id="parent", source="cli", profile_name="ai-engineer") + + db.create_session( + session_id="child", source="cli", parent_session_id="parent", + profile_name="other", + ) + + assert db.get_session("child")["profile_name"] == "other" + + def test_multi_generation_lineage_inherits_profile_name(self, db): + """profile_name survives a compress-then-branch chain (root -> rotation + child -> branch tip) — the exact lineage that used to land on default.""" + db.create_session(session_id="root", source="cli", profile_name="ai-engineer") + db.end_session("root", "compression") + + db.create_session(session_id="gen1", source="cli", parent_session_id="root") + db.create_session(session_id="gen2", source="cli", parent_session_id="gen1") + + assert db.get_session("gen1")["profile_name"] == "ai-engineer" + assert db.get_session("gen2")["profile_name"] == "ai-engineer" + def test_compression_child_inherits_gateway_origin_columns(self, db): """A compression fork's child inherits gateway routing metadata (session_key/chat_id/...) from the ended parent, so a crash before diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 4e75e0543d8b..ca03dceb3958 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3893,7 +3893,7 @@ def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3914,7 +3914,7 @@ 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, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3935,7 +3935,7 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "source": source, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3964,7 +3964,7 @@ def test_ensure_session_db_row_persists_session_model_override(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append( {"key": key, "model": model, "model_config": model_config, "cwd": cwd} ) @@ -3996,7 +3996,7 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): created = [] class _FakeDB: - def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None): + def create_session(self, key, source=None, model=None, model_config=None, parent_session_id=None, cwd=None, profile_name=None): created.append({"model": model, "model_config": model_config}) monkeypatch.setattr(server, "_get_db", lambda: _FakeDB()) @@ -4007,6 +4007,36 @@ def test_ensure_session_db_row_no_override_uses_global(monkeypatch): assert created == [{"model": "global/default", "model_config": None}] +def test_ensure_session_db_row_stamps_profile_name(monkeypatch, tmp_path): + """A profile session's row carries its owning profile_name, so unified + multi-profile aggregation never has to guess from which state.db file the + row happened to be read (the cross-profile session-jump bug).""" + profile_home = tmp_path / "profiles" / "mlperf" + profile_home.mkdir(parents=True) + created = [] + + class _ProfileDB: + def __init__(self, db_path=None): + created.append({"db_path": db_path}) + + def create_session(self, key, **kwargs): + created[-1].update({"key": key, "profile_name": kwargs.get("profile_name")}) + + def close(self): + pass + + monkeypatch.setattr("hermes_state.SessionDB", _ProfileDB) + monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") + + server._ensure_session_db_row( + {"session_key": "k1", "profile_home": str(profile_home)} + ) + + assert created and created[0]["key"] == "k1" + assert created[0]["profile_name"] == "mlperf" + assert created[0]["db_path"] == profile_home / "state.db" + + def test_session_title_clears_pending_after_persist(monkeypatch): class _FakeDB: def __init__(self): @@ -9081,6 +9111,7 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): def create_session(self, new_key, **kwargs): seen["created"] = new_key seen["parent"] = kwargs.get("parent_session_id") + seen["profile_name"] = kwargs.get("profile_name") def append_message(self, **kwargs): seen["msgs"].append(kwargs) @@ -9142,6 +9173,9 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): assert "result" in resp, resp assert seen.get("created") assert seen.get("parent") == "parent-key" + # The branch row is self-describing: stamped with the parent's owning + # profile, not left NULL for aggregators to mis-tag as "default". + assert seen.get("profile_name") == "mlperf" assert seen.get("title") == (seen["created"], "forked") assert len(seen["msgs"]) == 1 assert seen.get("launch") is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b7f757ab4485..6776606d5e60 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2144,6 +2144,10 @@ def _ensure_session_db_row(session: dict) -> None: model_config=model_config or None, parent_session_id=parent_session_id, cwd=_session_cwd(session) if session.get("explicit_cwd") else None, + # Self-describing rows: aggregators that merge multiple profile DBs + # into one list can't rely on which file a row came from alone. NULL + # means the launch/default profile (matches run_agent's convention). + profile_name=Path(profile_home).name if profile_home else None, ) except Exception: logger.debug("failed to persist desktop session row", exc_info=True) @@ -9706,6 +9710,14 @@ def _(rid, params: dict) -> dict: model_config={"_branched_from": old_key}, parent_session_id=old_key, cwd=_session_cwd(session), + # The branch stays on its parent's profile. Explicit stamp (not + # just the parent-backfill) so it holds even when the parent row + # predates the profile_name column. + profile_name=( + Path(session["profile_home"]).name + if session.get("profile_home") + else None + ), ) for msg in history: db.append_message( From 29dd62149813ecda1eb4e396b774149258cdd7b4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 10:05:05 -0500 Subject: [PATCH 4/4] fix(tui_gateway): bind the branched agent to the parent profile's home + state.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.branch wrote the child ROW into the parent's profile db but built the live agent with the launch defaults: _make_agent fell back to _get_db() and no HERMES_HOME override was active. The branched agent's own message flushes — and any later compression rotation it performed — therefore landed back on the launch profile, splitting the lineage one turn after the branch. Mirror session.create/resume: open the parent profile's SessionDB for the agent and hold the home override across the build, so config/skills/memory resolve to the profile too. Spotted in #70605's sibling implementation of the same fix. Co-authored-by: HexLab98 --- tests/test_tui_gateway_server.py | 15 ++++++--- tui_gateway/server.py | 53 ++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ca03dceb3958..044be273fc17 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -9151,11 +9151,12 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): monkeypatch.setattr(server, "_get_db", lambda: LaunchDB()) monkeypatch.setattr("hermes_state.SessionDB", ProfileDB) monkeypatch.setattr(server, "_claim_active_session_slot", lambda *a, **k: (None, None)) - monkeypatch.setattr( - server, - "_make_agent", - lambda *a, **k: FakeAgent(), - ) + + def _fake_make_agent(*a, **k): + seen["agent_session_db"] = k.get("session_db") + return FakeAgent() + + monkeypatch.setattr(server, "_make_agent", _fake_make_agent) monkeypatch.setattr(server, "_set_session_context", lambda *a, **k: {}) monkeypatch.setattr(server, "_clear_session_context", lambda *a, **k: None) monkeypatch.setattr(server, "_resolve_model", lambda: "test-model") @@ -9182,6 +9183,10 @@ def test_session_branch_writes_to_parent_profile_db(monkeypatch, tmp_path): assert seen.get("launch_create") is None child_sid = resp["result"]["session_id"] assert server._sessions[child_sid]["profile_home"] == str(profile_home) + # The branched AGENT must be bound to the parent profile's state.db — + # not just the row. Otherwise its own flushes (and a later compression + # rotation) land on the launch db, splitting the lineage again. + assert isinstance(seen.get("agent_session_db"), ProfileDB) finally: for k in list(server._sessions): server._sessions.pop(k, None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6776606d5e60..09749e0a47ca 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -9734,27 +9734,48 @@ def _(rid, params: dict) -> dict: lease.release() return _err(rid, 5008, f"branch failed: {e}") try: - tokens = _set_session_context(new_key) + # Bind the branched AGENT to the parent's profile, mirroring + # session.create/resume: home override so config/skills/memory resolve + # to the profile during the build, and the profile's own state.db + # handle so the live agent's message flushes — and any later + # compression rotation — persist there. Writing only the row to the + # parent's db while the agent stayed on the launch handle would + # recreate the cross-profile split one turn later. + parent_home = session.get("profile_home") + branch_db = None + if parent_home: + from hermes_state import SessionDB + + branch_db = SessionDB(db_path=Path(parent_home) / "state.db") + home_token = ( + set_hermes_home_override(parent_home) if parent_home else None + ) try: - agent = _make_agent( + tokens = _set_session_context(new_key) + try: + agent = _make_agent( + new_sid, + new_key, + session_id=new_key, + session_db=branch_db, + platform_override=source, + ) + finally: + _clear_session_context(tokens) + _init_session( new_sid, new_key, - session_id=new_key, - platform_override=source, + agent, + list(history), + cols=session.get("cols", 80), + cwd=_session_cwd(session), + session_db=branch_db, + source=source, + profile_home=parent_home, ) finally: - _clear_session_context(tokens) - parent_home = session.get("profile_home") - _init_session( - new_sid, - new_key, - agent, - list(history), - cols=session.get("cols", 80), - cwd=_session_cwd(session), - source=source, - profile_home=parent_home, - ) + if home_token is not None: + reset_hermes_home_override(home_token) if new_sid in _sessions: _sessions[new_sid]["active_session_lease"] = lease except Exception as e: