myclaude -r: unify resume picker with xiaohe client sessions

Bare -r/--resume now opens a numbered picker merging jsonl (source of
truth) with the xiaohe hosted-session index, deduped by cli_session_id
and sorted by real content activity (not jsonl mtime, which the xiaohe
projection re-touches). -l gains a Src column (xiaohe/cli); -r <id> and
-c stay passthrough. Adds read-only xiaohe_sessions data layer + tests.
This commit is contained in:
Zhengshou Lai
2026-08-15 13:42:53 +08:00
parent d6a0f455b0
commit 88170bdf57
5 changed files with 634 additions and 2 deletions
+235 -1
View File
@@ -9,6 +9,7 @@ import shutil
import sqlite3 import sqlite3
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from importlib.metadata import PackageNotFoundError, version from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
@@ -16,10 +17,12 @@ from pathlib import Path
import click import click
from rich import box from rich import box
from rich.console import Console from rich.console import Console
from rich.prompt import Prompt
from rich.table import Table from rich.table import Table
from rich.text import Text from rich.text import Text
from myagents.project_root import get_workspace_root from myagents.project_root import get_workspace_root
from myagents.xiaohe_sessions import session_index, workspace_ids_for_cwd
stderr_console = Console(stderr=True) stderr_console = Console(stderr=True)
console = Console() console = Console()
@@ -645,6 +648,221 @@ def _session_title_claude(session_file: Path) -> str:
return result return result
# Claude jsonl tail read for real-activity time (bounded scan keeps list cheap).
_TAIL_SCAN_LINES = 256
def _iso_to_epoch(value: str) -> float | None:
"""Parse a Claude ``timestamp`` (ISO-8601, may end in Z) to epoch seconds."""
try:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return dt.timestamp()
def _jsonl_content_updated_at(session_file: Path) -> float:
"""Newest message timestamp in a Claude jsonl (content truth, not mtime).
The xiaohe client re-touches jsonl files during projection, so file mtime
overstates recency; the last message timestamp is the real activity time.
"""
lines: list[str] = []
try:
with session_file.open(encoding="utf-8", errors="ignore") as fh:
for line in fh:
lines.append(line)
if len(lines) > _TAIL_SCAN_LINES:
lines.pop(0)
except OSError:
return 0.0
newest = 0.0
for line in lines:
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
ts = entry.get("timestamp")
if not ts:
msg = entry.get("message")
if isinstance(msg, dict):
ts = msg.get("timestamp")
if isinstance(ts, str) and ts:
parsed = _iso_to_epoch(ts)
if parsed and parsed > newest:
newest = parsed
return newest
@dataclass(frozen=True)
class ClaudeSessionRow:
"""One resumable Claude session: jsonl (source of truth) + xiaohe index."""
cli_session_id: str
title: str
origin: str # "xiaohe" (client) or "cli" (plain Claude Code)
status: str | None
updated_at: float # real activity: max(content ts, db ts, mtime)
jsonl_path: Path | None # None → client-only, not CLI-resumable
xiaohe_session_id: str | None
def _claude_session_rows(chat_cwd: Path) -> list[ClaudeSessionRow]:
"""Union of jsonl + xiaohe hosted sessions for chat_cwd, dedup by cli id.
jsonl files are the source of truth; the xiaohe hosted-session index adds
the client origin, status, and its own activity timestamp. Sessions known
only to the index (jsonl deleted/absent) are kept as client-only rows.
"""
index = session_index(workspace_ids_for_cwd(chat_cwd))
rows: list[ClaudeSessionRow] = []
seen: set[str] = set()
for session_file in _session_files("claude", chat_cwd):
cid = session_file.stem
seen.add(cid)
xh = index.get(cid)
db_ts = xh.updated_at if xh else None
content_ts = _jsonl_content_updated_at(session_file)
# Content/db timestamps are the real activity; file mtime is only a
# fallback because the xiaohe projection re-touches jsonl files.
activity = [t for t in (content_ts, db_ts) if t and t > 0]
updated_at = max(activity) if activity else session_file.stat().st_mtime
rows.append(
ClaudeSessionRow(
cli_session_id=cid,
title=_session_title_claude(session_file),
origin="xiaohe" if xh else "cli",
status=xh.status if xh else None,
updated_at=updated_at,
jsonl_path=session_file,
xiaohe_session_id=xh.xiaohe_session_id if xh else None,
)
)
for cid, xh in index.items():
if cid in seen:
continue
rows.append(
ClaudeSessionRow(
cli_session_id=cid,
title=xh.title or "(client-only)",
origin="xiaohe",
status=xh.status,
updated_at=xh.updated_at or 0.0,
jsonl_path=None,
xiaohe_session_id=xh.xiaohe_session_id,
)
)
rows.sort(key=lambda r: r.updated_at, reverse=True)
return rows
def _render_claude_table(
rows: list[ClaudeSessionRow], chat_cwd: Path, numbered: bool
) -> None:
"""Shared rich table for ``myclaude -l`` and the resume picker."""
console.print(f"[bold]Sessions in[/bold] {chat_cwd}")
table = Table(
box=box.SIMPLE_HEAD,
show_header=True,
header_style="dim",
expand=True,
pad_edge=False,
collapse_padding=True,
)
if numbered:
table.add_column("#", justify="right", no_wrap=True, style="dim", min_width=3)
table.add_column("Title", overflow="ellipsis", no_wrap=True, ratio=1, min_width=24)
table.add_column("Src", no_wrap=True, justify="center", min_width=6)
table.add_column("Status", overflow="ellipsis", no_wrap=True, style="dim", min_width=8)
table.add_column("Updated", justify="right", no_wrap=True, style="dim", min_width=16)
# Picker selects by number; the full id column is only useful for `-r <id>`.
if not numbered:
table.add_column("ID", overflow="ellipsis", no_wrap=True, style="cyan", min_width=36)
for i, row in enumerate(rows, 1):
cells: list[Text] = []
if numbered:
cells.append(Text(str(i)))
title_style = "dim" if not row.title else ""
if row.jsonl_path is None:
title_style = "dim italic"
cells.append(Text(row.title or "(empty)", style=title_style))
src_style = "magenta" if row.origin == "xiaohe" else "dim"
cells.append(Text(row.origin, style=src_style))
cells.append(Text(row.status or ""))
cells.append(Text(datetime.fromtimestamp(row.updated_at).strftime("%Y-%m-%d %H:%M")))
if not numbered:
cells.append(Text(row.cli_session_id))
table.add_row(*cells)
console.print(table)
def _list_sessions_claude(chat_cwd: Path) -> None:
"""List unified jsonl + xiaohe sessions for the claude backend."""
rows = _claude_session_rows(chat_cwd)
if not rows:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}")
return
_render_claude_table(rows, chat_cwd, numbered=False)
console.print(
"\n[dim]Resume with[/dim] [green]myclaude -r[/green] [dim](picker), "
"[green]myclaude -r <id>[/green] [dim](direct), "
"[green]myclaude -c[/green] [dim](continue last).[/dim]"
)
def _resume_picker_claude(chat_cwd: Path, use_tmux: bool = False) -> None:
"""Interactive unified resume picker for the claude backend.
Lists jsonl + xiaohe client sessions for chat_cwd; selecting one resumes
the jsonl via ``claude --resume <cli_session_id>``.
"""
rows = _claude_session_rows(chat_cwd)
if not rows:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}")
raise SystemExit(0)
_render_claude_table(rows, chat_cwd, numbered=True)
if not sys.stdin.isatty():
stderr_console.print(
"[yellow]Not a terminal — resume directly with[/yellow] "
"[green]myclaude -r <id>[/green]"
)
raise SystemExit(0)
selectable = [r for r in rows if r.jsonl_path is not None]
while True:
answer = Prompt.ask(
"[bold]Choose session[/bold] "
"(Enter = latest, [cyan]q[/cyan] = quit)",
default="",
)
if answer.strip().lower() == "q":
raise SystemExit(0)
if answer.strip() == "":
row = selectable[0]
break
if not answer.strip().isdigit():
continue
n = int(answer.strip())
if not 1 <= n <= len(rows):
continue
row = rows[n - 1]
if row.jsonl_path is None:
stderr_console.print(
"[red]Client-only session[/red] — no jsonl to resume in the "
"CLI; continue it in the xiaohe web/desktop client instead."
)
continue
break
console.print(
f"Resuming [cyan]{row.cli_session_id}[/cyan] ({row.title[:40]})…"
)
_launch(
"claude",
chat_cwd,
["--resume", row.cli_session_id],
use_tmux=use_tmux,
)
def _first_prompt_kimi(session_file: Path) -> str: def _first_prompt_kimi(session_file: Path) -> str:
"""Best-effort snippet of the first human prompt in a kimi context.jsonl.""" """Best-effort snippet of the first human prompt in a kimi context.jsonl."""
try: try:
@@ -909,6 +1127,9 @@ def _list_sessions(backend: str, chat_cwd: Path) -> None:
if backend == "hermes": if backend == "hermes":
_list_sessions_hermes(chat_cwd) _list_sessions_hermes(chat_cwd)
return return
if backend == "claude":
_list_sessions_claude(chat_cwd)
return
files = _session_files(backend, chat_cwd) files = _session_files(backend, chat_cwd)
if not files: if not files:
stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}") stderr_console.print(f"[yellow]No sessions for[/yellow] {chat_cwd}")
@@ -1051,6 +1272,12 @@ def build_cli(
assert parent is not None assert parent is not None
chat_cwd = _resolve_chat_cwd(parent.params.get("cwd")) chat_cwd = _resolve_chat_cwd(parent.params.get("cwd"))
extra = list(ctx.meta.get("passthrough", [])) extra = list(ctx.meta.get("passthrough", []))
if backend == "claude" and extra in (["-r"], ["--resume"]):
# Bare resume opens the unified picker (jsonl xiaohe client).
_resume_picker_claude(
chat_cwd, use_tmux=bool(parent.params.get("tmux"))
)
raise SystemExit(0)
_launch( _launch(
backend, backend,
chat_cwd, chat_cwd,
@@ -1059,10 +1286,17 @@ def build_cli(
offer_install=offer_install, offer_install=offer_install,
) )
resume_note = ""
if backend == "claude":
resume_note = (
"Bare `-r`/`--resume` opens the unified picker (jsonl + xiaohe "
"client sessions); `-r <id>` resumes directly.\n\n"
)
cli.help = ( cli.help = (
f"Launch {backend_title} in workspace/.\n\n" f"Launch {backend_title} in workspace/.\n\n"
f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}.\n\n" f"Unknown arguments ({_resume_syntax(backend)}, --continue, …) pass through to {backend}.\n\n"
"With --tmux/-t the agent runs in a per-directory tmux session that " + resume_note
+ "With --tmux/-t the agent runs in a per-directory tmux session that "
"survives SSH disconnects; re-run the same command to reattach." "survives SSH disconnects; re-run the same command to reattach."
) )
try: try:
+111
View File
@@ -0,0 +1,111 @@
"""Read-only access to xiaohe hosted-session SQLite stores.
myagents runs against Claude Code CLI sessions (``~/.claude/projects``), but the
xiaohe client also drives Claude Code and keeps a hosted-session index for the
same jsonl transcripts. ``myclaude -r`` unifies both sources so client sessions
and plain CLI sessions appear in one resumable list.
Every accessor takes explicit path parameters and degrades to an empty result
when a store is missing or unreadable — a machine without xiaohe data behaves
exactly as before.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from pathlib import Path
_SESSIONS_DB = Path.home() / ".xiaohe" / "data" / "sessions-claude.db"
_WORKSPACES_DB = Path.home() / ".xiaohe" / "data" / "workspaces.db"
def _connect_readonly(path: Path) -> sqlite3.Connection | None:
"""Open a SQLite store read-only, or ``None`` when unavailable."""
try:
return sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except (sqlite3.Error, OSError):
return None
def workspace_ids_for_cwd(
chat_cwd: Path, workspaces_db: Path = _WORKSPACES_DB
) -> list[str]:
"""Workspace ids whose bind path resolves to ``chat_cwd`` (e.g. ``primary``).
Empty when the store is missing or no workspace binds to the cwd.
"""
conn = _connect_readonly(workspaces_db)
if conn is None:
return []
try:
rows = conn.execute(
"SELECT workspace_id, workspace_path FROM workspaces "
"WHERE workspace_path IS NOT NULL"
).fetchall()
except sqlite3.Error:
return []
finally:
conn.close()
target = chat_cwd.resolve()
return [
str(rid)
for rid, raw_path in rows
if raw_path and Path(str(raw_path)).expanduser().resolve() == target
]
@dataclass(frozen=True)
class XiaoheSession:
"""Hosted-session index row for one cli_session_id (jsonl UUID)."""
cli_session_id: str
xiaohe_session_id: str
title: str | None
status: str | None
updated_at: float | None
def session_index(
workspace_ids: list[str], sessions_db: Path = _SESSIONS_DB
) -> dict[str, XiaoheSession]:
"""Map ``cli_session_id`` → hosted-session row for the given workspaces."""
if not workspace_ids:
return {}
conn = _connect_readonly(sessions_db)
if conn is None:
return {}
placeholders = ",".join("?" for _ in workspace_ids)
query = (
"SELECT cli_session_id, session_id, title, status, updated_at "
"FROM sessions "
f"WHERE workspace_id IN ({placeholders}) AND cli_session_id IS NOT NULL"
)
try:
rows = conn.execute(query, workspace_ids).fetchall()
except sqlite3.Error:
return {}
finally:
conn.close()
index: dict[str, XiaoheSession] = {}
for cli_id, xh_id, title, status, ts in rows:
index[str(cli_id)] = XiaoheSession(
cli_session_id=str(cli_id),
xiaohe_session_id=str(xh_id),
title=str(title) if title else None,
status=str(status) if status else None,
updated_at=float(ts) if isinstance(ts, (int, float)) else None,
)
return index
def default_sessions_db() -> Path:
"""Default hosted-sessions store path (kept importable for tests)."""
return _SESSIONS_DB
def default_workspaces_db() -> Path:
"""Default workspace store path (kept importable for tests)."""
return _WORKSPACES_DB
+17 -1
View File
@@ -37,6 +37,22 @@ class TestMyclaudeEntrypoint:
assert "myclaude" in result.output assert "myclaude" in result.output
def test_passthrough(self, tmp_path: Path) -> None: def test_passthrough(self, tmp_path: Path) -> None:
"""``--resume <id>`` still passes through to the native CLI."""
runner = CliRunner()
with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
patch("myagents.launcher.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(
claude_cli, ["--cwd", str(tmp_path), "--resume", "abc123"]
)
assert result.exit_code == 0
args = mock_run.call_args[0][0]
assert args[-2:] == ["--resume", "abc123"]
def test_bare_resume_opens_picker(self, tmp_path: Path) -> None:
"""Bare ``--resume`` opens the unified picker instead of the CLI."""
runner = CliRunner() runner = CliRunner()
with ( with (
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"), patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
@@ -45,7 +61,7 @@ class TestMyclaudeEntrypoint:
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"]) result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "--resume" in mock_run.call_args[0][0] mock_run.assert_not_called()
class TestMykimiEntrypoint: class TestMykimiEntrypoint:
+181
View File
@@ -0,0 +1,181 @@
"""Tests for unified claude session listing/resume (jsonl xiaohe index)."""
import copy
import json
import re
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
import myagents.launcher as L
from myagents.xiaohe_sessions import XiaoheSession
def _slug(path: Path) -> str:
return re.sub(r"[^A-Za-z0-9]", "-", str(path.resolve()))
def _write_jsonl(
proj_dir: Path, cid: str, ts: str, *, ai_title: str | None = None
) -> Path:
"""Write a minimal Claude Code jsonl with a message timestamp + optional aiTitle."""
lines: list[dict[str, object]] = [
{"type": "mode", "mode": "normal", "sessionId": cid},
{
"type": "permission-mode",
"permissionMode": "bypassPermissions",
"sessionId": cid,
},
]
if ai_title:
lines.append(
{"type": "ai-title", "aiTitle": ai_title, "sessionId": cid}
)
lines.append(
{
"type": "user",
"sessionId": cid,
"message": {
"id": f"m-{cid}",
"role": "user",
"content": [{"type": "text", "text": f"first prompt {cid}"}],
"timestamp": ts,
},
}
)
path = proj_dir / f"{cid}.jsonl"
path.write_text(
"\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8"
)
return path
@pytest.fixture
def claude_env(tmp_path: Path, monkeypatch):
"""Point the claude session root + xiaohe index at temp fixtures."""
projects = tmp_path / "projects"
cwd = tmp_path / "cwd"
cwd.mkdir()
proj_dir = projects / _slug(cwd)
proj_dir.mkdir(parents=True)
index: dict[str, XiaoheSession] = {}
def fake_workspace_ids(_chat_cwd):
return ["primary"]
def fake_session_index(workspace_ids):
return index
monkeypatch.setattr(L, "_BACKENDS", copy.deepcopy(L._BACKENDS))
L._BACKENDS["claude"]["sessions_root"] = lambda: projects
monkeypatch.setattr(L, "workspace_ids_for_cwd", fake_workspace_ids)
monkeypatch.setattr(L, "session_index", fake_session_index)
return SimpleNamespace(cwd=cwd, proj_dir=proj_dir, index=index)
class TestClaudeSessionRows:
def test_merges_jsonl_with_xiaohe_index(self, claude_env) -> None:
_write_jsonl(
claude_env.proj_dir,
"aaa",
"2026-08-15T04:00:00Z",
ai_title="Client A",
)
_write_jsonl(claude_env.proj_dir, "bbb", "2026-08-15T03:00:00Z")
claude_env.index["aaa"] = XiaoheSession(
cli_session_id="aaa",
xiaohe_session_id="x-1",
title="Client A",
status="ready",
updated_at=1000.0,
)
rows = L._claude_session_rows(claude_env.cwd)
by_id = {r.cli_session_id: r for r in rows}
assert by_id["aaa"].origin == "xiaohe"
assert by_id["aaa"].status == "ready"
assert by_id["aaa"].xiaohe_session_id == "x-1"
assert by_id["aaa"].title == "Client A"
assert by_id["bbb"].origin == "cli"
assert by_id["bbb"].status is None
def test_sorts_by_content_activity_desc(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "old", "2026-08-13T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "mid", "2026-08-14T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "new", "2026-08-15T04:00:00Z")
rows = L._claude_session_rows(claude_env.cwd)
assert [r.cli_session_id for r in rows] == ["new", "mid", "old"]
def test_db_only_session_is_client_only(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
claude_env.index["nojsonl"] = XiaoheSession(
cli_session_id="nojsonl",
xiaohe_session_id="x-2",
title="Lost",
status="ready",
updated_at=2000.0,
)
rows = L._claude_session_rows(claude_env.cwd)
by_id = {r.cli_session_id: r for r in rows}
assert by_id["nojsonl"].jsonl_path is None
assert by_id["nojsonl"].origin == "xiaohe"
def test_dedup_by_cli_session_id(self, claude_env) -> None:
_write_jsonl(claude_env.proj_dir, "dup", "2026-08-15T04:00:00Z")
claude_env.index["dup"] = XiaoheSession(
cli_session_id="dup",
xiaohe_session_id="x-3",
title="Dup",
status="ready",
updated_at=1.0,
)
rows = L._claude_session_rows(claude_env.cwd)
ids = [r.cli_session_id for r in rows]
assert ids.count("dup") == 1
class TestResumePickerClaude:
def test_non_tty_prints_and_exits(self, claude_env, capsys) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
with pytest.raises(SystemExit) as exc:
L._resume_picker_claude(claude_env.cwd)
assert exc.value.code == 0
err = capsys.readouterr().err
assert "Not a terminal" in err
assert "resume directly with myclaude -r <id>" in err
def test_selecting_number_launches_resume(
self, claude_env, monkeypatch
) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "bbb", "2026-08-15T03:00:00Z")
launched: list[list[str]] = []
monkeypatch.setattr(
L, "_launch", lambda *_a, **_k: launched.append(_a[2])
)
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(L.Prompt, "ask", lambda *a, **k: "2")
L._resume_picker_claude(claude_env.cwd)
assert launched == [["--resume", "bbb"]]
def test_enter_picks_latest(self, claude_env, monkeypatch) -> None:
_write_jsonl(claude_env.proj_dir, "aaa", "2026-08-15T04:00:00Z")
_write_jsonl(claude_env.proj_dir, "bbb", "2026-08-15T03:00:00Z")
launched: list[list[str]] = []
monkeypatch.setattr(
L, "_launch", lambda *_a, **_k: launched.append(_a[2])
)
monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(L.Prompt, "ask", lambda *a, **k: "")
L._resume_picker_claude(claude_env.cwd)
assert launched == [["--resume", "aaa"]]
+90
View File
@@ -0,0 +1,90 @@
"""Tests for the read-only xiaohe hosted-session data layer."""
import sqlite3
from pathlib import Path
from myagents.xiaohe_sessions import session_index, workspace_ids_for_cwd
def _make_workspaces_db(path: Path, rows: list[tuple[str, str | None]]) -> None:
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE workspaces (workspace_id TEXT, workspace_path TEXT)"
)
conn.executemany("INSERT INTO workspaces VALUES (?, ?)", rows)
conn.commit()
conn.close()
def _make_sessions_db(path: Path, rows: list[tuple]) -> None:
conn = sqlite3.connect(path)
conn.execute(
"CREATE TABLE sessions ("
"session_id TEXT PRIMARY KEY, workspace_id TEXT, cli_session_id TEXT, "
"title TEXT, status TEXT, updated_at REAL)"
)
conn.executemany("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?)", rows)
conn.commit()
conn.close()
class TestWorkspaceIdsForCwd:
def test_returns_matching_bind_workspaces(self, tmp_path: Path) -> None:
"""Bind workspaces whose path resolves to the cwd are returned."""
db = tmp_path / "workspaces.db"
cwd = tmp_path / "ws"
cwd.mkdir()
_make_workspaces_db(
db,
[
("primary", str(cwd)),
("bot-f", str(cwd)),
("other", str(tmp_path / "elsewhere")),
("session-ws", None),
],
)
result = workspace_ids_for_cwd(cwd, workspaces_db=db)
assert result == ["primary", "bot-f"]
def test_no_match_returns_empty(self, tmp_path: Path) -> None:
db = tmp_path / "workspaces.db"
cwd = tmp_path / "ws"
_make_workspaces_db(db, [("primary", str(tmp_path / "other"))])
assert workspace_ids_for_cwd(cwd, workspaces_db=db) == []
def test_missing_store_returns_empty(self, tmp_path: Path) -> None:
assert workspace_ids_for_cwd(tmp_path / "ws") == []
class TestSessionIndex:
def test_maps_cli_ids_for_matching_workspaces(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(
db,
[
("s1", "primary", "c1", "Title A", "ready", 1000.0),
("s2", "primary", "c2", None, "error", 2000.0),
("s3", "other", "c3", "Title B", "ready", 3000.0),
],
)
index = session_index(["primary"], sessions_db=db)
assert set(index) == {"c1", "c2"}
assert index["c1"].xiaohe_session_id == "s1"
assert index["c1"].status == "ready"
assert index["c2"].title is None
assert index["c2"].updated_at == 2000.0
def test_skips_null_cli_session_id(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(db, [("s1", "primary", None, "T", "ready", 1.0)])
assert session_index(["primary"], sessions_db=db) == {}
def test_empty_workspace_ids_skips_query(self, tmp_path: Path) -> None:
db = tmp_path / "sessions-claude.db"
_make_sessions_db(db, [("s1", "primary", "c1", "T", "ready", 1.0)])
assert session_index([], sessions_db=db) == {}
def test_missing_store_returns_empty(self, tmp_path: Path) -> None:
assert (
session_index(["primary"], sessions_db=tmp_path / "nope.db") == {}
)