From 6f6eb871d83415fe2980f3483cc41a435ba22196 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Fri, 5 Jun 2026 12:44:45 -0500 Subject: [PATCH] fix(gateway): new chats honor their profile in global-remote mode (#39993) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #39921. That PR scoped session.resume + prompt.submit to a session's profile, but a BRAND-NEW chat (session.create) under a non-launch profile was still built and persisted against the dashboard's launch profile. Two visible symptoms in app-global remote mode (one dashboard, many profiles): 1. "who are you" in profile S replied as the launch (default) profile/agent — the agent was built with the launch HERMES_HOME, so config/SOUL/identity came from the wrong profile. 2. "session not found" on later resume — _ensure_session_db_row persisted the row into the launch profile's state.db via _get_db(), so the session lived in the wrong db, the unified list mis-tagged it (it showed up under BOTH profiles), and resume routed to the wrong one. Fix — carry the owning profile through the create path too: - session.create accepts an optional `profile`; resolves its home and stores `profile_home` on the session (alongside what resume already set). - _start_agent_build binds that profile's HERMES_HOME while building the agent (config/skills/model/identity resolve to it) and hands the agent the profile's state.db so turns persist there. - _ensure_session_db_row writes the row into the profile's state.db, not the launch db — fixing the duplicate row + mis-tag + resume 404. - desktop sends the new-chat profile on session.create. None/launch profile → unchanged (single-profile and per-profile-remote setups take the same path). Verified live against a one-dashboard / multi-profile remote: a new chat under `work` builds as work's agent (correct SOUL identity), persists ONLY to work's state.db (launch db stays empty), the unified list tags it `work` exactly once, and it resumes cleanly. tests/test_tui_gateway_server.py: _make_agent mocks updated for the session_db param added in #39921's build path. --- .../app/session/hooks/use-session-actions.ts | 9 +++- tests/test_tui_gateway_server.py | 6 +-- tui_gateway/server.py | 49 ++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index e54fd88c073..b0a4c7efc1c 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -331,7 +331,14 @@ export function useSessionActions({ // so single-profile users are unaffected). await ensureGatewayProfile($newChatProfile.get()) const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd() - const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }) }) + // Pass the owning profile so a new chat under a non-launch profile (global + // remote mode) builds its agent + persists against THAT profile's home/db. + const newChatProfile = $newChatProfile.get() + const created = await requestGateway('session.create', { + cols: 96, + ...(cwd && { cwd }), + ...(newChatProfile ? { profile: newChatProfile } : {}) + }) const stored = created.stored_session_id ?? null if ( diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 47f1a19cc91..666a7728653 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -3577,7 +3577,7 @@ def test_session_create_close_race_does_not_orphan_worker(monkeypatch): release_build = threading.Event() build_entered = threading.Event() - def _slow_make_agent(sid, key, session_id=None): + def _slow_make_agent(sid, key, session_id=None, session_db=None): build_started.set() build_entered.set() release_build.wait(timeout=3.0) @@ -3685,7 +3685,7 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch): self.base_url = "" self.api_key = "" - monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) monkeypatch.setattr( server, @@ -3769,7 +3769,7 @@ def test_session_create_continues_when_state_db_is_unavailable(monkeypatch): emits = [] - monkeypatch.setattr(server, "_make_agent", lambda sid, key: _FakeAgent()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent()) monkeypatch.setattr(server, "_SlashWorker", _FakeWorker) monkeypatch.setattr(server, "_get_db", lambda: None) monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"}) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7e9fd45302f..54d5a935fcd 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -678,10 +678,24 @@ def _start_agent_build(sid: str, session: dict) -> None: worker = None notify_registered = False + home_token = None + profile_home = current.get("profile_home") try: tokens = _set_session_context(key) + # Build against the session's profile (global-remote): bind its + # HERMES_HOME so config/skills/model resolve to it, and hand the + # agent that profile's db so turns persist to the right state.db. + session_db = None + if profile_home: + home_token = set_hermes_home_override(profile_home) + try: + from hermes_state import SessionDB + + session_db = SessionDB(db_path=Path(profile_home) / "state.db") + except Exception: + session_db = None try: - agent = _make_agent(sid, key) + agent = _make_agent(sid, key, session_db=session_db) finally: _clear_session_context(tokens) @@ -725,6 +739,8 @@ def _start_agent_build(sid: str, session: dict) -> None: current["agent_error"] = str(e) _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: + if home_token is not None: + reset_hermes_home_override(home_token) with _sessions_lock: replaced = _sessions.get(sid) is not current if replaced: @@ -850,7 +866,22 @@ def _ensure_session_db_row(session: dict) -> None: key = session.get("session_key") if not key: return - db = _get_db() + # Persist into the session's own profile db (global remote mode), not the + # launch profile's — otherwise the row lands in the wrong state.db, the + # unified list mis-tags it, and resume 404s ("session not found"). + profile_home = session.get("profile_home") + if profile_home: + from hermes_state import SessionDB + + try: + db = SessionDB(db_path=Path(profile_home) / "state.db") + except Exception: + logger.debug("failed to open profile db for session row", exc_info=True) + return + close_db = True + else: + db = _get_db() + close_db = False if db is None: return try: @@ -862,6 +893,12 @@ def _ensure_session_db_row(session: dict) -> None: ) except Exception: logger.debug("failed to persist desktop session row", exc_info=True) + finally: + if close_db: + try: + db.close() + except Exception: + pass def _set_session_cwd(session: dict, cwd: str) -> str: @@ -2960,6 +2997,13 @@ def _(rid, params: dict) -> dict: resolved_cwd = _completion_cwd(params) _enable_gateway_prompts() + # ``profile`` (app-global remote mode): a new chat started under a non-launch + # profile must build its agent + persist against THAT profile's home/state.db, + # not the dashboard's launch profile. Stored on the session so _start_agent_build + # and each turn re-bind HERMES_HOME. None/own profile → launch (unchanged). + profile = (params.get("profile") or "").strip() or None + profile_home = _profile_home(profile) + ready = threading.Event() now = time.time() @@ -2981,6 +3025,7 @@ def _(rid, params: dict) -> dict: "inflight_turn": None, "last_active": now, "pending_title": title or None, + "profile_home": str(profile_home) if profile_home is not None else None, "running": False, "session_key": key, "show_reasoning": _load_show_reasoning(),