From f7dfbbcc00a98c41038da82c2623f2aba7a230bd Mon Sep 17 00:00:00 2001 From: Aura - jc <67582323+Catafal@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:18:05 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20sessions=20radar=20=E2=80=94=20real=20co?= =?UTF-8?q?dex=20IDs,=20codex=20projects,=20recency=20window,=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Codex session IDs were timestamp fragments: real rollout filenames embed hyphenated timestamps, so the UUID is now matched from the END of the stem (test fixtures updated to real formats — the unrealistic fixtures were what hid the bug) - Codex rows had no project: session_index.jsonl carries no cwd, so fall back to reading the rollout's first line (session_meta payload) - Ended sessions older than 7 days are hidden by default (--all / `all` token to include; explicit --state ended also disables the window) - Project filter matches the directory NAME ('bach'), not just full path - Rows sorted newest-first - REPL /sessions now accepts free-form filter tokens, e.g. `/sessions bach running` or `/sessions codex all` Co-Authored-By: Claude Fable 5 --- src/bach/cli/sessions.py | 8 +- src/bach/repl/help_content.py | 4 +- src/bach/repl/sessions_view.py | 43 +++++++-- src/bach/runtimes/session_discovery.py | 56 ++++++++---- src/bach/services/sessions_service.py | 43 ++++++++- tests/unit/test_session_discovery.py | 110 ++++++++++++++++++----- tests/unit/test_sessions_service.py | 120 +++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 53 deletions(-) diff --git a/src/bach/cli/sessions.py b/src/bach/cli/sessions.py index a60f609..9f8af7b 100644 --- a/src/bach/cli/sessions.py +++ b/src/bach/cli/sessions.py @@ -31,6 +31,7 @@ from bach.runtimes.transcript import read_tail_events from bach.services.session_tracker import SESSIONS_DIRNAME from bach.services.sessions_service import ( + DEFAULT_ENDED_WINDOW_S, SessionRow, adopt_session, find_artifact_for_ref, @@ -84,7 +85,7 @@ def _default_artifacts_dir() -> Path: def sessions_list( project: Annotated[ str | None, - typer.Option("--project", help="Filter by project path (exact)."), + typer.Option("--project", help="Filter by project path or name."), ] = None, runtime: Annotated[ str | None, @@ -98,6 +99,10 @@ def sessions_list( bool, typer.Option("--json", help="Output as JSON array instead of Rich table."), ] = False, + show_all: Annotated[ + bool, + typer.Option("--all", help="Include ended sessions older than the 7-day window."), + ] = False, ) -> None: """List discovered runtime sessions, optionally filtered and joined with Bach tasks.""" t = _theme() @@ -111,6 +116,7 @@ def sessions_list( project=project, runtime=runtime, state=state, + max_ended_age_s=None if show_all else DEFAULT_ENDED_WINDOW_S, ) except Exception as exc: _console.print(f"[{t.error}]error[/{t.error}] {exc}") diff --git a/src/bach/repl/help_content.py b/src/bach/repl/help_content.py index c191819..6b51bac 100644 --- a/src/bach/repl/help_content.py +++ b/src/bach/repl/help_content.py @@ -982,8 +982,8 @@ class CommandHelp: # ===================================================================== "sessions": CommandHelp( synopsis="Show live and recent agent sessions (runtime-plane radar).", - syntax="/sessions", - args="(none — use `bach sessions list --state/--runtime/--project` for filters)", + syntax="/sessions [project] [running|idle|ended] [claude|codex] [all]", + args="free-form tokens: project name, state, runtime, `all` for full history", what=( "Scans ~/.claude/projects/ and ~/.codex/sessions/ to find all " "active and recently-ended Claude Code / Codex sessions. Shows " diff --git a/src/bach/repl/sessions_view.py b/src/bach/repl/sessions_view.py index bde8ce8..c8902a9 100644 --- a/src/bach/repl/sessions_view.py +++ b/src/bach/repl/sessions_view.py @@ -25,7 +25,7 @@ from bach.config.themes import ThemePalette from bach.domain.models import DiscoveredSession -from bach.services.sessions_service import SessionRow +from bach.services.sessions_service import DEFAULT_ENDED_WINDOW_S, SessionRow logger = logging.getLogger(__name__) @@ -86,12 +86,39 @@ def render_sessions_table( logger.debug("event=sessions_table_rendered rows=%d", len(rows)) -def cmd_sessions(app: Any, _args: str) -> None: +def _parse_sessions_args(args: str) -> tuple[str | None, str | None, str | None, bool]: + """Parse free-form /sessions tokens into (project, runtime, state, show_all). + + Token classification by value, not position — `/sessions bach running` + and `/sessions running bach` mean the same thing. Unrecognized tokens + become the project filter (last one wins). + """ + project: str | None = None + runtime: str | None = None + state: str | None = None + show_all = False + for token in args.split(): + lowered = token.lower().lstrip("-") + if lowered in ("running", "idle", "ended"): + state = lowered + elif lowered in ("claude-code", "claude", "codex"): + runtime = "claude-code" if lowered == "claude" else lowered + elif lowered == "all": + show_all = True + elif lowered == "list": + continue # tolerate the CLI-style `/sessions list` spelling + else: + project = token + return project, runtime, state, show_all + + +def cmd_sessions(app: Any, args: str) -> None: """/sessions REPL command handler. - Discovers live sessions and renders them inline. No filters — the REPL - command is a quick-look tool; detailed filtering belongs to the CLI - `bach sessions list` command. + Accepts free-form filter tokens: a project name/path, a state + (running/idle/ended), a runtime (claude/codex), and `all` to include + ended sessions beyond the default 7-day window — e.g. + `/sessions bach running` or `/sessions codex all`. `app` is typed as object to avoid importing BachRepl here (circular dep). The duck-typed attribute access (app.console, app.current_theme) is safe: @@ -123,12 +150,18 @@ def _discover() -> list[DiscoveredSession]: idle_threshold_s=cfg.liveness_idle_minutes * 60, ) + project, runtime, state, show_all = _parse_sessions_args(args) + try: rows = list_sessions( sessions_dir=bach_home() / "sessions", artifacts_dir=bach_home(), discover_fn=_discover, read_events_fn=read_tail_events, + project=project, + runtime=runtime, + state=state, + max_ended_age_s=None if show_all else DEFAULT_ENDED_WINDOW_S, ) except Exception as exc: logger.warning("event=repl_sessions_error reason=%s", exc) diff --git a/src/bach/runtimes/session_discovery.py b/src/bach/runtimes/session_discovery.py index 4af5800..289bc04 100644 --- a/src/bach/runtimes/session_discovery.py +++ b/src/bach/runtimes/session_discovery.py @@ -32,6 +32,7 @@ import json import logging +import re import subprocess from collections.abc import Callable from datetime import UTC, datetime @@ -249,7 +250,11 @@ def _scan_codex( logger.debug("event=codex_discovery_skip_filename path=%s", transcript) continue - project_dir = index_map.get(session_id) + # The live session_index.jsonl carries only (id, thread_name, + # updated_at) — no working directory. The authoritative cwd lives in + # the rollout's first line (session_meta payload), so fall back to + # reading just that one line when the index can't resolve a project. + project_dir = index_map.get(session_id) or _read_codex_cwd(transcript) last_activity, liveness = _classify_liveness( transcript, has_runtime_process=has_codex_process, @@ -271,6 +276,22 @@ def _scan_codex( return sessions +def _read_codex_cwd(transcript: Path) -> Path | None: + """Best-effort cwd from a rollout's first line (session_meta payload). + + Reads ONE line only — rollouts can be tens of MB and this runs per + session during discovery, so the read must stay O(1). + """ + try: + with transcript.open(encoding="utf-8") as fh: + first = fh.readline() + record = json.loads(first) + cwd = record.get("payload", {}).get("cwd") + return Path(cwd) if isinstance(cwd, str) and cwd else None + except (OSError, json.JSONDecodeError, AttributeError): + return None + + def _read_codex_index(codex_home: Path) -> dict[str, Path]: """Read session_index.jsonl and return {session_id → project_dir}. @@ -305,18 +326,22 @@ def _read_codex_index(codex_home: Path) -> dict[str, Path]: return result +# Real Codex rollout filenames embed a HYPHENATED timestamp before the UUID +# (e.g. 'rollout-2026-06-09T00-19-05-.jsonl'), so splitting on the first +# '-' returns a timestamp fragment, not the UUID. Anchoring a UUID regex at the +# END of the stem is the only split that survives both timestamp formats. +_UUID_TAIL_RE = re.compile( + r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$" +) + + def _extract_codex_session_id(filename: str) -> str | None: """Extract the session UUID from a Codex rollout filename. - Expected format: rollout--.jsonl - where has no hyphens (e.g. '20260115T120000Z') and - may contain hyphens (e.g. '550e8400-e29b-41d4-a716-446655440000'). - - The extraction strategy: strip the 'rollout-' prefix and '.jsonl' - suffix, then split on '-' taking the SECOND token as the start of - the UUID (the first token is the timestamp). Because the UUID itself - contains hyphens, we must join everything after the first '-' separator - that follows the 'rollout-' prefix. + Expected format: rollout--.jsonl where is an ISO-ish + timestamp that itself contains hyphens (e.g. '2026-06-09T00-19-05') + and is a standard hyphenated UUID. The UUID is matched from + the END of the stem so the timestamp's hyphens cannot confuse the split. Returns None if the filename doesn't match the expected pattern. """ @@ -325,14 +350,11 @@ def _extract_codex_session_id(filename: str) -> str | None: stem = filename[:-6] # strip '.jsonl' if not stem.startswith("rollout-"): return None - rest = stem[len("rollout-") :] # remove 'rollout-' prefix - # rest is now '-' - # The timestamp has no hyphens, so split on the FIRST '-' gives ts and uuid. - dash_pos = rest.find("-") - if dash_pos < 0: - # No separator between timestamp and UUID — malformed filename. + match = _UUID_TAIL_RE.search(stem) + if match is None: + # No trailing UUID — malformed or unexpected filename. return None - return rest[dash_pos + 1 :] + return match.group(1) # --------------------------------------------------------------------------- diff --git a/src/bach/services/sessions_service.py b/src/bach/services/sessions_service.py index b42e7eb..05d4a36 100644 --- a/src/bach/services/sessions_service.py +++ b/src/bach/services/sessions_service.py @@ -29,7 +29,7 @@ import shlex from collections.abc import Callable from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -292,6 +292,18 @@ def find_artifact_for_ref( return None +# Default visibility window for ended sessions in list output (7 days). +DEFAULT_ENDED_WINDOW_S = 7 * 86400 + + +def _project_matches(project_dir: Path | None, query: str) -> bool: + """Match a project filter against full path, dir name, or substring.""" + if project_dir is None: + return False + path_str = str(project_dir) + return query == path_str or query == project_dir.name or query in path_str + + def list_sessions( *, sessions_dir: Path, @@ -302,12 +314,25 @@ def list_sessions( runtime: str | None = None, state: str | None = None, artifact_paths: list[Path] | None = None, + max_ended_age_s: int | None = DEFAULT_ENDED_WINDOW_S, + now: datetime | None = None, ) -> list[SessionRow]: """Discover sessions and join them with Bach task artifacts. Filters are applied in order (project → runtime → state). All filters are AND-ed — a session must match every supplied filter. + The project filter matches the full path, the directory name, or a + path substring — users think in project names ("bach"), not absolute + paths. + + Ended sessions older than max_ended_age_s are hidden by default + (None disables the window) so the table stays a radar, not an + archive — months of dead sessions otherwise drown the live ones. + Explicitly filtering state="ended" also disables the window: asking + for ended sessions means you want the history. + Rows are sorted by last_activity, newest first. + artifact_paths can be injected for testing; when None, the function scans artifacts_dir recursively. This allows tests to provide specific artifacts without touching real project directories. @@ -319,13 +344,23 @@ def list_sessions( # Apply filters before the join to avoid reading events for filtered-out sessions. if project is not None: - discovered = [ - s for s in discovered if s.project_dir is not None and str(s.project_dir) == project - ] + discovered = [s for s in discovered if _project_matches(s.project_dir, project)] if runtime is not None: discovered = [s for s in discovered if s.runtime.value == runtime] if state is not None: discovered = [s for s in discovered if s.liveness.value == state] + elif max_ended_age_s is not None: + cutoff = (now or datetime.now(UTC)) - timedelta(seconds=max_ended_age_s) + discovered = [ + s + for s in discovered + if s.liveness is not SessionLiveness.ended + or (s.last_activity is not None and s.last_activity >= cutoff) + ] + + # Newest activity first; sessions with no timestamp sink to the bottom. + epoch = datetime.fromtimestamp(0, UTC) + discovered.sort(key=lambda s: s.last_activity or epoch, reverse=True) # Build artifact lookup from injected paths or scanned directory. paths = artifact_paths if artifact_paths is not None else _scan_artifact_paths(artifacts_dir) diff --git a/tests/unit/test_session_discovery.py b/tests/unit/test_session_discovery.py index 7aa46ef..9cf7d33 100644 --- a/tests/unit/test_session_discovery.py +++ b/tests/unit/test_session_discovery.py @@ -278,8 +278,8 @@ def test_claude_non_jsonl_files_ignored(tmp_path: Path) -> None: def test_codex_single_session_discovered(tmp_path: Path) -> None: """One Codex rollout file is found and returned with runtime=codex.""" codex_home = tmp_path / "codex" - uuid = "codex-uuid-1" - _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + uuid = "019ea951-6b41-7281-8961-c2e0baca0001" + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "2026-01-15T12-00-00") sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -299,8 +299,8 @@ def test_codex_single_session_discovered(tmp_path: Path) -> None: def test_codex_session_id_extracted_from_filename(tmp_path: Path) -> None: """Session ID is the UUID part of 'rollout--.jsonl'.""" codex_home = tmp_path / "codex" - uuid = "my-test-uuid-9999" - _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000") + uuid = "019ea951-6b41-7281-8961-c2e0baca9999" + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "2026-01-15T12-00-00") sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -318,8 +318,18 @@ def test_codex_session_id_extracted_from_filename(tmp_path: Path) -> None: def test_codex_multiple_days(tmp_path: Path) -> None: """Rollout files across multiple date directories are all discovered.""" codex_home = tmp_path / "codex" - _make_codex_session(codex_home, "uuid-day1", ("2026", "01", "14"), "20260114T120000Z") - _make_codex_session(codex_home, "uuid-day2", ("2026", "01", "15"), "20260115T120000Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacada01", + ("2026", "01", "14"), + "2026-01-14T12-00-00", + ) + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacada02", + ("2026", "01", "15"), + "2026-01-15T12-00-00", + ) sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -336,7 +346,7 @@ def test_codex_multiple_days(tmp_path: Path) -> None: def test_codex_project_dir_from_index(tmp_path: Path) -> None: """session_index.jsonl entries with a project_dir field are decoded.""" codex_home = tmp_path / "codex" - uuid = "indexed-uuid" + uuid = "019ea951-6b41-7281-8961-c2e0bacaaaaa" project_path = "/home/user/myproject" # Write index entry with a project_dir field. @@ -355,7 +365,7 @@ def test_codex_project_dir_from_index(tmp_path: Path) -> None: ) # Also create the rollout file so discovery finds the session. - _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "2026-01-15T12-00-00") sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -373,7 +383,12 @@ def test_codex_project_dir_from_index(tmp_path: Path) -> None: def test_codex_project_dir_none_when_not_in_index(tmp_path: Path) -> None: """A Codex session with no index entry or missing project_dir → project_dir=None.""" codex_home = tmp_path / "codex" - _make_codex_session(codex_home, "no-index-uuid", ("2026", "01", "15"), "20260115T120002Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacaa0de", + ("2026", "01", "15"), + "2026-01-15T12-00-02", + ) sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -396,7 +411,12 @@ def test_codex_index_lags_dir_scan_union(tmp_path: Path) -> None: """ codex_home = tmp_path / "codex" # File on disk only — no index entry. - _make_codex_session(codex_home, "disk-only-uuid", ("2026", "01", "15"), "20260115T120001Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacad15c", + ("2026", "01", "15"), + "2026-01-15T12-00-01", + ) # Index entry for a session with no file (can happen if rollout was deleted). index_path = codex_home / "session_index.jsonl" index_path.parent.mkdir(parents=True, exist_ok=True) @@ -422,7 +442,7 @@ def test_codex_index_lags_dir_scan_union(tmp_path: Path) -> None: # Only the disk session is returned — no transcript, no session. session_ids = {s.session_id for s in sessions} - assert "disk-only-uuid" in session_ids + assert "019ea951-6b41-7281-8961-c2e0bacad15c" in session_ids assert "index-only-uuid" not in session_ids @@ -434,7 +454,12 @@ def test_codex_non_rollout_jsonl_ignored(tmp_path: Path) -> None: # A JSONL file that doesn't follow rollout naming. (sessions_dir / "some-other-file.jsonl").write_text("{}\n") # A real rollout. - _make_codex_session(codex_home, "real-codex-uuid", ("2026", "01", "15"), "20260115T120000Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacac0de", + ("2026", "01", "15"), + "2026-01-15T12-00-00", + ) sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -446,7 +471,7 @@ def test_codex_non_rollout_jsonl_ignored(tmp_path: Path) -> None: ) assert len(sessions) == 1 - assert sessions[0].session_id == "real-codex-uuid" + assert sessions[0].session_id == "019ea951-6b41-7281-8961-c2e0bacac0de" # --------------------------------------------------------------------------- @@ -522,9 +547,9 @@ def test_liveness_idle_for_codex_session(tmp_path: Path) -> None: codex_home = tmp_path / "codex" _make_codex_session( codex_home, - "stale-codex", + "019ea951-6b41-7281-8961-c2e0baca57a1", ("2026", "01", "15"), - "20260115T120000Z", + "2026-01-15T12-00-00", mtime_offset_s=-(IDLE_S + 60), ) @@ -662,7 +687,12 @@ def test_both_runtimes_discovered_together(tmp_path: Path) -> None: codex_home = tmp_path / "codex" _make_claude_session(projects_dir, "-home-user-proj", "claude-uuid") - _make_codex_session(codex_home, "codex-uuid", ("2026", "01", "15"), "20260115T120000Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacacccc", + ("2026", "01", "15"), + "2026-01-15T12-00-00", + ) sessions = discover_sessions( claude_projects_dir=projects_dir, @@ -882,7 +912,12 @@ def test_codex_malformed_index_line_skipped(tmp_path: Path) -> None: # Write a mix of bad and good lines. index_path.write_text("this is not json\n") # Also create a rollout file. - _make_codex_session(codex_home, "good-uuid", ("2026", "01", "15"), "20260115T120003Z") + _make_codex_session( + codex_home, + "019ea951-6b41-7281-8961-c2e0bacab00d", + ("2026", "01", "15"), + "2026-01-15T12-00-03", + ) sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -895,22 +930,20 @@ def test_codex_malformed_index_line_skipped(tmp_path: Path) -> None: # The rollout file is still discovered. assert len(sessions) == 1 - assert sessions[0].session_id == "good-uuid" + assert sessions[0].session_id == "019ea951-6b41-7281-8961-c2e0bacab00d" def test_codex_rollout_filename_with_multiple_hyphens_in_uuid(tmp_path: Path) -> None: """UUID containing multiple hyphens is extracted correctly from the filename. Filename format: rollout--.jsonl - The UUID itself contains hyphens, so parsing must split on the FIRST two - hyphens after 'rollout-' prefix: rollout--. - - Actually: the format is rollout-- where ts has no hyphens. - We test a realistic UUID like '550e8400-e29b-41d4-a716-446655440000'. + Real timestamps ALSO contain hyphens (2026-01-15T12-00-00), so the UUID + must be matched from the END of the stem — splitting on the first hyphen + returns a timestamp fragment (the bug this test pins). """ codex_home = tmp_path / "codex" uuid = "550e8400-e29b-41d4-a716-446655440000" - _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "20260115T120000Z") + _make_codex_session(codex_home, uuid, ("2026", "01", "15"), "2026-01-15T12-00-00") sessions = discover_sessions( claude_projects_dir=tmp_path / "cp", @@ -923,3 +956,32 @@ def test_codex_rollout_filename_with_multiple_hyphens_in_uuid(tmp_path: Path) -> assert len(sessions) == 1 assert sessions[0].session_id == uuid + + +def test_codex_project_dir_falls_back_to_session_meta_cwd(tmp_path: Path) -> None: + """Index has no project_dir → cwd is read from the rollout's first line. + + The live session_index.jsonl only carries (id, thread_name, updated_at), + so without this fallback every real Codex session shows no project. + """ + codex_home = tmp_path / "codex" + uuid = "019ea951-6b41-7281-8961-c2e0bacacafe" + _make_codex_session( + codex_home, + uuid, + ("2026", "01", "15"), + "2026-01-15T12-00-00", + lines=[{"type": "session_meta", "payload": {"id": uuid, "cwd": "/home/user/proj"}}], + ) + + sessions = discover_sessions( + claude_projects_dir=tmp_path / "cp", + codex_home=codex_home, + process_probe=_probe_empty, + now=NOW, + running_threshold_s=RUNNING_S, + idle_threshold_s=IDLE_S, + ) + + assert len(sessions) == 1 + assert sessions[0].project_dir == Path("/home/user/proj") diff --git a/tests/unit/test_sessions_service.py b/tests/unit/test_sessions_service.py index 569157a..c407eb5 100644 --- a/tests/unit/test_sessions_service.py +++ b/tests/unit/test_sessions_service.py @@ -718,3 +718,123 @@ def test_find_artifact_for_ref_returns_none_when_not_found(tmp_path: Path) -> No artifact = _make_artifact(tmp_path, task_id="t97") result = find_artifact_for_ref("t999", [artifact]) assert result is None + + +# --------------------------------------------------------------------------- +# Ended-session recency window, project-name matching, sorting (post-review UX) +# --------------------------------------------------------------------------- + + +def test_list_sessions_hides_old_ended_sessions_by_default(tmp_path: Path) -> None: + """Ended sessions older than the window vanish; recent + live ones stay.""" + from dataclasses import replace + from datetime import timedelta + + old_ended = replace( + _make_session(liveness=SessionLiveness.ended), + last_activity=NOW - timedelta(days=30), + ) + fresh_ended = replace( + _make_session(liveness=SessionLiveness.ended), + last_activity=NOW - timedelta(days=1), + ) + running = _make_session(liveness=SessionLiveness.running) + + rows = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path, + discover_fn=lambda: [old_ended, fresh_ended, running], + read_events_fn=lambda _p, _r: [], + now=NOW, + ) + + ids = {r.session.session_id for r in rows} + assert old_ended.session_id not in ids + assert fresh_ended.session_id in ids + assert running.session_id in ids + + +def test_list_sessions_window_disabled_with_none(tmp_path: Path) -> None: + """max_ended_age_s=None (the --all flag) shows the full history.""" + from dataclasses import replace + from datetime import timedelta + + old_ended = replace( + _make_session(liveness=SessionLiveness.ended), + last_activity=NOW - timedelta(days=300), + ) + rows = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path, + discover_fn=lambda: [old_ended], + read_events_fn=lambda _p, _r: [], + max_ended_age_s=None, + now=NOW, + ) + assert len(rows) == 1 + + +def test_list_sessions_state_ended_filter_disables_window(tmp_path: Path) -> None: + """Explicitly asking for ended sessions means you want the history.""" + from dataclasses import replace + from datetime import timedelta + + old_ended = replace( + _make_session(liveness=SessionLiveness.ended), + last_activity=NOW - timedelta(days=300), + ) + rows = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path, + discover_fn=lambda: [old_ended], + read_events_fn=lambda _p, _r: [], + state="ended", + now=NOW, + ) + assert len(rows) == 1 + + +def test_list_sessions_project_filter_matches_dir_name(tmp_path: Path) -> None: + """Users filter by project NAME ('bach'), not absolute path.""" + s_bach = _make_session(project_dir=Path("/Users/u/Documents/Github/bach")) + s_other = _make_session(project_dir=Path("/Users/u/Documents/Github/other")) + + rows = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path, + discover_fn=lambda: [s_bach, s_other], + read_events_fn=lambda _p, _r: [], + project="bach", + now=NOW, + ) + assert [r.session.session_id for r in rows] == [s_bach.session_id] + + +def test_list_sessions_sorted_newest_first(tmp_path: Path) -> None: + """Rows come back ordered by last_activity descending.""" + from dataclasses import replace + from datetime import timedelta + + older = replace(_make_session(), last_activity=NOW - timedelta(hours=5)) + newer = replace(_make_session(), last_activity=NOW) + + rows = list_sessions( + sessions_dir=tmp_path / "sessions", + artifacts_dir=tmp_path, + discover_fn=lambda: [older, newer], + read_events_fn=lambda _p, _r: [], + now=NOW, + ) + assert [r.session.session_id for r in rows] == [newer.session_id, older.session_id] + + +def test_repl_sessions_arg_parsing() -> None: + """/sessions free-form tokens classify by value, not position.""" + from bach.repl.sessions_view import _parse_sessions_args + + assert _parse_sessions_args("") == (None, None, None, False) + assert _parse_sessions_args("bach running") == ("bach", None, "running", False) + assert _parse_sessions_args("running bach") == ("bach", None, "running", False) + assert _parse_sessions_args("codex all") == (None, "codex", None, True) + assert _parse_sessions_args("claude ended") == (None, "claude-code", "ended", False) + assert _parse_sessions_args("list --all") == (None, None, None, True)