From 34aaaea9f29bf36511395df3df3d4502756c0e80 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Fri, 3 Jul 2026 09:59:17 +0200 Subject: [PATCH] feat(gateway): add sessions search --- ...3-001-feat-gateway-sessions-search-plan.md | 188 +++++++ gateway/slash_commands.py | 459 +++++++++++++++-- hermes_cli/session_listing.py | 115 ++++- hermes_state.py | 135 ++++- tests/gateway/test_resume_command.py | 484 ++++++++++++++++++ tests/hermes_cli/test_session_listing.py | 80 +++ tests/test_hermes_state.py | 135 +++++ 7 files changed, 1515 insertions(+), 81 deletions(-) create mode 100644 docs/plans/2026-07-03-001-feat-gateway-sessions-search-plan.md create mode 100644 tests/hermes_cli/test_session_listing.py diff --git a/docs/plans/2026-07-03-001-feat-gateway-sessions-search-plan.md b/docs/plans/2026-07-03-001-feat-gateway-sessions-search-plan.md new file mode 100644 index 0000000000000..760bf3e9dcc2a --- /dev/null +++ b/docs/plans/2026-07-03-001-feat-gateway-sessions-search-plan.md @@ -0,0 +1,188 @@ +--- +title: Gateway Sessions Search - Plan +type: feat +date: 2026-07-03 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +--- + +# Gateway Sessions Search - Plan + +## Goal Capsule + +- **Objective:** Add `/sessions search ` to gateway slash-command surfaces so Telegram, WhatsApp, Slack, Discord, Matrix, and other gateway platforms can search sessions that are relevant and visible to the caller; explicit admins can combine `all` with search to inspect the broader non-tool session set. +- **Authority:** User request and current `AGENTS.md` contribution rubric govern scope. Existing command behavior in `gateway/slash_commands.py` and `hermes_cli/session_listing.py` is the implementation pattern to extend. +- **Execution profile:** Small gateway feature, no new model tool, no new dependency, no prompt-context mutation, and no user-facing `.env` setting. +- **Stop conditions:** Stop implementation if a current exact issue or open PR for gateway `/sessions search ` is found before shipping. Related but non-exact work (#17193 cross-session management and #49038 Telegram resume picker) should be cited, not duplicated. +- **Tail ownership:** The PR should follow `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md`; use the existing broad issue as related context rather than creating a duplicate feature request unless maintainers require one. + +--- + +## Product Contract + +### Summary + +Gateway users can already run `/sessions` to list scoped sessions, `/sessions ` to delegate to resume, and `/resume ` to switch chats. This plan adds the missing discovery step: `/sessions search ` searches older title/id matches beyond the short recent list, filters them to the caller-visible chat/session origin, returns numbered rows, and lets the user resume a result with `/resume ` or `/sessions `. Explicit admins can use `/sessions all search ` to search the broader non-tool session set. + +### Problem Frame + +Messaging platforms are constrained text surfaces. When a user remembers only part of a session title or id, the current gateway flow makes them scan the newest named sessions or leave the chat to search elsewhere. The code already has the right gateway command route and security boundary, so the feature should extend the shared listing policy instead of adding a new command or platform-specific implementation. + +### Requirements + +**Search behavior** + +- R1. `/sessions search ` lists sessions whose title or session id contains ``, case-insensitively, including older matching sessions that are not present in the short recent `/sessions` or `/resume` list. +- R2. Search results keep the existing gateway listing shape with session title, id, optional source when cross-origin listing is allowed, and resume guidance. +- R3. Empty search queries return a helpful usage response rather than treating `search` as a resume target. + +**Resume continuity** + +- R4. Existing `/resume `, `/resume `, `/sessions <session id>`, and `/sessions <title>` behavior remains unchanged. +- R5. Numeric `/resume <number>` and `/sessions <number>` resume the numbered rows from the latest session list or search result for that chat; bare `/resume` refreshes that numbered list with the recent titled sessions. + +**Security and scope** + +- R6. Ordinary search results respect the same source, user, chat, thread, Matrix room, and admin override boundaries as existing gateway session listing and resume behavior. +- R7. Explicit admin `/sessions all search <query>` searches the broader non-tool, non-archived session set while preserving the existing admin gate. +- R8. Legacy DM sessions created before full chat-origin persistence remain searchable and resumable when the row proves the same source, user, and thread but has a blank `chat_id`. + +**Contribution workflow** + +- R9. Before opening the PR, search current open and closed issues and PRs for an exact `/sessions search` gateway duplicate and stop if one appears. +- R10. The PR body cites related session-management work without claiming to fix a broader issue that this narrow feature does not close. + +### Scope Boundaries + +- In scope: gateway slash-command behavior and shared session-listing helper behavior used by gateway surfaces. +- In scope: tests for parser behavior, title/id matching, no-query feedback, caller-visible search scope, admin `all search`, and unchanged non-search gateway origin scoping. +- Out of scope: Telegram inline keyboard search, full-text message search, fuzzy matching, pagination, `/resume search`, and cross-medium session sharing. +- Deferred to follow-up work: an interactive Telegram picker can add search UI later, likely in the same platform-specific path as #49038. + +### Acceptance Examples + +- AE1. Given a Telegram chat with owned sessions titled `Billing audit` and `Release planning`, when the user sends `/sessions search bill`, then the response lists `Billing audit` and does not list `Release planning`. +- AE2. Given a matching session id `abc123-release`, when the user sends `/sessions search 123`, then the response lists that session and shows resume guidance. +- AE3. Given a different user's same-platform session with a matching title, when the caller sends `/sessions search <matching text>`, then that session is not listed and cannot be resumed. +- AE4. Given `/sessions search bill` returns `1. Billing audit`, when the same chat sends `/resume 1`, then Hermes switches that chat to the `Billing audit` session. +- AE5. Given an existing titled session, when the user sends `/sessions Existing Title`, then Hermes resumes it exactly as before. +- AE6. Given an explicit admin caller, when the caller sends `/sessions all search <matching text>`, then matching cross-origin non-tool sessions are listed with source labels. +- AE7. Given an older Telegram DM session with the same `user_id` and blank `chat_id`, when that user sends `/sessions search <matching text>` from the current Telegram DM, then the session is listed and can be resumed by number. + +--- + +## Planning Contract + +### Key Technical Decisions + +- KTD1. Extend the existing `/sessions` command instead of adding `/session` or `/switch`. The command is already gateway-registered and accepted on messaging platforms, and adding a new slash command would widen public command surface without need. +- KTD2. Put query parsing and result selection in `hermes_cli/session_listing.py`. Gateway should keep enforcing caller visibility for ordinary search, while explicit admin `all search` can use the broader browser-style session set. +- KTD3. Store the latest numbered session rows per gateway session key for a short TTL. This matches existing messenger numbered-list ergonomics while still rechecking resume authorization before switching sessions. +- KTD4. Treat related GitHub work as context, not a duplicate. Search found no exact `/sessions search <query>` issue or PR, while #17193 covers broader cross-session management and #49038 covers a Telegram picker. + +### High-Level Technical Design + +```mermaid +flowchart TB + A[Gateway message: /sessions search query] --> B[parse session listing args] + B --> C[query session listing with search query] + C --> D[apply existing caller visibility or admin all gate] + D --> E[format session rows with ids and resume guidance] + E --> F[store numbered picks for this chat] + F --> G[User sends /resume number, /resume id, or /sessions title] + G --> H[existing resume authorization and switch flow] +``` + +### Assumptions + +- The intended spelling is `/sessions search <query>` because `/sessions` is the registered command; `/session search` is treated as a typo in the prompt, not a new command request. +- Search is for session title and id only. Message full-text search is already a different dashboard/API capability and is not part of this gateway command. +- Numbered search results are transient server-side picks scoped to the gateway session key and expire after a short TTL. Resume remains explicit, and the existing authorization guard still runs before any switch. + +### Sources and Research + +- `CONTRIBUTING.md` requires searching issues, PRs, and source before building; current searches found no exact `/sessions search <query>` feature or PR. +- `.github/PULL_REQUEST_TEMPLATE.md` requires duplicate-search confirmation, tests, focused changes, and a clear test plan. +- `gateway/slash_commands.py` already handles `/resume`, `/sessions`, target delegation, and resume authorization. +- `hermes_cli/session_listing.py` is the shared listing policy behind gateway `/sessions`. +- `tests/gateway/test_resume_command.py` already covers gateway `/sessions` dispatch, source scoping, and resume authorization. +- Related GitHub context: #17193 is an open broad cross-session management request; #49038 is an open Telegram inline resume-picker PR; neither is an exact gateway `/sessions search <query>` implementation. + +--- + +## Implementation Units + +### U1. Add Search Parsing and Listing Policy + +- **Goal:** Teach the shared session-listing helper to distinguish `/sessions search <query>` from `/sessions <resume-target>` and to filter rows by title or id with offset support for paged gateway search. +- **Requirements:** R1, R2, R3, R4, R5 +- **Dependencies:** None +- **Files:** `hermes_cli/session_listing.py`, `hermes_state.py`, `tests/hermes_cli/test_session_listing.py`, `tests/test_hermes_state.py` +- **Approach:** Extend the parser with a search intent while preserving existing list aliases, `all`, `--all`, `full`, `--full`, and target delegation. Add title/id search, persisted gateway-origin filters, and offset plumbing to the listing helper and session DB query path so gateway callers can page through older matching rows without changing formatter behavior. +- **Patterns to follow:** Existing `parse_session_listing_args`, `query_session_listing`, and `format_gateway_session_listing` helpers. +- **Test scenarios:** + - Parse `/sessions search billing` as a search query, not a resume target. + - Parse `/sessions search` and `/sessions search " "` as a missing query case. + - Preserve `/sessions list`, `/sessions all full`, and `/sessions Existing Title` behavior. + - Match title and id substrings case-insensitively while keeping non-matching rows out. +- **Verification:** Helper tests prove the new parsing and filtering contract without requiring a live gateway adapter. + +### U2. Wire Gateway `/sessions search` + +- **Goal:** Route gateway `/sessions search <query>` through the shared listing helper, format a caller-visible numbered result list, and make those numbers resumable in the same chat. +- **Requirements:** R1, R2, R3, R5, R6, R7, R8 +- **Dependencies:** U1 +- **Files:** `gateway/slash_commands.py`, `tests/gateway/test_resume_command.py` +- **Approach:** Update `_handle_sessions_command` to branch on the parsed search intent. For ordinary callers, prefilter by the persisted gateway origin fields, with a DM-only legacy fallback for same-source, same-user, same-thread rows that have blank `chat_id`, page through title/id matches until enough caller-visible rows are found, keep current non-search target delegation to `_handle_resume_command`, keep current admin cross-origin handling, and keep `_resume_row_visible` filtering after the query. Store the displayed row ids per gateway session key so `/resume <number>` and `/sessions <number>` resolve against the latest session list or search output before the normal resume authorization guard runs. For explicit admins, `/sessions all search <query>` widens the query the same way `/sessions all` already does. +- **Patterns to follow:** Existing `/sessions all` admin gate, non-admin cross-origin filtering, and `/resume` IDOR guard comments. +- **Test scenarios:** + - Covers AE1. A title match appears in `/sessions search <query>`. + - Covers AE2. An id match appears in `/sessions search <query>`. + - Covers AE3. A different user's or different chat's matching row is not listed. + - Covers AE4. `/resume <number>` after `/sessions search <query>` switches to the numbered search result. + - Covers AE6. Admin `/sessions all search <query>` can list matching cross-origin non-tool rows with source labels. + - Covers AE7. A legacy same-user DM row with blank `chat_id` appears in search and can be resumed by number. + - A missing query returns a usage/help response and does not call resume. + - Existing `/sessions <title>` still delegates to resume and switches sessions. + - Older visible matches still appear when newer hidden same-source matches would otherwise fill the first candidate page. +- **Verification:** Gateway tests exercise the real `SessionDB` against a temp database so source, user, and chat scoping are tested through persisted rows. + +### U3. Prepare PR-Ready Evidence + +- **Goal:** Make the feature easy to review under the repo's contribution guide and PR template. +- **Requirements:** R8, R9 +- **Dependencies:** U1, U2 +- **Files:** `.github/PULL_REQUEST_TEMPLATE.md` as reference only, no expected modification +- **Approach:** Re-run duplicate searches before PR creation, record related issue and PR context in the PR body, and include the targeted pytest commands that prove parser and gateway behavior. +- **Patterns to follow:** `CONTRIBUTING.md` duplicate-search guidance and the existing PR template sections. +- **Test scenarios:** Test expectation: none - this unit prepares shipping evidence and does not change runtime behavior. +- **Verification:** PR body states what was searched, names related non-duplicate work, and includes passing targeted test commands. + +--- + +## Verification Contract + +| Gate | Applies To | Done Signal | +|---|---|---| +| Duplicate search | U3 | `gh search issues` and `gh search prs` find no exact open or closed `/sessions search <query>` gateway implementation before PR creation. | +| Helper tests | U1 | Parser and listing helper tests pass for search, missing query, existing list flags, existing target delegation, and title/id matching. | +| Gateway tests | U2 | `tests/gateway/test_resume_command.py` passes for new search scenarios and existing resume/session behavior. | +| Focused regression | U1, U2 | Targeted pytest run covering `tests/hermes_cli/test_session_listing.py` and `tests/gateway/test_resume_command.py` passes. | +| PR template | U3 | PR body follows `.github/PULL_REQUEST_TEMPLATE.md`, marks the feature checkbox, links related work without overclaiming closure, and includes test commands. | + +--- + +## Definition of Done + +- `/sessions search <query>` works on gateway slash-command surfaces that already accept `/sessions`. +- Search matches title and id substrings case-insensitively, including older caller-visible matches beyond the short recent list. +- Numbered search rows can be resumed with `/resume <number>` or `/sessions <number>` in the same gateway chat. +- Ordinary search results respect the same source, user, chat, thread, Matrix room, and admin override boundaries as existing gateway session listing and resume behavior. +- Legacy same-user DM rows with blank `chat_id` remain searchable and resumable from the current DM without exposing other users' rows. +- Admin `/sessions all search <query>` uses the broader non-tool, non-archived session set behind `/sessions all` and shows source labels. +- Existing `/sessions`, `/sessions list`, `/sessions full`, `/sessions all`, `/sessions <target>`, `/resume <target>`, and `/resume <number>` behavior remains compatible. +- No new core model tool, plugin, dependency, `.env` key, or prompt-cache-affecting behavior is introduced. +- Targeted tests pass and the PR body carries duplicate-search evidence plus related issue/PR context. +- The final diff contains no unrelated branch cleanup, no assistant/tool attribution, and no local-only agent artifacts. diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 066911dcf693d..33887ca71ed58 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -53,6 +53,10 @@ # past this the reset proceeds and the cleanup is left to finish (or leak) in # its worker thread. (#35994) _RESET_CLEANUP_TIMEOUT_S = 30.0 +_SESSION_LIST_DISPLAY_LIMIT = 10 +_SESSION_SEARCH_PAGE_SIZE = 100 +_SESSION_SEARCH_MAX_CANDIDATES = 1000 +_RESUME_NUMBERED_PICK_TTL_S = 30 * 60 def _model_switch_skew_guard() -> Optional[str]: @@ -87,6 +91,214 @@ def _model_switch_skew_guard() -> Optional[str]: class GatewaySlashCommandsMixin: """In-session slash-command handlers for GatewayRunner.""" + def _resume_numbered_picks(self) -> dict: + picks = getattr(self, "_resume_numbered_pick_store", None) + if not isinstance(picks, dict): + picks = {} + setattr(self, "_resume_numbered_pick_store", picks) + return picks + + def _store_resume_numbered_picks( + self, + source: SessionSource, + rows: list[dict], + *, + allow_all: bool = False, + ) -> None: + session_key = self._session_key_for_source(source) + ids = [str(row.get("id") or "") for row in rows if row.get("id")] + picks = self._resume_numbered_picks() + if ids: + picks[session_key] = { + "ids": ids, + "allow_all": bool(allow_all), + "created_at": time.time(), + } + else: + picks.pop(session_key, None) + + def _get_resume_numbered_pick_ids(self, source: SessionSource) -> Optional[dict]: + session_key = self._session_key_for_source(source) + picks = self._resume_numbered_picks() + record = picks.get(session_key) + if not isinstance(record, dict): + return None + created_at = float(record.get("created_at") or 0.0) + if created_at <= 0 or time.time() - created_at > _RESUME_NUMBERED_PICK_TTL_S: + picks.pop(session_key, None) + return None + ids = [str(sid) for sid in record.get("ids") or [] if sid] + if not ids: + picks.pop(session_key, None) + return None + return {"ids": ids, "allow_all": bool(record.get("allow_all"))} + + def _session_listing_origin_filters(self, source: SessionSource) -> dict[str, Any]: + thread_id = str(getattr(source, "thread_id", "") or "") + filters: dict[str, Any] = {"thread_id": thread_id} + chat_id = str(getattr(source, "chat_id", "") or "") + if chat_id: + filters["chat_id"] = chat_id + if source.platform == Platform.MATRIX: + return filters + + chat_type = (getattr(source, "chat_type", "") or "").lower() + user_id = str(getattr(source, "user_id", "") or "") + if chat_type in {"dm", "direct", "private", ""}: + filters["chat_id_allow_empty"] = True + filters["thread_id_allow_empty"] = True + filters["chat_types"] = ["", "dm", "direct", "private"] + owner_ids = [] + if user_id: + owner_ids.append(user_id) + if source.platform == Platform.WHATSAPP and chat_id and chat_id not in owner_ids: + owner_ids.append(chat_id) + if len(owner_ids) == 1: + filters["user_id"] = owner_ids[0] + elif owner_ids: + filters["user_ids"] = owner_ids + return filters + + shared = is_shared_multi_user_session( + source, + group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), + thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), + ) + if user_id and not shared: + filters["user_id"] = user_id + filters["chat_id_allow_empty"] = True + filters["thread_id_allow_empty"] = True + return filters + + def _session_listing_legacy_origin_filters(self, source: SessionSource) -> Optional[dict[str, Any]]: + if source.platform == Platform.MATRIX: + return None + chat_type = (getattr(source, "chat_type", "") or "").lower() + if chat_type in {"dm", "direct", "private", ""}: + return None + thread_id = str(getattr(source, "thread_id", "") or "") + user_id = str(getattr(source, "user_id", "") or "") + if not thread_id or not user_id or str(getattr(source, "user_id_alt", "") or ""): + return None + return { + "user_id": user_id, + "chat_id": "", + "thread_id": "", + } + + @staticmethod + def _compact_session_search_text(value: str) -> str: + return re.sub(r"[\W_]+", "", str(value or "").lower()) + + def _session_search_content_counts( + self, + db_obj: Any, + rows: list[dict], + query: str, + ) -> dict[str, tuple[int, int]]: + conn = getattr(db_obj, "_conn", None) + if conn is None or not rows: + return {} + sanitizer = getattr(db_obj, "_sanitize_fts5_query", None) + if not callable(sanitizer): + return {} + fts_query = sanitizer(query or "") + if not fts_query: + return {} + session_ids: list[str] = [] + seen: set[str] = set() + for row in rows: + for key in ("id", "_lineage_root_id"): + sid = str(row.get(key) or "") + if sid and sid not in seen: + seen.add(sid) + session_ids.append(sid) + if not session_ids: + return {} + placeholders = ",".join("?" for _ in session_ids) + sql = f""" + SELECT + m.session_id, + SUM(CASE WHEN m.role IN ('user', 'assistant') THEN 1 ELSE 0 END) AS owned_hits, + COUNT(*) AS total_hits + FROM messages_fts + JOIN messages m ON m.id = messages_fts.rowid + WHERE m.session_id IN ({placeholders}) + AND (m.active = 1 OR m.compacted = 1) + AND messages_fts MATCH ? + GROUP BY m.session_id + """ + lock = getattr(db_obj, "_lock", None) + try: + if lock is not None: + with lock: + result_rows = conn.execute(sql, [*session_ids, fts_query]).fetchall() + else: + result_rows = conn.execute(sql, [*session_ids, fts_query]).fetchall() + except Exception: + return {} + counts: dict[str, tuple[int, int]] = {} + for result in result_rows: + sid = str(result["session_id"] if hasattr(result, "keys") else result[0]) + owned_value = result["owned_hits"] if hasattr(result, "keys") else result[1] + total_value = result["total_hits"] if hasattr(result, "keys") else result[2] + owned_hits = int(owned_value or 0) + total_hits = int(total_value or 0) + counts[sid] = (owned_hits, total_hits) + return counts + + def _rank_session_search_rows( + self, + db_obj: Any, + rows: list[dict], + query: str, + ) -> list[dict]: + needle = (query or "").strip().lower() + compact_needle = self._compact_session_search_text(needle) + content_counts = self._session_search_content_counts(db_obj, rows, query) + + def _title_id_score(row: dict) -> int: + values = [ + str(row.get("title") or ""), + str(row.get("id") or ""), + str(row.get("_lineage_root_id") or ""), + ] + score = 0 + for value in values: + lower = value.lower() + compact = self._compact_session_search_text(value) + tokens = set(re.findall(r"[a-z0-9]+", lower)) + if needle and lower == needle: + score = max(score, 3000) + if compact_needle and compact == compact_needle: + score = max(score, 2800) + if compact_needle and compact_needle in compact: + # Short fragments like "cod" inside "codex" are weak title + # matches; exact token or punctuation-normalized matches + # remain strong. + score = max(score, 80 if len(compact_needle) <= 3 and compact_needle not in tokens else 2400) + if needle and needle in lower: + score = max(score, 80 if len(needle) <= 3 and needle not in tokens else 2200) + return score + + def _content_score(row: dict) -> int: + owned_hits = 0 + total_hits = 0 + for key in ("id", "_lineage_root_id"): + sid = str(row.get(key) or "") + hits = content_counts.get(sid) + if hits: + owned_hits += hits[0] + total_hits += hits[1] + return min(owned_hits, 30) * 30 + min(max(total_hits - owned_hits, 0), 30) + + def _sort_key(row: dict) -> tuple[int, float, str]: + rank = _title_id_score(row) + _content_score(row) + last_active = float(row.get("last_active") or row.get("started_at") or 0.0) + return rank, last_active, str(row.get("id") or "") + + return sorted(rows, key=_sort_key, reverse=True) + def _typed_command_prefix_for(self, platform) -> str: """Return the prefix users can always type to reach Hermes commands. @@ -770,10 +982,10 @@ async def _resume_target_allowed( Generalizes the Matrix-only room guard to every adapter so a caller cannot bind their gateway session to another user's/room's persisted session id (IDOR). Uses the live origin when the target is active; - otherwise falls back to the DB row's source + user_id (the sessions - table has no chat_id). An identity-bearing caller is allowed only when - the row PROVES the same owner; a row that lacks enough ownership data - fails closed. An explicit admin ``--all`` override bypasses scoping. + otherwise falls back to the DB row's persisted origin fields. An + identity-bearing caller is allowed only when the row PROVES the same + owner; a row that lacks enough ownership data fails closed. An explicit + admin ``--all`` override bypasses scoping. """ if allow_override and self._resume_caller_is_admin(source): return True @@ -791,6 +1003,35 @@ async def _resume_target_allowed( row = await self._session_db.get_session(target_id) or {} except Exception: return False + parent_id = str(row.get("parent_session_id") or "") + if parent_id and ( + not row.get("user_id") + or not row.get("chat_id") + or not row.get("thread_id") + ): + row_source = str(row.get("source") or "") + merged_row = dict(row) + seen_parent_ids: set[str] = set() + for _ in range(20): + if not parent_id or parent_id in seen_parent_ids: + break + seen_parent_ids.add(parent_id) + try: + parent_row = await self._session_db.get_session(parent_id) or {} + except Exception: + break + if ( + parent_row.get("end_reason") != "compression" + or str(parent_row.get("source") or "") != row_source + ): + break + for key in ("user_id", "chat_id", "chat_type", "thread_id"): + if not merged_row.get(key) and parent_row.get(key): + merged_row[key] = parent_row[key] + if all(merged_row.get(key) for key in ("user_id", "chat_id", "thread_id")): + break + parent_id = str(parent_row.get("parent_session_id") or "") + row = merged_row caller_src = source.platform.value if source.platform else None row_src = row.get("source") if row_src and caller_src and str(row_src) != str(caller_src): @@ -800,16 +1041,18 @@ async def _resume_target_allowed( # Chat/thread origin recorded at session creation (see # SessionDB._insert_session_row). The sessions table historically stored # only source + user_id, so a same-user row could belong to a DIFFERENT - # chat; comparing the persisted origin closes that gap. Legacy rows - # created before origin capture have NULL here and therefore fail closed - # (they cannot prove the caller's chat) — resume them via a live session - # or an admin override. + # chat; comparing the persisted origin closes that gap. The one legacy + # fallback below is intentionally narrower: a threaded non-DM caller may + # resume a same-user row whose chat/thread fields are both blank. That + # keeps old topic handoff sessions reachable without allowing unthreaded + # group callers to bind arbitrary same-user rows by title/id. caller_chat = str(getattr(source, "chat_id", "") or "") row_chat = str(row.get("chat_id") or "") caller_thread = str(getattr(source, "thread_id", "") or "") row_thread = str(row.get("thread_id") or "") chat_type = (getattr(source, "chat_type", "") or "").lower() caller_is_dm = chat_type in {"dm", "direct", "private", ""} + legacy_thread_origin = bool(caller_thread) and not row_chat and not row_thread # build_session_key keys the participant on ``user_id_alt or user_id`` # (Signal/Feishu carry the canonical participant in user_id_alt), but the # sessions table only ever stored user_id — it has no user_id_alt column. @@ -825,6 +1068,9 @@ async def _resume_target_allowed( # an admin --all override still bypasses this above. caller_keys_on_alt = bool(str(getattr(source, "user_id_alt", "") or "")) if caller_uid: + owner_ids = {caller_uid} + if source.platform == Platform.WHATSAPP and caller_chat: + owner_ids.add(caller_chat) # Identity-bearing caller: allow only when the row PROVES the same # owner AND the same platform/origin AND the same chat/thread. A row # with no/blank user_id cannot be proven to belong to this caller; a @@ -845,7 +1091,7 @@ async def _resume_target_allowed( origin_ok = ( bool(row_src) and bool(caller_src) and str(row_src) == str(caller_src) - and row_thread == caller_thread + and (row_thread == caller_thread or legacy_thread_origin) ) if not origin_ok: return False @@ -863,17 +1109,17 @@ async def _resume_target_allowed( # apply there. if caller_keys_on_alt and not (bool(row_chat) and bool(caller_chat)): return False - return ( - bool(row_uid) and row_uid == caller_uid - and row_chat == caller_chat - ) + if not (bool(row_uid) and row_uid in owner_ids): + return False + if row_chat: + return row_chat in {caller_chat, caller_uid} + # Legacy gateway rows created before chat_id capture can still + # prove a DM owner through source + user_id + thread equality. + return True # Non-DM (group/channel/forum/thread): build_session_key includes - # chat_id, so a row (or caller) with NO chat provenance cannot prove - # same-chat. Require both sides non-blank and equal — a legacy - # NULL-chat row (or a caller missing its chat_id) fails closed even - # when both normalize to "". (CWE-639) - if not (bool(row_chat) and bool(caller_chat) and row_chat == caller_chat): - return False + # chat_id, so a row (or caller) with NO chat provenance usually + # cannot prove same-chat. Require both sides non-blank and equal, + # except for the same-user threaded legacy fallback above. # Within the same non-DM chat/thread, mirror build_session_key's # participant scoping: a SHARED group/thread session # (group_sessions_per_user=False, or a shared thread) is one session @@ -886,6 +1132,10 @@ async def _resume_target_allowed( group_sessions_per_user=getattr(self.config, "group_sessions_per_user", True), thread_sessions_per_user=getattr(self.config, "thread_sessions_per_user", False), ) + if legacy_thread_origin: + return bool(row_uid) and row_uid == caller_uid and not caller_keys_on_alt + if not (bool(row_chat) and bool(caller_chat) and row_chat == caller_chat): + return False if shared: return True # Per-user non-DM: the session key includes the participant @@ -3523,11 +3773,17 @@ async def _list_titled_sessions() -> list[dict]: if await self._resume_row_visible(source, s, allow_all) ] if not titled: + self._store_resume_numbered_picks(source, [], allow_all=allow_all) if source.platform == Platform.MATRIX and not allow_all: return t("gateway.resume.matrix_no_named_sessions") return t("gateway.resume.no_named_sessions") + self._store_resume_numbered_picks( + source, + titled[:_SESSION_LIST_DISPLAY_LIMIT], + allow_all=allow_all, + ) lines = [t("gateway.resume.list_header")] - for idx, s in enumerate(titled[:10], start=1): + for idx, s in enumerate(titled[:_SESSION_LIST_DISPLAY_LIMIT], start=1): title = s["title"] if source.platform == Platform.MATRIX and allow_all: origin = self._gateway_session_origin_for_id(str(s.get("id") or "")) @@ -3544,21 +3800,33 @@ async def _list_titled_sessions() -> list[dict]: # Resolve a numbered choice or a title to a session ID. if name.isdigit(): - try: - titled = await _list_titled_sessions() - titled = [ - s for s in titled - if await self._resume_row_visible(source, s, allow_all) - ] - except Exception as e: - logger.debug("Failed to list titled sessions for numeric resume: %s", e) - return t("gateway.resume.list_failed", error=e) index = int(name) - if index < 1 or index > len(titled): - return t("gateway.resume.out_of_range", index=index) - target = titled[index - 1] - target_id = target.get("id") - name = target.get("title") or name + pick_record = self._get_resume_numbered_pick_ids(source) + if pick_record is not None: + pick_ids = pick_record["ids"] + if index < 1 or index > len(pick_ids): + return t("gateway.resume.out_of_range", index=index) + if pick_record.get("allow_all") and self._resume_caller_is_admin(source): + allow_all = True + if source.platform == Platform.MATRIX: + allow_cross_room = True + target_id = pick_ids[index - 1] + name = target_id + else: + try: + titled = await _list_titled_sessions() + titled = [ + s for s in titled + if await self._resume_row_visible(source, s, allow_all) + ] + except Exception as e: + logger.debug("Failed to list titled sessions for numeric resume: %s", e) + return t("gateway.resume.list_failed", error=e) + if index < 1 or index > len(titled): + return t("gateway.resume.out_of_range", index=index) + target = titled[index - 1] + target_id = target.get("id") + name = target.get("title") or name else: # Try direct session ID lookup first (so `/resume <session_id>` # works in the gateway, not just `/resume <title>`). @@ -3659,19 +3927,22 @@ async def _handle_sessions_command(self, event: MessageEvent) -> str: from hermes_cli.session_listing import ( format_gateway_session_listing, - parse_session_listing_args, + parse_session_listing_request, query_session_listing, ) source = event.source raw_args = event.get_command_args().strip() try: - include_all, include_unnamed, target = parse_session_listing_args(raw_args) + parsed = parse_session_listing_request(raw_args) except ValueError as exc: return t("gateway.resume.parse_error", error=exc) - if target: - resume_event = dataclasses.replace(event, text=f"/resume {target}") + if parsed.search_requested and not parsed.search_query: + return "Usage: `/sessions search <query>`" + + if parsed.target: + resume_event = dataclasses.replace(event, text=f"/resume {parsed.target}") return await self._handle_resume_command(resume_event) # A cross-origin listing (`/sessions all`) is honored only for an @@ -3679,29 +3950,111 @@ async def _handle_sessions_command(self, event: MessageEvent) -> str: # user argument, so without this gate any caller could run # `/sessions all` and enumerate other origins' session ids / titles / # previews / sources — the enumeration half of the /resume IDOR. - cross_origin = include_all and self._resume_caller_is_admin(source) + cross_origin = parsed.include_all_sources and self._resume_caller_is_admin(source) current_entry = self.session_store.get_or_create_session(source) - rows = await asyncio.to_thread( - query_session_listing, - getattr(self._session_db, "_db", self._session_db), - source=source.platform.value if source.platform else None, - current_session_id=current_entry.session_id, - include_all_sources=cross_origin, - include_unnamed=include_unnamed, - limit=10, - exclude_sources=["tool"], - ) - if not cross_origin: + db_obj = getattr(self._session_db, "_db", self._session_db) + + async def _visible_rows(candidate_rows: list[dict]) -> list[dict]: + if cross_origin: + return candidate_rows # Scope the listing to the caller's own origin on every adapter so # session ids/previews from other users/rooms aren't enumerable. - rows = [ - row for row in rows + return [ + row for row in candidate_rows if await self._resume_row_visible(source, row, allow_all=False) ] + + query_kwargs = { + "source": source.platform.value if source.platform else None, + "current_session_id": current_entry.session_id, + "include_all_sources": cross_origin, + "include_unnamed": parsed.include_unnamed, + "search_query": parsed.search_query or None, + "search_message_content": parsed.search_requested and not cross_origin, + "exclude_sources": ["tool"], + } + query_scopes = [query_kwargs] + if not cross_origin: + scoped_query_kwargs = dict(query_kwargs) + scoped_query_kwargs.update(self._session_listing_origin_filters(source)) + query_scopes = [scoped_query_kwargs] + legacy_filters = self._session_listing_legacy_origin_filters(source) + if parsed.search_requested and legacy_filters: + legacy_query_kwargs = dict(query_kwargs) + legacy_query_kwargs.update(legacy_filters) + query_scopes.append(legacy_query_kwargs) + if parsed.search_requested: + rows = [] + seen_ids: set[str] = set() + offset = 0 + collect_for_ranking = bool(query_kwargs.get("search_message_content")) + while ( + (collect_for_ranking or len(rows) < _SESSION_LIST_DISPLAY_LIMIT) + and offset < _SESSION_SEARCH_MAX_CANDIDATES + ): + page_size = min( + _SESSION_SEARCH_PAGE_SIZE, + _SESSION_SEARCH_MAX_CANDIDATES - offset, + ) + page_candidates: list[dict] = [] + any_full_page = False + for scope_kwargs in query_scopes: + candidates = await asyncio.to_thread( + query_session_listing, + db_obj, + limit=page_size, + offset=offset, + **scope_kwargs, + ) + page_candidates.extend(candidates) + if len(candidates) >= page_size: + any_full_page = True + if not page_candidates: + break + page_candidates.sort( + key=lambda row: ( + float(row.get("_effective_last_active") or row.get("last_active") or 0.0), + float(row.get("started_at") or 0.0), + str(row.get("id") or ""), + ), + reverse=True, + ) + for row in await _visible_rows(page_candidates): + row_id = str(row.get("id") or "") + if not row_id or row_id in seen_ids: + continue + seen_ids.add(row_id) + rows.append(row) + if len(rows) >= _SESSION_LIST_DISPLAY_LIMIT and not collect_for_ranking: + break + if not any_full_page: + break + offset += page_size + if collect_for_ranking: + continue + if len(rows) >= _SESSION_LIST_DISPLAY_LIMIT: + break + if collect_for_ranking: + rows = self._rank_session_search_rows(db_obj, rows, parsed.search_query) + rows = rows[:_SESSION_LIST_DISPLAY_LIMIT] + else: + candidates = await asyncio.to_thread( + query_session_listing, + db_obj, + limit=_SESSION_LIST_DISPLAY_LIMIT, + **query_scopes[0], + ) + rows = await _visible_rows(candidates) + self._store_resume_numbered_picks(source, rows, allow_all=cross_origin) + title = "Named Sessions" + if parsed.search_requested: + title = f"Sessions matching `{parsed.search_query}`" + elif parsed.include_unnamed: + title = "Sessions" return format_gateway_session_listing( rows, include_source=cross_origin, - title="Sessions" if include_unnamed else "Named Sessions", + title=title, ) async def _handle_branch_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/session_listing.py b/hermes_cli/session_listing.py index 6ede6a218f951..99b131803b448 100644 --- a/hermes_cli/session_listing.py +++ b/hermes_cli/session_listing.py @@ -2,45 +2,106 @@ from __future__ import annotations +import re +from dataclasses import dataclass from typing import Any -def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str]: - """Parse `/sessions`-style args into listing flags plus a resume target. +@dataclass(frozen=True) +class SessionListingRequest: + include_all_sources: bool = False + include_unnamed: bool = False + target: str = "" + search_requested: bool = False + search_query: str = "" - Returns ``(include_all_sources, include_unnamed, target)``. ``list``/``ls`` - and ``browse`` are display aliases; ``all``/``--all`` widens source scope; - ``full``/``--full`` keeps unnamed sessions in the listing. Anything else is - treated as a target so `/sessions <id-or-title>` can delegate to `/resume`. - """ + +def parse_session_listing_request(raw_args: str) -> SessionListingRequest: + """Parse `/sessions` args into a typed listing/search/resume request.""" import shlex parts = shlex.split(raw_args or "") include_all = False include_unnamed = False target_parts: list[str] = [] + search_parts: list[str] = [] + search_requested = False + for part in parts: + if search_requested: + search_parts.append(part) + continue + lower = part.strip().lower() - if lower in {"list", "ls", "browse"}: + if lower in {"list", "ls", "browse"} and not target_parts: continue - if lower in {"all", "--all"}: + if lower in {"all", "--all"} and not target_parts: include_all = True continue - if lower in {"full", "--full"}: + if lower in {"full", "--full"} and not target_parts: include_unnamed = True continue + if lower in {"search", "find"} and not target_parts: + search_requested = True + continue target_parts.append(part) - return include_all, include_unnamed, " ".join(target_parts).strip() + + return SessionListingRequest( + include_all_sources=include_all, + include_unnamed=include_unnamed, + target=" ".join(target_parts).strip(), + search_requested=search_requested, + search_query=" ".join(search_parts).strip(), + ) + + +def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str]: + """Parse `/sessions`-style args into listing flags plus a resume target. + + Returns ``(include_all_sources, include_unnamed, target)``. ``list``/``ls`` + and ``browse`` are display aliases; ``all``/``--all`` widens source scope; + ``full``/``--full`` keeps unnamed sessions in the listing. Anything else is + treated as a target so `/sessions <id-or-title>` can delegate to `/resume`. + """ + parsed = parse_session_listing_request(raw_args) + return parsed.include_all_sources, parsed.include_unnamed, parsed.target + + +def _matches_search(row: dict[str, Any], query: str) -> bool: + needle = (query or "").strip().lower() + if not needle: + return True + compact_needle = re.sub(r"[\W_]+", "", needle) + haystacks = ( + str(row.get("title") or ""), + str(row.get("id") or ""), + str(row.get("_lineage_root_id") or ""), + ) + return any( + needle in value.lower() + or (bool(compact_needle) and compact_needle in re.sub(r"[\W_]+", "", value.lower())) + for value in haystacks + ) def query_session_listing( session_db: Any, *, source: str | None, + user_id: str | None = None, + user_ids: list[str] | None = None, + chat_id: str | None = None, + chat_id_allow_empty: bool = False, + chat_types: list[str] | None = None, + thread_id: str | None = None, + thread_id_allow_empty: bool = False, current_session_id: str | None = None, include_all_sources: bool = False, include_unnamed: bool = False, + search_query: str | None = None, + search_message_content: bool = False, limit: int = 10, + offset: int = 0, exclude_sources: list[str] | None = None, ) -> list[dict[str, Any]]: """Return session rows for interactive listing surfaces. @@ -51,16 +112,31 @@ def query_session_listing( """ query_source = None if include_all_sources else source fetch_limit = max(limit * 4, limit) - rows = session_db.list_sessions_rich( - source=query_source, - exclude_sources=exclude_sources, - limit=fetch_limit, - ) + search = (search_query or "").strip() + list_kwargs = { + "source": query_source, + "user_id": user_id, + "user_ids": user_ids, + "chat_id": chat_id, + "chat_id_allow_empty": chat_id_allow_empty, + "chat_types": chat_types, + "thread_id": thread_id, + "thread_id_allow_empty": thread_id_allow_empty, + "search_message_content": search_message_content, + "exclude_sources": exclude_sources, + "limit": fetch_limit, + "offset": offset, + } + if search: + list_kwargs.update(order_by_last_active=True, search_query=search) + rows = session_db.list_sessions_rich(**list_kwargs) result: list[dict[str, Any]] = [] for row in rows: if current_session_id and row.get("id") == current_session_id: continue - if not include_unnamed and not row.get("title"): + if search and not search_message_content and not _matches_search(row, search): + continue + if not include_unnamed and not row.get("title") and not search: continue result.append(row) if len(result) >= limit: @@ -92,6 +168,7 @@ def format_gateway_session_listing( preview_part = f" — _{preview}_" if preview else "" lines.append(f"{idx}. **{title_text}**{source_part} — `{session_id}`{preview_part}") lines.append("") - lines.append("Resume: `/resume <session id>` or `/resume <number>` from `/resume`.") - lines.append("More: `/sessions all`, `/sessions full`, `/sessions all full`.") + lines.append("Resume: `/resume <session id>` or `/sessions <session id or title>`.") + lines.append("Numbered picks: `/resume <number>` from the latest session list.") + lines.append("More: `/sessions all`, `/sessions full`, `/sessions search <query>`.") return "\n".join(lines) diff --git a/hermes_state.py b/hermes_state.py index ac1a126675322..76845c6c14172 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -2712,6 +2712,15 @@ def list_sessions_rich( include_archived: bool = False, archived_only: bool = False, id_query: str = None, + search_query: str = None, + user_id: str = None, + user_ids: List[str] = None, + chat_id: str = None, + chat_id_allow_empty: bool = False, + chat_types: List[str] = None, + thread_id: str = None, + thread_id_allow_empty: bool = False, + search_message_content: bool = False, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -2764,6 +2773,42 @@ def list_sessions_rich( if source: where_clauses.append("s.source = ?") params.append(source) + if user_ids is not None: + normalized_user_ids = [] + seen_user_ids = set() + for value in user_ids: + text = str(value or "") + if not text or text in seen_user_ids: + continue + normalized_user_ids.append(text) + seen_user_ids.add(text) + if normalized_user_ids: + placeholders = ",".join("?" for _ in normalized_user_ids) + where_clauses.append(f"COALESCE(s.user_id, '') IN ({placeholders})") + params.extend(normalized_user_ids) + else: + where_clauses.append("0") + elif user_id is not None: + where_clauses.append("COALESCE(s.user_id, '') = ?") + params.append(str(user_id or "")) + if chat_id is not None: + if chat_id_allow_empty and str(chat_id or ""): + where_clauses.append("COALESCE(s.chat_id, '') IN (?, '')") + params.append(str(chat_id or "")) + else: + where_clauses.append("COALESCE(s.chat_id, '') = ?") + params.append(str(chat_id or "")) + if chat_types: + placeholders = ",".join("?" for _ in chat_types) + where_clauses.append(f"COALESCE(s.chat_type, '') IN ({placeholders})") + params.extend(str(chat_type or "") for chat_type in chat_types) + if thread_id is not None: + if thread_id_allow_empty and str(thread_id or ""): + where_clauses.append("COALESCE(s.thread_id, '') IN (?, '')") + params.append(str(thread_id or "")) + else: + where_clauses.append("COALESCE(s.thread_id, '') = ?") + params.append(str(thread_id or "")) if exclude_sources: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") @@ -2775,6 +2820,49 @@ def list_sessions_rich( if min_message_count > 0: where_clauses.append("s.message_count >= ?") params.append(min_message_count) + search_needle = (search_query or "").strip().lower() + search_compact = re.sub(r"[\W_]+", "", search_needle) + content_search_query = "" + if search_message_content and search_needle and self._fts_enabled: + content_search_query = self._sanitize_fts5_query(search_needle) + + def _like_pattern(value: str) -> str: + escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + return f"%{escaped}%" + + def _compact_sql(expr: str) -> str: + return ( + "REPLACE(REPLACE(REPLACE(REPLACE(" + f"LOWER(COALESCE({expr}, '')), '-', ''), '_', ''), '.', ''), ' ', '')" + ) + + if search_needle and not order_by_last_active: + search_clause = ( + "(LOWER(COALESCE(s.title, '')) LIKE ? ESCAPE '\\' " + "OR LOWER(s.id) LIKE ? ESCAPE '\\'" + ) + like_pattern = _like_pattern(search_needle) + params.extend([like_pattern, like_pattern]) + if search_compact: + search_clause += ( + f" OR {_compact_sql('s.title')} LIKE ? ESCAPE '\\'" + f" OR {_compact_sql('s.id')} LIKE ? ESCAPE '\\'" + ) + compact_pattern = _like_pattern(search_compact) + params.extend([compact_pattern, compact_pattern]) + if content_search_query: + search_clause += ( + " OR EXISTS (" + "SELECT 1 FROM messages_fts " + "JOIN messages sm ON sm.id = messages_fts.rowid " + "WHERE sm.session_id = s.id " + "AND (sm.active = 1 OR sm.compacted = 1) " + "AND messages_fts MATCH ?" + ")" + ) + params.append(content_search_query) + where_clauses.append(search_clause + ")") + if archived_only: where_clauses.append("s.archived = 1") elif not include_archived: @@ -2806,26 +2894,55 @@ def list_sessions_rich( # ended_at is written, while stale websocket siblings may satisfy # the timestamp test and hijack resume/list projection. outer_where = where_sql - id_params: List[Any] = [] + filter_clauses: List[str] = [] + filter_params: List[Any] = [] if id_needle: # Admit a surfaced row if its own id or any id in its forward # compression chain matches the needle. LIKE with a leading # wildcard can't use an index, but the chain membership and # the small result set keep this bounded — far cheaper than # fetching every session and scanning in Python. - id_clause = ( + filter_clauses.append( "EXISTS (SELECT 1 FROM chain cq" " WHERE cq.root_id = s.id" " AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')" ) - like_pattern = ( - "%" - + id_needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - + "%" + filter_params.append(_like_pattern(id_needle)) + if search_needle: + search_clause = ( + "EXISTS (SELECT 1 FROM chain cq" + " JOIN sessions cs ON cs.id = cq.cur_id" + " WHERE cq.root_id = s.id" + " AND (LOWER(COALESCE(cs.title, '')) LIKE ? ESCAPE '\\'" + " OR LOWER(cq.cur_id) LIKE ? ESCAPE '\\'" ) - id_params = [like_pattern] + like_pattern = _like_pattern(search_needle) + filter_params.extend([like_pattern, like_pattern]) + if search_compact: + search_clause += ( + f" OR {_compact_sql('cs.title')} LIKE ? ESCAPE '\\'" + f" OR {_compact_sql('cq.cur_id')} LIKE ? ESCAPE '\\'" + ) + compact_pattern = _like_pattern(search_compact) + filter_params.extend([compact_pattern, compact_pattern]) + if content_search_query: + search_clause += ( + " OR EXISTS (" + "SELECT 1 FROM chain cm " + "JOIN messages sm ON sm.session_id = cm.cur_id " + "JOIN messages_fts ON messages_fts.rowid = sm.id " + "WHERE cm.root_id = s.id " + "AND (sm.active = 1 OR sm.compacted = 1) " + "AND messages_fts MATCH ?" + ")" + ) + filter_params.append(content_search_query) + filter_clauses.append(search_clause + "))") + if filter_clauses: + combined_filter = " AND ".join(filter_clauses) outer_where = ( - f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}" + f"{where_sql} AND {combined_filter}" + if where_sql else f"WHERE {combined_filter}" ) query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( @@ -2871,7 +2988,7 @@ def list_sessions_rich( """ # WHERE params apply twice (CTE seed + outer select); the id filter # only applies to the outer select. - params = params + params + id_params + [limit, offset] + params = params + params + filter_params + [limit, offset] else: query = f""" SELECT s.*, diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index d245503f3c889..04745ce3fae55 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -451,6 +451,490 @@ async def test_sessions_all_does_not_leak_cross_origin_for_non_admin(self, tmp_p assert "Discord" not in result db.close() + @pytest.mark.asyncio + async def test_sessions_search_lists_same_origin_title_matches(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("billing_session", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("billing_session", "Billing Audit") + db.create_session("release_session", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("release_session", "Release Planning") + + event = _make_event(text="/sessions search bill") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Sessions matching" in result + assert "Billing Audit" in result + assert "billing_session" in result + assert "Release Planning" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_lists_same_origin_id_matches(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("abc123_release", "telegram", user_id="12345", chat_id="67890") + db.create_session("other_session", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("other_session", "Other Work") + + event = _make_event(text="/sessions search 123") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "abc123_release" in result + assert "Other Work" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_does_not_leak_cross_user_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("owned_match", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("owned_match", "Billing Mine") + db.create_session("victim_match", "telegram", user_id="99999", chat_id="67890") + db.set_session_title("victim_match", "Billing Theirs") + + event = _make_event(text="/sessions search billing") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Billing Mine" in result + assert "victim_match" not in result + assert "Billing Theirs" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_lists_legacy_same_user_dm_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("legacy_google", "telegram", user_id="12345") + db.set_session_title("legacy_google", "Google Ads Legacy") + db.create_session("other_user_legacy_google", "telegram", user_id="99999") + db.set_session_title("other_user_legacy_google", "Google Ads Other") + + event = _make_event(text="/sessions search google") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Google Ads Legacy" in result + assert "legacy_google" in result + assert "Google Ads Other" not in result + db.close() + + @pytest.mark.asyncio + async def test_resume_number_uses_legacy_same_user_dm_search_result(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("legacy_google", "telegram", user_id="12345") + db.set_session_title("legacy_google", "Google Ads Legacy") + db.append_message("legacy_google", "user", "old legacy prompt") + + search_event = _make_event(text="/sessions search google") + runner = _make_runner(session_db=db, event=search_event) + + search_result = await runner._handle_sessions_command(search_event) + resume_result = await runner._handle_resume_command(_make_event(text="/resume 1")) + + assert "Google Ads Legacy" in search_result + assert "Google Ads Legacy" in resume_result + runner.session_store.switch_session.assert_called_with( + _session_key_for_event(search_event), + "legacy_google", + ) + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_lists_whatsapp_legacy_lid_owner_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "whatsapp", + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + chat_type="dm", + ) + db.create_session("legacy_winton", "whatsapp", user_id="219842296701033@lid") + db.set_session_title("legacy_winton", "Winton Email Sheet Update") + db.create_session("other_winton", "whatsapp", user_id="other@lid") + db.set_session_title("other_winton", "Winton Other User") + + event = _make_event( + text="/sessions search winton", + platform=Platform.WHATSAPP, + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + ) + event.source.chat_type = "dm" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=event, + ) + + result = await runner._handle_sessions_command(event) + + assert "Winton Email Sheet Update" in result + assert "legacy_winton" in result + assert "Winton Other User" not in result + db.close() + + @pytest.mark.asyncio + async def test_resume_number_uses_whatsapp_legacy_lid_owner_search_result(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "whatsapp", + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + chat_type="dm", + ) + db.create_session("legacy_winton", "whatsapp", user_id="219842296701033@lid") + db.set_session_title("legacy_winton", "Winton Email Sheet Update") + db.append_message("legacy_winton", "user", "old WhatsApp prompt") + search_event = _make_event( + text="/sessions search winton", + platform=Platform.WHATSAPP, + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + ) + search_event.source.chat_type = "dm" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=search_event, + ) + + search_result = await runner._handle_sessions_command(search_event) + resume_result = await runner._handle_resume_command( + _make_event( + text="/resume 1", + platform=Platform.WHATSAPP, + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + ) + ) + + assert "Winton Email Sheet Update" in search_result + assert "Winton Email Sheet Update" in resume_result + runner.session_store.switch_session.assert_called_with( + _session_key_for_event(search_event), + "legacy_winton", + ) + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_lists_whatsapp_compressed_legacy_owner_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "whatsapp", + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + chat_type="dm", + ) + db.create_session("winton_root", "whatsapp", user_id="219842296701033@lid") + db.set_session_title("winton_root", "Winton Email Sheet Update") + db.end_session("winton_root", "compression") + db.create_session("winton_mid", "whatsapp", parent_session_id="winton_root") + db.set_session_title("winton_mid", "Winton Email Sheet Update #2") + db.end_session("winton_mid", "compression") + db.create_session("winton_tip", "whatsapp", parent_session_id="winton_mid") + db.set_session_title("winton_tip", "Winton Email Sheet Update #3") + db.append_message("winton_tip", "user", "continued prompt") + search_event = _make_event( + text="/sessions search winton", + platform=Platform.WHATSAPP, + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + ) + search_event.source.chat_type = "dm" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=search_event, + ) + + search_result = await runner._handle_sessions_command(search_event) + resume_result = await runner._handle_resume_command( + _make_event( + text="/resume 1", + platform=Platform.WHATSAPP, + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + ) + ) + + assert "Winton Email Sheet Update #3" in search_result + assert "Winton Email Sheet Update #3" in resume_result + runner.session_store.switch_session.assert_called_with( + _session_key_for_event(search_event), + "winton_tip", + ) + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_lists_thread_legacy_content_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "telegram", + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + db.create_session("legacy_cod", "telegram", user_id="12345") + db.set_session_title("legacy_cod", "AN-94 Prestige Barrel Build") + db.append_message("legacy_cod", "user", "best cod loadout") + db.create_session("other_legacy_cod", "telegram", user_id="99999") + db.set_session_title("other_legacy_cod", "Other AN-94") + db.append_message("other_legacy_cod", "user", "best cod loadout") + + event = _make_event(text="/sessions search cod", user_id="12345", chat_id="group-a") + event.source.chat_type = "group" + event.source.thread_id = "18" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=event, + ) + + result = await runner._handle_sessions_command(event) + + assert "AN-94 Prestige Barrel Build" in result + assert "legacy_cod" in result + assert "other_legacy_cod" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_ranks_relevant_content_above_weak_title_match(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "telegram", + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + db.create_session("legacy_loadout", "telegram", user_id="12345") + db.set_session_title("legacy_loadout", "AN-94 Prestige Barrel Build") + for _ in range(5): + db.append_message("legacy_loadout", "user", "cod loadout attachments") + db.create_session( + "recent_codex", + "telegram", + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + db.set_session_title("recent_codex", "Codex Usage Status") + + event = _make_event(text="/sessions search cod", user_id="12345", chat_id="group-a") + event.source.chat_type = "group" + event.source.thread_id = "18" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=event, + ) + + result = await runner._handle_sessions_command(event) + + assert result.index("AN-94 Prestige Barrel Build") < result.index("Codex Usage Status") + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_matches_punctuation_normalized_title(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "telegram", + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + db.create_session("legacy_an94", "telegram", user_id="12345") + db.set_session_title("legacy_an94", "AN-94 Prestige Barrel Build") + + event = _make_event(text="/sessions search an94", user_id="12345", chat_id="group-a") + event.source.chat_type = "group" + event.source.thread_id = "18" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=event, + ) + + result = await runner._handle_sessions_command(event) + + assert "AN-94 Prestige Barrel Build" in result + assert "legacy_an94" in result + db.close() + + @pytest.mark.asyncio + async def test_resume_number_uses_thread_legacy_content_search_result(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session( + "current_session_001", + "telegram", + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + db.create_session("legacy_cod", "telegram", user_id="12345") + db.set_session_title("legacy_cod", "AN-94 Prestige Barrel Build") + db.append_message("legacy_cod", "user", "best cod loadout") + search_event = _make_event(text="/sessions search cod", user_id="12345", chat_id="group-a") + search_event.source.chat_type = "group" + search_event.source.thread_id = "18" + runner = _make_runner( + session_db=db, + current_session_id="current_session_001", + event=search_event, + ) + + search_result = await runner._handle_sessions_command(search_event) + resume_event = _make_event(text="/resume 1", user_id="12345", chat_id="group-a") + resume_event.source.chat_type = "group" + resume_event.source.thread_id = "18" + resume_result = await runner._handle_resume_command(resume_event) + + assert "AN-94 Prestige Barrel Build" in search_result + assert "AN-94 Prestige Barrel Build" in resume_result + runner.session_store.switch_session.assert_called_with( + _session_key_for_event(search_event), + "legacy_cod", + ) + db.close() + + @pytest.mark.asyncio + async def test_resume_allows_compression_tip_when_parent_proves_legacy_thread_owner(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("root_an94", "telegram", user_id="12345") + db.set_session_title("root_an94", "AN-94 Prestige Barrel Build") + db.end_session("root_an94", "compression") + db.create_session("tip_an94", "telegram", parent_session_id="root_an94") + db.set_session_title("tip_an94", "AN-94 Prestige Barrel Build #2") + runner = _make_runner(session_db=db) + runner._gateway_session_origin_for_id = lambda sid: None + caller = SessionSource( + platform=Platform.TELEGRAM, + user_id="12345", + chat_id="group-a", + chat_type="group", + thread_id="18", + ) + + assert await runner._resume_target_allowed(caller, "tip_an94", allow_override=False) is True + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_finds_old_visible_match_after_hidden_matches(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("owned_old_match", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("owned_old_match", "Billing Mine") + db.append_message("owned_old_match", "user", "old visible prompt") + for idx in range(45): + session_id = f"victim_match_{idx:02d}" + db.create_session(session_id, "telegram", user_id="99999", chat_id="other-chat") + db.set_session_title(session_id, f"Billing Hidden {idx:02d}") + db.append_message(session_id, "user", "newer hidden prompt") + + event = _make_event(text="/sessions search billing") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "Billing Mine" in result + assert "owned_old_match" in result + assert "Billing Hidden" not in result + db.close() + + @pytest.mark.asyncio + async def test_resume_number_uses_latest_sessions_search_results(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("owned_old_match", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("owned_old_match", "Billing Mine") + db.append_message("owned_old_match", "user", "old visible prompt") + for idx in range(45): + session_id = f"victim_match_{idx:02d}" + db.create_session(session_id, "telegram", user_id="99999", chat_id="other-chat") + db.set_session_title(session_id, f"Billing Hidden {idx:02d}") + db.append_message(session_id, "user", "newer hidden prompt") + + search_event = _make_event(text="/sessions search billing") + runner = _make_runner(session_db=db, event=search_event) + + search_result = await runner._handle_sessions_command(search_event) + resume_result = await runner._handle_resume_command(_make_event(text="/resume 1")) + + assert "Billing Mine" in search_result + assert "Billing Mine" in resume_result + runner.session_store.switch_session.assert_called_with( + _session_key_for_event(search_event), + "owned_old_match", + ) + db.close() + + @pytest.mark.asyncio + async def test_sessions_all_search_admin_uses_cross_origin_scope(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + db.create_session("owned_match", "telegram", user_id="12345", chat_id="67890") + db.set_session_title("owned_match", "Billing Mine") + db.create_session("discord_match", "discord") + db.set_session_title("discord_match", "Billing Discord") + db.create_session("tool_match", "tool") + db.set_session_title("tool_match", "Billing Tool") + + event = _make_event(text="/sessions all search billing") + runner = _make_runner(session_db=db, event=event) + runner._resume_caller_is_admin = lambda _source: True + + result = await runner._handle_sessions_command(event) + + assert "Billing Mine" in result + assert "Billing Discord" in result + assert "discord_match" in result + assert "`telegram`" in result + assert "`discord`" in result + assert "tool_match" not in result + assert "Billing Tool" not in result + db.close() + + @pytest.mark.asyncio + async def test_sessions_search_requires_query(self, tmp_path): + from hermes_state import SessionDB + db = SessionDB(db_path=tmp_path / "state.db") + + event = _make_event(text="/sessions search") + runner = _make_runner(session_db=db, event=event) + + result = await runner._handle_sessions_command(event) + + assert "/sessions search <query>" in result + runner.session_store.switch_session.assert_not_called() + db.close() + @pytest.mark.asyncio async def test_resume_blocks_cross_user_and_unowned_rows(self, tmp_path): """An identity-bearing caller cannot resume a session it can't prove it diff --git a/tests/hermes_cli/test_session_listing.py b/tests/hermes_cli/test_session_listing.py new file mode 100644 index 0000000000000..ff4d627ef0bfe --- /dev/null +++ b/tests/hermes_cli/test_session_listing.py @@ -0,0 +1,80 @@ +from hermes_cli.session_listing import ( + parse_session_listing_args, + parse_session_listing_request, + query_session_listing, +) + + +class FakeSessionDB: + def __init__(self, rows): + self.rows = rows + self.calls = [] + + def list_sessions_rich(self, **kwargs): + self.calls.append(kwargs) + offset = kwargs.get("offset", 0) + return self.rows[offset: offset + kwargs["limit"]] + + +def test_parse_session_search_request(): + parsed = parse_session_listing_request("all full search Billing Audit") + + assert parsed.include_all_sources is True + assert parsed.include_unnamed is True + assert parsed.search_query == "Billing Audit" + assert parsed.target == "" + + +def test_parse_session_search_request_requires_query(): + parsed = parse_session_listing_request("search") + + assert parsed.search_requested is True + assert parsed.search_query == "" + assert parsed.target == "" + + +def test_legacy_session_arg_parser_preserves_target_behavior(): + assert parse_session_listing_args("Existing Title") == ( + False, + False, + "Existing Title", + ) + + +def test_query_session_listing_filters_title_and_id_case_insensitively(): + db = FakeSessionDB( + [ + {"id": "abc-001", "title": "Billing Audit"}, + {"id": "release-ABC", "title": "Release Notes"}, + {"id": "other", "title": "Roadmap"}, + ] + ) + + rows = query_session_listing( + db, + source="telegram", + search_query="abc", + limit=10, + ) + + assert [row["id"] for row in rows] == ["abc-001", "release-ABC"] + assert db.calls[0]["search_query"] == "abc" + + +def test_query_session_listing_includes_unnamed_id_matches_for_search(): + db = FakeSessionDB( + [ + {"id": "untitled-match-123", "title": None}, + {"id": "named-miss", "title": "Named Session"}, + ] + ) + + rows = query_session_listing( + db, + source="telegram", + search_query="match-123", + include_unnamed=False, + limit=10, + ) + + assert [row["id"] for row in rows] == ["untitled-match-123"] diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index eb884129bc56f..f542d263e2542 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -3273,6 +3273,120 @@ def test_rich_list_source_filter(self, db): assert len(sessions) == 1 assert sessions[0]["id"] == "s1" + def test_rich_list_gateway_origin_filters(self, db): + db.create_session("same", "telegram", user_id="u1", chat_id="c1", thread_id="t1") + db.create_session("other-user", "telegram", user_id="u2", chat_id="c1", thread_id="t1") + db.create_session("other-chat", "telegram", user_id="u1", chat_id="c2", thread_id="t1") + db.create_session("other-thread", "telegram", user_id="u1", chat_id="c1", thread_id="t2") + + sessions = db.list_sessions_rich( + source="telegram", + user_id="u1", + chat_id="c1", + thread_id="t1", + ) + + assert [session["id"] for session in sessions] == ["same"] + + def test_rich_list_gateway_dm_filters_can_include_legacy_blank_chat(self, db): + db.create_session("same-chat", "telegram", user_id="u1", chat_id="c1", chat_type="dm") + db.create_session("legacy-same-user", "telegram", user_id="u1") + db.create_session("other-user", "telegram", user_id="u2") + db.create_session("group-chat", "telegram", user_id="u1", chat_id="c1", chat_type="group") + + sessions = db.list_sessions_rich( + source="telegram", + user_id="u1", + chat_id="c1", + chat_id_allow_empty=True, + chat_types=["", "dm", "direct", "private"], + ) + + assert [session["id"] for session in sessions] == [ + "legacy-same-user", + "same-chat", + ] + + def test_rich_list_gateway_filters_can_match_alternate_user_ids(self, db): + db.create_session( + "current-whatsapp", + "whatsapp", + user_id="27618000020@s.whatsapp.net", + chat_id="219842296701033@lid", + chat_type="dm", + ) + db.create_session("legacy-whatsapp", "whatsapp", user_id="219842296701033@lid") + db.create_session("other-whatsapp", "whatsapp", user_id="other@lid") + + sessions = db.list_sessions_rich( + source="whatsapp", + user_ids=["27618000020@s.whatsapp.net", "219842296701033@lid"], + chat_id="219842296701033@lid", + chat_id_allow_empty=True, + chat_types=["", "dm", "direct", "private"], + ) + + assert [session["id"] for session in sessions] == [ + "legacy-whatsapp", + "current-whatsapp", + ] + + def test_rich_list_gateway_thread_filters_can_include_legacy_blank_thread(self, db): + db.create_session("same-thread", "telegram", user_id="u1", chat_id="c1", thread_id="t1") + db.create_session("legacy-thread", "telegram", user_id="u1") + db.create_session("other-thread", "telegram", user_id="u1", chat_id="c1", thread_id="t2") + + sessions = db.list_sessions_rich( + source="telegram", + user_id="u1", + chat_id="c1", + chat_id_allow_empty=True, + thread_id="t1", + thread_id_allow_empty=True, + ) + + assert [session["id"] for session in sessions] == [ + "legacy-thread", + "same-thread", + ] + + def test_rich_list_search_can_match_message_content_when_requested(self, db): + db.create_session("title-miss", "telegram", user_id="u1") + db.set_session_title("title-miss", "AN-94 Prestige Barrel Build") + db.append_message("title-miss", role="user", content="best cod loadout") + db.create_session("title-only", "telegram", user_id="u1") + db.set_session_title("title-only", "Codex Usage Tracker") + + title_only = db.list_sessions_rich( + source="telegram", + user_id="u1", + order_by_last_active=True, + search_query="cod", + ) + with_content = db.list_sessions_rich( + source="telegram", + user_id="u1", + order_by_last_active=True, + search_query="cod", + search_message_content=True, + ) + + assert [session["id"] for session in title_only] == ["title-only"] + assert {session["id"] for session in with_content} == {"title-miss", "title-only"} + + def test_rich_list_search_matches_punctuation_normalized_title(self, db): + db.create_session("title-match", "telegram", user_id="u1") + db.set_session_title("title-match", "AN-94 Prestige Barrel Build") + + matches = db.list_sessions_rich( + source="telegram", + user_id="u1", + order_by_last_active=True, + search_query="an94", + ) + + assert [session["id"] for session in matches] == ["title-match"] + def test_rich_list_cwd_prefix_filter(self, db): db.create_session("s1", "cli", cwd="/repo") db.create_session("s2", "cli", cwd="/repo/subdir") @@ -4662,6 +4776,27 @@ def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db): assert [s["id"] for s in matches] == [tip] assert matches[0]["_lineage_root_id"] == root + def test_list_sessions_rich_search_matches_projected_tip_title(self, db): + root = "20260602_235959_root_title" + tip = "20260603_010000_tip_title" + db.create_session(session_id=root, source="cli") + db.set_session_title(root, "Original Topic") + db.append_message(root, role="user", content="root conversation") + db.end_session(root, "compression") + db.create_session(session_id=tip, source="cli", parent_session_id=root) + db.set_session_title(tip, "Billing Followup") + db.append_message(tip, role="user", content="continued conversation") + + matches = db.list_sessions_rich( + source="cli", + order_by_last_active=True, + search_query="followup", + ) + + assert [s["id"] for s in matches] == [tip] + assert matches[0]["title"] == "Billing Followup" + assert matches[0]["_lineage_root_id"] == root + class TestListCronJobRuns: """``list_cron_job_runs`` powers the desktop cron run-history endpoint.