mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Add messaging session handoff summary
This commit is contained in:
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user