mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #71864 from NousResearch/bb/worktree-follow
fix(gateway): follow a session into the worktree it settled in
This commit is contained in:
commit
651c0931de
2 changed files with 235 additions and 1 deletions
160
tests/tui_gateway/test_session_cwd_follow.py
Normal file
160
tests/tui_gateway/test_session_cwd_follow.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -11860,7 +11934,7 @@ def _run_prompt_submit(
|
|||
# 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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue