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:
Brooklyn Nicholson
2026-06-04 19:41:51 -05:00
parent a40e20e136
commit 9dbd3c57d7
15 changed files with 604 additions and 45 deletions

View File

@ -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"