Files
hermes-webui/tests/test_issue1013_handoff_dock.py
T
Sebastian B 6a0defbc2f test: agent-less CI compat for transport-port tests
The new normalize test now stubs agent.anthropic_adapter in sys.modules
(CI has no agent package — real import raised ModuleNotFoundError), and the
issue-1013 codex fake ports to the v0.21 _get_transport contract instead of
the deleted _normalize_codex_response method.
2026-09-02 15:29:04 +01:00

963 lines
39 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
import pytest
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_catch_surfaces_400_message_verbatim():
"""Finding #5 (handoff UI): a 400 from the summary endpoint carries an
actionable message (e.g. an ambiguous custom-provider slug collision). The
catch must surface it verbatim instead of the generic 'try again' text."""
assert "e && e.status === 400 && e.message" in SESSIONS_JS
# The generic 'try again' text must no longer be the sole error message.
assert "errorText," in SESSIONS_JS
def test_model_save_catches_surface_error_and_abort():
"""Finding #5 (settings UI): aux + default model saves must surface the
server's actionable message and abort (retain dirty state) rather than
swallowing it into a generic failure / 'settings saved'."""
panels_js = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
# Default-model save no longer degrades to "settings saved" after a failure.
assert "Failed to update default model — settings saved" not in panels_js
# It surfaces the server message and aborts.
assert "const _msg=(_modelErr&&_modelErr.message)?_modelErr.message:''" in panels_js
# Aux save surfaces the server message too.
assert "const _base=t('settings_aux_save_failed')||'Failed to save auxiliary model';" in panels_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_stale_runtime_handoff_summary_returns_typed_409_without_persisting(monkeypatch):
"""A stale runtime must remain retryable instead of becoming fallback success."""
import api.routes as routes
import api.models as models
monkeypatch.setattr(routes, "require", lambda body, *keys: None)
monkeypatch.setattr(
routes,
"j",
lambda _handler, payload, status=200, extra_headers=None: {
"status": status,
"payload": payload,
},
)
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 a handoff", "timestamp": 1.0},
{"role": "assistant", "content": "Context is ready", "timestamp": 2.0},
],
)
monkeypatch.setattr(
routes,
"_persist_handoff_summary",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("stale runtime persisted a fallback summary")
),
)
monkeypatch.setattr(
routes,
"ensure_agent_runtime_current",
lambda: (_ for _ in ()).throw(
routes.AgentRuntimeChangedError("restart required")
),
)
response = routes._handle_handoff_summary(object(), {"session_id": "stale-handoff"})
assert response == {
"status": 409,
"payload": {
"error": "restart required",
"type": "agent_runtime_stale",
"retryable": True,
},
}
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_ambiguous_custom_provider_handoff_returns_400_not_fallback(monkeypatch):
"""Finding #4: a custom-provider slug collision on a handoff must surface as a
400 with the actionable rename message — NOT a 200 local fallback.
Previously AmbiguousCustomProviderError fell into the generic ``except
Exception`` handler that returns 200 with a ``warning`` the UI ignores,
marking the handoff handled and hiding the fix from the user.
"""
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, "j",
lambda _handler, payload, status=200, extra_headers=None: {**payload, "_status": status},
)
monkeypatch.setattr(
routes, "bad",
lambda _handler, msg, status=400: {"ok": False, "error": msg, "_status": status},
)
persisted = []
monkeypatch.setattr(
routes, "_persist_handoff_summary",
lambda sid, summary, channel, rounds, fallback=False: persisted.append(sid) 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},
],
)
# require_ai_agent_class() imports run_agent and hermes_cli.runtime_provider
# before model resolution; stub them so execution reaches resolve_model_provider.
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 _DummyAgent:
def __init__(self, *args, **kwargs):
pass
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _DummyAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
rename_msg = (
"Custom providers ['Foo (Bar)', 'foo-bar'] all normalize to the same "
"provider slug 'foo-bar'; rename one so each has a unique slug."
)
def _raise_ambiguous(*args, **kwargs):
raise cfg.AmbiguousCustomProviderError(rename_msg)
monkeypatch.setattr(cfg, "resolve_model_provider", _raise_ambiguous)
response = routes._handle_handoff_summary(object(), {"session_id": "session-ambiguous"})
assert response.get("_status") == 400, response
assert response.get("type") == "custom_provider_ambiguous", response
assert response.get("error") == rename_msg
# Must NOT have silently degraded to a persisted fallback summary.
assert persisted == [], "ambiguity must not persist a 200 fallback summary"
assert "summary" not in response
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"
@pytest.mark.parametrize(
("provider", "base_url", "expects_output_cap", "expects_normalized_base_url"),
[
("openai-codex", "https://chatgpt.com/backend-api/codex", False, False),
("custom:chatgpt-codex", "https://chatgpt.com/backend-api/codex", False, False),
("custom:chatgpt-codex", " https://chatgpt.com/backend-api/codex ", False, True),
("custom:responses-proxy", "https://responses.example/v1", True, False),
],
)
def test_handoff_summary_codex_output_cap_matches_provider_compatibility(
monkeypatch, provider, base_url, expects_output_cap, expects_normalized_base_url,
):
"""ChatGPT Codex rejects the cap, while other Responses transports retain it."""
import api.config as cfg
import api.models as models
import api.routes as routes
if expects_normalized_base_url:
real_urlsplit = routes.urlsplit
def _strict_urlsplit(value):
assert value == value.strip()
return real_urlsplit(value)
monkeypatch.setattr(routes, "urlsplit", _strict_urlsplit)
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)
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": "What remains to do?", "timestamp": 1.0},
{"role": "assistant", "content": "One review step remains.", "timestamp": 2.0},
],
)
monkeypatch.setattr(
cfg,
"resolve_model_provider",
lambda resolved_model=None: ("gpt-test", provider, base_url),
)
monkeypatch.setattr(
routes,
"_persist_handoff_summary",
lambda *args, **kwargs: {"ok": True},
)
request_kwargs = []
class _CodexAgent:
api_mode = "codex_responses"
def __init__(self, *args, **kwargs):
self.model = kwargs.get("model")
self.provider = kwargs.get("provider")
self.base_url = kwargs.get("base_url")
self.reasoning_config = None
def _build_api_kwargs(self, *args, **kwargs):
return {"model": self.model, "instructions": "summary", "input": [], "store": False}
def _run_codex_stream(self, kwargs):
request_kwargs.append(kwargs)
return object()
def _get_transport(self, mode=None):
# hermes-agent v0.21 contract: normalization goes through the
# provider transport (CodexTransport.normalize_response returns
# NormalizedResponse with .content); the old
# AIAgent._normalize_codex_response method no longer exists.
assert mode == "codex_responses"
return _CodexTransport()
def release_clients(self):
return None
class _CodexTransport:
def normalize_response(self, response, **kwargs):
return types.SimpleNamespace(content="- You should complete the remaining review.")
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = _CodexAgent
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": provider,
"base_url": base_url,
}
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-codex-summary"})
assert response["ok"] is True
assert response["fallback"] is False
assert len(request_kwargs) == 1
assert ("max_output_tokens" in request_kwargs[0]) is expects_output_cap
if expects_output_cap:
assert request_kwargs[0]["max_output_tokens"] == 700
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()