feat(desktop): drag sessions into chat as @session links + spawn loader
Drag a sidebar session into the composer to drop an @session:<profile>/<id> chip the agent resolves via session_search. New READ shape dumps a whole session by id (head+tail when large); a `profile` param reads another profile's DB read-only, and a cross-profile locate scan resolves bare ids when the model drops the owning profile from the link. Also: ASCII "waking up <profile>" overlay during lazy gateway swaps, global haptic rate-limit to kill the reconnect-storm "clickity" buzz, and reauth toasts surfaced once per disconnect instead of every backoff tick.
This commit is contained in:
@ -399,3 +399,124 @@ class TestShapePrecedence:
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(query=None, db=db)) # type: ignore
|
||||
assert result["mode"] == "browse"
|
||||
|
||||
def test_session_id_without_anchor_reads(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
# session_id alone (no anchor, no query) → read shape, not browse.
|
||||
result = json.loads(session_search(session_id="s_oldest", db=db))
|
||||
assert result["mode"] == "read"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Read shape — dump a whole session by id (serves @session links)
|
||||
# =========================================================================
|
||||
|
||||
class TestReadShape:
|
||||
def test_read_returns_full_session(self, db):
|
||||
_seed_modpack_sessions(db)
|
||||
result = json.loads(session_search(session_id="s_oldest", db=db))
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "read"
|
||||
assert result["session_id"] == "s_oldest"
|
||||
assert result["message_count"] == 5
|
||||
assert result["truncated"] is False
|
||||
assert len(result["messages"]) == 5
|
||||
assert result["session_meta"]["title"] == "Building the Modpack"
|
||||
|
||||
def test_read_unknown_session_errors(self, db):
|
||||
result = json.loads(session_search(session_id="ghost", db=db))
|
||||
assert result["success"] is False
|
||||
|
||||
def test_read_truncates_large_session(self, db):
|
||||
db.create_session("s_big", source="cli")
|
||||
for i in range(50):
|
||||
db.append_message("s_big", role="user" if i % 2 == 0 else "assistant", content=f"m{i}")
|
||||
db._conn.commit()
|
||||
result = json.loads(session_search(session_id="s_big", db=db))
|
||||
assert result["mode"] == "read"
|
||||
assert result["message_count"] == 50
|
||||
assert result["truncated"] is True
|
||||
assert len(result["messages"]) == 30 # head 20 + tail 10
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Cross-profile read — `profile` swaps in another profile's DB (read-only)
|
||||
# =========================================================================
|
||||
|
||||
class TestCrossProfileRead:
|
||||
def _patch_profiles(self, monkeypatch, home, exists=True):
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
monkeypatch.setattr(profiles_mod, "normalize_profile_name", lambda n: n)
|
||||
monkeypatch.setattr(profiles_mod, "validate_profile_name", lambda n: None)
|
||||
monkeypatch.setattr(profiles_mod, "profile_exists", lambda n: exists)
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: home)
|
||||
|
||||
def test_profile_param_reads_other_db(self, db, tmp_path, monkeypatch):
|
||||
other_home = tmp_path / "other_home"
|
||||
other_home.mkdir()
|
||||
other = SessionDB(other_home / "state.db")
|
||||
other.create_session("s_other", source="cli")
|
||||
other._conn.execute(
|
||||
"UPDATE sessions SET title = ? WHERE id = ?", ("Other Profile Chat", "s_other")
|
||||
)
|
||||
other.append_message("s_other", role="user", content="hello from the other profile")
|
||||
other._conn.commit()
|
||||
|
||||
self._patch_profiles(monkeypatch, other_home)
|
||||
|
||||
# s_other lives only in the other profile; the current `db` lacks it.
|
||||
result = json.loads(session_search(session_id="s_other", profile="other", db=db))
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "read"
|
||||
assert result["session_meta"]["title"] == "Other Profile Chat"
|
||||
|
||||
def test_bare_id_locates_across_profiles(self, db, tmp_path, monkeypatch):
|
||||
# The real-world failure: model dropped the owning profile and passed a
|
||||
# bare id. The tool must scan profiles and find it anyway.
|
||||
other_home = tmp_path / "asdf_home"
|
||||
other_home.mkdir()
|
||||
other = SessionDB(other_home / "state.db")
|
||||
other.create_session("s_far", source="cli")
|
||||
other.append_message("s_far", role="user", content="hi")
|
||||
other._conn.commit()
|
||||
|
||||
from collections import namedtuple
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
Info = namedtuple("Info", "name path")
|
||||
monkeypatch.setattr(profiles_mod, "get_profile_dir", lambda n: tmp_path / "default_home")
|
||||
monkeypatch.setattr(profiles_mod, "list_profiles", lambda: [Info("asdf", other_home)])
|
||||
|
||||
# `db` (current profile) lacks s_far; no profile passed → scan finds it.
|
||||
result = json.loads(session_search(session_id="s_far", db=db))
|
||||
assert result["success"] is True
|
||||
assert result["mode"] == "read"
|
||||
assert result["profile"] == "asdf"
|
||||
|
||||
def test_unknown_profile_errors(self, db, monkeypatch, tmp_path):
|
||||
self._patch_profiles(monkeypatch, tmp_path, exists=False)
|
||||
result = json.loads(session_search(session_id="x", profile="ghost", db=db))
|
||||
assert result["success"] is False
|
||||
assert "ghost" in result.get("error", "")
|
||||
|
||||
def test_combined_value_autosplits(self, db, tmp_path, monkeypatch):
|
||||
# Agent passed the raw "@session:<profile>/<id>" value as session_id with
|
||||
# no separate profile — the tool should recover both.
|
||||
other_home = tmp_path / "other_home"
|
||||
other_home.mkdir()
|
||||
other = SessionDB(other_home / "state.db")
|
||||
other.create_session("s_other", source="cli")
|
||||
other.append_message("s_other", role="user", content="hi")
|
||||
other._conn.commit()
|
||||
|
||||
self._patch_profiles(monkeypatch, other_home)
|
||||
|
||||
# Every permutation the model might send must resolve to (asdf, s_other).
|
||||
for kwargs in (
|
||||
{"session_id": "asdf/s_other"}, # full value, no profile
|
||||
{"session_id": "asdf/s_other", "profile": "asdf"}, # full value AND profile
|
||||
{"session_id": "s_other", "profile": "asdf"}, # bare id + profile
|
||||
):
|
||||
result = json.loads(session_search(db=db, **kwargs))
|
||||
assert result["success"] is True, kwargs
|
||||
assert result["mode"] == "read"
|
||||
assert result["session_id"] == "s_other"
|
||||
|
||||
Reference in New Issue
Block a user