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
+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"]]