diff --git a/tests/tui_gateway/test_projects_rpc.py b/tests/tui_gateway/test_projects_rpc.py index 9e1a6f5256..1cd5a320c2 100644 --- a/tests/tui_gateway/test_projects_rpc.py +++ b/tests/tui_gateway/test_projects_rpc.py @@ -5,6 +5,7 @@ from __future__ import annotations import contextlib import os import subprocess +import threading from pathlib import Path import pytest @@ -511,6 +512,51 @@ def test_explicit_desktop_and_terminal_cwds_are_context_workspaces(): ) is False +@pytest.mark.parametrize( + ("explicit_cwd", "launch_artifact"), + [(True, False), (False, True)], +) +def test_desktop_agent_rebuild_preserves_workspace_provenance( + monkeypatch, explicit_cwd, launch_artifact +): + captured = {} + session = { + "agent": object(), + "attached_images": [], + "cwd": "/picked/repo" if explicit_cwd else "/opt/hermes", + "edit_snapshots": {}, + "explicit_cwd": explicit_cwd, + "history": ["old"], + "history_lock": threading.Lock(), + "history_version": 0, + "image_counter": 0, + "running": False, + "session_key": "stored-session", + "show_reasoning": False, + "source": "desktop", + "tool_progress_mode": "off", + "tool_started_at": {}, + } + + def _make_agent(*_args, **kwargs): + captured.update(kwargs) + return object() + + monkeypatch.setattr(server, "_set_session_context", lambda _key: []) + monkeypatch.setattr(server, "_clear_session_context", lambda _tokens: None) + monkeypatch.setattr(server, "_make_agent", _make_agent) + monkeypatch.setattr(server, "_config_model_target", lambda: None) + monkeypatch.setattr(server, "_load_show_reasoning", lambda: False) + monkeypatch.setattr(server, "_load_tool_progress_mode", lambda: "off") + monkeypatch.setattr(server, "_session_info", lambda *_args: {}) + monkeypatch.setattr(server, "_emit", lambda *_args: None) + monkeypatch.setattr(server, "_restart_slash_worker", lambda *_args: None) + + server._reset_session_agent("live-session", session) + + assert captured["context_cwd_is_launch_artifact"] is launch_artifact + + def test_home_container_dirs_are_never_a_workspace(tmp_path): """`/home` and `/Users` hold homes; they are not workspaces themselves. diff --git a/tests/tui_gateway/test_session_db_ownership_teardown.py b/tests/tui_gateway/test_session_db_ownership_teardown.py index 766629a7a0..c3cb310c11 100644 --- a/tests/tui_gateway/test_session_db_ownership_teardown.py +++ b/tests/tui_gateway/test_session_db_ownership_teardown.py @@ -368,6 +368,34 @@ def test_deferred_build_transfers_the_handle_on_success( assert session["agent"]._owns_session_db is True +def test_deferred_build_uses_the_preserved_desktop_workspace_provenance( + build_env, registered, monkeypatch +): + captured: dict = {} + + def _fake_make_agent(*_args, session_db=None, **kwargs): + captured.update(kwargs) + return types.SimpleNamespace(_session_db=session_db, _owns_session_db=False) + + monkeypatch.setattr(server, "_make_agent", _fake_make_agent) + monkeypatch.setattr( + server, "_session_source", lambda current: current.get("source") + ) + sid, session = "sid-workspace", _session(build_env.profile_home) + session.update( + { + "cwd": "C:/picked/repo", + "explicit_cwd": True, + "source": "desktop", + } + ) + registered(sid, session) + + _run_build(sid, session) + + assert captured["context_cwd_is_launch_artifact"] is False + + def test_deferred_build_closes_the_handle_when_the_session_is_reaped_midbuild( build_env, registered, monkeypatch ): diff --git a/tests/tui_gateway/test_session_resume_db_ownership.py b/tests/tui_gateway/test_session_resume_db_ownership.py index e1cdf63927..e14d79a031 100644 --- a/tests/tui_gateway/test_session_resume_db_ownership.py +++ b/tests/tui_gateway/test_session_resume_db_ownership.py @@ -134,6 +134,28 @@ def test_resume_closes_profile_db_when_session_not_found(profile_dbs): assert scoped[0].closed == 1 +def test_deferred_desktop_resume_keeps_stored_workspace_provenance( + profile_dbs, monkeypatch, tmp_path +): + workspace = tmp_path / "workspace" + workspace.mkdir() + + def _factory(db_path=None, **kwargs): + db = _RecordingDB(db_path=db_path, **kwargs) + db.rows["s1"] = {"id": "s1", "cwd": str(workspace)} + profile_dbs.append(db) + return db + + monkeypatch.setattr("hermes_state.SessionDB", _factory) + + resp = _resume(session_id="s1", profile="work", source="desktop") + session = server._sessions[resp["result"]["session_id"]] + + assert session["cwd"] == str(workspace) + assert session["explicit_cwd"] is True + assert server._context_cwd_is_launch_artifact(session) is False + + def test_resume_closes_profile_db_when_reopen_fails(profile_dbs, monkeypatch): """The 'resume failed' early return must not leak the handle.""" diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py index 8b32c05188..485456a87f 100644 --- a/tui_gateway/methods_session.py +++ b/tui_gateway/methods_session.py @@ -689,6 +689,7 @@ def _(rid, params: dict) -> dict: profile_home=profile_home, lazy=True, todo_state=_todo_state_from_history(history), + explicit_cwd=bool(profile_resume_cwd), ) if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None: return _reuse_live_response(*live) @@ -760,6 +761,7 @@ def _(rid, params: dict) -> dict: profile_home=profile_home, model_override=overrides.get("model_override"), resume_runtime_overrides=overrides or None, + explicit_cwd=bool(profile_resume_cwd), ) record["resume_history_ready"] = threading.Event() record["resume_hydrating"] = True @@ -860,6 +862,7 @@ def _(rid, params: dict) -> dict: model_override=overrides.get("model_override"), resume_runtime_overrides=overrides or None, todo_state=_todo_state_from_history(history), + explicit_cwd=bool(profile_resume_cwd), ) if (live := _claim_or_reuse_live(sid, target, record, lease)) is not None: return _reuse_live_response(*live) @@ -998,6 +1001,7 @@ def _(rid, params: dict) -> dict: cwd=profile_resume_cwd, session_db=db, source=source, + explicit_cwd=bool(profile_resume_cwd), ) # Ownership TRANSFER — the registered session's agent now # holds this handle for its whole life, and _init_session @@ -3324,6 +3328,7 @@ def _(rid, params: dict) -> dict: session_db=branch_db, source=source, profile_home=parent_home, + explicit_cwd=bool(session.get("explicit_cwd")), ) # Ownership TRANSFER — the branched session's agent holds this # handle for its whole life and closes it on teardown. Drop is diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 106f8d1fde..d10ff2c865 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3221,7 +3221,12 @@ def _start_agent_build(sid: str, session: dict) -> None: # Lazy-resumed (watch) sessions carry the stored conversation # id — pass it through so the upgrade continues that session # instead of starting a fresh one under the same key. - kw = {"session_db": session_db} + kw = { + "session_db": session_db, + "context_cwd_is_launch_artifact": ( + _context_cwd_is_launch_artifact(current) + ), + } if resume_sid := current.get("resume_session_id"): kw["session_id"] = resume_sid kw["platform_override"] = _session_source(current) @@ -8672,6 +8677,9 @@ def _reset_session_agent(sid: str, session: dict) -> dict: session["session_key"], session_id=session["session_key"], platform_override=_session_source(session), + context_cwd_is_launch_artifact=( + _context_cwd_is_launch_artifact(session) + ), ) finally: _clear_session_context(tokens) @@ -9041,6 +9049,7 @@ def _init_session( session_db=None, source: str | None = None, profile_home: str | None = None, + explicit_cwd: bool = False, ): now = time.time() with _sessions_lock: @@ -9057,6 +9066,7 @@ def _init_session( "attached_images": [], "image_counter": 0, "cwd": cwd or _completion_cwd(), + "explicit_cwd": bool(explicit_cwd), "cols": cols, "slash_worker": None, "show_reasoning": _load_show_reasoning(), @@ -10462,6 +10472,7 @@ def _deferred_session_record( model_override=None, resume_runtime_overrides: dict | None = None, todo_state: dict | None = None, + explicit_cwd: bool = False, ) -> dict: """A live-session record whose AIAgent is built later (lazy watch / cold resume) — _init_session's shape minus the agent.""" @@ -10478,7 +10489,7 @@ def _deferred_session_record( "cwd": cwd, "display_history_prefix": display_history_prefix or [], "edit_snapshots": {}, - "explicit_cwd": False, + "explicit_cwd": bool(explicit_cwd), "history": history, "history_lock": threading.Lock(), "history_version": 0,