from __future__ import annotations import json import pytest from hermes_cli import projects_db as pdb from tools import project_tools, terminal_tool from tools.registry import registry def _use_projects_db(monkeypatch, tmp_path): db_path = tmp_path / "projects.db" original_connect_closing = pdb.connect_closing monkeypatch.setattr(pdb, "connect_closing", lambda: original_connect_closing(db_path=db_path)) return db_path, original_connect_closing def test_agent_session_project_create_keeps_profile_active_pointer_and_moves_session(monkeypatch, tmp_path): db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) with connect_closing(db_path=db_path) as conn: active_id = pdb.create_project(conn, name="Foreground", folders=["/work/foreground"]) pdb.set_active(conn, active_id) workspace_moves: list[tuple[str, str, str]] = [] project_tools.set_project_workspace_callback(lambda *args: workspace_moves.append(args)) try: result = json.loads( project_tools.project_create( "Background", path="/work/background", task_id="session-background" ) ) finally: project_tools.set_project_workspace_callback(None) with connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == active_id created = pdb.get_project(conn, result["id"]) assert result["success"] is True assert created is not None assert created.name == "Background" assert workspace_moves == [("session-background", "/work/background", "Background")] def test_legacy_project_create_sets_profile_active_pointer(monkeypatch, tmp_path): db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) with connect_closing(db_path=db_path) as conn: previous_id = pdb.create_project(conn, name="Previous", folders=["/work/previous"]) pdb.set_active(conn, previous_id) result = json.loads(project_tools.project_create("Created", path="/work/created")) with connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == result["id"] assert result["success"] is True def test_legacy_project_switch_sets_profile_active_pointer(monkeypatch, tmp_path): db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) with connect_closing(db_path=db_path) as conn: active_id = pdb.create_project(conn, name="Active", folders=["/work/active"]) target_id = pdb.create_project(conn, name="Target", folders=["/work/target"]) pdb.set_active(conn, active_id) result = json.loads(project_tools.project_switch(target_id)) with connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == target_id assert result["success"] is True assert result["id"] == target_id def test_agent_session_project_switch_keeps_profile_active_pointer(monkeypatch, tmp_path): db_path = tmp_path / "projects.db" original_connect_closing = pdb.connect_closing with original_connect_closing(db_path=db_path) as conn: active_id = pdb.create_project(conn, name="Foreground", folders=["/work/foreground"]) background_id = pdb.create_project(conn, name="Background", folders=["/work/background"]) pdb.set_active(conn, active_id) monkeypatch.setattr(pdb, "connect_closing", lambda: original_connect_closing(db_path=db_path)) workspace_moves: list[tuple[str, str, str]] = [] project_tools.set_project_workspace_callback(lambda *args: workspace_moves.append(args)) try: result = json.loads(project_tools.project_switch(background_id, task_id="session-background")) finally: project_tools.set_project_workspace_callback(None) with original_connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == active_id assert result["success"] is True assert result["id"] == background_id assert workspace_moves == [("session-background", "/work/background", "Background")] def test_session_without_workspace_callback_switch_sets_profile_active_pointer(monkeypatch, tmp_path): """CLI/messaging agents pass a task_id but have no GUI workspace to move — the pointer is the switch.""" db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) with connect_closing(db_path=db_path) as conn: active_id = pdb.create_project(conn, name="Active", folders=["/work/active"]) target_id = pdb.create_project(conn, name="Target", folders=["/work/target"]) pdb.set_active(conn, active_id) result = json.loads(project_tools.project_switch(target_id, task_id="cli-session")) with connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == target_id assert result["success"] is True def test_session_switch_to_pathless_project_sets_profile_active_pointer(monkeypatch, tmp_path): db_path, connect_closing = _use_projects_db(monkeypatch, tmp_path) with connect_closing(db_path=db_path) as conn: active_id = pdb.create_project(conn, name="Active", folders=["/work/active"]) pathless_id = pdb.create_project(conn, name="Pathless") pdb.set_active(conn, active_id) workspace_moves: list[tuple[str, str, str]] = [] project_tools.set_project_workspace_callback(lambda *args: workspace_moves.append(args)) try: json.loads(project_tools.project_switch(pathless_id, task_id="session-a")) finally: project_tools.set_project_workspace_callback(None) with connect_closing(db_path=db_path) as conn: assert pdb.get_active_id(conn) == pathless_id assert workspace_moves == [] @pytest.fixture def gui_sessions(monkeypatch): """Live GUI sessions wired through the real gateway workspace callback.""" monkeypatch.setattr("hermes_cli.banner.prefetch_update_check", lambda: None) from tui_gateway import server monkeypatch.setattr(server, "_sessions", {}) monkeypatch.setattr(terminal_tool, "_task_env_overrides", {}) monkeypatch.setattr(server, "_emit", lambda *a, **k: None) monkeypatch.setattr(server, "_persist_session_cwd_and_schedule_git_meta", lambda *a, **k: None) project_tools.set_project_workspace_callback(server._apply_project_workspace) def open_session(key, cwd=""): session = {"session_key": key, "cwd": cwd, "source": "desktop"} server._sessions[f"sid-{key}"] = session server._register_session_cwd(session) return session try: yield open_session finally: project_tools.set_project_workspace_callback(None) def _tool(action, task_id=None, **args): return json.loads(registry.dispatch("desktop_project", {"action": action, **args}, task_id=task_id)) def test_two_sessions_switch_projects_independently(gui_sessions, tmp_path): folders = {name: tmp_path / name for name in ("foreground", "a", "b")} for folder in folders.values(): folder.mkdir() with pdb.connect_closing() as conn: foreground = pdb.create_project(conn, name="Foreground", folders=[str(folders["foreground"])]) pdb.set_active(conn, foreground) tab_a = gui_sessions("tab-a", str(folders["foreground"])) tab_b = gui_sessions("tab-b", str(folders["foreground"])) project_a = _tool("create", "tab-a", name="Project A", path=str(folders["a"]))["id"] project_b = _tool("create", "tab-b", name="Project B", path=str(folders["b"]))["id"] # Each tab's workspace moved; the profile-global pointer the sidebar owns did not. assert (tab_a["cwd"], tab_b["cwd"]) == (str(folders["a"]), str(folders["b"])) with pdb.connect_closing() as conn: assert pdb.get_active_id(conn) == foreground listed_a, listed_b = _tool("list", "tab-a"), _tool("list", "tab-b") assert listed_a["active_id"] == project_a assert listed_b["active_id"] == project_b assert [p["id"] for p in listed_a["projects"] if p["active"]] == [project_a] # Tab B switching into A's project leaves tab A (and the global pointer) where they were. assert _tool("switch", "tab-b", name="Project A")["id"] == project_a assert _tool("list", "tab-b")["active_id"] == project_a assert _tool("list", "tab-a")["active_id"] == project_a assert _tool("switch", "tab-a", name="Foreground")["id"] == foreground assert _tool("list", "tab-a")["active_id"] == foreground assert _tool("list", "tab-b")["active_id"] == project_a with pdb.connect_closing() as conn: assert pdb.get_active_id(conn) == foreground def test_project_list_reports_no_project_for_a_detached_session(gui_sessions, tmp_path): with pdb.connect_closing() as conn: active = pdb.create_project(conn, name="Sidebar pick", folders=[str(tmp_path)]) pdb.set_active(conn, active) gui_sessions("detached") listed = _tool("list", "detached") assert listed["active_id"] is None assert not any(p["active"] for p in listed["projects"]) # A caller the gateway never registered keeps reading the profile-global pointer. assert _tool("list")["active_id"] == active def test_idempotent_create_from_a_session_keeps_profile_active_pointer(gui_sessions, tmp_path): folders = {name: tmp_path / name for name in ("foreground", "existing")} for folder in folders.values(): folder.mkdir() with pdb.connect_closing() as conn: foreground = pdb.create_project(conn, name="Foreground", folders=[str(folders["foreground"])]) existing = pdb.create_project( conn, name="Existing", folders=[str(folders["existing"])], primary_path=str(folders["existing"])) pdb.set_active(conn, foreground) tab = gui_sessions("tab", str(folders["foreground"])) result = _tool("create", "tab", name="Again", path=str(folders["existing"])) assert result["id"] == existing assert tab["cwd"] == str(folders["existing"]) with pdb.connect_closing() as conn: assert pdb.get_active_id(conn) == foreground