HERMES-GIELIE/tests/test_title_aux_routing.py

556 lines
23 KiB
Python
Raw Normal View History

"""Regression tests for auxiliary title-generation config routing.
Covers:
- _aux_title_configured() broad detection (provider, model, base_url)
- generate_title_raw_via_aux() reads timeout from config instead of hardcoding 15.0
2026-04-28 10:09:26 +00:00
- aux→agent fallback triggers on 'llm_invalid_aux' status
- _aux_title_timeout rejects zero, negative, and non-numeric values
"""
import sys
import types
import unittest
from unittest.mock import MagicMock, patch
# Stub agent.auxiliary_client so it is importable in the test environment
# (the real package lives in hermes-agent, which is not installed here).
_agent_stub = types.ModuleType('agent')
_aux_stub = types.ModuleType('agent.auxiliary_client')
sys.modules.setdefault('agent', _agent_stub)
sys.modules.setdefault('agent.auxiliary_client', _aux_stub)
_agent_stub.auxiliary_client = _aux_stub
def _patch_tg_config(config_dict):
"""Return a patch context manager that makes _get_auxiliary_task_config return config_dict."""
return patch('agent.auxiliary_client._get_auxiliary_task_config', return_value=config_dict, create=True)
class TestAuxTitleConfigured(unittest.TestCase):
def _call(self, tg_config):
from api.streaming import _aux_title_configured
with _patch_tg_config(tg_config):
return _aux_title_configured()
def test_model_set_returns_true(self):
self.assertTrue(self._call({'provider': '', 'model': 'gpt-4o-mini', 'base_url': ''}))
def test_base_url_set_returns_true(self):
self.assertTrue(self._call({'provider': '', 'model': '', 'base_url': 'http://localhost:1234'}))
def test_provider_set_non_auto_returns_true(self):
self.assertTrue(self._call({'provider': 'openai', 'model': '', 'base_url': ''}))
def test_provider_auto_returns_false(self):
self.assertFalse(self._call({'provider': 'auto', 'model': '', 'base_url': ''}))
def test_provider_auto_case_insensitive_returns_false(self):
self.assertFalse(self._call({'provider': 'AUTO', 'model': '', 'base_url': ''}))
def test_all_empty_returns_false(self):
self.assertFalse(self._call({'provider': '', 'model': '', 'base_url': ''}))
def test_empty_dict_returns_false(self):
self.assertFalse(self._call({}))
def test_provider_configured_model_blank_returns_true(self):
"""Regression: provider set + blank model must still be treated as configured."""
self.assertTrue(self._call({'provider': 'anthropic', 'model': '', 'base_url': ''}))
def test_base_url_only_returns_true(self):
"""Regression: base_url alone (no model) must still be treated as configured."""
self.assertTrue(self._call({'provider': '', 'model': '', 'base_url': 'https://api.example.com'}))
def test_import_error_returns_false(self):
from api.streaming import _aux_title_configured
with patch('agent.auxiliary_client._get_auxiliary_task_config', side_effect=ImportError("no module"), create=True):
self.assertFalse(_aux_title_configured())
class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
"""Verify generate_title_raw_via_aux() reads timeout from config rather than hardcoding 15.0."""
def _run_with_config(self, tg_config, expected_timeout):
from api.streaming import generate_title_raw_via_aux
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
mock_resp = types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(content='Test Title'),
finish_reason='stop',
)
]
)
captured = {}
def fake_call_llm(**kwargs):
captured['timeout'] = kwargs.get('timeout')
return mock_resp
with _patch_tg_config(tg_config):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='What is the weather?',
assistant_text='It is sunny.',
)
self.assertEqual(result, 'Test Title')
self.assertAlmostEqual(captured['timeout'], expected_timeout)
def test_default_timeout_when_not_set(self):
"""No timeout in config → uses 15.0 default."""
self._run_with_config({'provider': '', 'model': 'gpt-4o', 'base_url': ''}, 15.0)
def test_custom_timeout_from_config(self):
"""Regression: timeout set in config must be used instead of hardcoded 15.0."""
self._run_with_config(
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': 30.0},
30.0,
)
def test_integer_timeout_from_config(self):
"""Config timeout as int is coerced to float."""
self._run_with_config(
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': 5},
5.0,
)
def test_timeout_none_in_config_falls_back_to_default(self):
"""Explicit None in config falls back to 15.0."""
self._run_with_config(
{'provider': '', 'model': 'gpt-4o', 'base_url': '', 'timeout': None},
15.0,
)
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
class TestReasoningModelTitleGeneration(unittest.TestCase):
"""Regression coverage for reasoning models that spend output budget on reasoning."""
def test_title_budget_defaults_to_reasoning_safe_value(self):
"""Title generation should not use a tiny output cap that starves final content."""
from api.streaming import _title_completion_budget, _title_retry_completion_budget
self.assertEqual(_title_completion_budget(), 512)
self.assertEqual(_title_retry_completion_budget(), 1024)
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
def test_aux_short_circuits_on_empty_reasoning_without_retrying(self):
"""Regression for #2083: reasoning models that emit only hidden
reasoning tokens (no visible content) must NOT trigger a budget-doubling
retry — the second call invariably produces the same empty-reasoning
shape and just doubles the GPU/credit burn. Short-circuit to the local
fallback path instead."""
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
from api.streaming import generate_title_raw_via_aux
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
call_count = [0]
def fake_call_llm(**kwargs):
call_count[0] += 1
return {
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
'choices': [
{
'message': {'content': '', 'reasoning': 'long hidden reasoning'},
'finish_reason': 'length',
}
]
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
}
with _patch_tg_config({'provider': 'ollama', 'model': 'kimi-k2.6', 'base_url': 'https://ollama.com/v1'}):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertIsNone(result)
self.assertEqual(status, 'llm_empty_reasoning_aux')
# One call per prompt at the base budget — no retry on prompt 0, no
# second-prompt attempt either (short-circuited).
self.assertEqual(call_count[0], 1)
def test_aux_still_retries_finish_length_without_reasoning(self):
"""Length-truncated responses WITHOUT reasoning tokens still get the
budget-doubling retry — those are legitimately recoverable by giving
the model more headroom."""
from api.streaming import generate_title_raw_via_aux
responses = [
{'choices': [{'message': {'content': ''}, 'finish_reason': 'length'}]},
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
{'choices': [{'message': {'content': 'Useful Session Title'}, 'finish_reason': 'stop'}]},
]
captured_budgets = []
def fake_call_llm(**kwargs):
captured_budgets.append(kwargs.get('max_tokens'))
return responses.pop(0)
with _patch_tg_config({'provider': 'ollama', 'model': 'kimi-k2.6', 'base_url': 'https://ollama.com/v1'}):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertEqual(result, 'Useful Session Title')
self.assertEqual(status, 'llm_aux_retry')
self.assertEqual(captured_budgets, [512, 1024])
def test_aux_returns_specific_status_when_reasoning_retry_still_empty(self):
"""Diagnostics should expose the provider failure mode instead of generic llm_error_aux."""
from api.streaming import generate_title_raw_via_aux
def empty_length_response(**kwargs):
return {
'choices': [
{
'message': {'content': '', 'reasoning': 'still reasoning'},
'finish_reason': 'length',
}
]
}
with _patch_tg_config({'provider': 'ollama', 'model': 'kimi-k2.6', 'base_url': 'https://ollama.com/v1'}):
with patch('agent.auxiliary_client.call_llm', side_effect=empty_length_response, create=True):
result, status = generate_title_raw_via_aux(
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertIsNone(result)
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
self.assertEqual(status, 'llm_empty_reasoning_aux')
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
def test_agent_route_short_circuits_on_empty_reasoning_without_retrying(self):
"""Regression for #2083 on the active-agent route: empty-reasoning
responses must NOT trigger a budget-doubling retry."""
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
from api.streaming import generate_title_raw_via_agent
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
call_count = [0]
def fake_create(**kwargs):
call_count[0] += 1
return {
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
'choices': [
{
'message': {'content': '', 'reasoning': 'long hidden reasoning'},
'finish_reason': 'length',
}
]
fix: skip budget-doubling title retry for reasoning-only responses (#2083) Reasoning models (Qwen3-thinking via LM Studio, DeepSeek-R1, Kimi-K2, etc.) can burn their entire output budget on hidden reasoning tokens and emit no visible content. The previous title-generation retry path classified that as llm_length and doubled the budget — but the second call produces the same shape, so the retry only doubled the GPU/credit burn. Repeated across the two prompts in _title_prompts() this came to ~3000 reasoning tokens of GPU work per new chat. On local LM Studio servers behind a custom: provider (where is_lmstudio=False means reasoning_effort: none never reaches the model) it manifested as the GPU never going idle after a prompt. Fix: - _extract_title_response: classify reasoning-bearing empty responses as llm_empty_reasoning regardless of finish_reason. The presence of reasoning_content is the diagnostic signal, not finish_reason. - _title_retry_status: drop llm_empty_reasoning from the retry set. Length-truncated responses WITHOUT reasoning still retry (those are legitimately recoverable by a larger budget). - Add _title_should_skip_remaining_attempts() and break out of the prompt-iteration loop on empty-reasoning. A second prompt against the same model would produce the same shape. - Falls through to _fallback_title_from_exchange for a local-summary title. Tests updated to invert the previous reasoning-retry assertions: - test_aux_short_circuits_on_empty_reasoning_without_retrying - test_aux_still_retries_finish_length_without_reasoning - test_agent_route_short_circuits_on_empty_reasoning_without_retrying - test_agent_route_still_retries_finish_length_without_reasoning Companion agent-side work (LM Studio classifier for custom: providers) is tracked separately on the hermes-agent side; this WebUI fix is the belt-and-braces guard so the loop stops regardless of agent classifier state. Reported by @darkopetrovic. Closes #2083. Co-authored-by: darkopetrovic <darkopetrovic@users.noreply.github.com> (cherry picked from commit efeae4a86e377069c0f09d140429ecb111a8dd1a)
2026-05-11 23:26:19 +00:00
}
client = types.SimpleNamespace(
chat=types.SimpleNamespace(
completions=types.SimpleNamespace(create=fake_create)
)
)
agent = MagicMock()
agent.api_mode = 'openai'
agent.provider = 'ollama'
agent.model = 'kimi-k2.6'
agent.base_url = 'https://ollama.com/v1'
agent.reasoning_config = None
agent._build_api_kwargs.return_value = {}
agent._ensure_primary_openai_client.return_value = client
result, status = generate_title_raw_via_agent(
agent,
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertIsNone(result)
self.assertEqual(status, 'llm_empty_reasoning')
# One call per prompt at base budget — no retry, no second-prompt attempt.
self.assertEqual(call_count[0], 1)
self.assertIsNone(agent.reasoning_config)
def test_agent_route_still_retries_finish_length_without_reasoning(self):
"""The active-agent route should preserve retry-on-length-no-reasoning."""
from api.streaming import generate_title_raw_via_agent
responses = [
{'choices': [{'message': {'content': ''}, 'finish_reason': 'length'}]},
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
{'choices': [{'message': {'content': 'Agent Session Title'}, 'finish_reason': 'stop'}]},
]
captured_budgets = []
def fake_create(**kwargs):
captured_budgets.append(kwargs.get('max_tokens') or kwargs.get('max_completion_tokens'))
return responses.pop(0)
client = types.SimpleNamespace(
chat=types.SimpleNamespace(
completions=types.SimpleNamespace(create=fake_create)
)
)
agent = MagicMock()
agent.api_mode = 'openai'
agent.provider = 'ollama'
agent.model = 'kimi-k2.6'
agent.base_url = 'https://ollama.com/v1'
agent.reasoning_config = None
agent._build_api_kwargs.return_value = {}
agent._ensure_primary_openai_client.return_value = client
result, status = generate_title_raw_via_agent(
agent,
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
)
self.assertEqual(result, 'Agent Session Title')
self.assertEqual(status, 'llm_retry')
self.assertEqual(captured_budgets, [512, 1024])
self.assertIsNone(agent.reasoning_config)
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming.get_session')
def test_fallback_title_status_keeps_underlying_llm_reason(
self, mock_get_session, mock_aux_title, mock_configured,
):
"""Local fallback should not hide that the LLM failed because it hit length."""
from api.streaming import _run_background_title_update
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'Hey nur ein kurzer Test'},
{'role': 'assistant', 'content': 'Alles klar, ich helfe dir dabei.'},
]
mock_get_session.return_value = mock_session
mock_aux_title.return_value = (None, 'llm_length_aux', '')
events = []
_run_background_title_update(
session_id='reasoning-title-session',
user_text='Hey nur ein kurzer Test',
assistant_text='Alles klar, ich helfe dir dabei.',
placeholder_title='Untitled',
put_event=lambda event_type, data: events.append((event_type, data)),
agent=None,
)
title_status = [data for event_type, data in events if event_type == 'title_status']
self.assertTrue(title_status)
self.assertEqual(title_status[0]['status'], 'fallback')
self.assertEqual(title_status[0]['reason'], 'local_summary:llm_length_aux')
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming.get_session')
def test_generic_fallback_title_is_not_persisted(
self, mock_get_session, mock_aux_title, mock_configured,
):
"""A generic local fallback is worse than the provisional first-message title."""
from api.streaming import _run_background_title_update
provisional_title = '\u5e2e\u6211\u53bb\u627e\u4e00\u672c\u300a\u7ea2\u697c\u68a6\u300b\u7535\u5b50\u4e66'
first_user_text = provisional_title + '\u3002'
mock_session = MagicMock()
mock_session.title = provisional_title
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': first_user_text},
{'role': 'assistant', 'content': ''},
]
mock_get_session.return_value = mock_session
mock_aux_title.return_value = (None, 'llm_error_aux', '')
events = []
_run_background_title_update(
session_id='generic-title-session',
user_text=first_user_text,
assistant_text='',
placeholder_title=provisional_title,
put_event=lambda event_type, data: events.append((event_type, data)),
agent=None,
)
title_events = [data for event_type, data in events if event_type == 'title']
title_status = [data for event_type, data in events if event_type == 'title_status']
self.assertEqual(title_events, [])
self.assertTrue(title_status)
self.assertEqual(title_status[0]['status'], 'skipped')
self.assertEqual(title_status[0]['reason'], 'llm_error_aux')
self.assertEqual(title_status[0]['title'], provisional_title)
self.assertEqual(mock_session.title, provisional_title)
self.assertFalse(mock_session.llm_title_generated)
mock_session.save.assert_not_called()
v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031) * fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 20:07:35 +00:00
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
2026-04-28 10:09:26 +00:00
"""_aux_title_timeout must reject zero, negative, and non-numeric values."""
def _call(self, tg_config, default=15.0):
from api.streaming import _aux_title_timeout
with _patch_tg_config(tg_config):
return _aux_title_timeout(default=default)
def test_timeout_zero_falls_back_to_default(self):
"""timeout: 0 is not strictly positive → fall back to default."""
result = self._call({'timeout': 0}, default=15.0)
self.assertEqual(result, 15.0)
def test_timeout_negative_falls_back_to_default(self):
"""timeout: -1 is not strictly positive → fall back to default."""
result = self._call({'timeout': -1}, default=15.0)
self.assertEqual(result, 15.0)
def test_timeout_non_numeric_string_falls_back_to_default(self):
"""timeout: 'abc' cannot be coerced to float → fall back to default."""
result = self._call({'timeout': 'abc'}, default=15.0)
self.assertEqual(result, 15.0)
def test_timeout_empty_string_falls_back_to_default(self):
"""timeout: '' cannot be coerced to a positive float → fall back to default."""
result = self._call({'timeout': ''}, default=15.0)
self.assertEqual(result, 15.0)
def test_timeout_positive_passes_through(self):
"""A valid positive timeout is returned as-is."""
result = self._call({'timeout': 25.0}, default=15.0)
self.assertEqual(result, 25.0)
def test_custom_default_used_on_invalid(self):
"""When the value is invalid, the caller-supplied *default* is returned."""
result = self._call({'timeout': 0}, default=20.0)
self.assertEqual(result, 20.0)
class TestAuxInvalidAuxTriggersAgentFallback(unittest.TestCase):
2026-04-28 10:09:26 +00:00
"""When aux returns llm_invalid_aux, the agent route must be tried as fallback.
Pins the behaviour so the fallback tuple in _run_background_title_update
stays synchronised with the statuses that _generate_llm_session_title_via_aux
actually emits.
"""
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming._generate_llm_session_title_for_agent')
@patch('api.streaming.get_session')
def test_llm_invalid_aux_triggers_agent_fallback(
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
):
"""Simulate aux returning (None, 'llm_invalid_aux', '...') and verify agent fallback fires."""
from api.streaming import _run_background_title_update
# Build a mock session that passes all the pre-checks
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'What is the weather?'},
{'role': 'assistant', 'content': 'It is sunny and warm.'},
]
mock_get_session.return_value = mock_session
# aux route returns invalid title
mock_aux_title.return_value = (None, 'llm_invalid_aux', 'bad thinking preamble')
# agent route succeeds
mock_agent_title.return_value = ('Weather Report', 'llm', '')
events = []
def fake_put_event(event_type, data):
events.append((event_type, data))
_run_background_title_update(
session_id='test-session',
user_text='What is the weather?',
assistant_text='It is sunny and warm.',
placeholder_title='Untitled',
put_event=fake_put_event,
agent=MagicMock(),
)
# The agent fallback must have been invoked
mock_agent_title.assert_called_once()
# A title must have been produced via the agent route
title_events = [(e, d) for e, d in events if e == 'title']
self.assertTrue(len(title_events) > 0, "Expected a 'title' event to be emitted")
self.assertEqual(title_events[0][1]['title'], 'Weather Report')
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming._generate_llm_session_title_for_agent')
@patch('api.streaming.get_session')
def test_llm_error_aux_triggers_agent_fallback(
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
):
"""Simulate aux returning (None, 'llm_error_aux', '') and verify agent fallback fires."""
from api.streaming import _run_background_title_update
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'Tell me a joke.'},
{'role': 'assistant', 'content': 'Why did the chicken cross the road?'},
]
mock_get_session.return_value = mock_session
mock_aux_title.return_value = (None, 'llm_error_aux', '')
mock_agent_title.return_value = ('Chicken Joke', 'llm', '')
events = []
def fake_put_event(event_type, data):
events.append((event_type, data))
_run_background_title_update(
session_id='test-session-2',
user_text='Tell me a joke.',
assistant_text='Why did the chicken cross the road?',
placeholder_title='Untitled',
put_event=fake_put_event,
agent=MagicMock(),
)
mock_agent_title.assert_called_once()
@patch('api.streaming._aux_title_configured', return_value=True)
@patch('api.streaming._generate_llm_session_title_via_aux')
@patch('api.streaming._generate_llm_session_title_for_agent')
@patch('api.streaming.get_session')
def test_success_status_does_not_trigger_agent_fallback(
self, mock_get_session, mock_agent_title, mock_aux_title, mock_configured,
):
"""When aux succeeds, the agent route must NOT be called."""
from api.streaming import _run_background_title_update
mock_session = MagicMock()
mock_session.title = 'Untitled'
mock_session.llm_title_generated = False
mock_session.messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Hi there'},
]
mock_get_session.return_value = mock_session
# aux succeeds on first try
mock_aux_title.return_value = ('Greeting', 'llm_aux', '')
events = []
def fake_put_event(event_type, data):
events.append((event_type, data))
_run_background_title_update(
session_id='test-session-3',
user_text='Hello',
assistant_text='Hi there',
placeholder_title='Untitled',
put_event=fake_put_event,
agent=MagicMock(),
)
# Agent route must NOT have been invoked
mock_agent_title.assert_not_called()
if __name__ == '__main__':
unittest.main()