diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ecc5de..f3ba6229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Fixed +- **Manual "Regenerate title" now works for conversations that open with several queued user messages.** For a session whose transcript begins with consecutive user turns (no assistant reply before the second user message), the first-exchange walker stopped at the second user row with no assistant text, so "Regenerate title" skipped the LLM call, silently persisted the local fallback (a truncated first message), and returned `200` with that same wrong title on every retry — leaving the session permanently stuck. Manual regeneration now scans past the opening user rows to the first complete user+assistant pair so it reaches the aux model. The automatic in-stream title path is deliberately unchanged. Thanks @Raylands. (#7545, closes #7543) + - **Custom providers that Hermes auto-discovered now show their full live `/v1/models` catalog again instead of just the saved models.** When a provider entry carries `models_discovered: true` alongside a `models:` mapping of per-model metadata (vision, context length), that mapping is discovery metadata — not a hand-curated allowlist — but the WebUI treated it as one and suppressed the live catalog, collapsing the model picker to only the already-saved IDs. Such a provider now defers to its live `/v1/models` catalog (matching the Hermes Agent's own `_models_config_is_allowlist` semantics), while an explicit `discover_models: false` opt-out (including the Agent-compatible string forms `false`/`no`/`0`) still pins the configured list, and a transient empty live probe falls back to the configured models rather than dropping the provider from the picker. Thanks @evgenyponomarev. (#7409, closes #7404) - **Ctrl-C and `ctl.sh`-launched daemons now shut down gracefully instead of leaving the server running.** The WebUI installed a SIGTERM handler that drains in-flight work through `serve_forever()`'s `finally`, but SIGINT fell through to Python's default, so a daemon started via `./ctl.sh start` could only be stopped with `kill ` and the Settings "Stop server" button did not stop it. SIGINT now shares the same idempotent graceful-shutdown handler as SIGTERM. Thanks @aniruddhaadak80. (#7315, closes #7078) diff --git a/api/streaming.py b/api/streaming.py index 4386fe75..f41ab71f 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -4011,11 +4011,21 @@ def _looks_like_current_user_turn(msg, msg_text) -> bool: return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates) -def _first_exchange_snippets(messages): +def _first_exchange_snippets(messages, *, scan_past_consecutive_users: bool = False): """Return (first_user_text, first_assistant_text) snippets for title generation. Prefer the first substantive assistant answer in the opening exchange, skipping empty placeholders and assistant tool-call preambles. + + ``scan_past_consecutive_users`` (opt-in) keeps scanning past consecutive + opening user rows (queued first turns) until the first COMPLETE user+assistant + pair — needed by the manual "Regenerate title" path (#7543), which otherwise + aborted at the second user row with an empty assistant snippet and silently + persisted the local fallback. It defaults False so the automatic in-stream + background-title path keeps its exact prior behavior (a transcript with no + assistant text before the second user row yields an empty assistant snippet, + which _background_title_generation_inputs treats as "not yet eligible" — the + stream teardown then emits stream_end on its synchronous path unchanged). """ user_text = '' asst_text = '' @@ -4025,11 +4035,14 @@ def _first_exchange_snippets(messages): role = m.get('role') if role == 'user': candidate = _strip_thinking_markup(_title_exchange_input_text(m.get('content'))) - if not user_text and candidate: + if candidate and not user_text: user_text = candidate - continue - if user_text and candidate: + elif user_text and candidate and not scan_past_consecutive_users: + # Legacy/default behavior: a second populated user row before any + # assistant text ends the opening exchange (asst_text stays empty). break + # When scan_past_consecutive_users is set, keep going past consecutive + # user rows (#7543) until the first user+assistant pair. elif role == 'assistant' and user_text: candidate = _message_text(m.get('content')) # Skip tool-call preambles *only* when content is empty or looks @@ -5098,7 +5111,12 @@ def generate_session_title_for_session(session, *, prefer_latest: bool = False, if prefer_latest: user_text, assistant_text = _latest_exchange_snippets(messages) else: - user_text, assistant_text = _first_exchange_snippets(messages) + # Manual "Regenerate title" (#7543): scan past consecutive opening user + # rows to the first complete user+assistant pair, so a transcript that + # opens with queued user turns still reaches the aux LLM instead of + # silently persisting the local fallback. The automatic in-stream path + # keeps the default (no scan-past) so its stream teardown is unchanged. + user_text, assistant_text = _first_exchange_snippets(messages, scan_past_consecutive_users=True) if not user_text: return None, 'empty_user_message', '' from api import profiles as profiles_api diff --git a/tests/test_issue7543_title_first_exchange.py b/tests/test_issue7543_title_first_exchange.py new file mode 100644 index 00000000..e3d226ca --- /dev/null +++ b/tests/test_issue7543_title_first_exchange.py @@ -0,0 +1,165 @@ +"""Regression coverage for nesquena/hermes-webui#7543. + +Bug: manual "Regenerate title" failed with missing_exchange for sessions +whose transcript opens with consecutive user rows (no assistant text before +the second user turn) — _first_exchange_snippets() aborted at the second +user message, the aux call was skipped, and the deterministic local fallback +was persisted (200 + identical wrong title on every retry). +""" + +from unittest.mock import MagicMock + +import api.profiles as profiles_api +import api.streaming as streaming + + +class _ProfileEnv: + def __enter__(self): + return None + + def __exit__(self, exc_type, exc, tb): + return False + + +def _capturing_llm_result(captured, title="LLM Title", status="llm_aux"): + # Guard-faithful stand-in for the aux route: mirrors the documented + # generate_title_raw_via_aux contract — empty assistant_text is rejected + # with missing_exchange (the provider edge), never the selection logic. + def _fake(user_text, assistant_text, **kwargs): + captured["user_text"] = user_text + captured["assistant_text"] = assistant_text + if not user_text or not assistant_text: + return None, "missing_exchange", "" + return title, status, "" + + return _fake + + +def _run_generation(monkeypatch, messages, captured, prefer_latest=False): + monkeypatch.setattr(profiles_api, "profile_env_for_background_worker", lambda *a, **k: _ProfileEnv()) + monkeypatch.setattr(streaming, "_aux_title_generation_enabled", lambda: True) + monkeypatch.setattr(streaming, "_generate_llm_session_title_via_aux", _capturing_llm_result(captured)) + session = MagicMock() + session.messages = messages + session.session_id = "issue7543" + return streaming.generate_session_title_for_session(session, prefer_latest=prefer_latest) + + +# --- Fix A: _first_exchange_snippets scans past consecutive user rows (opt-in) --- + + +def test_first_exchange_snippets_scan_past_consecutive_user_rows(): + # Channel-backed import/projection shape: opening run of user rows, + # first assistant answer only much later (#7543 repro shape). + # scan_past_consecutive_users=True is the manual-regen behavior. + messages = [ + {"role": "user", "content": "Opening question about certificates"}, + {"role": "user", "content": "Follow-up that used to trigger the break"}, + {"role": "user", "content": "Another queued user turn"}, + {"role": "assistant", "content": "## Real first answer with substance"}, + ] + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=True + ) + assert user_text == "Opening question about certificates" + assert asst_text == "## Real first answer with substance" + + +def test_first_exchange_snippets_default_stops_at_second_user_row(): + # The DEFAULT (automatic in-stream background-title path) must keep master's + # exact behavior: a second populated user row before any assistant text ends + # the opening exchange with an empty assistant snippet. This is load-bearing — + # _background_title_generation_inputs treats an empty assistant snippet as + # "not yet eligible", which keeps the stream teardown on its synchronous + # stream_end path (regression guard for test_issue3929 emits_done). + messages = [ + {"role": "user", "content": "Opening question about certificates"}, + {"role": "user", "content": "Follow-up that used to trigger the break"}, + {"role": "user", "content": "Another queued user turn"}, + {"role": "assistant", "content": "## Real first answer with substance"}, + ] + user_text, asst_text = streaming._first_exchange_snippets(messages) + assert user_text == "Opening question about certificates" + assert asst_text == "" + + +def test_first_exchange_snippets_normal_pair_unchanged(): + # Classic [user, assistant] opening must keep its exact behavior in BOTH modes. + messages = [ + {"role": "user", "content": "Please fix the stale sidebar title controls"}, + {"role": "assistant", "content": "I will add a regenerate-title action."}, + {"role": "user", "content": "Second question"}, + ] + for scan in (False, True): + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=scan + ) + assert user_text == "Please fix the stale sidebar title controls" + assert asst_text == "I will add a regenerate-title action." + + +def test_first_exchange_snippets_without_any_assistant_text_still_empty(): + # No assistant text anywhere -> still unusable for the LLM path in BOTH modes; + # the missing_exchange rejection in generate_title_raw_via_aux stays intact. + messages = [ + {"role": "user", "content": "Question one"}, + {"role": "user", "content": "Question two"}, + ] + for scan in (False, True): + user_text, asst_text = streaming._first_exchange_snippets( + messages, scan_past_consecutive_users=scan + ) + assert user_text == "Question one" + assert asst_text == "" + + +def test_issue7543_real_transcript_shape_reaches_llm_path(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "Wie kann ich auf einem windows server 2025 ein zertifikat erstellen"} , + {"role": "user", "content": "Wächst das Log so nicht in eine unendliche Schleife"}, + {"role": "assistant", "content": "## Zertifikat für Windows Admin Center mit AD-CS"}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured) + assert status == "llm_aux" + assert title == "LLM Title" + assert captured["user_text"].startswith("Wie kann ich auf einem windows server 2025") + assert captured["assistant_text"].startswith("## Zertifikat") + + +def test_regenerate_helper_errors_with_real_walkers_when_no_user_text_exists(monkeypatch): + """Observable error path through the real walkers: no user text anywhere + (orphan assistant rows only) -> empty_user_message, aux never called.""" + captured = {} + messages = [{"role": "assistant", "content": "orphan answer without any user turn"}] + title, status, _raw = _run_generation(monkeypatch, messages, captured) + assert title is None + assert status == "empty_user_message" + assert "user_text" not in captured # aux path never reached + + +def test_regenerate_helper_prefer_latest_path_unchanged(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": "Latest question"}, + {"role": "assistant", "content": "Latest answer"}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured, prefer_latest=True) + assert status == "llm_aux" + assert captured["user_text"] == "Latest question" + assert captured["assistant_text"] == "Latest answer" + + +def test_regenerate_helper_prefer_latest_with_empty_last_user_message_errors(monkeypatch): + captured = {} + messages = [ + {"role": "user", "content": "First question"}, + {"role": "assistant", "content": "First answer"}, + {"role": "user", "content": " "}, + ] + title, status, _raw = _run_generation(monkeypatch, messages, captured, prefer_latest=True) + assert title is None + assert status == "empty_user_message" + assert "user_text" not in captured