* 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>
166 lines
7.2 KiB
Python
166 lines
7.2 KiB
Python
"""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
|