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:
@@ -37,6 +37,22 @@ class TestMyclaudeEntrypoint:
|
||||
assert "myclaude" in result.output
|
||||
|
||||
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()
|
||||
with (
|
||||
patch("myagents.launcher.shutil.which", return_value="/usr/bin/claude"),
|
||||
@@ -45,7 +61,7 @@ class TestMyclaudeEntrypoint:
|
||||
mock_run.return_value = MagicMock(returncode=0)
|
||||
result = runner.invoke(claude_cli, ["--cwd", str(tmp_path), "--resume"])
|
||||
assert result.exit_code == 0
|
||||
assert "--resume" in mock_run.call_args[0][0]
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestMykimiEntrypoint:
|
||||
|
||||
@@ -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"]]
|
||||
@@ -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") == {}
|
||||
)
|
||||
Reference in New Issue
Block a user