diff --git a/tests/tui_gateway/test_session_cwd_follow.py b/tests/tui_gateway/test_session_cwd_follow.py index 09177b472eff1..109e0c1693a56 100644 --- a/tests/tui_gateway/test_session_cwd_follow.py +++ b/tests/tui_gateway/test_session_cwd_follow.py @@ -118,6 +118,52 @@ def test_a_deleted_directory_is_not_a_move(session, repo_with_worktree, tmp_path assert session["cwd"] == str(repo) +def test_an_unrelated_repo_is_not_a_move(session, repo_with_worktree, tmp_path): + """Git workspace A visiting unrelated git repo B is a visit, not a re-home. + + Only checkouts sharing the same common .git dir (the shape `git worktree + add` produces) count as a relocation; `cd ~/other-project && git log` + must not re-anchor the chat onto the foreign repo. + """ + repo, _ = repo_with_worktree + other = tmp_path / "other" + other.mkdir() + _git(other, "init", "-b", "main") + _git(other, "config", "user.email", "t@example.com") + _git(other, "config", "user.name", "t") + (other / "x.txt").write_text("x\n", encoding="utf-8") + _git(other, "add", ".") + _git(other, "commit", "-m", "init") + terminal_tool.record_session_cwd(session["session_key"], str(other)) + + assert server._reconcile_session_cwd_from_terminal(session) is False + assert session["cwd"] == str(repo) + + +def test_an_explicit_workspace_is_never_overridden(session, repo_with_worktree): + """A user-chosen cwd must survive even a legitimate same-repo worktree move.""" + repo, worktree = repo_with_worktree + session["explicit_cwd"] = True + terminal_tool.record_session_cwd(session["session_key"], str(worktree)) + + assert server._reconcile_session_cwd_from_terminal(session) is False + assert session["cwd"] == str(repo) + + +def test_a_settle_adopted_cwd_can_keep_following(session, repo_with_worktree): + """The settle marker keeps a session following the agent across worktrees.""" + repo, worktree = repo_with_worktree + terminal_tool.record_session_cwd(session["session_key"], str(worktree)) + assert server._reconcile_session_cwd_from_terminal(session) is True + assert session["explicit_cwd"] is True + assert session["cwd_from_settle"] is True + + # Agent moves back to the primary checkout: still follows. + terminal_tool.record_session_cwd(session["session_key"], str(repo)) + assert server._reconcile_session_cwd_from_terminal(session) is True + assert session["cwd"] == str(repo) + + def test_remote_backends_do_not_reanchor(session, repo_with_worktree, monkeypatch): """A remote cwd names a path on the host, not one this gateway can probe.""" repo, worktree = repo_with_worktree diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7f2d4e5699a8..029d8ae72367a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2346,8 +2346,11 @@ def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool: A plain `cd` is deliberately NOT a workspace move (see ``_apply_project_workspace``): browsing to /tmp to read a log must not re-home the chat. What we adopt here is narrower — the session's recorded - cwd is in a DIFFERENT git working tree than its workspace. That is a - relocation by any reading, and it is the only shape this reconciles. + cwd is in a DIFFERENT working tree of the SAME repository (the shape + ``git worktree add`` produces). Everything else — a non-git workspace + stepping into a repo, or a git workspace visiting an unrelated repo — is + a browsing visit, and a user's explicitly chosen workspace is never + overridden at all. Local backends only: a remote/SSH cwd names a path on the host, which this gateway can neither stat nor probe with git. @@ -2355,6 +2358,14 @@ def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool: if not session or not _is_local_terminal_backend(): return False + # A workspace the user (or GUI) explicitly chose is never overridden by + # where the agent's terminal happened to settle — only another explicit + # action (`_set_session_cwd`, a project switch) moves it. A cwd this very + # function adopted is marked `cwd_from_settle` so a session can keep + # following the agent through successive worktrees. + if session.get("explicit_cwd") and not session.get("cwd_from_settle"): + return False + try: from tools.terminal_tool import get_session_cwd @@ -2383,10 +2394,23 @@ def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool: if not landed or not current_root or landed == current_root: return False + # And only between checkouts of the SAME repository — the shape a real + # `git worktree add` produces (linked worktrees share the common .git + # dir). Settling in an UNRELATED repo (`cd ~/other-project && git log`) + # is likewise a visit: adopting it would re-home the chat onto whatever + # foreign repo the terminal last touched. + landed_common = _git_common_repo_root_for_cwd(resolved) + current_common = _git_common_repo_root_for_cwd(current) + if not landed_common or landed_common != current_common: + return False + session["cwd"] = resolved # The session works here now, so this is its workspace — a desktop chat - # whose cwd was an unpersisted launch artifact earns a real row. + # whose cwd was an unpersisted launch artifact earns a real row. The + # settle marker keeps this adoption overridable by the NEXT settle while + # still yielding to a user's explicit choice (see the guard above). session["explicit_cwd"] = True + session["cwd_from_settle"] = True _register_session_cwd(session) with _session_db(session) as db: @@ -2684,6 +2708,9 @@ def _set_session_cwd(session: dict, cwd: str) -> str: # An explicit user choice — persist it as the workspace (and let a later # lazy row creation persist it too, not the launch-dir fallback). session["explicit_cwd"] = True + # A user's choice supersedes any earlier settle-adopted cwd: from here on + # the terminal wandering must not move the workspace again. + session["cwd_from_settle"] = False _register_session_cwd(session) with _session_db(session) as db: if db is not None: @@ -5549,6 +5576,8 @@ def _apply_project_workspace(task_id: str, path: str, _name: str = "") -> None: session["cwd"] = resolved session["explicit_cwd"] = True + # An explicit project switch supersedes any earlier settle-adopted cwd. + session["cwd_from_settle"] = False _register_session_cwd(session) with _session_db(session) as db: