From e5842ff472a50c255516095558b2f7f49a4ff2f3 Mon Sep 17 00:00:00 2001 From: illidan Date: Tue, 8 Sep 2026 12:15:57 +0800 Subject: [PATCH] fix(sessions): include descendants when filtering by filesystem root --- hermes_state_sessions.py | 7 ++-- tests/test_session_root_cwd_filter.py | 46 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/test_session_root_cwd_filter.py diff --git a/hermes_state_sessions.py b/hermes_state_sessions.py index a7039236bc5cd..65fe826799f71 100644 --- a/hermes_state_sessions.py +++ b/hermes_state_sessions.py @@ -53,10 +53,13 @@ def _cwd_prefix_clause(cwd_prefix: str) -> Tuple[str, List[str]]: prefix = cwd_prefix.rstrip("/\\") or cwd_prefix # ``_``/``%`` are LIKE wildcards but ordinary path characters: unescaped, a # prefix also matches sibling directories. The ``=`` arm keeps the raw prefix. - esc = _escape_like(prefix) + # A filesystem root already ends with its separator: adding another would + # search for //repo (or \\repo) instead of its direct descendants. + children = [_escape_like(prefix if prefix.endswith(sep) else prefix + sep) + "%" + for sep in ("/", "\\")] return ( "(s.cwd = ? OR s.cwd LIKE ? ESCAPE '\\' OR s.cwd LIKE ? ESCAPE '\\')", - [prefix, f"{esc}/%", f"{esc}\\\\%"], + [prefix, *children], ) diff --git a/tests/test_session_root_cwd_filter.py b/tests/test_session_root_cwd_filter.py new file mode 100644 index 0000000000000..a370f546731b1 --- /dev/null +++ b/tests/test_session_root_cwd_filter.py @@ -0,0 +1,46 @@ +"""Root and ordinary directory filters share exact directory-boundary semantics.""" + +import pytest + +from hermes_state import SessionDB + + +@pytest.mark.parametrize("prefix, descendants, outside", [ + ("/", ["/", "/repo", "/repo/src"], ["relative", "C:\\repo"]), + ("\\", ["\\", "\\repo", "\\repo\\src"], ["relative", "/repo"]), + ("C:\\", ["C:\\", "C:\\repo", "C:\\repo\\src"], ["D:\\repo", "relative"]), + ("/work/a_%/", ["/work/a_%", "/work/a_%/src"], ["/work/aXX/src", "/work/a_%other"]), +]) +@pytest.mark.parametrize("order_by_last_active", [False, True]) +def test_directory_filter_agrees_across_list_count_and_resume( + tmp_path, prefix, descendants, outside, order_by_last_active, +): + # These are stored path strings, not a simulated host OS or real directories. + db = SessionDB(tmp_path / "state.db") + try: + for index, cwd in enumerate(descendants + outside): + db.create_session(f"s{index}", source="cli", cwd=cwd) + expected = {f"s{i}" for i in range(len(descendants))} + rows = db.list_sessions_rich(cwd_prefix=prefix, order_by_last_active=order_by_last_active) + assert {row["id"] for row in rows} == expected + assert db.session_count(cwd_prefix=prefix) == len(expected) + assert {row["id"] for row in db.search_sessions(workspace_key=prefix)} == expected + finally: + db.close() + + +def test_root_prune_matches_its_preview_and_preserves_outside_rows(tmp_path): + db = SessionDB(tmp_path / "state.db") + try: + for sid, cwd in [("root", "/"), ("child", "/repo/src"), ("outside", "relative")]: + db.create_session(sid, source="cli", cwd=cwd) + db.append_message(sid, role="user", content=sid) + db.end_session(sid, end_reason="completed") + candidates = db.list_prune_candidates(cwd_prefix="/") + assert {row["id"] for row in candidates} == {"root", "child"} + assert db.count_prune_matches(cwd_prefix="/") == len(candidates) + assert db.prune_sessions(older_than_days=None, cwd_prefix="/") == len(candidates) + assert {row["id"] for row in db.search_sessions()} == {"outside"} + assert db.get_messages("outside")[0]["content"] == "outside" + finally: + db.close()