Files
hermes-webui/tests/test_claude_code_profile_agnostic_detail_load.py
T
Zhike ChenandClaude Opus 5 ef8f42c7b4 fix: resolve CLI session display in WebUI
Claude Code sessions listed in the sidebar rendered "Session not available
in web UI." when clicked under a named (non-root) profile.

Root cause: get_claude_code_sessions() scans ~/.claude/projects and stamps
profile=None on every row, because those JSONL transcripts belong to no
Hermes profile. /api/sessions lists them regardless of active profile, but
the GET /api/session detail load ran them through
_session_visible_to_active_profile, whose _profiles_match coerces None ->
'default'. With hermes_profile=feng-family active, the gate 404'd before
_claim_or_synthesize_cli_session ever ran, so the frontend hit its 404
branch and painted the empty-state message. POST /api/session/import_cli
correctly returned 200 with an inline read-only payload (Claude Code
sessions are read_only by design and never get a sidecar), which is why no
file appeared in webui/sessions/ -- Session.save() was never supposed to
run and was not silently failing.

Exempt profile-less claude_code rows from the detail-load profile gate via
_is_profile_agnostic_foreign_session(). Profile-tagged foreign rows keep
the #5419 409 cross-profile contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:01:09 +08:00

159 lines
5.6 KiB
Python

"""Claude Code sessions must open under a NAMED (non-root) active profile.
``get_claude_code_sessions()`` scans ``~/.claude/projects`` and stamps
``profile: None`` on every row — those JSONL transcripts belong to no Hermes
profile. ``/api/sessions`` lists them regardless of the active profile, but the
``GET /api/session`` detail load ran them through
``_session_visible_to_active_profile``, which coerces ``None`` -> ``'default'``
via ``_profiles_match``. Under a named profile (e.g. ``feng-family``) that gate
404'd before ``_claim_or_synthesize_cli_session`` ever ran, so every Claude Code
row in the sidebar rendered "Session not available in web UI." when clicked.
These pin the exemption: profile-less Claude Code rows bypass the gate, while
profile-tagged foreign rows stay fully scoped (the #5419 409 contract).
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from urllib.parse import urlparse
import api.routes as routes
from api.models import Session
CLAUDE_SID = "claude_code_491fbe3e6ea1248d70a4177f"
def _claude_code_row():
"""A row shaped exactly like get_claude_code_sessions() emits."""
return {
"session_id": CLAUDE_SID,
"title": "Claude Code transcript",
"workspace": "/home/user/project",
"model": "claude-code",
"message_count": 2,
"created_at": 1.0,
"updated_at": 2.0,
"last_message_at": 2.0,
"pinned": False,
"archived": False,
"project_id": None,
"profile": None,
"source_tag": "claude_code",
"raw_source": "claude_code",
"session_source": "external_agent",
"source_label": "Claude Code",
"is_cli_session": True,
"read_only": True,
}
def _synth_for(row):
s = Session(
session_id=row["session_id"],
title=row["title"],
workspace=row["workspace"],
model=row["model"],
messages=[
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
],
created_at=row["created_at"],
updated_at=row["updated_at"],
profile=row["profile"],
is_cli_session=True,
source_tag=row["source_tag"],
raw_source=row["raw_source"],
session_source=row["session_source"],
source_label=row["source_label"],
read_only=True,
)
return s
def _capture(monkeypatch):
cap = {}
def fake_j(handler, data, status=200, extra_headers=None):
cap["data"] = data
cap["status"] = status
return True
def fake_bad(handler, msg, status=400, extra_headers=None):
cap["error"] = msg
cap["status"] = status
return True
monkeypatch.setattr(routes, "j", fake_j)
monkeypatch.setattr(routes, "bad", fake_bad)
return cap
def test_claude_code_detail_load_survives_named_active_profile(monkeypatch):
row = _claude_code_row()
cap = _capture(monkeypatch)
synth = _synth_for(row)
handler = MagicMock()
parsed = urlparse("/api/session?session_id=%s&messages=0&resolve_model=0" % CLAUDE_SID)
with patch("api.routes.get_session", side_effect=KeyError(CLAUDE_SID)), \
patch("api.routes._get_active_profile_name", return_value="feng-family"), \
patch("api.routes._lookup_cli_session_metadata", return_value=row), \
patch(
"api.routes._claim_or_synthesize_cli_session",
return_value=(synth, "not_claimable"),
):
assert routes.handle_get(handler, parsed) is True
assert cap.get("error") is None, (
"profile-less Claude Code row must not be 404'd by the detail-load "
"profile gate under a named active profile"
)
assert cap["status"] == 200
sess = cap["data"]["session"]
assert sess["session_id"] == CLAUDE_SID
assert sess["read_only"] is True
assert sess["is_cli_session"] is True
assert sess["source_tag"] == "claude_code"
assert len(sess["messages"]) == 2
def test_profile_tagged_foreign_session_still_scoped(monkeypatch):
"""Negative control: a row that DOES carry a profile keeps the #5419 409."""
row = dict(_claude_code_row())
row.update(
session_id="20260101_000000_abc123",
profile="other-profile",
source_tag="telegram",
raw_source="telegram",
session_source="messaging",
source_label="Telegram",
)
cap = _capture(monkeypatch)
handler = MagicMock()
parsed = urlparse("/api/session?session_id=%s&messages=0&resolve_model=0" % row["session_id"])
with patch("api.routes.get_session", side_effect=KeyError(row["session_id"])), \
patch("api.routes._get_active_profile_name", return_value="feng-family"), \
patch("api.routes._lookup_cli_session_metadata", return_value=row):
assert routes.handle_get(handler, parsed) is True
assert cap["status"] == 409
assert cap["data"]["code"] == "session_profile_mismatch"
def test_profile_agnostic_predicate_is_narrow():
assert routes._is_profile_agnostic_foreign_session(_claude_code_row()) is True
# A Claude Code row that somehow carries a profile stays scoped.
tagged = dict(_claude_code_row(), profile="feng-family")
assert routes._is_profile_agnostic_foreign_session(tagged) is False
# A profile-less row from any other source stays scoped.
other = dict(_claude_code_row(), source_tag="cli", raw_source="cli")
assert routes._is_profile_agnostic_foreign_session(other) is False
# Missing / empty metadata is never exempt.
assert routes._is_profile_agnostic_foreign_session({}) is False
assert routes._is_profile_agnostic_foreign_session(None) is False