Add messaging session handoff summary

This commit is contained in:
Frank Song
2026-05-01 21:29:10 +08:00
committed by Hermes Bot
parent f8ed6dac05
commit 20ef643bb8
10 changed files with 1425 additions and 44 deletions
+3
View File
@@ -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
+92 -1
View File
@@ -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.
+724 -15
View File
@@ -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": <unix_ts_or_iso> }
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": <unix_ts_or_iso> }
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(
+1
View File
@@ -365,6 +365,7 @@
</div>
</div>
</div>
<div id="handoffHintContainer" class="handoff-hint-container" style="display:none;"></div>
</div>
<!-- Queue pill outer: same positioning wrapper as .queue-card (max-width + padding) -->
<div class="queue-pill-outer">
+6
View File
@@ -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){
+255 -26
View File
@@ -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 = `
<div class="handoff-hint-text">
<span class="handoff-hint-icon">${li('arrow-left', 18)}</span>
<span>${esc(hintText)}</span>
</div>
<button class="handoff-hint-dismiss" onclick="event.stopPropagation(); _dismissHandoffHint('${esc(sid)}')" title="Dismiss">
${li('x', 14)}
</button>
`;
// 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;
+54
View File
@@ -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;
+72 -2
View File
@@ -3425,6 +3425,66 @@ function _compressionStatusCardHtml({
${bodyHtml}
</div>`;
}
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)
: '<span class="tool-card-running-dot"></span>';
const bodyHtml=isDone&&!isError
? renderMd(detail)
: `<p>${esc(detail)}</p>`;
return `
<div class="tool-card-row compression-card-row handoff-card-row" data-compression-card="1" data-handoff-card="1">
<div class="tool-card tool-card-handoff-summary${isError?' tool-card-compress-error':''} open">
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
${icon}
<span class="tool-card-name">${esc(label)}</span>
${meta?`<span class="tool-card-preview">${esc(meta)}</span>`:''}
<span class="tool-card-toggle">${li('chevron-right',12)}</span>
</div>
<div class="tool-card-detail">
<div class="tool-card-result handoff-summary-body">${bodyHtml}</div>
</div>
</div>
</div>`;
}
function _handoffCardsNode(state){
const wrap=document.createElement('div');
wrap.className='compression-turn handoff-turn';
wrap.innerHTML=`<div class="compression-turn-blocks">${_handoffCardsHtml(state)}</div>`;
return wrap;
}
function _contextCompactionMessageHtml(m, tsTitle='', preservedMessages=[]){
const text=msgContent(m)||String(m.content||'');
return `<div class="compression-turn"><div class="compression-turn-blocks">${_compressionReferenceCardHtml(text, false, tsTitle)}${_preservedCompressionTaskListCardsHtml(preservedMessages)}</div></div>`;
@@ -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){
+148
View File
@@ -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
+70
View File
@@ -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 = '<div id="handoffHintContainer"'
assert marker in INDEX
msg_inner_idx = INDEX.index('<div class="messages-inner" id="msgInner">')
composer_flyout_idx = INDEX.index('<div class="composer-flyout">')
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