Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions tests/tui_gateway/test_session_cwd_follow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""A session that settles into another git worktree re-anchors onto it.

The desktop already follows a session that moves (``followActiveSessionCwd``
refreshes the project tree and scopes into the new project), but it only ever
sees a move when the backend reports one on ``session.info``. These tests
exercise the backend half against real git worktrees on disk.
"""

from __future__ import annotations

import os
import subprocess

import pytest

import tools.terminal_tool as terminal_tool
import tui_gateway.server as server


def _git(cwd, *args):
subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)


@pytest.fixture
def repo_with_worktree(tmp_path):
"""A real repo on ``main`` plus a linked worktree on ``feature``."""
repo = tmp_path / "proj"
repo.mkdir()
_git(repo, "init", "-b", "main")
_git(repo, "config", "user.email", "t@example.com")
_git(repo, "config", "user.name", "t")
(repo / "README.md").write_text("hi\n")
_git(repo, "add", ".")
_git(repo, "commit", "-m", "init")

worktree = tmp_path / "proj-feature"
_git(repo, "worktree", "add", "-b", "feature", str(worktree))

from tui_gateway import git_probe

git_probe.invalidate()
yield repo, worktree
git_probe.invalidate()


@pytest.fixture
def session(repo_with_worktree):
repo, _ = repo_with_worktree
key = "sess-follow"
terminal_tool.clear_session_cwd(key)
yield {"session_key": key, "cwd": str(repo), "source": "desktop"}
terminal_tool.clear_session_cwd(key)


@pytest.fixture(autouse=True)
def _no_db(monkeypatch):
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_persist_session_git_meta", lambda *_a: None)
monkeypatch.setattr(server, "_register_session_cwd", lambda _s: None)
monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: True)


def test_settling_in_a_worktree_reanchors_the_session(session, repo_with_worktree):
"""The whole reported bug: work goes to the worktree, the session says main."""
_, 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["cwd"] == str(worktree)
# The move is the session's workspace now, so it earns a persisted row.
assert session["explicit_cwd"] is True


def test_a_subdirectory_of_the_same_checkout_is_not_a_move(session, repo_with_worktree):
repo, _ = repo_with_worktree
sub = repo / "src"
sub.mkdir()
terminal_tool.record_session_cwd(session["session_key"], str(sub))

assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)


def test_browsing_outside_a_repo_is_not_a_move(session, repo_with_worktree, tmp_path):
"""`cd /tmp` to read a log must not re-home the workspace."""
repo, _ = repo_with_worktree
scratch = tmp_path / "scratch"
scratch.mkdir()
terminal_tool.record_session_cwd(session["session_key"], str(scratch))

assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)


def test_a_deleted_directory_is_not_a_move(session, repo_with_worktree, tmp_path):
repo, _ = repo_with_worktree
terminal_tool.record_session_cwd(session["session_key"], str(tmp_path / "gone"))

assert server._reconcile_session_cwd_from_terminal(session) is False
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
monkeypatch.setattr(server, "_is_local_terminal_backend", lambda: False)
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_settled_session_info_reports_the_worktree_branch(
session, repo_with_worktree, monkeypatch
):
"""End of turn: the emitted session.info is what the desktop follows."""
_, worktree = repo_with_worktree
emitted: list[tuple[str, str, dict]] = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append((ev, sid, payload or {})))
terminal_tool.record_session_cwd(session["session_key"], str(worktree))

server._emit_settled_session_info("sid-1", session, agent=None)

assert len(emitted) == 1
event, sid, payload = emitted[0]
assert (event, sid) == ("session.info", "sid-1")
assert payload["cwd"] == str(worktree)
assert payload["branch"] == "feature"


def test_settled_session_info_still_emits_when_nothing_moved(
session, repo_with_worktree, monkeypatch
):
repo, _ = repo_with_worktree
emitted: list[dict] = []
monkeypatch.setattr(server, "_emit", lambda ev, sid, payload=None: emitted.append(payload or {}))

server._emit_settled_session_info("sid-1", session, agent=None)

assert len(emitted) == 1
assert emitted[0]["cwd"] == str(repo)
assert emitted[0]["branch"] == "main"


def test_reconcile_ignores_a_foreign_sessions_record(session, repo_with_worktree):
"""cwd records are per session key — another chat's move must not leak in."""
repo, worktree = repo_with_worktree
terminal_tool.record_session_cwd("someone-else", str(worktree))

assert server._reconcile_session_cwd_from_terminal(session) is False
assert session["cwd"] == str(repo)
terminal_tool.clear_session_cwd("someone-else")


def test_os_normalized_paths_are_not_a_move(session, repo_with_worktree):
"""A trailing-slash / unnormalized record is the same dir, not a relocation."""
repo, _ = repo_with_worktree
terminal_tool.record_session_cwd(session["session_key"], str(repo) + os.sep)

assert server._reconcile_session_cwd_from_terminal(session) is False
76 changes: 75 additions & 1 deletion tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2177,6 +2177,80 @@ def _display_session_cwd(session: dict | None) -> str:
return healed


def _reconcile_session_cwd_from_terminal(session: dict | None) -> bool:
"""Re-anchor a session that SETTLED in another git checkout. Returns moved.

An agent told to work in a fresh worktree does exactly that — `git worktree
add`, `cd` into it, and every later command runs there — but the session
stayed pinned to wherever it started, so the desktop kept labelling the chat
with the primary checkout's branch while all the work landed elsewhere.

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.

Local backends only: a remote/SSH cwd names a path on the host, which this
gateway can neither stat nor probe with git.
"""
if not session or not _is_local_terminal_backend():
return False

try:
from tools.terminal_tool import get_session_cwd

recorded = get_session_cwd(session.get("session_key") or "")
except Exception:
return False

if not recorded:
return False

resolved = os.path.abspath(os.path.expanduser(str(recorded)))
current = os.path.abspath(os.path.expanduser(_session_cwd(session)))
if resolved == current or not os.path.isdir(resolved):
return False

# The worktree ROOT, not the common repo root: folding worktrees together
# here is exactly what hides the move we're looking for.
landed = _git_repo_root_for_cwd(resolved)
if not landed or landed == _git_repo_root_for_cwd(current):
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.
session["explicit_cwd"] = True
_register_session_cwd(session)

with _session_db(session) as db:
if db is not None:
try:
db.update_session_cwd(session.get("session_key", ""), resolved)
except Exception:
logger.debug("failed to persist settled session cwd", exc_info=True)

_persist_session_git_meta(session, resolved)
return True


def _emit_settled_session_info(sid: str, session: dict, agent) -> None:
"""Emit end-of-turn ``session.info``, reconciling a settled cwd first.

The turn is over, so the agent has stopped moving: this is the one moment
where its recorded cwd is a stable answer to "where does this session
work". Reconciling before building the payload means the same event that
already tells the desktop the turn ended also carries the new cwd/branch —
the client follows it with no new event type and no extra round trip.
"""
try:
_reconcile_session_cwd_from_terminal(session)
except Exception:
logger.debug("failed to reconcile settled session cwd", exc_info=True)
_emit("session.info", sid, _session_info(agent, session))


def _session_source(session: dict | None) -> str:
if session:
source = str(session.get("source") or "").strip()
Expand Down Expand Up @@ -11860,7 +11934,7 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None:
# frame paths retire the marker as they emit).
_retire_turn_marker(session, marker_key)
session.pop("_auto_continue_scheduled", None)
_emit("session.info", sid, _session_info(agent, session))
_emit_settled_session_info(sid, session, agent)

# A user prompt that arrived mid-turn (interrupt + queue) wins over
# every auto follow-up below — drain it first and skip them this cycle;
Expand Down