mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Stage 303: PR #1717
This commit is contained in:
+7
-2
@@ -1229,9 +1229,13 @@ def import_cli_session(
|
||||
profile=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
parent_session_id=None,
|
||||
):
|
||||
"""Create a new WebUI session populated with CLI messages.
|
||||
Returns the Session object.
|
||||
"""Create a new WebUI session populated with CLI/agent messages.
|
||||
|
||||
Preserve parent_session_id from state.db so imported continuation segments
|
||||
keep their lineage in the WebUI store and sidebar instead of reappearing as
|
||||
detached orphan chats.
|
||||
"""
|
||||
s = Session(
|
||||
session_id=session_id,
|
||||
@@ -1242,6 +1246,7 @@ def import_cli_session(
|
||||
profile=profile,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
parent_session_id=parent_session_id,
|
||||
)
|
||||
s.save(touch_updated_at=False)
|
||||
return s
|
||||
|
||||
+20
-3
@@ -1270,9 +1270,15 @@ def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict:
|
||||
if cli_meta.get("last_message_at") is not None:
|
||||
merged["last_message_at"] = cli_meta["last_message_at"]
|
||||
if cli_meta.get("message_count") is not None:
|
||||
merged["message_count"] = cli_meta["message_count"]
|
||||
merged["message_count"] = max(
|
||||
_numeric_count(merged.get("message_count")),
|
||||
_numeric_count(cli_meta.get("message_count")),
|
||||
)
|
||||
elif cli_meta.get("actual_message_count") is not None:
|
||||
merged["message_count"] = cli_meta["actual_message_count"]
|
||||
merged["message_count"] = max(
|
||||
_numeric_count(merged.get("message_count")),
|
||||
_numeric_count(cli_meta.get("actual_message_count")),
|
||||
)
|
||||
|
||||
if cli_meta.get("title"):
|
||||
current_title = merged.get("title")
|
||||
@@ -2622,7 +2628,13 @@ def handle_get(handler, parsed) -> bool:
|
||||
_t3 = _time.monotonic()
|
||||
if load_messages:
|
||||
if is_messaging_session and cli_messages:
|
||||
_all_msgs = cli_messages
|
||||
sidecar_messages = getattr(s, "messages", []) or []
|
||||
# Recovery/aggregate sidecars can intentionally contain a
|
||||
# longer visible conversation than the single state.db
|
||||
# segment for this messaging session id. Prefer the longer
|
||||
# sidecar so repaired WebUI history is not hidden behind the
|
||||
# canonical per-segment transcript.
|
||||
_all_msgs = sidecar_messages if len(sidecar_messages) > len(cli_messages) else cli_messages
|
||||
else:
|
||||
_all_msgs = s.messages
|
||||
else:
|
||||
@@ -7661,6 +7673,7 @@ def _handle_session_import_cli(handler, body):
|
||||
"raw_source": existing.raw_source or cli_meta.get("raw_source") or cli_meta.get("source_tag"),
|
||||
"session_source": existing.session_source or cli_meta.get("session_source"),
|
||||
"source_label": existing.source_label or cli_meta.get("source_label"),
|
||||
"parent_session_id": existing.parent_session_id or cli_meta.get("parent_session_id"),
|
||||
}
|
||||
for attr, value in updates.items():
|
||||
if getattr(existing, attr, None) != value:
|
||||
@@ -7702,6 +7715,7 @@ def _handle_session_import_cli(handler, body):
|
||||
cli_thread_id = None
|
||||
cli_session_key = None
|
||||
cli_platform = None
|
||||
cli_parent_session_id = None
|
||||
cli_read_only = False
|
||||
for cs in get_cli_sessions():
|
||||
if cs["session_id"] == sid:
|
||||
@@ -7720,6 +7734,7 @@ def _handle_session_import_cli(handler, body):
|
||||
cli_thread_id = cs.get("thread_id")
|
||||
cli_session_key = cs.get("session_key")
|
||||
cli_platform = cs.get("platform")
|
||||
cli_parent_session_id = cs.get("parent_session_id")
|
||||
cli_read_only = bool(cs.get("read_only"))
|
||||
break
|
||||
|
||||
@@ -7750,6 +7765,7 @@ def _handle_session_import_cli(handler, body):
|
||||
"raw_source": cli_raw_source or cli_source_tag,
|
||||
"session_source": cli_session_source,
|
||||
"source_label": cli_source_label,
|
||||
"parent_session_id": cli_parent_session_id,
|
||||
"read_only": True,
|
||||
"messages": msgs,
|
||||
"tool_calls": [],
|
||||
@@ -7764,6 +7780,7 @@ def _handle_session_import_cli(handler, body):
|
||||
profile=profile,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
parent_session_id=cli_parent_session_id,
|
||||
)
|
||||
if cron_project_id:
|
||||
s.project_id = cron_project_id
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import json
|
||||
|
||||
|
||||
def test_import_cli_session_preserves_parent_session_id():
|
||||
from api.models import import_cli_session, SESSION_DIR, Session
|
||||
|
||||
parent_id = 'parent_lineage_001'
|
||||
child_id = 'child_lineage_001'
|
||||
|
||||
# Ensure clean fixture state for direct model-level import.
|
||||
for sid in (parent_id, child_id):
|
||||
try:
|
||||
(SESSION_DIR / f'{sid}.json').unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session = import_cli_session(
|
||||
child_id,
|
||||
'Child Session',
|
||||
[{'role': 'user', 'content': 'hello', 'timestamp': 1.0}],
|
||||
model='test-model',
|
||||
parent_session_id=parent_id,
|
||||
created_at=1.0,
|
||||
updated_at=2.0,
|
||||
)
|
||||
|
||||
assert session.parent_session_id == parent_id
|
||||
|
||||
payload = json.loads((SESSION_DIR / f'{child_id}.json').read_text(encoding='utf-8'))
|
||||
assert payload['parent_session_id'] == parent_id
|
||||
|
||||
loaded = Session.load(child_id)
|
||||
assert loaded.parent_session_id == parent_id
|
||||
assert loaded.compact()['parent_session_id'] == parent_id
|
||||
@@ -90,6 +90,7 @@ def test_session_import_cli_refresh_matches_messages_despite_timestamp_type_diff
|
||||
self.raw_source = "weixin"
|
||||
self.session_source = "messaging"
|
||||
self.source_label = "WeChat"
|
||||
self.parent_session_id = None
|
||||
|
||||
def compact(self):
|
||||
return {"session_id": session_id, "title": "Imported"}
|
||||
@@ -141,6 +142,7 @@ def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverge
|
||||
self.session_source = "messaging"
|
||||
self.source_label = "Telegram"
|
||||
self.is_cli_session = True
|
||||
self.parent_session_id = None
|
||||
|
||||
def compact(self):
|
||||
return {"session_id": session_id, "title": "Imported"}
|
||||
@@ -169,3 +171,113 @@ def test_session_import_cli_refresh_rejects_prefix_if_non_timing_content_diverge
|
||||
assert response["session"]["messages"] == existing.messages
|
||||
assert existing.messages[0]["content"] == "old-prefix"
|
||||
assert save_calls == []
|
||||
|
||||
|
||||
def test_session_import_cli_preserves_parent_metadata_on_existing_import(monkeypatch):
|
||||
"""Refreshing an already-imported CLI session must persist lineage metadata."""
|
||||
import api.routes as routes
|
||||
|
||||
session_id = "existing_parent_lineage_001"
|
||||
parent_id = "root_parent_lineage_001"
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self):
|
||||
self.messages = [{"role": "user", "content": "hello", "timestamp": 1.0}]
|
||||
self.source_tag = "telegram"
|
||||
self.raw_source = "telegram"
|
||||
self.session_source = "messaging"
|
||||
self.source_label = "Telegram"
|
||||
self.parent_session_id = None
|
||||
self.is_cli_session = True
|
||||
|
||||
def compact(self):
|
||||
return {"session_id": session_id, "title": "Imported", "parent_session_id": self.parent_session_id}
|
||||
|
||||
def save(self, touch_updated_at=False):
|
||||
save_calls.append(touch_updated_at)
|
||||
|
||||
save_calls = []
|
||||
existing = FakeSession()
|
||||
|
||||
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: existing if sid == session_id else None))
|
||||
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: existing.messages if sid == session_id else [])
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_cli_sessions",
|
||||
lambda: [{
|
||||
"session_id": session_id,
|
||||
"source_tag": "telegram",
|
||||
"raw_source": "telegram",
|
||||
"session_source": "messaging",
|
||||
"source_label": "Telegram",
|
||||
"parent_session_id": parent_id,
|
||||
}],
|
||||
)
|
||||
|
||||
response = routes._handle_session_import_cli(object(), {"session_id": session_id})
|
||||
|
||||
assert response["imported"] is False
|
||||
assert existing.parent_session_id == parent_id
|
||||
assert response["session"]["parent_session_id"] == parent_id
|
||||
assert save_calls == [False]
|
||||
|
||||
|
||||
def test_read_only_import_payload_includes_parent_session_id(monkeypatch):
|
||||
"""Read-only CLI/session imports should also expose lineage in the payload."""
|
||||
import api.routes as routes
|
||||
|
||||
session_id = "readonly_parent_lineage_001"
|
||||
parent_id = "readonly_root_lineage_001"
|
||||
messages = [{"role": "user", "content": "hello", "timestamp": 1.0}]
|
||||
|
||||
monkeypatch.setattr(routes.Session, "load", classmethod(lambda _cls, sid: None))
|
||||
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
||||
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
||||
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
||||
monkeypatch.setattr(routes, "get_cli_session_messages", lambda sid: messages if sid == session_id else [])
|
||||
monkeypatch.setattr(
|
||||
routes,
|
||||
"get_cli_sessions",
|
||||
lambda: [{
|
||||
"session_id": session_id,
|
||||
"title": "Read-only child",
|
||||
"model": "test-model",
|
||||
"created_at": 1.0,
|
||||
"updated_at": 2.0,
|
||||
"source_tag": "discord",
|
||||
"raw_source": "discord",
|
||||
"session_source": "messaging",
|
||||
"source_label": "Discord",
|
||||
"parent_session_id": parent_id,
|
||||
"read_only": True,
|
||||
}],
|
||||
)
|
||||
|
||||
response = routes._handle_session_import_cli(object(), {"session_id": session_id})
|
||||
|
||||
assert response["imported"] is False
|
||||
assert response["session"]["parent_session_id"] == parent_id
|
||||
assert response["session"]["messages"] == messages
|
||||
|
||||
|
||||
def test_merge_cli_sidebar_metadata_keeps_larger_sidecar_message_count():
|
||||
"""Sidebar metadata merge should not shrink repaired aggregate sidecar counts."""
|
||||
import api.routes as routes
|
||||
|
||||
merged = routes._merge_cli_sidebar_metadata(
|
||||
{"session_id": "sid", "message_count": 535, "title": "Recovered"},
|
||||
{"session_id": "sid", "message_count": 407, "source_tag": "discord"},
|
||||
)
|
||||
|
||||
assert merged["message_count"] == 535
|
||||
|
||||
|
||||
def test_messaging_session_loader_prefers_longer_sidecar_transcript():
|
||||
"""Pin the /api/session invariant that repaired sidecars can be longer than state.db segments."""
|
||||
handler = _extract_handler("handle_get")
|
||||
old = "if is_messaging_session and cli_messages:\n _all_msgs = cli_messages"
|
||||
assert old not in handler
|
||||
assert "sidecar_messages = getattr(s, \"messages\", []) or []" in handler
|
||||
assert "len(sidecar_messages) > len(cli_messages)" in handler
|
||||
|
||||
Reference in New Issue
Block a user