* fix(api): scan to first complete exchange for title regeneration (#7543) _first_exchange_snippets() aborted at a second consecutive user message before collecting assistant text, so transcripts opening with queued user rows (channel-backed imports) hit the missing_exchange rejection and the deterministic fallback got persisted with HTTP 200 — invisible in the UI and unrecoverable by retrying. Keep scanning until the first complete user+assistant pair instead, and fall back to the last complete exchange when the opening user message yields no user text (privacy scrubbing) instead of failing with empty_user_message. Tests: tests/test_issue7543_title_first_exchange.py (8 cases, incl. the issue-pinned transcript shape and walker-mocked discriminator tests for both fixes). * test(api): drop local-machine notes and sys.path hack from 7543 test module - Docstring described the change as a local-only uncommitted hotfix with machine-specific paths — wrong for an upstream PR and unsafe to publish. - Drop the defensive REPO_ROOT sys.path insert; the repo conftest already puts the package root on sys.path (matches the neighboring test modules). * fix(api): drop unreachable latest-exchange fallback from title regeneration Greptile flagged the mocked-walker fallback test (P2) as fabricating internal state, and an exhaustive enumeration of realistic transcript shapes (5460 combinations, lengths 1-6) confirms it: both walkers share the same text extraction and the forward walker scans every message, so 'first walker empty while latest walker yields a pair' is unreachable from real transcripts. The fallback branch was dead code. Remove it together with its two walker-mocked tests; the fixed first- exchange walker alone resolves the reported issue shape. Replace the mocked tests with an observable E2E error-path test through the real walkers, and make the aux mock guard-faithful (empty assistant_text -> missing_exchange per the generate_title_raw_via_aux contract) so the fail-before proof actually exercises the endpoint contract. * fix(#7543): scope first-exchange scan-past to the manual-regen path only The full suite caught that broadening the SHARED _first_exchange_snippets also activated the automatic in-stream background-title path for consecutive-user-row transcripts, moving stream teardown from the synchronous stream_end branch to the background-title-thread branch (test_issue3929 emits_done regressed: stream_end was no longer in the synchronous queue). #7543 is specifically about the MANUAL 'Regenerate title' endpoint, so gate the scan-past behavior behind an opt-in scan_past_consecutive_users flag (default False = master behavior) and pass True only from generate_session_title_for_session. Auto in-stream path is now byte-identical to master; added a default-behavior regression guard test. * docs(changelog): stamp #7545 (manual title regen for queued-user transcripts) --------- Co-authored-by: Raylands <Raylands@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: n <a@n>
This commit is contained in:
3 files changed
+190
-5
No files matched your search
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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)
|
- **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 <pid>` 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)
|
- **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 <pid>` 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)
|
||||||
|
|||||||
+23
-5
@@ -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)
|
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.
|
"""Return (first_user_text, first_assistant_text) snippets for title generation.
|
||||||
|
|
||||||
Prefer the first substantive assistant answer in the opening exchange,
|
Prefer the first substantive assistant answer in the opening exchange,
|
||||||
skipping empty placeholders and assistant tool-call preambles.
|
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 = ''
|
user_text = ''
|
||||||
asst_text = ''
|
asst_text = ''
|
||||||
@@ -4025,11 +4035,14 @@ def _first_exchange_snippets(messages):
|
|||||||
role = m.get('role')
|
role = m.get('role')
|
||||||
if role == 'user':
|
if role == 'user':
|
||||||
candidate = _strip_thinking_markup(_title_exchange_input_text(m.get('content')))
|
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
|
user_text = candidate
|
||||||
continue
|
elif user_text and candidate and not scan_past_consecutive_users:
|
||||||
if user_text and candidate:
|
# Legacy/default behavior: a second populated user row before any
|
||||||
|
# assistant text ends the opening exchange (asst_text stays empty).
|
||||||
break
|
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:
|
elif role == 'assistant' and user_text:
|
||||||
candidate = _message_text(m.get('content'))
|
candidate = _message_text(m.get('content'))
|
||||||
# Skip tool-call preambles *only* when content is empty or looks
|
# 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:
|
if prefer_latest:
|
||||||
user_text, assistant_text = _latest_exchange_snippets(messages)
|
user_text, assistant_text = _latest_exchange_snippets(messages)
|
||||||
else:
|
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:
|
if not user_text:
|
||||||
return None, 'empty_user_message', ''
|
return None, 'empty_user_message', ''
|
||||||
from api import profiles as profiles_api
|
from api import profiles as profiles_api
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user