diff --git a/CHANGELOG.md b/CHANGELOG.md index ae291eb7..e0192ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -380,6 +380,9 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co - **`popstate` handler refuses to switch sessions mid-stream** — Opus pre-release follow-up. Mirrors the same `S.busy` guard the cross-tab storage handler had. A user mid-stream who absent-mindedly hits browser Back used to lose their active turn (PR #1392 introduced the popstate listener without the guard). Now shows a toast and stays on the current session. 1 regression test in `test_v050254_opus_followups.py`. (`static/sessions.js`) +### Added +- **Messaging sessions get a WebUI handoff path without exposing every raw channel segment** — Weixin and Telegram sessions imported from Hermes Agent are now treated as messaging-source conversations: sidebar results keep only the latest visible session per channel, preserve source metadata through compact/import paths, and avoid destructive/duplicating menu actions that would imply WebUI owns the external channel history. Messaging sessions with enough external conversation rounds show a composer-docked handoff prompt; clicking it generates a transcript card summary for the user without inserting a fake command bubble. This is PR2 for the #1013 channel-handoff direction and intentionally does not cover the separate CLI Session follow-up. (`api/models.py`, `api/routes.py`, `static/index.html`, `static/messages.js`, `static/sessions.js`, `static/style.css`, `static/ui.js`, `tests/test_gateway_sync.py`, `tests/test_issue1013_handoff_dock.py`) @franksong2702 — refs #1013 + ## [v0.50.253] — 2026-05-01 ### Added diff --git a/api/models.py b/api/models.py index 07de6a43..90a2ce06 100644 --- a/api/models.py +++ b/api/models.py @@ -355,6 +355,7 @@ class Session: self.parent_session_id = parent_session_id self.is_cli_session = bool(kwargs.get('is_cli_session', False)) self.source_tag = kwargs.get('source_tag') + self.raw_source = kwargs.get('raw_source') self.session_source = kwargs.get('session_source') self.source_label = kwargs.get('source_label') self.enabled_toolsets = enabled_toolsets # List[str] or None — per-session toolset override @@ -379,7 +380,7 @@ class Session: 'compression_anchor_visible_idx', 'compression_anchor_message_key', 'context_length', 'threshold_tokens', 'last_prompt_tokens', 'parent_session_id', - 'is_cli_session', 'source_tag', 'session_source', 'source_label', + 'is_cli_session', 'source_tag', 'raw_source', 'session_source', 'source_label', 'enabled_toolsets', ] meta = {k: getattr(self, k, None) for k in METADATA_FIELDS} @@ -483,6 +484,7 @@ class Session: 'pending_user_message': self.pending_user_message, 'is_cli_session': self.is_cli_session, 'source_tag': self.source_tag, + 'raw_source': self.raw_source, 'session_source': self.session_source, 'source_label': self.source_label, 'enabled_toolsets': self.enabled_toolsets, @@ -1133,6 +1135,95 @@ def get_cli_session_messages(sid) -> list: return msgs +def count_conversation_rounds(sid: str, since: float | None = None) -> int: + """Count conversation rounds for a session from state.db. + + A "round" = one user message + one agent reply. Consecutive user + messages are merged into a single round so that multi-part questions + don't inflate the count. + + Parameters + ---------- + sid : str + Gateway session ID (e.g. ``20260430_151231_7209a0``). + since : float | None + Unix timestamp. If provided, only messages **after** this + timestamp are counted. + + Returns + ------- + int + Number of complete conversation rounds. + """ + import os, sqlite3, datetime + + try: + from api.profiles import get_active_hermes_home + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve() + db_path = hermes_home / 'state.db' + if not db_path.exists(): + return 0 + + try: + with sqlite3.connect(str(db_path)) as conn: + conn.row_factory = sqlite3.Row + cur = conn.cursor() + cur.execute( + "SELECT role, timestamp FROM messages WHERE session_id = ? ORDER BY timestamp ASC", + (sid,), + ) + rows = cur.fetchall() + except Exception: + return 0 + + rounds = 0 + seen_user = False # have we seen a user msg in the current round? + seen_agent_after_user = False # have we seen an agent reply after that user msg? + + for row in rows: + role = (row['role'] or '').strip().lower() + ts_raw = row['timestamp'] + + # Parse timestamp and apply the ``since`` filter. + if since is not None and ts_raw is not None: + try: + if isinstance(ts_raw, (int, float)): + ts_val = float(ts_raw) + else: + # ISO-8601 string + ts_val = datetime.datetime.fromisoformat( + str(ts_raw).replace('Z', '+00:00') + ).timestamp() + if ts_val <= since: + continue + except Exception: + pass + + if role == 'user': + if seen_user and not seen_agent_after_user: + # Consecutive user message — merge into current round. + pass + elif seen_user and seen_agent_after_user: + # Previous round completed, starting a new one. + rounds += 1 + seen_agent_after_user = False + seen_user = True + elif role == 'assistant': + if seen_user: + seen_agent_after_user = True + + # Close the last round if it was completed. + if seen_user and seen_agent_after_user: + rounds += 1 + + return rounds + + +CONVERSATION_ROUND_THRESHOLD = 10 + + def delete_cli_session(sid) -> bool: """Delete a CLI session from state.db (messages + session row). Returns True if deleted, False if not found or error. diff --git a/api/routes.py b/api/routes.py index a388058e..e3d75158 100644 --- a/api/routes.py +++ b/api/routes.py @@ -9,6 +9,7 @@ import json import logging import os import queue +import re import shutil import sys import threading @@ -16,6 +17,7 @@ import time import uuid from pathlib import Path from urllib.parse import parse_qs +from api.agent_sessions import MESSAGING_SOURCES logger = logging.getLogger(__name__) @@ -38,6 +40,88 @@ _RUNNING_CRON_JOBS: dict[str, float] = {} # job_id → start_timestamp _RUNNING_CRON_LOCK = threading.Lock() _CRON_OUTPUT_CONTENT_LIMIT = 8000 _CRON_OUTPUT_HEADER_CONTEXT = 200 +_MESSAGING_RAW_SOURCES = {str(s).strip().lower() for s in MESSAGING_SOURCES} +_MESSAGING_SESSION_METADATA_CACHE: dict[str, object] = { + "path": None, + "mtime": None, + "identity": {}, +} +_MESSAGING_SESSION_METADATA_LOCK = threading.Lock() +_STALE_MESSAGING_END_REASONS = {"session_reset", "session_switch"} + + +def _normalize_messaging_source(raw_source) -> str: + return str(raw_source or "").strip().lower() + + +def _is_known_messaging_source(raw_source) -> bool: + return _normalize_messaging_source(raw_source) in _MESSAGING_RAW_SOURCES + + +def _safe_first(*values): + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + +def _gateway_session_metadata_path(): + try: + from api.profiles import get_active_hermes_home + hermes_home = Path(get_active_hermes_home()).expanduser().resolve() + except Exception: + hermes_home = Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser().resolve() + return hermes_home / "sessions" / "sessions.json" + + +def _load_gateway_session_identity_map() -> dict[str, dict]: + path = _gateway_session_metadata_path() + if not path.exists(): + return {} + + try: + st = path.stat() + cache = _MESSAGING_SESSION_METADATA_CACHE + with _MESSAGING_SESSION_METADATA_LOCK: + if cache["path"] == str(path) and cache["mtime"] == st.st_mtime: + return cache["identity"].copy() + except Exception: + return {} + + try: + raw_sessions = json.loads(path.read_text(encoding="utf-8")) + except Exception as _json_err: + logger.debug("Failed to parse gateway sessions metadata from %s: %s", path, _json_err) + return {} + + mapping: dict[str, dict] = {} + if isinstance(raw_sessions, dict): + for _entry in raw_sessions.values(): + if not isinstance(_entry, dict): + continue + session_id = _safe_first(_entry.get("session_id")) + if not session_id: + continue + origin = _entry.get("origin") if isinstance(_entry.get("origin"), dict) else {} + platform = _safe_first(origin.get("platform"), _entry.get("platform")) + mapping[session_id] = { + "session_key": _safe_first(_entry.get("session_key"), _entry.get("key")), + "chat_id": _safe_first(origin.get("chat_id"), _entry.get("chat_id")), + "thread_id": _safe_first(origin.get("thread_id"), _entry.get("thread_id")), + "chat_type": _safe_first(origin.get("chat_type"), _entry.get("chat_type")), + "user_id": _safe_first(origin.get("user_id"), _entry.get("user_id")), + "platform": platform, + "raw_source": platform, + } + + with _MESSAGING_SESSION_METADATA_LOCK: + _MESSAGING_SESSION_METADATA_CACHE["path"] = str(path) + _MESSAGING_SESSION_METADATA_CACHE["mtime"] = st.st_mtime + _MESSAGING_SESSION_METADATA_CACHE["identity"] = mapping + return mapping.copy() def _mark_cron_running(job_id: str): @@ -698,6 +782,275 @@ def _session_model_state_from_request( return model_value, provider +def _lookup_gateway_session_identity(session_id: str) -> dict: + if not session_id: + return {} + metadata = _load_gateway_session_identity_map().get(str(session_id)) + return metadata if isinstance(metadata, dict) else {} + + +def _lookup_cli_session_metadata(session_id: str) -> dict: + if not session_id: + return {} + try: + for row in get_cli_sessions(): + if row.get("session_id") == session_id: + return row + except Exception: + return {} + return {} + + +def _messaging_session_identity(session: dict, raw_source: str) -> str: + metadata = _lookup_gateway_session_identity(session.get("session_id")) + session_key = _safe_first( + metadata.get("session_key"), + session.get("session_key"), + session.get("gateway_session_key"), + ) + if session_key: + return f"{raw_source}|session_key:{session_key}" + + chat_id = _safe_first( + metadata.get("chat_id"), + session.get("chat_id"), + session.get("origin_chat_id"), + ) + thread_id = _safe_first(metadata.get("thread_id"), session.get("thread_id")) + chat_type = _safe_first(metadata.get("chat_type"), session.get("chat_type")) + user_id = _safe_first( + metadata.get("user_id"), + session.get("user_id"), + session.get("origin_user_id"), + ) + + identity_parts = [] + if chat_type: + identity_parts.append(f"chat_type:{chat_type}") + if chat_id: + identity_parts.append(f"chat_id:{chat_id}") + if thread_id: + identity_parts.append(f"thread_id:{thread_id}") + if user_id: + identity_parts.append(f"user_id:{user_id}") + + if identity_parts: + return f"{raw_source}|" + "|".join(identity_parts) + return raw_source + + +def _session_messaging_raw_source(session: dict) -> str: + raw = _safe_first( + session.get("raw_source"), + session.get("source_tag"), + session.get("source"), + session.get("platform"), + ) + if not raw: + raw = session.get("source_label") or "messaging" + return _normalize_messaging_source(raw) + + +def _has_durable_messaging_identity(session: dict) -> bool: + metadata = _lookup_gateway_session_identity(session.get("session_id")) + return bool(_safe_first( + metadata.get("session_key"), + session.get("session_key"), + session.get("gateway_session_key"), + metadata.get("chat_id"), + session.get("chat_id"), + session.get("origin_chat_id"), + metadata.get("thread_id"), + session.get("thread_id"), + )) + + +def _numeric_count(value) -> int: + try: + return int(float(_safe_first(value, 0) or 0)) + except (TypeError, ValueError): + return 0 + + +def _should_hide_stale_messaging_session( + session: dict, + active_gateway_session_ids: set[str], + active_gateway_sources: set[str], +) -> bool: + """Hide stale Gateway-owned internal rows after an external chat moved on. + + Hermes Gateway keeps the external conversation identity in sessions.json. + Compression/session-reset can leave old Agent state.db rows behind; those + rows are implementation segments, not distinct conversations users chose. + Only apply this aggressive hiding when Gateway is currently advertising an + active session for the same messaging source. Without that source-of-truth + file we keep the old fallback behavior. + """ + raw_source = _session_messaging_raw_source(session) + if not _is_known_messaging_source(raw_source): + return False + if not active_gateway_session_ids or raw_source not in active_gateway_sources: + return False + + sid = _safe_first(session.get("session_id")) + if sid and sid in active_gateway_session_ids: + return False + + if _safe_first(session.get("end_reason")) in _STALE_MESSAGING_END_REASONS: + return True + + if not _has_durable_messaging_identity(session): + return True + + if session.get("parent_session_id"): + return True + + message_count = _numeric_count(session.get("message_count")) + actual_count = _numeric_count(session.get("actual_message_count")) + if message_count <= 0 and actual_count <= 0: + return True + + return False + + +def _is_messaging_session_record(session) -> bool: + """Return true for sessions backed by external messaging channels.""" + if not session: + return False + if ( + (getattr(session, "session_source", None) if not isinstance(session, dict) else session.get("session_source")) == "messaging" + ): + return True + raw = _safe_first( + getattr(session, "raw_source", None) if not isinstance(session, dict) else session.get("raw_source"), + getattr(session, "source_tag", None) if not isinstance(session, dict) else session.get("source_tag"), + getattr(session, "source", None) if not isinstance(session, dict) else session.get("source"), + session.get("source_label") if isinstance(session, dict) else None, + ) + return _is_known_messaging_source(raw) + + +def _is_messaging_session_id(sid: str) -> bool: + """Detect messaging-backed sessions from WebUI metadata or Agent rows.""" + try: + session = Session.load(sid) + if _is_messaging_session_record(session): + return True + except Exception: + pass + return _is_messaging_session_record(_lookup_cli_session_metadata(sid)) + + +def _session_sort_timestamp(session: dict) -> float: + return float( + _safe_first( + session.get("last_message_at"), + session.get("updated_at"), + session.get("created_at"), + session.get("started_at"), + 0, + ) or 0 + ) or 0.0 + + +def _merge_cli_sidebar_metadata(ui_session: dict, cli_meta: dict) -> dict: + """Merge source-of-truth CLI metadata into a sidebar session row. + + Preserve UI-owned state (archived/pinned) while replacing metadata that can + legitimately drift in WebUI snapshots. + """ + if not ui_session: + return ui_session + if not cli_meta: + return dict(ui_session) + merged = dict(ui_session) + merged["is_cli_session"] = True + for key in ( + "source_tag", + "raw_source", + "session_source", + "source_label", + "user_id", + "chat_id", + "chat_type", + "thread_id", + "session_key", + "platform", + "parent_session_id", + "end_reason", + "actual_message_count", + "_lineage_root_id", + "_lineage_tip_id", + "_compression_segment_count", + ): + value = _safe_first(cli_meta.get(key)) + if value: + merged[key] = value + + if cli_meta.get("created_at") is not None: + merged["created_at"] = cli_meta["created_at"] + if cli_meta.get("updated_at") is not None: + merged["updated_at"] = cli_meta["updated_at"] + if cli_meta.get("last_message_at") is not None: + merged["last_message_at"] = cli_meta["last_message_at"] + if cli_meta.get("message_count") is not None: + merged["message_count"] = cli_meta["message_count"] + elif cli_meta.get("actual_message_count") is not None: + merged["message_count"] = cli_meta["actual_message_count"] + + if cli_meta.get("title"): + current_title = merged.get("title") + if not current_title or current_title == "Untitled": + merged["title"] = cli_meta["title"] + + if cli_meta.get("model"): + if not merged.get("model") or merged.get("model") == "unknown": + merged["model"] = cli_meta["model"] + return merged + + +def _messaging_source_key(session: dict) -> str | None: + raw = _session_messaging_raw_source(session) + if not _is_known_messaging_source(raw): + return None + return _messaging_session_identity(session, raw) + + +def _keep_latest_messaging_session_per_source(sessions: list[dict]) -> list[dict]: + """Keep only the newest sidebar row per messaging session identity.""" + gateway_metadata = _load_gateway_session_identity_map() + active_gateway_session_ids = {str(sid) for sid in gateway_metadata.keys() if sid} + active_gateway_sources = { + _normalize_messaging_source(_safe_first(meta.get("raw_source"), meta.get("platform"))) + for meta in gateway_metadata.values() + if isinstance(meta, dict) + } + active_gateway_sources = {source for source in active_gateway_sources if _is_known_messaging_source(source)} + + kept_sources: set[str] = set() + best_by_source: dict[str, dict] = {} + kept: list[dict] = [] + for session in sessions: + key = _messaging_source_key(session) + if not key: + kept.append(session) + continue + if _should_hide_stale_messaging_session(session, active_gateway_session_ids, active_gateway_sources): + continue + if key in kept_sources: + kept_sources.add(key) + current = best_by_source.get(key) + if current is None or _session_sort_timestamp(session) > _session_sort_timestamp(current): + best_by_source[key] = session + continue + kept_sources.add(key) + best_by_source[key] = session + + kept.extend(best_by_source.values()) + kept.sort(key=_session_sort_timestamp, reverse=True) + return kept + + from api.models import ( Session, get_session, @@ -1497,6 +1850,16 @@ def handle_get(handler, parsed) -> bool: settings = load_settings() if settings.get("show_cli_sessions"): cli = get_cli_sessions() + cli_by_id = {s["session_id"]: s for s in cli} + for s in webui_sessions: + if not s.get("is_cli_session"): + continue + meta = cli_by_id.get(s.get("session_id")) + if not meta: + continue + for key in ("source_tag", "raw_source", "session_source", "source_label"): + if not s.get(key) and meta.get(key): + s[key] = meta[key] webui_ids = {s["session_id"] for s in webui_sessions} from api.models import _hide_from_default_sidebar as _cron_hide deduped_cli = [s for s in cli @@ -1509,6 +1872,7 @@ def handle_get(handler, parsed) -> bool: key=lambda s: s.get("last_message_at") or s.get("updated_at", 0) or 0, reverse=True, ) + merged = _keep_latest_messaging_session_per_source(merged) safe_merged = [] for s in merged: item = dict(s) @@ -2140,9 +2504,14 @@ def handle_post(handler, parsed) -> bool: return bad(handler, "session_id is required") if not all(c in '0123456789abcdefghijklmnopqrstuvwxyz_' for c in sid): return bad(handler, "Invalid session_id", 400) + is_messaging_session = _is_messaging_session_id(sid) # Delete from WebUI session store with LOCK: SESSIONS.pop(sid, None) + try: + SESSION_INDEX_FILE.unlink(missing_ok=True) + except Exception: + logger.debug("Failed to unlink session index") # Evict cached agent so turn count doesn't leak into a recycled session from api.config import _evict_session_agent _evict_session_agent(sid) @@ -2159,22 +2528,20 @@ def handle_post(handler, parsed) -> bool: # Lock entries in SESSION_AGENT_LOCKS forever. with SESSION_AGENT_LOCKS_LOCK: SESSION_AGENT_LOCKS.pop(sid, None) - try: - SESSION_INDEX_FILE.unlink(missing_ok=True) - except Exception: - logger.debug("Failed to unlink session index") try: from api.terminal import close_terminal close_terminal(sid) except Exception: logger.debug("Failed to close workspace terminal for deleted session %s", sid) - # Also delete from CLI state.db (for CLI sessions shown in sidebar) - try: - from api.models import delete_cli_session + # Also delete from CLI state.db for CLI sessions shown in sidebar, + # but never erase external messaging channel memory via WebUI delete. + if not is_messaging_session: + try: + from api.models import delete_cli_session - delete_cli_session(sid) - except Exception: - logger.debug("Failed to delete CLI session %s", sid) + delete_cli_session(sid) + except Exception: + logger.debug("Failed to delete CLI session %s", sid) return j(handler, {"ok": True}) if parsed.path == "/api/session/clear": @@ -2293,6 +2660,12 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/session/compress": return _handle_session_compress(handler, body) + if parsed.path == "/api/session/conversation-rounds": + return _handle_conversation_rounds(handler, body) + + if parsed.path == "/api/session/handoff-summary": + return _handle_handoff_summary(handler, body) + if parsed.path == "/api/session/retry": try: require(body, "session_id") @@ -2659,13 +3032,33 @@ def handle_post(handler, parsed) -> bool: require(body, "session_id") except ValueError as e: return bad(handler, str(e)) + sid = body["session_id"] try: - s = get_session(body["session_id"]) + s = get_session(sid) except KeyError: - return bad(handler, "Session not found", 404) - with _get_session_agent_lock(body["session_id"]): + if not _is_messaging_session_id(sid): + return bad(handler, "Session not found", 404) + msgs = get_cli_session_messages(sid) + if not msgs: + return bad(handler, "Session not found", 404) + cli_meta = next((cs for cs in get_cli_sessions() if cs["session_id"] == sid), {}) + s = import_cli_session( + sid, + cli_meta.get("title") or title_from(msgs, "CLI Session"), + msgs, + cli_meta.get("model") or "unknown", + profile=cli_meta.get("profile"), + created_at=cli_meta.get("created_at"), + updated_at=cli_meta.get("updated_at"), + ) + s.is_cli_session = True + s.source_tag = cli_meta.get("source_tag") + s.raw_source = cli_meta.get("raw_source") or cli_meta.get("source_tag") + s.session_source = cli_meta.get("session_source") + s.source_label = cli_meta.get("source_label") + with _get_session_agent_lock(sid): s.archived = bool(body.get("archived", True)) - s.save() + s.save(touch_updated_at=False) return j(handler, {"ok": True, "session": s.compact()}) # ── Session move to project (POST) ── @@ -5148,6 +5541,292 @@ def _handle_session_compress(handler, body): return bad(handler, f"Compression failed: {_sanitize_error(e)}") +def _handle_conversation_rounds(handler, body): + """Return conversation-round count for a gateway session. + + Request body:: + + { "session_id": "...", "since": } + + Response:: + + { "ok": true, "rounds": 12, "threshold": 10, "should_show": true } + """ + try: + require(body, "session_id") + except ValueError as e: + return bad(handler, str(e)) + + sid = str(body.get("session_id") or "").strip() + if not sid: + return bad(handler, "session_id is required") + + since = body.get("since") + if since is not None: + try: + since = float(since) + except (TypeError, ValueError): + return bad(handler, "since must be a unix timestamp (number)") + + from api.models import count_conversation_rounds, CONVERSATION_ROUND_THRESHOLD + + rounds = count_conversation_rounds(sid, since=since) + return j(handler, { + "ok": True, + "rounds": rounds, + "threshold": CONVERSATION_ROUND_THRESHOLD, + "should_show": rounds >= CONVERSATION_ROUND_THRESHOLD, + }) + + +def _handle_handoff_summary(handler, body): + """Generate an on-demand handoff summary for a gateway session. + + Request body:: + + { "session_id": "...", "since": } + + Uses the session's configured model to produce a concise summary of + recent conversation activity. Returns the summary text so the caller + can display it in a tool-card. + """ + try: + require(body, "session_id") + except ValueError as e: + return bad(handler, str(e)) + + sid = str(body.get("session_id") or "").strip() + if not sid: + return bad(handler, "session_id is required") + + since = body.get("since") + if since is not None: + try: + since = float(since) + except (TypeError, ValueError): + return bad(handler, "since must be a unix timestamp (number)") + + from api.models import get_cli_session_messages, count_conversation_rounds, CONVERSATION_ROUND_THRESHOLD + + rounds = count_conversation_rounds(sid, since=since) + if rounds < CONVERSATION_ROUND_THRESHOLD: + return bad(handler, "Not enough conversation rounds to generate a summary.", 400) + + # Filter messages by ``since``. + all_msgs = get_cli_session_messages(sid) + if since is not None: + import datetime as _dt + filtered = [] + for m in all_msgs: + ts_raw = m.get("timestamp") + if ts_raw is None: + continue + try: + if isinstance(ts_raw, (int, float)): + ts_val = float(ts_raw) + else: + ts_val = _dt.datetime.fromisoformat( + str(ts_raw).replace("Z", "+00:00") + ).timestamp() + if ts_val > since: + filtered.append(m) + except Exception: + pass + msgs = filtered + else: + msgs = all_msgs + + # Cap to last 50 messages. + msgs = msgs[-50:] + + if len(msgs) < 2: + return bad(handler, "Not enough messages to summarize.", 400) + + # Build a lightweight conversation transcript for the LLM. + lines = [] + for m in msgs: + role = m.get("role", "") + content = m.get("content", "") + if isinstance(content, list): + content = " ".join( + str(p.get("text") or p.get("content") or "") + for p in content + if isinstance(p, dict) + ) + content = str(content or "").strip()[:1000] + if role in ("user", "assistant") and content: + label = "User" if role == "user" else "Agent" + lines.append(f"{label}: {content}") + transcript = "\n".join(lines) + + def _fallback_handoff_summary(items): + """Return a deterministic summary when LLM summary generation is unavailable.""" + recent = [] + for m in items: + role = m.get("role", "") + content = m.get("content", "") + if isinstance(content, list): + content = " ".join( + str(p.get("text") or p.get("content") or "") + for p in content + if isinstance(p, dict) + ) + content = " ".join(str(content or "").split()).strip() + if role in ("user", "assistant") and content: + label = "User" if role == "user" else "Agent" + recent.append(f"- {label}: {content[:180]}") + if not recent: + return "Recent external-channel messages were found, but no readable text was available." + return "Recent external-channel activity:\n" + "\n".join(recent[-6:]) + + def _agent_text_completion(agent, system_prompt, user_text, max_tokens=700): + """Use the current Hermes Agent transport without mutating conversation history.""" + api_messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_text}, + ] + disabled_reasoning = {"enabled": False} + previous_reasoning = getattr(agent, "reasoning_config", None) + try: + agent.reasoning_config = disabled_reasoning + if getattr(agent, "api_mode", "") == "codex_responses": + codex_kwargs = agent._build_api_kwargs(api_messages) + codex_kwargs.pop("tools", None) + codex_kwargs["max_output_tokens"] = max_tokens + resp = agent._run_codex_stream(codex_kwargs) + assistant_message, _ = agent._normalize_codex_response(resp) + return str((assistant_message.content or "") if assistant_message else "").strip() + + if getattr(agent, "api_mode", "") == "anthropic_messages": + from agent.anthropic_adapter import build_anthropic_kwargs, normalize_anthropic_response + + ant_kwargs = build_anthropic_kwargs( + model=agent.model, + messages=api_messages, + tools=None, + max_tokens=max_tokens, + reasoning_config=disabled_reasoning, + is_oauth=getattr(agent, "_is_anthropic_oauth", False), + preserve_dots=agent._anthropic_preserve_dots(), + base_url=getattr(agent, "_anthropic_base_url", None), + ) + resp = agent._anthropic_messages_create(ant_kwargs) + assistant_message, _ = normalize_anthropic_response( + resp, + strip_tool_prefix=getattr(agent, "_is_anthropic_oauth", False), + ) + return str((assistant_message.content or "") if assistant_message else "").strip() + + api_kwargs = agent._build_api_kwargs(api_messages) + api_kwargs.pop("tools", None) + api_kwargs["temperature"] = 0.2 + api_kwargs["timeout"] = 30.0 + if "max_completion_tokens" in api_kwargs: + api_kwargs["max_completion_tokens"] = max_tokens + else: + api_kwargs["max_tokens"] = max_tokens + resp = agent._ensure_primary_openai_client(reason="handoff_summary").chat.completions.create( + **api_kwargs, + ) + choice = (getattr(resp, "choices", None) or [None])[0] + msg = getattr(choice, "message", None) if choice is not None else None + return str(getattr(msg, "content", "") or "").strip() + finally: + agent.reasoning_config = previous_reasoning + + # Call LLM for summary. + try: + import api.config as _cfg + import hermes_cli.runtime_provider as _runtime_provider + import run_agent as _run_agent + + # Try to resolve model from an existing session, fall back to default. + resolved_model = None + resolved_provider = None + resolved_base_url = None + try: + from api.models import get_session + s_obj = get_session(sid) + resolved_model = getattr(s_obj, "model", None) + except Exception: + pass + + resolved_model, resolved_provider, resolved_base_url = _cfg.resolve_model_provider(resolved_model) + + resolved_api_key = None + try: + _rt = _runtime_provider.resolve_runtime_provider(requested=resolved_provider) + resolved_api_key = _rt.get("api_key") + if not resolved_provider: + resolved_provider = _rt.get("provider") + if not resolved_base_url: + resolved_base_url = _rt.get("base_url") + except Exception as _e: + logger.warning("resolve_runtime_provider failed for handoff summary: %s", _e) + + if not resolved_api_key: + return j(handler, { + "ok": True, + "summary": _fallback_handoff_summary(msgs), + "message_count": len(msgs), + "rounds": rounds, + "fallback": True, + }) + + agent = _run_agent.AIAgent( + model=resolved_model, + provider=resolved_provider, + base_url=resolved_base_url, + api_key=resolved_api_key, + platform="webui", + quiet_mode=True, + enabled_toolsets=[], + session_id=sid, + ) + + summary_system_prompt = ( + "You are summarizing a conversation that happened on an external channel " + "(WeChat/Telegram) so the user can quickly catch up when switching to Web UI.\n\n" + "Focus on:\n" + "- Unfinished tasks or action items\n" + "- Pending questions that need replies\n" + "- Key decisions made\n" + "- Open disagreements or TBD items\n\n" + "Keep it concise — 2-5 bullet points max. " + "If the conversation is purely casual with no actionable items, " + "say so in one sentence." + ) + summary_user_text = f"Conversation transcript:\n{transcript}" + + try: + summary_text = _agent_text_completion(agent, summary_system_prompt, summary_user_text) + finally: + try: + agent.release_clients() + except Exception: + pass + if not summary_text: + summary_text = _fallback_handoff_summary(msgs) + + return j(handler, { + "ok": True, + "summary": summary_text, + "message_count": len(msgs), + "rounds": rounds, + "fallback": summary_text.startswith("Recent external-channel activity:"), + }) + except Exception as e: + logger.warning("Handoff summary generation failed: %s", e) + return j(handler, { + "ok": True, + "summary": _fallback_handoff_summary(msgs), + "message_count": len(msgs), + "rounds": rounds, + "fallback": True, + "warning": f"Summary generation used local fallback: {_sanitize_error(e)}", + }) + + def _handle_skill_save(handler, body): try: require(body, "name", "content") @@ -5228,13 +5907,33 @@ def _handle_session_import_cli(handler, body): existing = Session.load(sid) if existing: fresh_msgs = get_cli_session_messages(sid) + changed = False + cli_meta = None + for cs in list(get_cli_sessions()): + if cs["session_id"] == sid: + cli_meta = cs + break if fresh_msgs and len(fresh_msgs) > len(existing.messages): # Prefix-equality guard: only extend if existing messages are a prefix of # the fresh CLI messages. Prevents silently dropping WebUI-added messages # on hybrid sessions (user sent messages via WebUI while CLI continued). if existing.messages == fresh_msgs[:len(existing.messages)]: existing.messages = fresh_msgs - existing.save(touch_updated_at=False) + changed = True + if cli_meta: + updates = { + "is_cli_session": True, + "source_tag": existing.source_tag or cli_meta.get("source_tag"), + "raw_source": existing.raw_source or cli_meta.get("raw_source") or cli_meta.get("source_tag"), + "session_source": existing.session_source or cli_meta.get("session_source"), + "source_label": existing.source_label or cli_meta.get("source_label"), + } + for attr, value in updates.items(): + if getattr(existing, attr, None) != value: + setattr(existing, attr, value) + changed = True + if changed: + existing.save(touch_updated_at=False) return j( handler, { @@ -5259,6 +5958,9 @@ def _handle_session_import_cli(handler, body): cli_title = None cli_source_tag = None model = "unknown" + cli_raw_source = None + cli_session_source = None + cli_source_label = None for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") @@ -5267,6 +5969,9 @@ def _handle_session_import_cli(handler, body): updated_at = cs.get("updated_at") cli_title = cs.get("title") cli_source_tag = cs.get("source_tag") + cli_raw_source = cs.get("raw_source") + cli_session_source = cs.get("session_source") + cli_source_label = cs.get("source_label") break # Use the CLI session title if available (e.g., cron job name), otherwise derive from messages @@ -5289,6 +5994,10 @@ def _handle_session_import_cli(handler, body): if cron_project_id: s.project_id = cron_project_id s.is_cli_session = True + s.source_tag = cli_source_tag + s.raw_source = cli_raw_source or cli_source_tag + s.session_source = cli_session_source + s.source_label = cli_source_label s._cli_origin = sid s.save(touch_updated_at=False) return j( diff --git a/static/index.html b/static/index.html index b972611f..287f5ba1 100644 --- a/static/index.html +++ b/static/index.html @@ -365,6 +365,7 @@ +
diff --git a/static/messages.js b/static/messages.js index 9708eabe..36cd47ad 100644 --- a/static/messages.js +++ b/static/messages.js @@ -44,6 +44,12 @@ async function send(){ if(!text&&!S.pendingFiles.length)return; // Don't send while an inline message edit is active if(document.querySelector('.msg-edit-area'))return; + + // Dismiss handoff hint when user sends a message (resets seen_at). + if(S.session&&S.session.session_id&&typeof _dismissHandoffHint==='function'){ + _dismissHandoffHint(S.session.session_id); + } + const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning(); // If busy or a manual compression is still running, handle based on busy_input_mode if(S.busy||compressionRunning){ diff --git a/static/sessions.js b/static/sessions.js index 0cc3ab2d..1d72759d 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -537,6 +537,230 @@ async function loadSession(sid){ _resolveSessionModelForDisplaySoon(sid); // Clear the in-flight session marker now that this load has completed (#1060). if (_loadingSessionId === sid) _loadingSessionId = null; + + // ── Cross-channel handoff hint ── + // After session fully loaded, check if this is a messaging session with + // enough conversation rounds to warrant a handoff hint bar. + if (S.session && _isMessagingSession(S.session)) { + _checkAndShowHandoffHint(sid); + } else { + _hideHandoffHint(); + } +} + +// ── Handoff hint logic ────────────────────────────────────────────────────── + +const _HANDOFF_THRESHOLD = 10; // conversation rounds +const _HANDOFF_STORAGE_PREFIX = 'handoff:'; + +function _isMessagingSession(session) { + if (!session) return false; + // session_source is set by PR #1294 source normalization + if (session.session_source === 'messaging') return true; + // Fallback: check raw_source directly + const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase(); + return ['weixin', 'telegram', 'discord', 'slack'].includes(raw); +} + +function _handoffStorageKey(sid) { + return _HANDOFF_STORAGE_PREFIX + sid + ':dismissed_at'; +} + +function _getHandoffDismissedAt(sid) { + try { + const val = localStorage.getItem(_handoffStorageKey(sid)); + return val ? parseFloat(val) : null; + } catch { return null; } +} + +function _setHandoffDismissedAt(sid, ts) { + try { + localStorage.setItem(_handoffStorageKey(sid), String(ts)); + } catch {} +} + +function _handoffMessagesEl() { + return document.getElementById('messages'); +} + +function _handoffIsMessagesNearBottom(el) { + if (!el) return false; + return el.scrollHeight - el.scrollTop - el.clientHeight < 150; +} + +function _syncHandoffDockSpace(open) { + const messages = _handoffMessagesEl(); + if (!messages) return; + const wasNearBottom = _handoffIsMessagesNearBottom(messages); + if (!open) { + messages.classList.remove('handoff-dock-visible'); + messages.style.removeProperty('--handoff-dock-height'); + if (wasNearBottom && typeof scrollToBottom === 'function') requestAnimationFrame(scrollToBottom); + return; + } + messages.classList.add('handoff-dock-visible'); + const measure = () => { + const container = $('handoffHintContainer'); + const h = container && container.getBoundingClientRect().height; + if (h > 0) messages.style.setProperty('--handoff-dock-height', Math.ceil(h + 24) + 'px'); + if (wasNearBottom && typeof scrollToBottom === 'function') scrollToBottom(); + }; + requestAnimationFrame(measure); + setTimeout(measure, 360); +} + +function _getChannelLabel(session) { + if (!session) return ''; + // Use source_label from PR #1294 if available + if (session.source_label) return session.source_label; + const raw = (session.raw_source || session.source_tag || session.source || '').toLowerCase(); + const labels = { weixin: 'WeChat', telegram: 'Telegram', discord: 'Discord', slack: 'Slack' }; + return labels[raw] || raw || ''; +} + +async function _checkAndShowHandoffHint(sid) { + try { + const since = _getHandoffDismissedAt(sid); + const body = { session_id: sid }; + if (since != null) body.since = since; + + const result = await api('/api/session/conversation-rounds', { + method: 'POST', + body: JSON.stringify(body), + }); + + // Stale? Session switched while we were fetching. + if (!S.session || S.session.session_id !== sid) return; + + if (result && result.ok && result.should_show) { + _showHandoffHint(sid, result.rounds); + } else { + _hideHandoffHint(); + } + } catch (e) { + console.warn('Handoff hint check failed:', e); + _hideHandoffHint(); + } +} + +function _showHandoffHint(sid, rounds) { + const container = $('handoffHintContainer'); + if (!container) return; + + // Clear any existing content. + container.innerHTML = ''; + container.style.display = ''; + container.classList.add('is-visible'); + + const channel = _getChannelLabel(S.session); + const hintText = channel + ? `${channel} has ${rounds} new conversation rounds — click to view summary` + : `${rounds} new conversation rounds — click to view summary`; + + const bar = document.createElement('div'); + bar.className = 'handoff-hint-bar'; + bar.id = 'handoffHintBar'; + bar.innerHTML = ` +
+ ${li('arrow-left', 18)} + ${esc(hintText)} +
+ + `; + + // Click on the bar (not the dismiss button) triggers summary generation. + bar.addEventListener('click', (e) => { + if (e.target.closest('.handoff-hint-dismiss')) return; + _generateHandoffSummary(sid, rounds); + }); + + container.appendChild(bar); + _syncHandoffDockSpace(true); +} + +function _hideHandoffHint() { + const container = $('handoffHintContainer'); + if (container) { + container.innerHTML = ''; + container.style.display = 'none'; + container.classList.remove('is-visible'); + } + _syncHandoffDockSpace(false); +} + +function _dismissHandoffHint(sid) { + _setHandoffDismissedAt(sid, Date.now() / 1000); + _hideHandoffHint(); +} + +async function _generateHandoffSummary(sid, rounds) { + // Treat handoff like a slash-command result: the composer dock entry + // disappears and the transient summary card renders in the transcript. + _hideHandoffHint(); + const channel = _getChannelLabel(S.session); + if (typeof setHandoffUi === 'function') { + setHandoffUi({ + sessionId: sid, + phase: 'running', + channel, + rounds, + }); + } + + try { + const since = _getHandoffDismissedAt(sid); + const body = { session_id: sid }; + if (since != null) body.since = since; + + const result = await api('/api/session/handoff-summary', { + method: 'POST', + body: JSON.stringify(body), + }); + + // Stale? + if (!S.session || S.session.session_id !== sid) return; + + if (result && result.ok && result.summary) { + const summaryText = result.summary; + if (typeof setHandoffUi === 'function') { + setHandoffUi({ + sessionId: sid, + phase: 'done', + channel, + rounds: result.rounds || rounds, + summary: summaryText, + fallback: !!result.fallback, + }); + } + } else { + if (typeof setHandoffUi === 'function') { + setHandoffUi({ + sessionId: sid, + phase: 'error', + channel, + rounds, + errorText: 'Could not generate summary. Please try again.', + }); + } + } + } catch (e) { + console.warn('Handoff summary failed:', e); + if (S.session && S.session.session_id === sid && typeof setHandoffUi === 'function') { + setHandoffUi({ + sessionId: sid, + phase: 'error', + channel, + rounds, + errorText: 'Summary generation failed: ' + e.message, + }); + } + } + + // Generating a summary should not dismiss the handoff entry point. Only the + // explicit X button suppresses it until enough newer external-channel rounds + // arrive. } function _resolveSessionModelForDisplaySoon(sid){ @@ -901,6 +1125,7 @@ function _openSessionActionMenu(session, anchorEl){ return; } closeSessionActionMenu(); + const isMessagingSession = _isMessagingSession(session); const menu=document.createElement('div'); menu.className='session-action-menu open'; menu.appendChild(_buildSessionAction( @@ -943,22 +1168,24 @@ function _openSessionActionMenu(session, anchorEl){ }catch(err){showToast(t('session_archive_failed')+err.message);} } )); - menu.appendChild(_buildSessionAction( - t('session_duplicate'), - t('session_duplicate_desc'), - ICONS.dup, - async()=>{ - closeSessionActionMenu(); - try{ - const res=await api('/api/session/duplicate',{method:'POST',body:JSON.stringify({session_id:session.session_id})}); - if(res.session){ - await loadSession(res.session.session_id); - await renderSessionList(); - showToast(t('session_duplicated')); - } - }catch(err){showToast(t('session_duplicate_failed')+err.message);} - } - )); + if(!isMessagingSession){ + menu.appendChild(_buildSessionAction( + t('session_duplicate'), + t('session_duplicate_desc'), + ICONS.dup, + async()=>{ + closeSessionActionMenu(); + try{ + const res=await api('/api/session/duplicate',{method:'POST',body:JSON.stringify({session_id:session.session_id})}); + if(res.session){ + await loadSession(res.session.session_id); + await renderSessionList(); + showToast(t('session_duplicated')); + } + }catch(err){showToast(t('session_duplicate_failed')+err.message);} + } + )); + } if(session.active_stream_id){ menu.appendChild(_buildSessionAction( t('session_stop_response'), @@ -971,16 +1198,18 @@ function _openSessionActionMenu(session, anchorEl){ } )); } - menu.appendChild(_buildSessionAction( - t('session_delete'), - t('session_delete_desc'), - ICONS.trash, - async()=>{ - closeSessionActionMenu(); - await deleteSession(session.session_id); - }, - 'danger' - )); + if(!isMessagingSession){ + menu.appendChild(_buildSessionAction( + t('session_delete'), + t('session_delete_desc'), + ICONS.trash, + async()=>{ + closeSessionActionMenu(); + await deleteSession(session.session_id); + }, + 'danger' + )); + } document.body.appendChild(menu); _sessionActionMenu = menu; _sessionActionAnchor = anchorEl; diff --git a/static/style.css b/static/style.css index 153ba8bb..2afe2348 100644 --- a/static/style.css +++ b/static/style.css @@ -559,6 +559,7 @@ /* Terminal flyout reserves transcript space so recent messages stay readable above it. */ .messages.terminal-open{padding-bottom:var(--terminal-card-height,320px);scroll-padding-bottom:var(--terminal-card-height,320px);transition:padding-bottom .26s cubic-bezier(.2,.8,.2,1);} .messages.terminal-collapsed{padding-bottom:var(--terminal-dock-height,72px);scroll-padding-bottom:var(--terminal-dock-height,72px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);} + .messages.handoff-dock-visible{padding-bottom:var(--handoff-dock-height,72px);scroll-padding-bottom:var(--handoff-dock-height,72px);transition:padding-bottom .22s cubic-bezier(.2,.8,.2,1);} .messages.terminal-expanding-from-dock{transition:none!important;} .queue-card-inner{background:var(--surface);border:1px solid var(--border);border-bottom:none;border-radius:14px 14px 0 0;contain:paint;transform:translateY(100%);opacity:0;transition:transform .35s cubic-bezier(.32,.72,.16,1),opacity .2s ease;overflow:hidden;max-height:240px;overflow-y:auto;padding-bottom:4px;} .queue-card.visible .queue-card-inner{transform:translateY(0);opacity:1;} @@ -1037,6 +1038,19 @@ .composer-terminal-dock[hidden]{display:none!important;} .composer-terminal-dock-title{min-width:0;display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;} .composer-terminal-dock-dot{width:7px;height:7px;border-radius:999px;background:var(--success);box-shadow:0 0 0 3px color-mix(in srgb,var(--success) 16%,transparent);flex:0 0 auto;} + + /* ── Handoff hint bar ── */ + .handoff-hint-container{position:absolute;left:0;right:0;bottom:-2px;width:min(calc(100% - 112px),560px);margin:0 auto;box-sizing:border-box;overflow:visible;pointer-events:none;z-index:3;} + .handoff-hint-container.is-visible{pointer-events:auto;} + .handoff-hint-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:42px;border:1px solid var(--border);border-radius:13px;background:color-mix(in srgb,var(--surface) 86%,transparent);box-shadow:0 8px 22px rgba(0,0,0,.16);backdrop-filter:blur(10px);padding:7px 9px 7px 12px;cursor:pointer;transform:translateY(100%);opacity:0;transition:transform .32s cubic-bezier(.32,.72,.16,1),opacity .2s ease,background .15s ease,border-color .15s ease;} + .handoff-hint-container.is-visible .handoff-hint-bar{transform:translateY(0);opacity:.94;} + .handoff-hint-bar:hover{background:color-mix(in srgb,var(--surface) 92%,transparent);border-color:color-mix(in srgb,var(--border) 70%,var(--accent));} + .handoff-hint-bar[hidden]{display:none!important;} + .handoff-hint-text{display:flex;align-items:center;gap:8px;min-width:0;font-size:13px;font-weight:500;color:var(--text);} + .handoff-hint-text span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} + .handoff-hint-icon{width:18px;height:18px;flex:0 0 auto;color:var(--accent);} + .handoff-hint-dismiss{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--muted);border-radius:8px;cursor:pointer;flex:0 0 auto;transition:background .15s ease,color .15s ease;} + .handoff-hint-dismiss:hover{background:color-mix(in srgb,var(--muted) 12%,transparent);color:var(--text);} #terminalDockWorkspaceLabel{min-width:0;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);text-transform:none;letter-spacing:0;font-weight:600;} .composer-terminal-resize-handle{height:12px;display:flex;align-items:center;justify-content:center;flex:0 0 auto;cursor:ns-resize;touch-action:none;background:linear-gradient(to bottom,rgba(255,255,255,.04),transparent);} .composer-terminal-resize-handle::before{content:"";width:52px;height:4px;border-radius:999px;background:var(--border2);opacity:.72;transition:opacity .15s,background .15s;} @@ -1318,6 +1332,7 @@ .ctx-tooltip{right:-4px;min-width:190px;max-width:220px;} .composer-terminal-panel{width:calc(100% - 20px);} .composer-terminal-panel.is-collapsed{bottom:-1px;width:calc(100% - 28px);} + .handoff-hint-container{bottom:-1px;width:calc(100% - 28px);} .composer-terminal-inner{height:var(--composer-terminal-height,190px);min-height:140px;max-height:min(300px,44vh);border-radius:12px;padding-bottom:28px;} .composer-terminal-dock{min-height:40px;padding:6px 7px 6px 10px;border-radius:12px;gap:8px;} .composer-terminal-dock-title{font-size:11px;} @@ -1784,6 +1799,45 @@ body.resizing{user-select:none;cursor:col-resize;} .tool-card-compress-reference .tool-card-name{ color:var(--blue); } +.tool-card-handoff-summary{ + background:rgba(124,185,255,.04); + border-color:rgba(124,185,255,.18); +} +.tool-card-handoff-summary .tool-card-name{ + color:var(--blue); +} +.tool-card-handoff-summary .tool-card-preview{ + margin-left:10px; +} +.handoff-summary-body{ + color:var(--text); + font-size:var(--font-size-sm); + line-height:1.65; +} +.handoff-summary-body p{ + margin:0 0 8px; +} +.handoff-summary-body p:last-child{ + margin-bottom:0; +} +.handoff-summary-body ul, +.handoff-summary-body ol{ + margin:4px 0 4px 20px; +} +.handoff-summary-body li{ + margin:3px 0; +} +.handoff-summary-body strong{ + color:var(--strong); +} +.handoff-summary-body code{ + font-family:'SF Mono',ui-monospace,monospace; + font-size:.92em; + background:var(--code-inline-bg); + color:var(--code-text); + padding:1px 5px; + border-radius:4px; +} .compression-row{ margin:0 0 4px; diff --git a/static/ui.js b/static/ui.js index f97083a5..9ad3c6cf 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3425,6 +3425,66 @@ function _compressionStatusCardHtml({ ${bodyHtml}
`; } +function _handoffStateForCurrentSession(){ + const state=window._handoffUi; + if(!state||!S.session||state.sessionId!==S.session.session_id) return null; + return state; +} +function clearHandoffUi(){ + window._handoffUi=null; + renderMessages(); +} +function setHandoffUi(state){ + if(!state){ + clearHandoffUi(); + return; + } + window._handoffUi={...state}; + renderMessages(); +} +function _handoffCardsHtml(state){ + if(!state) return ''; + const channel=String(state.channel||'').trim(); + const label=channel?`${channel} handoff summary`:'Handoff summary'; + const isError=state.phase==='error'; + const isDone=state.phase==='done'; + const detail=isError + ? String(state.errorText||'Could not generate summary. Please try again.') + : isDone + ? String(state.summary||'') + : 'Generating handoff summary...'; + const meta=typeof state.rounds==='number' + ? `${state.rounds} external conversation rounds` + : ''; + const icon=isError + ? li('x',13) + : isDone + ? li('check',13) + : ''; + const bodyHtml=isDone&&!isError + ? renderMd(detail) + : `

${esc(detail)}

`; + return ` +
+
+
+ ${icon} + ${esc(label)} + ${meta?`${esc(meta)}`:''} + ${li('chevron-right',12)} +
+
+
${bodyHtml}
+
+
+
`; +} +function _handoffCardsNode(state){ + const wrap=document.createElement('div'); + wrap.className='compression-turn handoff-turn'; + wrap.innerHTML=`
${_handoffCardsHtml(state)}
`; + return wrap; +} function _contextCompactionMessageHtml(m, tsTitle='', preservedMessages=[]){ const text=msgContent(m)||String(m.content||''); return `
${_compressionReferenceCardHtml(text, false, tsTitle)}${_preservedCompressionTaskListCardsHtml(preservedMessages)}
`; @@ -3455,13 +3515,20 @@ function renderMessages(){ const inner=$('msgInner'); const sid=S.session?S.session.session_id:null; const msgCount=S.messages.length; + const hasTransientTranscriptUi=!!( + (window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) || + (window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)) + ); // Fast path: switching back to a previously rendered session with same count. // Guard: sid !== _sessionHtmlCacheSid ensures in-session updates (edits, // new messages, tool_complete) always get a fresh rebuild. // Skip cache if this session is still streaming — the live smd parser writes // into a DOM node inside the cached subtree; serving cached HTML detaches it. - if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]){ + // Also skip cache for transient transcript cards such as /compress and + // cross-channel handoff summaries; otherwise the cached transcript returns + // before those cards can be inserted. + if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){ const cached=_sessionHtmlCache.get(sid); if(cached&&cached.msgCount===msgCount){ inner.innerHTML=cached.html; @@ -3477,6 +3544,8 @@ function renderMessages(){ const compressionState=_compressionStateForCurrentSession(); if(window._compressionUi && !compressionState) clearCompressionUi(); + const handoffState=_handoffStateForCurrentSession(); + if(window._handoffUi && !handoffState) window._handoffUi=null; const sessionCompressionAnchor=( S.session && typeof S.session.compression_anchor_visible_idx==='number' ) ? S.session.compression_anchor_visible_idx : null; @@ -3709,6 +3778,7 @@ function renderMessages(){ _insertCompressionLikeNode(compressionNode); _insertCompressionLikeNode(referenceNode); _insertCompressionLikeNode(preservedOnlyNode, preservedOnlyAnchor); + _insertCompressionLikeNode(handoffState?_handoffCardsNode(handoffState):null, visWithIdx.length?visWithIdx.length-1:null); renderCompressionUi(); // Insert settled tool call cards (history view only). // During live streaming, tool cards are rendered in #liveToolCards by the @@ -3904,7 +3974,7 @@ function renderMessages(){ if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner); // Populate session cache so switching back here skips a full rebuild. _sessionHtmlCacheSid=sid; - if(sid){ + if(sid&&!hasTransientTranscriptUi){ const _html=inner.innerHTML; // Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions. if(_html.length<300_000){ diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index e9eef69a..ee1451f4 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -789,6 +789,154 @@ def test_imported_cli_session_metadata_survives_compact(cleanup_test_sessions): assert compact['source_label'] == 'Telegram' +def test_import_cli_preserves_messaging_source_metadata(cleanup_test_sessions): + """Importing a messaging agent session should keep source metadata for WebUI policy.""" + conn = _ensure_state_db() + sid = 'gw_import_weixin_meta_001' + cleanup_test_sessions.append(sid) + try: + _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session') + + data, status = post('/api/session/import_cli', {'session_id': sid}) + assert status == 200 + session = data.get('session', {}) + assert session.get('is_cli_session') is True + assert session.get('source_tag') == 'weixin' + assert session.get('raw_source') == 'weixin' + assert session.get('session_source') == 'messaging' + assert session.get('source_label') == 'Weixin' + finally: + try: + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + + +def test_sessions_response_backfills_imported_messaging_source_metadata(cleanup_test_sessions): + """Old imported messaging sessions should still expose source metadata in /api/sessions.""" + from api.models import Session + + conn = _ensure_state_db() + sid = 'gw_legacy_import_weixin_001' + cleanup_test_sessions.append(sid) + try: + _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session') + s = Session( + session_id=sid, + title='Legacy Imported Weixin', + messages=[{'role': 'user', 'content': 'hello', 'timestamp': time.time()}], + model='openai/gpt-5', + ) + s.is_cli_session = True + s.save(touch_updated_at=False) + post('/api/settings', {'show_cli_sessions': True}) + + data, status = get('/api/sessions') + assert status == 200 + session = next(item for item in data.get('sessions', []) if item.get('session_id') == sid) + assert session.get('source_tag') == 'weixin' + assert session.get('raw_source') == 'weixin' + assert session.get('session_source') == 'messaging' + assert session.get('source_label') == 'Weixin' + finally: + try: + post('/api/settings', {'show_cli_sessions': False}) + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + + +def test_sessions_response_keeps_only_latest_messaging_session_per_source(cleanup_test_sessions): + """Sidebar should expose only the newest session for each messaging source.""" + from api.models import Session + + conn = _ensure_state_db() + old_sid = 'gw_old_weixin_visible_001' + new_sid = 'gw_new_weixin_visible_001' + cleanup_test_sessions.extend([old_sid, new_sid]) + try: + _insert_gateway_session(conn, session_id=old_sid, source='weixin', title='Old Weixin', started_at=time.time() - 100) + _insert_gateway_session(conn, session_id=new_sid, source='weixin', title='New Weixin', started_at=time.time()) + + old = Session( + session_id=old_sid, + title='Old Imported Weixin', + messages=[{'role': 'user', 'content': 'old', 'timestamp': time.time() - 100}], + model='openai/gpt-5', + ) + old.is_cli_session = True + old.save(touch_updated_at=False) + post('/api/settings', {'show_cli_sessions': True}) + + data, status = get('/api/sessions') + assert status == 200 + ids = {item.get('session_id') for item in data.get('sessions', [])} + assert new_sid in ids + assert old_sid not in ids + finally: + try: + post('/api/settings', {'show_cli_sessions': False}) + _remove_test_sessions(conn, old_sid, new_sid) + conn.close() + except Exception: + pass + + +def test_archiving_raw_messaging_session_imports_without_erasing_agent_memory(cleanup_test_sessions): + """Archive should be the safe hide path for raw messaging sessions.""" + conn = _ensure_state_db() + sid = 'gw_archive_weixin_001' + cleanup_test_sessions.append(sid) + try: + _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session') + + data, status = post('/api/session/archive', {'session_id': sid, 'archived': True}) + assert status == 200 + session = data.get('session', {}) + assert session.get('archived') is True + assert session.get('session_source') == 'messaging' + + remaining = conn.execute( + "SELECT COUNT(*) FROM messages WHERE session_id = ?", + (sid,), + ).fetchone()[0] + assert remaining == 2 + finally: + try: + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + + +def test_delete_imported_messaging_session_preserves_agent_memory(cleanup_test_sessions): + """WebUI delete must not delete Hermes Agent memory for external channels.""" + conn = _ensure_state_db() + sid = 'gw_delete_weixin_safe_001' + cleanup_test_sessions.append(sid) + try: + _insert_gateway_session(conn, session_id=sid, source='weixin', title='Weixin Session') + _, import_status = post('/api/session/import_cli', {'session_id': sid}) + assert import_status == 200 + + _, delete_status = post('/api/session/delete', {'session_id': sid}) + assert delete_status == 200 + + remaining = conn.execute( + "SELECT COUNT(*) FROM messages WHERE session_id = ?", + (sid,), + ).fetchone()[0] + assert remaining == 2 + finally: + try: + _remove_test_sessions(conn, sid) + conn.close() + except Exception: + pass + + def test_imported_cron_sessions_hidden_from_sidebar_by_default(cleanup_test_sessions): """Cron sessions already imported into the WebUI store should stay hidden from the sidebar.""" from api.models import Session diff --git a/tests/test_issue1013_handoff_dock.py b/tests/test_issue1013_handoff_dock.py new file mode 100644 index 00000000..a6836beb --- /dev/null +++ b/tests/test_issue1013_handoff_dock.py @@ -0,0 +1,70 @@ +"""Regression guards for cross-channel handoff UI and summary generation.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +ROUTES = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") + + +def test_handoff_hint_is_docked_in_composer_flyout_not_transcript(): + """Handoff should use the Terminal-style composer dock, not transcript flow.""" + marker = '
') + composer_flyout_idx = INDEX.index('
') + handoff_idx = INDEX.index(marker) + assert handoff_idx > composer_flyout_idx + assert not (msg_inner_idx < handoff_idx < composer_flyout_idx) + + +def test_handoff_dock_reserves_transcript_space_like_terminal_dock(): + assert ".messages.handoff-dock-visible" in STYLE_CSS + assert ".handoff-hint-container{position:absolute" in STYLE_CSS + assert "_syncHandoffDockSpace(true)" in SESSIONS_JS + assert "_syncHandoffDockSpace(false)" in SESSIONS_JS + + +def test_handoff_summary_renders_as_transcript_card_not_dock_card(): + assert "function setHandoffUi" in SESSIONS_JS or "function setHandoffUi" in (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + ui_js = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + assert "_handoffCardsNode" in ui_js + assert "data-handoff-card" in ui_js + assert 'data-compression-card="1" data-handoff-card="1"' in ui_js + assert 'class="tool-card-result handoff-summary-body"' in ui_js + assert "renderMd(detail)" in ui_js + assert "_insertCompressionLikeNode(handoffState?_handoffCardsNode" in ui_js + assert "window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid)" in ui_js + assert "!hasTransientTranscriptUi" in ui_js + assert "handoff-summary-card" not in SESSIONS_JS + assert "handoff-summary-card" not in STYLE_CSS + + +def test_handoff_summary_does_not_call_removed_agent_get_response(): + """Current Hermes Agent exposes run_conversation/private transports, not get_response.""" + handoff_start = ROUTES.index("def _handle_handoff_summary") + next_handler = ROUTES.index("\ndef _handle_skill_save", handoff_start) + handoff_body = ROUTES[handoff_start:next_handler] + assert ".get_response(" not in handoff_body + assert "_agent_text_completion" in handoff_body + assert "_fallback_handoff_summary" in handoff_body + + +def test_generating_handoff_summary_does_not_dismiss_future_hints(): + """Summary generation is a read action; only explicit dismiss should suppress the dock.""" + generate_start = SESSIONS_JS.index("async function _generateHandoffSummary") + resolve_start = SESSIONS_JS.index("function _resolveSessionModelForDisplaySoon", generate_start) + generate_body = SESSIONS_JS[generate_start:resolve_start] + + dismiss_start = SESSIONS_JS.index("function _dismissHandoffHint") + generate_start_after_dismiss = SESSIONS_JS.index("async function _generateHandoffSummary", dismiss_start) + dismiss_body = SESSIONS_JS[dismiss_start:generate_start_after_dismiss] + + assert "_setHandoffDismissedAt(" not in generate_body + assert "_setHandoffDismissedAt(" in dismiss_body + assert "setHandoffUi({" in generate_body + assert ":dismissed_at'" in SESSIONS_JS + assert ":seen_at'" not in SESSIONS_JS