mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
01404ac062
* Shorten session sidebar relative time labels * feat: adaptive session title refresh based on conversation evolution Addresses #869 — the 'Optional' part: adapt session names to current conversation context instead of only generating once from the first exchange. Backend (api/streaming.py): - Add _latest_exchange_snippets() to extract last user+assistant pair - Add _count_exchanges() to count user messages - Add _get_title_refresh_interval() to read the setting - Add _run_background_title_refresh() — refreshes title from latest exchange with LLM, skips if title is unchanged or user manually renamed - Add _maybe_schedule_title_refresh() — checks exchange count and schedules refresh after stream_end (non-blocking) Config (api/config.py): - Add auto_title_refresh_every setting (default '0' = off) - Enum validation: {'0', '5', '10', '20'} Frontend: - Settings UI dropdown (static/index.html) - Wire up load/save in panels.js - i18n keys for all 6 locales (en/ru/es/de/zh/zh-Hant) Default: off. Opt-in via Settings > Conversation > Adaptive title refresh. * test: add 37 tests for adaptive title refresh helpers Covers all five new functions introduced in this PR: _count_exchanges, _latest_exchange_snippets, _get_title_refresh_interval, _run_background_title_refresh, _maybe_schedule_title_refresh Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> * fix(settings): show selected state on theme/skin/font-size picker cards The CSS rule `#mainSettings .theme-pick-btn { border-color: var(--border) !important }` was overriding the inline `style.borderColor = "var(--accent)"` set by `_syncThemePicker()` and siblings — `!important` beats inline styles. Active cards showed no visual highlight. Fix: move to `.active` CSS class with `border-color:var(--accent)!important` so the active rule wins over the base rule, and clear the stale inline borderColor/boxShadow from the sync functions. 5 regression tests added. Closes #1057 * fix: rename test file to match PR number, fix stale issue reference * docs: v0.50.211 release notes and version bump Compact sidebar timestamps, adaptive title refresh (opt-in), settings picker fix. * docs(changelog): correct settings tab for adaptive title refresh The v0.50.211 entry for #1058 said "Settings → Appearance" but the toggle is actually rendered inside settingsPanePreferences (the Preferences tab) per static/index.html:604+. The commit message also had the wrong tab ("Conversation"). Updated CHANGELOG to match the actual UI surface so users can find the toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: create state dir before writing settings file save_settings() called SETTINGS_FILE.write_text() without ensuring the parent directory exists. In fresh environments (CI, first run without HERMES_WEBUI_STATE_DIR set) this raised FileNotFoundError. Add mkdir(parents=True, exist_ok=True) before the write. --------- Co-authored-by: Pavol Biely <biely@webtec.sk> Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
383 lines
16 KiB
Python
383 lines
16 KiB
Python
"""Tests for adaptive session title refresh helpers (PR #1058).
|
|
|
|
Covers all five new functions added to api/streaming.py:
|
|
- _count_exchanges
|
|
- _latest_exchange_snippets
|
|
- _get_title_refresh_interval
|
|
- _run_background_title_refresh
|
|
- _maybe_schedule_title_refresh
|
|
"""
|
|
import sys
|
|
import os
|
|
import threading
|
|
import types
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Ensure the project root is on sys.path
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
from api.streaming import (
|
|
_count_exchanges,
|
|
_latest_exchange_snippets,
|
|
_get_title_refresh_interval,
|
|
_run_background_title_refresh,
|
|
_maybe_schedule_title_refresh,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _restore_auth_sessions():
|
|
"""Snapshot and restore api.auth._sessions around each test.
|
|
|
|
Importing api.streaming can trigger api.config.load_settings() which may
|
|
call into api.auth and create a real session token. Without this fixture,
|
|
that stale token leaks into test_auth_session_persistence.py tests (which
|
|
assume _sessions starts empty) when our file runs first alphabetically.
|
|
"""
|
|
import api.auth as _auth
|
|
snapshot = dict(_auth._sessions)
|
|
yield
|
|
_auth._sessions.clear()
|
|
_auth._sessions.update(snapshot)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _user_msg(text):
|
|
return {'role': 'user', 'content': text}
|
|
|
|
|
|
def _asst_msg(text, tool_calls=None):
|
|
msg = {'role': 'assistant', 'content': text}
|
|
if tool_calls is not None:
|
|
msg['tool_calls'] = tool_calls
|
|
return msg
|
|
|
|
|
|
def _tool_only_asst():
|
|
"""Assistant message that only has tool_calls, no real text."""
|
|
return {'role': 'assistant', 'content': '', 'tool_calls': [{'id': 't1', 'type': 'function'}]}
|
|
|
|
|
|
def _make_session(title='My Title', llm_title_generated=True, messages=None, session_id='sid1'):
|
|
s = MagicMock()
|
|
s.title = title
|
|
s.llm_title_generated = llm_title_generated
|
|
s.messages = messages or []
|
|
s.session_id = session_id
|
|
s.save = MagicMock()
|
|
return s
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _count_exchanges
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCountExchanges:
|
|
def test_empty_messages_returns_zero(self):
|
|
assert _count_exchanges([]) == 0
|
|
|
|
def test_none_messages_returns_zero(self):
|
|
assert _count_exchanges(None) == 0
|
|
|
|
def test_counts_only_user_messages(self):
|
|
msgs = [_user_msg('hello'), _asst_msg('hi'), _user_msg('world')]
|
|
assert _count_exchanges(msgs) == 2
|
|
|
|
def test_skips_empty_user_messages(self):
|
|
msgs = [_user_msg(''), _user_msg(' '), _user_msg('real question')]
|
|
assert _count_exchanges(msgs) == 1
|
|
|
|
def test_counts_list_content_user_messages(self):
|
|
msgs = [
|
|
{'role': 'user', 'content': [{'type': 'text', 'text': 'list question'}]},
|
|
]
|
|
assert _count_exchanges(msgs) == 1
|
|
|
|
def test_skips_empty_list_content(self):
|
|
msgs = [
|
|
{'role': 'user', 'content': [{'type': 'text', 'text': ' '}]},
|
|
]
|
|
assert _count_exchanges(msgs) == 0
|
|
|
|
def test_ignores_non_user_roles(self):
|
|
msgs = [_asst_msg('response'), {'role': 'system', 'content': 'system prompt'}]
|
|
assert _count_exchanges(msgs) == 0
|
|
|
|
def test_ignores_non_dict_entries(self):
|
|
msgs = ['not a dict', _user_msg('real'), None]
|
|
assert _count_exchanges(msgs) == 1
|
|
|
|
def test_five_exchanges(self):
|
|
msgs = []
|
|
for i in range(5):
|
|
msgs.append(_user_msg(f'question {i}'))
|
|
msgs.append(_asst_msg(f'answer {i}'))
|
|
assert _count_exchanges(msgs) == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _latest_exchange_snippets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestLatestExchangeSnippets:
|
|
def test_empty_returns_empty_strings(self):
|
|
u, a = _latest_exchange_snippets([])
|
|
assert u == '' and a == ''
|
|
|
|
def test_none_returns_empty_strings(self):
|
|
u, a = _latest_exchange_snippets(None)
|
|
assert u == '' and a == ''
|
|
|
|
def test_basic_pair(self):
|
|
msgs = [_user_msg('first q'), _asst_msg('first a'),
|
|
_user_msg('second q'), _asst_msg('second a')]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert u == 'second q'
|
|
assert a == 'second a'
|
|
|
|
def test_returns_latest_not_first(self):
|
|
msgs = [_user_msg('old q'), _asst_msg('old a'),
|
|
_user_msg('new q'), _asst_msg('new a')]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert u == 'new q'
|
|
|
|
def test_skips_tool_call_only_assistant(self):
|
|
"""An assistant msg with tool_calls and no real text should be skipped."""
|
|
msgs = [_user_msg('q'), _asst_msg('real answer'),
|
|
_user_msg('q2'), _tool_only_asst()]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
# _tool_only_asst should be skipped; fall back to previous real assistant
|
|
assert a == 'real answer'
|
|
assert u == 'q2'
|
|
|
|
def test_truncates_long_content(self):
|
|
long_text = 'x' * 600
|
|
msgs = [_user_msg(long_text), _asst_msg(long_text)]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert len(u) == 500
|
|
assert len(a) == 500
|
|
|
|
def test_no_assistant_message(self):
|
|
msgs = [_user_msg('q')]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert u == 'q'
|
|
assert a == ''
|
|
|
|
def test_no_user_message(self):
|
|
msgs = [_asst_msg('a')]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert u == ''
|
|
assert a == 'a'
|
|
|
|
def test_ignores_non_dict_entries(self):
|
|
msgs = ['noise', _user_msg('q'), None, _asst_msg('a')]
|
|
u, a = _latest_exchange_snippets(msgs)
|
|
assert u == 'q'
|
|
assert a == 'a'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_title_refresh_interval
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetTitleRefreshInterval:
|
|
def test_returns_int_for_valid_setting(self):
|
|
# _get_title_refresh_interval does a local import: `from api.config import load_settings`
|
|
# so patch the source module, not api.streaming
|
|
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '5'}):
|
|
assert _get_title_refresh_interval() == 5
|
|
|
|
def test_returns_zero_for_off_setting(self):
|
|
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': '0'}):
|
|
assert _get_title_refresh_interval() == 0
|
|
|
|
def test_returns_zero_when_key_absent(self):
|
|
with patch('api.config.load_settings', return_value={}):
|
|
assert _get_title_refresh_interval() == 0
|
|
|
|
def test_returns_zero_on_exception(self):
|
|
with patch('api.config.load_settings', side_effect=Exception('boom')):
|
|
assert _get_title_refresh_interval() == 0
|
|
|
|
def test_valid_values_10_and_20(self):
|
|
for val in ('10', '20'):
|
|
with patch('api.config.load_settings', return_value={'auto_title_refresh_every': val}):
|
|
assert _get_title_refresh_interval() == int(val)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _run_background_title_refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunBackgroundTitleRefresh:
|
|
def _make_put_event(self):
|
|
events = []
|
|
def put(name, data):
|
|
events.append((name, data))
|
|
return put, events
|
|
|
|
def _make_session_obj(self, title='Old Title'):
|
|
s = MagicMock()
|
|
s.title = title
|
|
s.llm_title_generated = True
|
|
s.save = MagicMock()
|
|
return s
|
|
|
|
def test_skips_when_title_changed_before_call(self):
|
|
"""If the title has changed (manual rename) since the refresh was scheduled, skip."""
|
|
put, events = self._make_put_event()
|
|
with patch('api.streaming.get_session') as mock_get, \
|
|
patch('api.streaming.SESSIONS', {}), \
|
|
patch('api.streaming.LOCK', threading.Lock()):
|
|
s = self._make_session_obj(title='Different Title')
|
|
mock_get.return_value = s
|
|
_run_background_title_refresh(
|
|
'sid', 'user', 'asst', 'Old Title', put, agent=None
|
|
)
|
|
# No 'title' event should have been emitted
|
|
assert not any(name == 'title' for name, _ in events)
|
|
|
|
def test_skips_if_session_not_found(self):
|
|
put, events = self._make_put_event()
|
|
with patch('api.streaming.get_session', side_effect=KeyError('not found')):
|
|
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
|
assert events == []
|
|
|
|
def test_skips_when_title_is_untitled(self):
|
|
put, events = self._make_put_event()
|
|
with patch('api.streaming.get_session') as mock_get:
|
|
s = self._make_session_obj(title='Untitled')
|
|
mock_get.return_value = s
|
|
_run_background_title_refresh('sid', 'u', 'a', 'Untitled', put)
|
|
assert not any(name == 'title' for name, _ in events)
|
|
|
|
def test_skips_same_title(self):
|
|
"""If the LLM generates a title identical to the current one, no event is emitted."""
|
|
put, events = self._make_put_event()
|
|
with patch('api.streaming.get_session') as mock_get, \
|
|
patch('api.streaming._aux_title_configured', return_value=True), \
|
|
patch('api.streaming._generate_llm_session_title_via_aux',
|
|
return_value=('Old Title', 'llm_ok', 'raw')), \
|
|
patch('api.streaming.SESSIONS', {}), \
|
|
patch('api.streaming.LOCK', threading.Lock()):
|
|
s = self._make_session_obj(title='Old Title')
|
|
mock_get.return_value = s
|
|
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
|
assert not any(name == 'title' for name, _ in events)
|
|
|
|
def test_emits_title_event_on_new_title(self):
|
|
put, events = self._make_put_event()
|
|
s = self._make_session_obj(title='Old Title')
|
|
# Use a real dict for SESSIONS so .get() works, pre-populated with our session
|
|
fake_sessions = {'sid': s}
|
|
with patch('api.streaming.get_session', return_value=s), \
|
|
patch('api.streaming._aux_title_configured', return_value=True), \
|
|
patch('api.streaming._generate_llm_session_title_via_aux',
|
|
return_value=('New Refreshed Title', 'llm_ok', 'raw')), \
|
|
patch('api.streaming.SESSIONS', fake_sessions), \
|
|
patch('api.streaming.LOCK', threading.Lock()):
|
|
_run_background_title_refresh('sid', 'u', 'a', 'Old Title', put)
|
|
title_events = [(n, d) for n, d in events if n == 'title']
|
|
assert len(title_events) == 1
|
|
assert title_events[0][1]['title'] == 'New Refreshed Title'
|
|
|
|
def test_exceptions_are_silently_swallowed(self):
|
|
"""Any unexpected error inside must not propagate — it's a background daemon."""
|
|
put, events = self._make_put_event()
|
|
with patch('api.streaming.get_session', side_effect=RuntimeError('oops')):
|
|
# Should not raise
|
|
_run_background_title_refresh('sid', 'u', 'a', 'title', put)
|
|
assert events == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _maybe_schedule_title_refresh
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMaybeScheduleTitleRefresh:
|
|
def _noop_put(self, name, data):
|
|
pass
|
|
|
|
def test_does_nothing_when_disabled(self):
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=0):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|
|
|
|
def test_does_nothing_when_title_is_empty(self):
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
session = _make_session(title='', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|
|
|
|
def test_does_nothing_for_untitled(self):
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
session = _make_session(title='Untitled', messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|
|
|
|
def test_does_nothing_when_title_not_llm_generated(self):
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
session = _make_session(llm_title_generated=False,
|
|
messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|
|
|
|
def test_does_nothing_when_exchange_count_not_at_interval(self):
|
|
"""Refresh only fires when exchange_count % interval == 0 (and > 0)."""
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
# 4 exchanges — not a multiple of 5
|
|
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 4)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|
|
|
|
def test_spawns_thread_at_exact_interval(self):
|
|
"""Refresh fires when exchange_count == refresh_interval."""
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
spawned = []
|
|
with patch('threading.Thread') as mock_thread_cls:
|
|
mock_thread = MagicMock()
|
|
mock_thread_cls.return_value = mock_thread
|
|
# 5 user messages = 5 exchanges
|
|
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert mock_thread_cls.called
|
|
assert mock_thread.start.called
|
|
|
|
def test_spawns_thread_at_multiple_of_interval(self):
|
|
"""Refresh fires at 10 exchanges when interval is 5."""
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5):
|
|
with patch('threading.Thread') as mock_thread_cls:
|
|
mock_thread = MagicMock()
|
|
mock_thread_cls.return_value = mock_thread
|
|
# 10 exchanges
|
|
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 10)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert mock_thread_cls.called
|
|
|
|
def test_does_nothing_when_no_exchange_content(self):
|
|
"""Even at interval, if both snippets are empty, don't spawn."""
|
|
with patch('api.streaming._get_title_refresh_interval', return_value=5), \
|
|
patch('api.streaming._latest_exchange_snippets', return_value=('', '')):
|
|
spawned = []
|
|
with patch('threading.Thread', side_effect=lambda **kw: spawned.append(kw) or MagicMock()):
|
|
session = _make_session(messages=[_user_msg('q'), _asst_msg('a')] * 5)
|
|
_maybe_schedule_title_refresh(session, self._noop_put, None)
|
|
assert spawned == []
|