f3400ce745
_first_user_line indexed splitlines()[0] on a whitespace-only user message
(image-only / tool-only turn) and raised IndexError, which aborted the whole
CLI picker and the desktop session.foreign.list RPC. partition("\n") yields
"" for blank text and the loop moves on to the first real user line.
Reported first in #92290.
278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""Tests for hermes_cli.foreign_sessions — Claude Code / Codex CLI import.
|
|
|
|
Fixture JSONL is synthesized inline (tmp_path); the SessionDB is opened
|
|
against a temp path so nothing touches the real HERMES_HOME store.
|
|
"""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.foreign_sessions import (
|
|
_list_sessions,
|
|
gather_foreign_sessions,
|
|
import_foreign_session,
|
|
parse_claude_session,
|
|
parse_codex_session,
|
|
)
|
|
|
|
|
|
# ── fixture builders ─────────────────────────────────────────────────────
|
|
|
|
|
|
def _claude_lines():
|
|
def msg(role, content):
|
|
return {
|
|
"type": role,
|
|
"sessionId": "abc-123",
|
|
"cwd": "/home/user/proj",
|
|
"message": {"role": role, "content": content},
|
|
}
|
|
|
|
return [
|
|
{"type": "summary", "summary": "Fix the flaky test"},
|
|
msg("user", "Please fix the flaky test in CI."),
|
|
msg(
|
|
"assistant",
|
|
[
|
|
{"type": "text", "text": "Looking into it now."},
|
|
{"type": "tool_use", "name": "Bash", "id": "t1", "input": {}},
|
|
],
|
|
),
|
|
# tool_result echoed back as a user message — must NOT become a turn
|
|
msg("user", [{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]),
|
|
msg("assistant", [{"type": "text", "text": "Fixed — the sleep was too short."}]),
|
|
msg("user", "Great, thanks!"),
|
|
msg("assistant", [{"type": "text", "text": "Anytime."}]),
|
|
]
|
|
|
|
|
|
def _write_claude_fixture(tmp_path, extra_lines=None):
|
|
proj = tmp_path / ".claude" / "projects" / "-home-user-proj"
|
|
proj.mkdir(parents=True)
|
|
f = proj / "abc-123.jsonl"
|
|
lines = [json.dumps(entry) for entry in _claude_lines()]
|
|
if extra_lines:
|
|
lines = extra_lines + lines
|
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return f
|
|
|
|
|
|
def _codex_lines():
|
|
def item(payload):
|
|
return {"timestamp": "2026-08-15T21:35:28Z", "type": "response_item", "payload": payload}
|
|
|
|
def message(role, kind, text):
|
|
return item({"type": "message", "role": role, "content": [{"type": kind, "text": text}]})
|
|
|
|
return [
|
|
{
|
|
"type": "session_meta",
|
|
"payload": {"session_id": "0000-1111", "cwd": "/home/user/repo"},
|
|
},
|
|
# developer/system payloads must never be imported
|
|
message("developer", "input_text", "<skills_instructions>secret system stuff"),
|
|
message("user", "input_text", "<recommended_plugins>\ninjected wrapper"),
|
|
message("user", "input_text", "Summarize the transcripts please."),
|
|
message("assistant", "output_text", "Reading them one at a time."),
|
|
item({"type": "custom_tool_call", "name": "shell", "call_id": "c1"}),
|
|
item({"type": "custom_tool_call_output", "call_id": "c1", "output": "big output"}),
|
|
message("assistant", "output_text", "Done — here is the summary."),
|
|
message("user", "input_text", "Now write it to a file."),
|
|
message("assistant", "output_text", "Written to summary.md."),
|
|
]
|
|
|
|
|
|
def _write_codex_fixture(tmp_path, extra_lines=None):
|
|
day = tmp_path / ".codex" / "sessions" / "2026" / "08" / "15"
|
|
day.mkdir(parents=True)
|
|
f = day / "rollout-2026-08-15T21-35-28-0000-1111.jsonl"
|
|
lines = [json.dumps(entry) for entry in _codex_lines()]
|
|
if extra_lines:
|
|
lines = extra_lines + lines
|
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
return f
|
|
|
|
|
|
@pytest.fixture
|
|
def session_db(tmp_path):
|
|
from hermes_state import SessionDB
|
|
|
|
db = SessionDB(db_path=tmp_path / "state.db")
|
|
yield db
|
|
db.close()
|
|
|
|
|
|
def _assert_alternating(messages):
|
|
roles = [m["role"] for m in messages]
|
|
assert roles, "no messages"
|
|
assert roles[0] == "user"
|
|
for a, b in zip(roles, roles[1:]):
|
|
assert a != b, f"two consecutive {a} messages"
|
|
|
|
|
|
# ── parsing ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_claude_session(tmp_path):
|
|
f = _write_claude_fixture(tmp_path)
|
|
parsed = parse_claude_session(f)
|
|
turns = parsed["turns"]
|
|
_assert_alternating(turns)
|
|
assert len(turns) == 4 # tool_result-only user line skipped; assistants merge
|
|
assert parsed["cwd"] == "/home/user/proj"
|
|
assert parsed["title_guess"] == "Fix the flaky test"
|
|
assert parsed["session_id"] == "abc-123"
|
|
# tool_use flattened to a bracketed summary, merged with adjacent text
|
|
joined_assistant = "\n".join(t["content"] for t in turns if t["role"] == "assistant")
|
|
assert "[ran tool: Bash]" in joined_assistant
|
|
# no fabricated tool_call structures
|
|
assert all(set(t) == {"role", "content"} for t in turns)
|
|
|
|
|
|
def test_parse_codex_session(tmp_path):
|
|
f = _write_codex_fixture(tmp_path)
|
|
parsed = parse_codex_session(f)
|
|
turns = parsed["turns"]
|
|
_assert_alternating(turns)
|
|
assert len(turns) == 4
|
|
assert parsed["cwd"] == "/home/user/repo"
|
|
assert parsed["session_id"] == "0000-1111"
|
|
assert parsed["title_guess"].startswith("Summarize the transcripts")
|
|
# developer + wrapper user lines excluded
|
|
all_text = "\n".join(t["content"] for t in turns)
|
|
assert "skills_instructions" not in all_text
|
|
assert "recommended_plugins" not in all_text
|
|
# tool call summarized in assistant text
|
|
assert "[ran tool: shell]" in all_text
|
|
|
|
|
|
def test_malformed_lines_are_skipped(tmp_path):
|
|
garbage = [
|
|
"this is not json at all {{{",
|
|
'"just a string"',
|
|
json.dumps({"type": "user", "message": "not-a-dict-payload... wait, string"}),
|
|
json.dumps({"type": "user"}), # missing message
|
|
"",
|
|
]
|
|
f = _write_claude_fixture(tmp_path, extra_lines=garbage)
|
|
parsed = parse_claude_session(f)
|
|
assert len(parsed["turns"]) == 4 # good lines still parse
|
|
|
|
g = _write_codex_fixture(tmp_path, extra_lines=["not json", json.dumps({"type": "response_item"})])
|
|
parsed_codex = parse_codex_session(g)
|
|
assert len(parsed_codex["turns"]) == 4
|
|
|
|
|
|
# ── discovery ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_list_sessions(tmp_path):
|
|
_write_claude_fixture(tmp_path)
|
|
_write_codex_fixture(tmp_path)
|
|
claude = _list_sessions("claude", tmp_path / ".claude" / "projects")
|
|
codex = _list_sessions("codex", tmp_path / ".codex" / "sessions")
|
|
assert len(claude) == 1 and claude[0].source == "claude"
|
|
assert claude[0].turn_count == 4
|
|
assert len(codex) == 1 and codex[0].source == "codex"
|
|
assert codex[0].turn_count == 4
|
|
both = gather_foreign_sessions(
|
|
claude_root=tmp_path / ".claude" / "projects",
|
|
codex_root=tmp_path / ".codex" / "sessions",
|
|
)
|
|
assert len(both) == 2
|
|
assert both[0].mtime >= both[1].mtime # newest first
|
|
|
|
|
|
def test_list_sessions_missing_roots(tmp_path):
|
|
assert _list_sessions("claude", tmp_path / "nope") == []
|
|
assert _list_sessions("codex", tmp_path / "nope") == []
|
|
|
|
|
|
# ── import into SessionDB ────────────────────────────────────────────────
|
|
|
|
|
|
def test_import_claude_session(tmp_path, session_db):
|
|
f = _write_claude_fixture(tmp_path)
|
|
session_id = import_foreign_session("claude", f, db=session_db)
|
|
row = session_db.get_session(session_id)
|
|
assert row is not None
|
|
assert row["source"] == "claude-code"
|
|
assert row["cwd"] == "/home/user/proj"
|
|
assert row["message_count"] == 4
|
|
title = session_db.get_session_title(session_id)
|
|
assert title.startswith("Imported from Claude Code: ")
|
|
assert "Please fix the flaky test" in title
|
|
messages = session_db.get_messages(session_id)
|
|
assert len(messages) == 4
|
|
_assert_alternating(messages)
|
|
origin = json.loads(row["origin_json"])
|
|
assert origin["imported_from"]["tool"] == "claude-code"
|
|
assert origin["imported_from"]["path"] == str(f)
|
|
|
|
|
|
def test_import_codex_session(tmp_path, session_db):
|
|
f = _write_codex_fixture(tmp_path)
|
|
session_id = import_foreign_session("@codex", f, db=session_db)
|
|
row = session_db.get_session(session_id)
|
|
assert row is not None
|
|
assert row["source"] == "codex-cli"
|
|
assert row["message_count"] == 4
|
|
title = session_db.get_session_title(session_id)
|
|
assert title.startswith("Imported from Codex CLI: ")
|
|
messages = session_db.get_messages(session_id)
|
|
_assert_alternating(messages)
|
|
# resumable: resolve_session_id round-trips
|
|
assert session_db.resolve_session_id(session_id) == session_id
|
|
|
|
|
|
def test_import_rejects_bad_input(tmp_path, session_db):
|
|
with pytest.raises(ValueError, match="Unknown foreign session source"):
|
|
import_foreign_session("gemini", tmp_path / "x.jsonl", db=session_db)
|
|
with pytest.raises(ValueError, match="not found"):
|
|
import_foreign_session("claude", tmp_path / "missing.jsonl", db=session_db)
|
|
empty = tmp_path / "empty.jsonl"
|
|
empty.write_text("not json\n", encoding="utf-8")
|
|
with pytest.raises(ValueError, match="No user/assistant conversation"):
|
|
import_foreign_session("claude", empty, db=session_db)
|
|
|
|
|
|
def test_leading_assistant_gets_single_stub(tmp_path):
|
|
day = tmp_path / ".codex" / "sessions" / "2026" / "01" / "01"
|
|
day.mkdir(parents=True)
|
|
f = day / "rollout-x.jsonl"
|
|
lines = [
|
|
json.dumps(
|
|
{
|
|
"type": "response_item",
|
|
"payload": {
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "Continuing from before."}],
|
|
},
|
|
}
|
|
)
|
|
]
|
|
f.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
parsed = parse_codex_session(f)
|
|
_assert_alternating(parsed["turns"])
|
|
assert len(parsed["turns"]) == 2
|
|
assert parsed["turns"][0]["role"] == "user"
|
|
|
|
|
|
def test_whitespace_only_user_turn_does_not_break_discovery(tmp_path):
|
|
"""A blank user message (image-only / tool-only turn) must not crash listing; the title
|
|
comes from the first non-blank user line and the blank turn is dropped."""
|
|
project = tmp_path / ".claude" / "projects" / "p"
|
|
project.mkdir(parents=True)
|
|
f = project / "blank.jsonl"
|
|
lines = [
|
|
{"type": "user", "sessionId": "w", "message": {"role": "user", "content": " \n "}},
|
|
{"type": "assistant", "sessionId": "w", "message": {"role": "assistant", "content": "hi"}},
|
|
{"type": "user", "sessionId": "w", "message": {"role": "user", "content": "real question"}},
|
|
]
|
|
f.write_text("\n".join(json.dumps(line) for line in lines) + "\n", encoding="utf-8")
|
|
listed = _list_sessions("claude", project.parent)
|
|
assert [s.title_guess for s in listed] == ["real question"]
|
|
assert listed[0].turn_count == 3 # leading assistant reply gets the user stub
|