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
38 changes: 37 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1304,13 +1304,49 @@ def _curses_browse(stdscr):
return None


def _resolve_workspace_key() -> Optional[str]:
"""The current workspace identity for cwd-scoped resume.

Git repo root when CWD is inside a repo (so all sessions across its
subdirs/worktrees group together), else the CWD itself. Returns None when
neither can be determined — callers fall back to the global MRU then.
"""
try:
import subprocess

result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return os.path.abspath(result.stdout.strip())
except Exception:
pass
try:
return os.getcwd()
except Exception:
return None


def _resolve_last_session(source: str = "cli") -> Optional[str]:
"""Look up the most recently-used session ID for a source."""
"""Look up the most recently-used session ID for a source.

Scoped to the current workspace first (git repo root, else cwd) so
``hermes -c`` from repo A continues repo A's last session rather than the
global MRU. Falls back to the unscoped MRU when no session matches the
current workspace, preserving the old behaviour for fresh directories.
"""
db = None
try:
from hermes_state import SessionDB

db = SessionDB()
ws_key = _resolve_workspace_key()
if ws_key:
sessions = db.search_sessions(source=source, limit=1, workspace_key=ws_key)
if sessions:
return sessions[0]["id"]
# Fallback: global MRU for this source.
sessions = db.search_sessions(source=source, limit=1)
return sessions[0]["id"] if sessions else None
except Exception:
Expand Down
54 changes: 41 additions & 13 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]:
return "(s.cwd = ? OR s.cwd LIKE ? OR s.cwd LIKE ?)", [prefix, f"{prefix}/%", f"{prefix}\\%"]


def _workspace_key_clause(key: str) -> Tuple[str, List[str]]:
"""Match sessions whose ``workspace_key(row)`` equals ``key``.

Mirrors :func:`workspace_key`: a session belongs to workspace ``key``
when its recorded ``git_repo_root`` equals ``key``, or — for rows that
predate per-session git metadata — when its ``cwd`` is at or under
``key`` (so a session started in ``repo/src`` still groups with ``repo``).
Used by ``hermes -c``/``--resume`` to continue the most recent session in
the *current* workspace rather than the global MRU.
"""
prefix = key.rstrip("/\\") or key
cwd_clause, cwd_params = _cwd_prefix_clause(prefix)
return (
f"(s.git_repo_root = ? OR (COALESCE(s.git_repo_root, '') = '' AND {cwd_clause}))",
[prefix, *cwd_params],
)


# Session preview = the head of the first user message, shown wherever a
# session has no title (sidebar rows, pickers, exports, the desktop's
# `sessionTitle` fallback).
Expand Down Expand Up @@ -8656,12 +8674,18 @@ def search_sessions(
source: str = None,
limit: int = 20,
offset: int = 0,
workspace_key: str = None,
) -> List[Dict[str, Any]]:
"""List sessions, optionally filtered by source.

Returns rows enriched with a computed ``last_active`` column (latest
message timestamp for the session, falling back to ``started_at``),
ordered by most-recently-used first.

Pass ``workspace_key`` to scope rows to one workspace — matching
:func:`workspace_key` semantics (git repo root, else cwd). Used by
``hermes -c``/``--resume`` so the "last" session is the last one in
the *current* workspace, not the global MRU.
"""
select_with_last_active = (
"SELECT s.*, COALESCE(m.last_active, s.started_at) AS last_active "
Expand All @@ -8671,20 +8695,24 @@ def search_sessions(
"FROM messages GROUP BY session_id"
") m ON m.session_id = s.id "
)
where_clauses = []
params: list = []
if source:
where_clauses.append("s.source = ?")
params.append(source)
if workspace_key:
ws_clause, ws_params = _workspace_key_clause(workspace_key)
where_clauses.append(ws_clause)
params.extend(ws_params)
where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
params.extend([limit, offset])
with self._lock:
if source:
cursor = self._conn.execute(
f"{select_with_last_active}"
"WHERE s.source = ? "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(source, limit, offset),
)
else:
cursor = self._conn.execute(
f"{select_with_last_active}"
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
(limit, offset),
)
cursor = self._conn.execute(
f"{select_with_last_active}"
f"{where_sql} "
"ORDER BY last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ?",
params,
)
return [dict(row) for row in cursor.fetchall()]

# =========================================================================
Expand Down
195 changes: 195 additions & 0 deletions tests/hermes_cli/test_resolve_last_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,198 @@ def test_resolve_last_session_not_limited_to_newest_started_20(tmp_path, monkeyp

monkeypatch.setattr("hermes_state.SessionDB", lambda: real_session_db(db_path=state_db))
assert _resolve_last_session("cli") == target


# ---------------------------------------------------------------------------
# cwd-scoped resume: -c prefers the last session in the current workspace.
# ---------------------------------------------------------------------------


class _WorkspaceAwareDB:
"""Fake SessionDB whose ``search_sessions`` honors ``workspace_key`` the
same way the real ``_workspace_key_clause`` does: a row matches the key
when its ``git_repo_root`` equals it, or (no repo root recorded) when its
``cwd`` is at or under it."""

def __init__(self, rows):
self._rows = rows
self.closed = False

def search_sessions(self, source=None, limit=20, workspace_key=None, **_kw):
rows = [r for r in self._rows if r.get("source") == source] if source else list(self._rows)
if workspace_key:
key = workspace_key.rstrip("/")
def _in_ws(r):
grr = (r.get("git_repo_root") or "").rstrip("/")
if grr:
return grr == key
cwd = (r.get("cwd") or "").rstrip("/")
return cwd == key or cwd.startswith(key + "/")
rows = [r for r in rows if _in_ws(r)]
rows.sort(
key=lambda r: float(r.get("last_active") or r.get("started_at") or 0),
reverse=True,
)
return rows[:limit]

def close(self):
self.closed = True


def test_resolve_last_session_prefers_workspace_session_over_global_mru(monkeypatch, tmp_path):
# Two sessions: a globally-recent one in repo B, an older one in repo A.
# CWD is repo A → -c must pick repo A's session, not the global MRU.
repo_a = tmp_path / "repo-a"
repo_b = tmp_path / "repo-b"
repo_a.mkdir()
repo_b.mkdir()
monkeypatch.chdir(repo_a)
# Pretend both are git repo roots so _resolve_workspace_key returns the dir.
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)

rows = [
{"id": "global_recent", "source": "cli", "started_at": 9000.0,
"last_active": 9000.0, "cwd": str(repo_b), "git_repo_root": str(repo_b)},
{"id": "repo_a_older", "source": "cli", "started_at": 1000.0,
"last_active": 1000.0, "cwd": str(repo_a), "git_repo_root": str(repo_a)},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "repo_a_older"


def test_resolve_last_session_falls_back_to_global_when_workspace_empty(monkeypatch, tmp_path):
# CWD has no matching session → fall back to the global MRU.
repo_a = tmp_path / "repo-a"
repo_a.mkdir()
monkeypatch.chdir(repo_a)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)
rows = [
{"id": "elsewhere", "source": "cli", "started_at": 9000.0,
"last_active": 9000.0, "cwd": "/other/repo", "git_repo_root": "/other/repo"},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "elsewhere"


def test_resolve_last_session_workspace_matches_cwd_subdir_without_git_root(monkeypatch, tmp_path):
# No git repo root on the session (legacy row) — match via cwd prefix so a
# session started in repo/src still resumes from repo/.
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.chdir(repo)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 1, stdout="", stderr="not a repo"
),
)
rows = [
{"id": "subdir_session", "source": "cli", "started_at": 500.0,
"last_active": 500.0, "cwd": str(repo / "src"), "git_repo_root": ""},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "subdir_session"


def test_resolve_last_session_workspace_key_filters_by_source(monkeypatch, tmp_path):
# A TUI session in repo A is newer than a CLI session in repo A; asking for
# source="cli" must skip the TUI row even within the same workspace.
repo = tmp_path / "repo"
repo.mkdir()
monkeypatch.chdir(repo)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo), stderr=""
),
)
rows = [
{"id": "tui_recent", "source": "tui", "started_at": 9000.0,
"last_active": 9000.0, "cwd": str(repo), "git_repo_root": str(repo)},
{"id": "cli_older", "source": "cli", "started_at": 1000.0,
"last_active": 1000.0, "cwd": str(repo), "git_repo_root": str(repo)},
]
monkeypatch.setattr("hermes_state.SessionDB", lambda: _WorkspaceAwareDB(rows))
assert _resolve_last_session("cli") == "cli_older"


def test_search_sessions_workspace_key_filters_at_sql_level(tmp_path, monkeypatch):
# End-to-end: search_sessions(workspace_key=...) must filter by
# git_repo_root, and fall back to cwd-prefix for rows without one.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)

import hermes_state
from pathlib import Path

db = hermes_state.SessionDB(db_path=Path(tmp_path / "state.db"))
try:
# repo A: two sessions — one with git_repo_root, one legacy (cwd only).
db.create_session("repo_a_rooted", source="cli", cwd=str(tmp_path / "repo-a"),
git_repo_root=str(tmp_path / "repo-a"))
db.create_session("repo_a_legacy", source="cli", cwd=str(tmp_path / "repo-a" / "src"))
# repo B: one session, globally most recent.
db.create_session("repo_b", source="cli", cwd=str(tmp_path / "repo-b"),
git_repo_root=str(tmp_path / "repo-b"))
with db._lock:
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a_rooted"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (50.0, "repo_a_legacy"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b"))
db._conn.commit()

key = str(tmp_path / "repo-a")
rows = db.search_sessions(source="cli", limit=10, workspace_key=key)
ids = sorted(r["id"] for r in rows)
assert ids == ["repo_a_legacy", "repo_a_rooted"]
assert "repo_b" not in ids

# No workspace_key → global MRU includes repo_b.
all_rows = db.search_sessions(source="cli", limit=10)
assert all_rows[0]["id"] == "repo_b"
finally:
db.close()


def test_resolve_last_session_real_db_prefers_workspace(monkeypatch, tmp_path):
# End-to-end through the real SessionDB + _resolve_last_session: -c from
# repo A picks repo A's session even though repo B is globally newer.
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)

import hermes_state
from pathlib import Path

repo_a = tmp_path / "repo-a"
repo_a.mkdir()
state_db = Path(tmp_path / "state.db")
real_db = hermes_state.SessionDB
db = real_db(db_path=state_db)
try:
db.create_session("repo_a", source="cli", cwd=str(repo_a), git_repo_root=str(repo_a))
db.create_session("repo_b", source="cli", cwd="/other/repo-b", git_repo_root="/other/repo-b")
with db._lock:
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (100.0, "repo_a"))
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (9000.0, "repo_b"))
db._conn.commit()
finally:
db.close()

monkeypatch.chdir(repo_a)
monkeypatch.setattr(
"hermes_cli.main.subprocess.run",
lambda cmd, **kw: __import__("subprocess").CompletedProcess(
cmd, 0, stdout=str(repo_a), stderr=""
),
)
monkeypatch.setattr("hermes_state.SessionDB", lambda: real_db(db_path=state_db))
assert _resolve_last_session("cli") == "repo_a"
Loading