diff --git a/apps/desktop/src/lib/session-refs.test.ts b/apps/desktop/src/lib/session-refs.test.ts index 0028150ef3af..5d4b1371a646 100644 --- a/apps/desktop/src/lib/session-refs.test.ts +++ b/apps/desktop/src/lib/session-refs.test.ts @@ -90,6 +90,12 @@ describe('linkifySessionRefs', () => { expect(linkifySessionRefs('me@session:abc')).toBe('me@session:abc') expect(linkifySessionRefs('a/@session:abc')).toBe('a/@session:abc') }) + + it('leaves a ref a model already wrapped in a markdown link alone', () => { + const text = '[that chat](@session:work/abc123)' + + expect(linkifySessionRefs(text)).toBe(text) + }) }) // The agent-authored path: assistant markdown runs through preprocessMarkdown diff --git a/apps/desktop/src/lib/session-refs.ts b/apps/desktop/src/lib/session-refs.ts index e672c1b6f05e..3ec9fbc62be5 100644 --- a/apps/desktop/src/lib/session-refs.ts +++ b/apps/desktop/src/lib/session-refs.ts @@ -8,8 +8,9 @@ /** Mirrors the composer/transcript form in `directive-text.tsx`: a bare value, * or one fenced in backticks/quotes so a value with spaces survives. The - * lookbehind keeps `foo@session:` and URL paths from matching. */ -export const SESSION_REF_RE = /(? str: + """Recover the session id from an `@session:[/]` value.""" + assert link.startswith("@session:"), link + value = link[len("@session:"):] + + return value.rsplit("/", 1)[-1] + + +class TestSessionLink: + def test_link_carries_the_named_profile(self): + assert _session_link("s_oldest", "work") == "@session:work/s_oldest" + + def test_link_falls_back_to_a_bare_id_when_the_profile_is_unknown(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.profiles.get_active_profile_name", + lambda: "custom", + ) + + assert _session_link("s_oldest") == "@session:s_oldest" + + def test_link_survives_a_failure_to_resolve_the_profile(self, monkeypatch): + def boom(): + raise RuntimeError("no profile") + + monkeypatch.setattr("hermes_cli.profiles.get_active_profile_name", boom) + + assert _session_link("s_oldest") == "@session:s_oldest" + + def test_read_result_links_to_the_session_it_read(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(session_id="s_oldest", db=db)) + + assert _linked_session_id(result["link"]) == result["session_id"] + + def test_every_discovery_result_links_to_its_own_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(query="modpack", limit=5, db=db)) + + assert result["results"] + for entry in result["results"]: + assert _linked_session_id(entry["link"]) == entry["session_id"] + + def test_every_browse_result_links_to_its_own_session(self, db): + _seed_modpack_sessions(db) + result = json.loads(session_search(db=db)) + + assert result["results"] + for entry in result["results"]: + assert _linked_session_id(entry["link"]) == entry["session_id"] + + def test_link_splits_the_way_the_tool_parses_it_back(self): + """The agent hands its own link back as session_id; the value has to + survive the profile/id partition in session_search.""" + value = _session_link("s_middle", "work")[len("@session:"):] + profile, _, session_id = value.partition("/") + + assert (profile, session_id) == ("work", "s_middle") + + # ========================================================================= # Cross-profile read — `profile` swaps in another profile's DB (read-only) # ========================================================================= diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index bc09e82511d9..2b2d99c359d6 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -291,6 +291,28 @@ def _resolve_profile_db(profile: str): return SessionDB(db_path=profiles_mod.get_profile_dir(canon) / "state.db", read_only=True) +def _session_link(session_id: str, profile: str = None) -> str: + """The reference the agent writes to point the user at a session. + + Same value the desktop composer emits when a session is dragged into a + message, so the desktop renders it as a link carrying the session's title. + The profile segment is omitted when we can't name it confidently — a bare + id still resolves, it just can't disambiguate across profiles. + """ + name = (profile or "").strip() + if not name: + try: + from hermes_cli.profiles import get_active_profile_name + + resolved = get_active_profile_name() + name = "" if resolved == "custom" else resolved + except Exception: + logging.debug("get_active_profile_name failed for session link", exc_info=True) + name = "" + + return f"@session:{name}/{session_id}" if name else f"@session:{session_id}" + + def _locate_session_db(session_id: str): """Scan every profile's ``state.db`` (read-only) for a session id. @@ -335,7 +357,7 @@ def _locate_session_db(session_id: str): return None, None -def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: +def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str: """Read shape: dump a whole session by id (head + tail when large). Serves the linked-session case — the user dropped an @session reference and @@ -366,6 +388,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: "success": True, "mode": "read", "session_id": session_id, + "link": _session_link(session_id, link_profile), "session_meta": { "when": _format_timestamp(meta.get("started_at")), "source": meta.get("source"), @@ -384,7 +407,7 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10) -> str: return json.dumps(response, ensure_ascii=False) -def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str: +def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str: """Return metadata for the most recent sessions (no LLM calls, no FTS5).""" try: sessions = db.list_sessions_rich( @@ -405,6 +428,7 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None) -> str continue results.append({ "session_id": sid, + "link": _session_link(sid, link_profile), "title": s.get("title") or None, "source": s.get("source", ""), "started_at": s.get("started_at", ""), @@ -630,6 +654,7 @@ def _discover( limit: int, sort: Optional[str], current_session_id: str = None, + link_profile: str = None, ) -> str: """Discovery shape: FTS5 + anchored window + bookends per hit. Single call.""" role_list = role_filter if role_filter else ["user", "assistant"] @@ -764,6 +789,9 @@ def _discover( entry["parent_session_id"] = lineage_root results.append(entry) + for entry in results: + entry["link"] = _session_link(entry["session_id"], link_profile) + _final_payload = { "success": True, "mode": "discover", @@ -848,7 +876,7 @@ def session_search( # Read shape: a session_id with no anchor → dump the whole session. if isinstance(session_id, str) and session_id.strip(): sid = session_id.strip() - result = _read_session(db, sid) + result = _read_session(db, sid, link_profile=profile) if json.loads(result).get("success"): return result @@ -858,7 +886,7 @@ def session_search( located, owner = _locate_session_db(sid) if located is not None: try: - found = json.loads(_read_session(located, sid)) + found = json.loads(_read_session(located, sid, link_profile=owner)) finally: located.close() if found.get("success"): @@ -876,7 +904,7 @@ def session_search( # Browse shape: no query → recent sessions. if not query or not isinstance(query, str) or not query.strip(): - return _list_recent_sessions(db, limit, current_session_id) + return _list_recent_sessions(db, limit, current_session_id, link_profile=profile) # Parse role_filter role_list: Optional[List[str]] = None @@ -897,6 +925,7 @@ def session_search( limit=limit, sort=sort_norm, current_session_id=current_session_id, + link_profile=profile, ) @@ -962,6 +991,14 @@ SESSION_SEARCH_SCHEMA = { " session_search()\n" " Returns recent sessions chronologically: titles, previews, timestamps. " "Use when the user asks \"what was I working on\" without naming a topic.\n\n" + "LINKING THE USER TO A SESSION\n\n" + " When you refer the user to a session, write its `link` value inline in " + "your reply — every result carries one, e.g. " + "`@session:default/20260722_204335_d62c16`. Copy it verbatim; do not " + "reformat it as a markdown link or wrap it in backticks. Hermes renders " + "it as the session's title, so there is no need to also spell out the " + "title, date, or id around it — write \"picked up in @session:... \" " + "rather than restating the metadata.\n\n" "FTS5 SYNTAX\n\n" " AND is the default — multi-word queries require all terms. Use OR explicitly " "for broader recall (`alpha OR beta OR gamma`), quoted phrases for exact match "