mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
700 lines
28 KiB
Python
700 lines
28 KiB
Python
"""Regression guards for cross-channel handoff UI and summary generation."""
|
|
|
|
import json
|
|
import time
|
|
import sqlite3
|
|
from pathlib import Path
|
|
import sys
|
|
import types
|
|
|
|
|
|
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")
|
|
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def _new_state_db(path: Path) -> sqlite3.Connection:
|
|
"""Create a minimal state.db shape for handoff-summary persistence tests."""
|
|
conn = sqlite3.connect(str(path))
|
|
conn.executescript(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
title TEXT,
|
|
model TEXT,
|
|
started_at REAL NOT NULL,
|
|
message_count INTEGER DEFAULT 0,
|
|
parent_session_id TEXT,
|
|
ended_at REAL,
|
|
end_reason TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT,
|
|
timestamp REAL
|
|
);
|
|
"""
|
|
)
|
|
return conn
|
|
|
|
|
|
def _extract_handoff_marker_payload(message):
|
|
content = message.get("content") if isinstance(message, dict) else None
|
|
if not isinstance(content, str):
|
|
return None
|
|
try:
|
|
data = json.loads(content)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
if not data.get("_handoff_summary_card"):
|
|
return None
|
|
return data
|
|
|
|
|
|
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_dock_width_aligns_with_existing_slide_up_panels():
|
|
assert ".handoff-hint-container{position:absolute;left:0;right:0;bottom:-2px;width:min(calc(100% - 112px),560px);" in STYLE_CSS
|
|
assert ".handoff-hint-container{bottom:-2px;width:calc(100% - 28px);}" in STYLE_CSS
|
|
start = STYLE_CSS.find(".handoff-hint-container")
|
|
assert start != -1
|
|
end = STYLE_CSS.find("}", start)
|
|
assert end != -1
|
|
handoff_hint_rule = STYLE_CSS[start:end+1]
|
|
assert "width:min(calc(100% - 112px),560px)" in handoff_hint_rule
|
|
assert "border-bottom:none;border-radius:13px 13px 0 0" in STYLE_CSS
|
|
assert "padding:7px 12px 9px" in STYLE_CSS
|
|
assert ".handoff-hint-text{min-width:0;display:flex;align-items:center;gap:10px;color:var(--muted);font-size:12px;font-weight:700;line-height:1.2;" in STYLE_CSS
|
|
assert ".handoff-hint-action,.handoff-hint-dismiss{border:none;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:700;line-height:1.2;" in STYLE_CSS
|
|
assert ".handoff-hint-dot{width:7px;height:7px;border-radius:999px;background:var(--success);" in STYLE_CSS
|
|
|
|
|
|
def test_handoff_summary_fallback_displays_clear_user_note():
|
|
assert "const isFallback=!!state.fallback;" in UI_JS
|
|
assert "class=\"handoff-summary-fallback-note\"" in UI_JS
|
|
assert "Fallback summary generated from recent turns; no model-based rewrite was used." in UI_JS
|
|
|
|
|
|
def test_handoff_delete_clears_local_storage_markers():
|
|
assert "function _clearHandoffStorageForSession(sid) {" in SESSIONS_JS
|
|
assert "_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null);" in SESSIONS_JS
|
|
assert "_setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null);" in SESSIONS_JS
|
|
assert "_clearHandoffStorageForSession(sid);" in SESSIONS_JS
|
|
assert "ids.forEach(_clearHandoffStorageForSession);" 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_card_rendering_uses_persisted_messages():
|
|
"""Persistent summary markers are parsed from message history and rendered via compression-like cards."""
|
|
assert "_collectHandoffSummaryStates" in UI_JS
|
|
assert "_handoffSummaryStateFromMessage" in UI_JS
|
|
assert "_handoffSummaryPayload" in UI_JS or "_parseHandoffSummaryPayload" in UI_JS
|
|
assert "_insertCompressionLikeNodeByRawIdx" in UI_JS
|
|
assert "_isHandoffSummaryToolPayload" in UI_JS
|
|
assert "_buildHandoffSummaryToolMessage" in SESSIONS_JS
|
|
|
|
|
|
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_handoff_summary_prompt_uses_you_and_你():
|
|
"""Summary prompt should use assistant-facing pronouns instead of “user/用户”."""
|
|
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]
|
|
prompt_start = handoff_body.index("summary_system_prompt = (")
|
|
prompt_end = handoff_body.index("summary_user_text =", prompt_start)
|
|
prompt_body = handoff_body[prompt_start:prompt_end]
|
|
|
|
assert "speak using “you”" in prompt_body
|
|
assert "用“你”" in prompt_body
|
|
assert "the user" not in prompt_body.lower()
|
|
assert "用户" not in prompt_body
|
|
|
|
|
|
def test_generating_handoff_summary_marks_session_as_handled():
|
|
"""Summary success uses a max(dismissed/handled) baseline for future checks."""
|
|
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 "_getHandoffSince(sid)" in generate_body
|
|
assert "_setHandoffSummaryHandledAt(sid, Date.now() / 1000)" in generate_body
|
|
assert "_hasMatchingHandoffSummary" not in generate_body
|
|
assert "_setHandoffDismissedAt(" in dismiss_body
|
|
assert "_setHandoffSummaryHandledAt(" not in dismiss_body
|
|
assert "_HANDOFF_SUFFIX_SUMMARY_HANDLED_AT" in SESSIONS_JS
|
|
assert "setHandoffUi({" in generate_body
|
|
assert "phase: 'done'" not in generate_body
|
|
assert "_getHandoffSince(sid)" in SESSIONS_JS
|
|
assert "_HANDOFF_SUFFIX_SUMMARY_HANDLED_AT" in SESSIONS_JS
|
|
assert "_HANDOFF_SUFFIX_DISMISSED_AT" in SESSIONS_JS
|
|
|
|
|
|
def test_handoff_hints_use_max_baseline_since():
|
|
"""Handled and dismissed state are coalesced with max() before calling conversation-rounds."""
|
|
check_start = SESSIONS_JS.index("async function _checkAndShowHandoffHint")
|
|
resolve_start = SESSIONS_JS.index("function _showHandoffHint", check_start)
|
|
check_body = SESSIONS_JS[check_start:resolve_start]
|
|
assert "_getHandoffSince(sid)" in check_body
|
|
assert "_getHandoffSummaryHandledAt(sid)" in SESSIONS_JS
|
|
assert "_getHandoffDismissedAt(sid)" in SESSIONS_JS
|
|
assert "Math.max(dismissedAt, summaryHandledAt)" in SESSIONS_JS
|
|
|
|
assert "_isHandoffSummaryHandled" not in SESSIONS_JS
|
|
|
|
|
|
def test_no_api_key_handoff_summary_persists_fallback_summary(monkeypatch):
|
|
"""No-API-key path should persist fallback summary markers."""
|
|
import api.routes as routes
|
|
import api.config as cfg
|
|
import api.models as models
|
|
|
|
# Force API-path validation to focus on fallback behavior only.
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
|
|
persisted = []
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_persist_handoff_summary",
|
|
lambda sid, summary, channel, rounds, fallback=False: persisted.append({
|
|
"sid": sid,
|
|
"summary": summary,
|
|
"channel": channel,
|
|
"rounds": rounds,
|
|
"fallback": fallback,
|
|
}) or {"ok": True},
|
|
)
|
|
|
|
monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD)
|
|
monkeypatch.setattr(
|
|
models,
|
|
"get_cli_session_messages",
|
|
lambda sid: [
|
|
{"role": "user", "content": "Need help with setup", "timestamp": 1.0},
|
|
{"role": "assistant", "content": "I'll help you", "timestamp": 2.0},
|
|
],
|
|
)
|
|
monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None))
|
|
|
|
fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider")
|
|
fake_runtime_module.resolve_runtime_provider = lambda requested=None: {"api_key": "", "provider": "openrouter", "base_url": None}
|
|
fake_hermes_cli = types.ModuleType("hermes_cli")
|
|
fake_hermes_cli.__path__ = []
|
|
fake_hermes_cli.runtime_provider = fake_runtime_module
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module)
|
|
|
|
response = routes._handle_handoff_summary(object(), {"session_id": "session-without-api-key"})
|
|
|
|
assert response["ok"] is True
|
|
assert response["fallback"] is True
|
|
assert response["summary"].startswith("-")
|
|
assert "You asked:" in response["summary"]
|
|
assert "Recent external-channel activity:" not in response["summary"]
|
|
assert len(persisted) == 1
|
|
assert persisted[0]["sid"] == "session-without-api-key"
|
|
assert persisted[0]["fallback"] is True
|
|
assert persisted[0]["rounds"] == models.CONVERSATION_ROUND_THRESHOLD
|
|
|
|
|
|
def test_exception_handoff_summary_persists_fallback_summary(monkeypatch):
|
|
"""Unhandled summary exception should still persist a fallback handoff marker."""
|
|
import api.routes as routes
|
|
import api.config as cfg
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
|
|
persisted = []
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_persist_handoff_summary",
|
|
lambda sid, summary, channel, rounds, fallback=False: persisted.append({
|
|
"sid": sid,
|
|
"summary": summary,
|
|
"channel": channel,
|
|
"rounds": rounds,
|
|
"fallback": fallback,
|
|
}) or {"ok": True},
|
|
)
|
|
|
|
monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD)
|
|
monkeypatch.setattr(
|
|
models,
|
|
"get_cli_session_messages",
|
|
lambda sid: [
|
|
{"role": "user", "content": "Could you check this?", "timestamp": 1.0},
|
|
{"role": "assistant", "content": "Sure, I can help", "timestamp": 2.0},
|
|
],
|
|
)
|
|
monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None))
|
|
|
|
fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider")
|
|
fake_runtime_module.resolve_runtime_provider = lambda requested=None: {
|
|
"api_key": "x",
|
|
"provider": "openrouter",
|
|
"base_url": None,
|
|
}
|
|
fake_hermes_cli = types.ModuleType("hermes_cli")
|
|
fake_hermes_cli.__path__ = []
|
|
fake_hermes_cli.runtime_provider = fake_runtime_module
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module)
|
|
|
|
class _Client:
|
|
class completions:
|
|
@staticmethod
|
|
def create(*args, **kwargs):
|
|
raise RuntimeError("intentional handoff-summary failure")
|
|
|
|
class _Chat:
|
|
completions = _Client.completions
|
|
|
|
class _OpenAIClient:
|
|
chat = _Chat
|
|
|
|
class _FailingAgent:
|
|
api_mode = ""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.model = kwargs.get("model")
|
|
self.reasoning_config = None
|
|
|
|
def _build_api_kwargs(self, *args, **kwargs):
|
|
return {}
|
|
|
|
def _ensure_primary_openai_client(self, reason=None):
|
|
return _OpenAIClient()
|
|
|
|
def release_clients(self):
|
|
return None
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = _FailingAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
response = routes._handle_handoff_summary(object(), {"session_id": "session-with-exception"})
|
|
|
|
assert response["ok"] is True
|
|
assert response["fallback"] is True
|
|
assert response["summary"].startswith("-")
|
|
assert "You asked:" in response["summary"]
|
|
assert "Recent external-channel activity:" not in response["summary"]
|
|
assert "warning" in response
|
|
assert len(persisted) == 1
|
|
assert persisted[0]["sid"] == "session-with-exception"
|
|
assert persisted[0]["fallback"] is True
|
|
assert persisted[0]["rounds"] == models.CONVERSATION_ROUND_THRESHOLD
|
|
|
|
|
|
def test_handoff_summary_retries_once_when_length_limit_reached(monkeypatch):
|
|
"""finish_reason='length' should trigger one retry with larger budget."""
|
|
import api.routes as routes
|
|
import api.config as cfg
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
|
|
persisted = []
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_persist_handoff_summary",
|
|
lambda sid, summary, channel, rounds, fallback=False: persisted.append({
|
|
"sid": sid,
|
|
"summary": summary,
|
|
"channel": channel,
|
|
"rounds": rounds,
|
|
"fallback": fallback,
|
|
}) or {"ok": True},
|
|
)
|
|
|
|
monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD)
|
|
monkeypatch.setattr(
|
|
models,
|
|
"get_cli_session_messages",
|
|
lambda sid: [
|
|
{"role": "user", "content": "Can we switch to a different method?", "timestamp": 1.0},
|
|
{"role": "assistant", "content": "Sure, here is the outline.", "timestamp": 2.0},
|
|
{"role": "user", "content": "Keep going.", "timestamp": 3.0},
|
|
{"role": "assistant", "content": "Step 1 is done, step 2 is pending.", "timestamp": 4.0},
|
|
],
|
|
)
|
|
monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None))
|
|
|
|
completion_calls = []
|
|
|
|
def _choice(content, finish_reason="stop"):
|
|
return types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content=content),
|
|
finish_reason=finish_reason,
|
|
)
|
|
|
|
class _Client:
|
|
class completions:
|
|
@staticmethod
|
|
def create(*args, **kwargs):
|
|
max_tokens = kwargs.get("max_tokens") or kwargs.get("max_completion_tokens")
|
|
completion_calls.append(max_tokens)
|
|
if len(completion_calls) == 1:
|
|
return types.SimpleNamespace(choices=[
|
|
_choice("- You can do step A, B, and C", finish_reason="length")
|
|
])
|
|
return types.SimpleNamespace(choices=[
|
|
_choice("- You should continue with step D.\n- You can then review results.", finish_reason="stop")
|
|
])
|
|
|
|
class _Chat:
|
|
completions = _Client.completions
|
|
|
|
class _OpenAIClient:
|
|
chat = _Chat
|
|
|
|
class _LengthAwareAgent:
|
|
api_mode = ""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.model = kwargs.get("model")
|
|
self.reasoning_config = None
|
|
|
|
def _build_api_kwargs(self, *args, **kwargs):
|
|
return {}
|
|
|
|
def _ensure_primary_openai_client(self, reason=None):
|
|
return _OpenAIClient()
|
|
|
|
def release_clients(self):
|
|
return None
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = _LengthAwareAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider")
|
|
fake_runtime_module.resolve_runtime_provider = lambda requested=None: {
|
|
"api_key": "x",
|
|
"provider": "openrouter",
|
|
"base_url": None,
|
|
}
|
|
fake_hermes_cli = types.ModuleType("hermes_cli")
|
|
fake_hermes_cli.__path__ = []
|
|
fake_hermes_cli.runtime_provider = fake_runtime_module
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module)
|
|
|
|
response = routes._handle_handoff_summary(object(), {"session_id": "session-length-retry"})
|
|
|
|
assert response["ok"] is True
|
|
assert response["fallback"] is False
|
|
assert response["summary"].startswith("- You should continue with step D.")
|
|
assert completion_calls == [700, 1400]
|
|
assert len(persisted) == 1
|
|
assert persisted[0]["fallback"] is False
|
|
assert persisted[0]["sid"] == "session-length-retry"
|
|
|
|
|
|
def test_handoff_summary_falls_back_when_retry_still_incomplete(monkeypatch):
|
|
"""Retry may still truncate; fallback should still return deterministic concise bullets."""
|
|
import api.routes as routes
|
|
import api.config as cfg
|
|
import api.models as models
|
|
|
|
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
|
|
monkeypatch.setattr(routes, "bad", lambda _handler, msg, status=400: {"ok": False, "error": msg, "status": status})
|
|
monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload)
|
|
|
|
persisted = []
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"_persist_handoff_summary",
|
|
lambda sid, summary, channel, rounds, fallback=False: persisted.append({
|
|
"sid": sid,
|
|
"summary": summary,
|
|
"channel": channel,
|
|
"rounds": rounds,
|
|
"fallback": fallback,
|
|
}) or {"ok": True},
|
|
)
|
|
|
|
monkeypatch.setattr(models, "count_conversation_rounds", lambda sid, since=None: models.CONVERSATION_ROUND_THRESHOLD)
|
|
monkeypatch.setattr(
|
|
models,
|
|
"get_cli_session_messages",
|
|
lambda sid: [
|
|
{"role": "user", "content": "Could you plan next moves?", "timestamp": 1.0},
|
|
{"role": "assistant", "content": "Let's draft a schedule.", "timestamp": 2.0},
|
|
{"role": "user", "content": "Anything else?", "timestamp": 3.0},
|
|
{"role": "assistant", "content": "Yes, one more check is needed.", "timestamp": 4.0},
|
|
],
|
|
)
|
|
monkeypatch.setattr(cfg, "resolve_model_provider", lambda resolved_model=None: ("gpt-test", "openrouter", None))
|
|
|
|
class _Client:
|
|
class completions:
|
|
@staticmethod
|
|
def create(*args, **kwargs):
|
|
return types.SimpleNamespace(choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(
|
|
content="I can help summarize this but",
|
|
),
|
|
finish_reason="length",
|
|
)
|
|
])
|
|
|
|
class _Chat:
|
|
completions = _Client.completions
|
|
|
|
class _LengthAwareAgent:
|
|
api_mode = ""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self.model = kwargs.get("model")
|
|
self.reasoning_config = None
|
|
|
|
def _build_api_kwargs(self, *args, **kwargs):
|
|
return {}
|
|
|
|
def _ensure_primary_openai_client(self, reason=None):
|
|
return _Chat()
|
|
|
|
def release_clients(self):
|
|
return None
|
|
|
|
fake_run_agent = types.ModuleType("run_agent")
|
|
fake_run_agent.AIAgent = _LengthAwareAgent
|
|
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
|
|
|
fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider")
|
|
fake_runtime_module.resolve_runtime_provider = lambda requested=None: {
|
|
"api_key": "x",
|
|
"provider": "openrouter",
|
|
"base_url": None,
|
|
}
|
|
fake_hermes_cli = types.ModuleType("hermes_cli")
|
|
fake_hermes_cli.__path__ = []
|
|
fake_hermes_cli.runtime_provider = fake_runtime_module
|
|
monkeypatch.setitem(sys.modules, "hermes_cli", fake_hermes_cli)
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.runtime_provider", fake_runtime_module)
|
|
|
|
response = routes._handle_handoff_summary(object(), {"session_id": "session-length-fallback"})
|
|
|
|
assert response["ok"] is True
|
|
assert response["fallback"] is True
|
|
assert response["summary"].startswith("- You asked:")
|
|
assert "Recent external-channel activity:" not in response["summary"]
|
|
assert len(persisted) == 1
|
|
assert persisted[0]["fallback"] is True
|
|
assert persisted[0]["sid"] == "session-length-fallback"
|
|
|
|
|
|
def test_handoff_summary_persistence_targets_both_backends_for_messaging_session(tmp_path, monkeypatch):
|
|
"""Messaging sessions should persist handoff summary markers into both local JSON and state.db."""
|
|
import api.routes as routes
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
sid = "messaging_1013_both_backends_01"
|
|
mock_home = tmp_path / "hermes_home"
|
|
mock_home.mkdir()
|
|
mock_sessions = tmp_path / "sessions"
|
|
mock_sessions.mkdir()
|
|
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home)
|
|
monkeypatch.setattr(models, "SESSION_DIR", mock_sessions)
|
|
|
|
conn = _new_state_db(mock_home / "state.db")
|
|
try:
|
|
seed_ts = time.time() - 10
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
|
|
"VALUES (?, 'telegram', 'Messaging Session', 'openai/gpt-5', ?, 0, NULL, NULL, NULL)",
|
|
(sid, seed_ts),
|
|
)
|
|
conn.commit()
|
|
|
|
session = models.Session(
|
|
session_id=sid,
|
|
title="Imported Messaging Session",
|
|
workspace=str(tmp_path),
|
|
messages=[{"role": "user", "content": "Need help", "timestamp": 1.0}],
|
|
)
|
|
session.is_cli_session = True
|
|
session.session_source = "messaging"
|
|
session.source_tag = "telegram"
|
|
session.raw_source = "telegram"
|
|
session.source_label = "Telegram"
|
|
session.save(touch_updated_at=False)
|
|
|
|
routes._persist_handoff_summary(sid, "Please handoff after context", "telegram", 2, False)
|
|
|
|
saved = models.Session.load(sid)
|
|
assert len(saved.messages) == 2
|
|
marker = saved.messages[-1]
|
|
assert marker.get("name") == "handoff_summary"
|
|
marker_payload = _extract_handoff_marker_payload(marker)
|
|
assert marker_payload is not None
|
|
assert marker_payload.get("session_id") == sid
|
|
assert marker_payload.get("summary") == "Please handoff after context"
|
|
assert marker_payload.get("channel") == "telegram"
|
|
assert marker_payload.get("rounds") == 2
|
|
|
|
rows = conn.execute(
|
|
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY rowid ASC",
|
|
(sid,),
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
assert rows[0][0] == "tool"
|
|
db_payload = _extract_handoff_marker_payload({"content": rows[0][1]})
|
|
assert db_payload is not None
|
|
assert db_payload.get("session_id") == sid
|
|
assert db_payload.get("summary") == "Please handoff after context"
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_persisted_handoff_summary_deduplicates_identical_tail_markers(tmp_path, monkeypatch):
|
|
"""When the tail already contains the same handoff marker, repeated generation should be idempotent."""
|
|
import api.routes as routes
|
|
import api.models as models
|
|
import api.profiles as profiles
|
|
|
|
sid = "messaging_1013_dedupe_tail"
|
|
mock_home = tmp_path / "hermes_home"
|
|
mock_home.mkdir()
|
|
mock_sessions = tmp_path / "sessions"
|
|
mock_sessions.mkdir()
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home)
|
|
monkeypatch.setattr(models, "SESSION_DIR", mock_sessions)
|
|
|
|
conn = _new_state_db(mock_home / "state.db")
|
|
try:
|
|
baseline = time.time()
|
|
conn.execute(
|
|
"INSERT INTO sessions (id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason) "
|
|
"VALUES (?, 'telegram', 'Messaging Session', 'openai/gpt-5', ?, 1, NULL, NULL, NULL)",
|
|
(sid, baseline),
|
|
)
|
|
conn.commit()
|
|
|
|
marker = routes._build_handoff_summary_tool_message(sid, "Repeat me", "telegram", 3, False)
|
|
session = models.Session(
|
|
session_id=sid,
|
|
title="Imported Messaging Session",
|
|
workspace=str(tmp_path),
|
|
messages=[
|
|
{"role": "user", "content": "Need help", "timestamp": baseline - 1},
|
|
marker,
|
|
],
|
|
)
|
|
session.is_cli_session = True
|
|
session.session_source = "messaging"
|
|
session.source_tag = "telegram"
|
|
session.raw_source = "telegram"
|
|
session.source_label = "Telegram"
|
|
session.save(touch_updated_at=False)
|
|
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'tool', ?, ?)",
|
|
(sid, marker["content"], marker["timestamp"]),
|
|
)
|
|
conn.commit()
|
|
|
|
routes._persist_handoff_summary(sid, "Repeat me", "telegram", 3, False)
|
|
|
|
refreshed = models.Session.load(sid)
|
|
assert len(refreshed.messages) == 2
|
|
|
|
rows = conn.execute(
|
|
"SELECT content FROM messages WHERE session_id = ? ORDER BY rowid ASC",
|
|
(sid,),
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
assert _extract_handoff_marker_payload({"content": rows[0][0]}) is not None
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def test_persist_handoff_summary_falls_back_when_local_session_file_missing(tmp_path, monkeypatch):
|
|
"""Messaging session IDs should still persist to state.db when no local WebUI session exists."""
|
|
import api.routes as routes
|
|
import api.profiles as profiles
|
|
|
|
sid = "messaging_1013_no_local_file"
|
|
mock_home = tmp_path / "hermes_home"
|
|
mock_home.mkdir()
|
|
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: mock_home)
|
|
conn = _new_state_db(mock_home / "state.db")
|
|
|
|
# Force messaging classification while keeping the local shell absent.
|
|
monkeypatch.setattr(routes, "_is_messaging_session_id", lambda _sid: True)
|
|
try:
|
|
routes._persist_handoff_summary(sid, "Persist without local shell", "telegram", 1, True)
|
|
rows = conn.execute(
|
|
"SELECT role, content FROM messages WHERE session_id = ? ORDER BY rowid ASC",
|
|
(sid,),
|
|
).fetchall()
|
|
assert len(rows) == 1
|
|
assert rows[0][0] == "tool"
|
|
payload = _extract_handoff_marker_payload({"content": rows[0][1]})
|
|
assert payload is not None
|
|
assert payload.get("session_id") == sid
|
|
assert payload.get("fallback") is True
|
|
finally:
|
|
conn.close()
|