Title generation via the aux client ran without the Agent's ambient conversation context, so relay targets that key on it (OpenCode Go) rejected the completion call with HTTP 400. Publish the webui session id as the Agent conversation context around the aux completion (restoring it in a finally block) and pass it from all three title entry paths: background update, background refresh and the on-demand regenerate helper. Refs #7470
1278 lines
56 KiB
Python
1278 lines
56 KiB
Python
"""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
|
|
- aux→agent fallback triggers on 'llm_invalid_aux' status
|
|
- _aux_title_timeout rejects zero, negative, and non-numeric values
|
|
"""
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import types
|
|
import unittest
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from tests._aux_client_helpers import auxiliary_client_modules, patch_tg_config
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _install_auxiliary_client_modules():
|
|
"""Scope the synthetic hermes-agent modules to each test (#6630)."""
|
|
with auxiliary_client_modules():
|
|
yield
|
|
|
|
|
|
def _patch_tg_config(config_dict):
|
|
"""Return a patch context manager that makes _get_auxiliary_task_config return config_dict."""
|
|
return patch_tg_config(config_dict)
|
|
|
|
|
|
class TestAuxiliaryClientModuleRestore(unittest.TestCase):
|
|
"""Restoration properties the #6630 fix depends on.
|
|
|
|
These drive the helper directly. The fixture boundary itself is proved by
|
|
the cross-file run in the PR description: running this file before
|
|
tests/test_issue4685_post_compression_context_metering.py used to fail that
|
|
file's agent.context_compressor import and no longer does.
|
|
"""
|
|
|
|
def test_prior_modules_restored_by_identity(self):
|
|
prior_agent = types.ModuleType("agent")
|
|
prior_aux = types.ModuleType("agent.auxiliary_client")
|
|
prior_agent.auxiliary_client = prior_aux
|
|
with patch.dict(sys.modules, {"agent": prior_agent, "agent.auxiliary_client": prior_aux}):
|
|
with auxiliary_client_modules():
|
|
self.assertIsNot(sys.modules["agent"], prior_agent)
|
|
self.assertIsNot(sys.modules["agent.auxiliary_client"], prior_aux)
|
|
self.assertIs(sys.modules["agent"], prior_agent)
|
|
self.assertIs(sys.modules["agent.auxiliary_client"], prior_aux)
|
|
self.assertIs(prior_agent.auxiliary_client, prior_aux)
|
|
|
|
def test_absent_keys_removed(self):
|
|
with patch.dict(sys.modules):
|
|
sys.modules.pop("agent", None)
|
|
sys.modules.pop("agent.auxiliary_client", None)
|
|
with auxiliary_client_modules():
|
|
self.assertIn("agent", sys.modules)
|
|
self.assertIn("agent.auxiliary_client", sys.modules)
|
|
self.assertNotIn("agent", sys.modules)
|
|
self.assertNotIn("agent.auxiliary_client", sys.modules)
|
|
|
|
def test_prior_none_value_preserved(self):
|
|
"""A present-but-None entry is not an absent entry."""
|
|
with patch.dict(sys.modules, {"agent": None, "agent.auxiliary_client": None}):
|
|
with auxiliary_client_modules():
|
|
self.assertIsNotNone(sys.modules["agent"])
|
|
self.assertIn("agent", sys.modules)
|
|
self.assertIsNone(sys.modules["agent"])
|
|
self.assertIsNone(sys.modules["agent.auxiliary_client"])
|
|
|
|
def test_restored_after_exception(self):
|
|
prior_agent = types.ModuleType("agent")
|
|
prior_aux = types.ModuleType("agent.auxiliary_client")
|
|
prior_agent.auxiliary_client = prior_aux
|
|
with patch.dict(sys.modules, {"agent": prior_agent, "agent.auxiliary_client": prior_aux}):
|
|
with self.assertRaisesRegex(RuntimeError, "boom"):
|
|
with auxiliary_client_modules():
|
|
raise RuntimeError("boom")
|
|
self.assertIs(sys.modules["agent"], prior_agent)
|
|
self.assertIs(sys.modules["agent.auxiliary_client"], prior_aux)
|
|
|
|
|
|
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
|
|
|
|
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_webui_prefixed_model_id_is_stripped_before_aux_call(self):
|
|
"""Regression: @provider:model picker ids must not reach provider APIs verbatim."""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Gemini Title'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
captured = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
captured.update(kwargs)
|
|
return mock_resp
|
|
|
|
tg_config = {
|
|
'provider': 'gemini',
|
|
'model': '@gemini:gemini-3.1-flash-lite',
|
|
'base_url': '',
|
|
}
|
|
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='Как настроить title generation?',
|
|
assistant_text='Нужно указать auxiliary.title_generation в config.yaml.',
|
|
)
|
|
|
|
self.assertEqual(result, 'Gemini Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
self.assertEqual(captured.get('provider'), 'gemini')
|
|
self.assertEqual(captured.get('model'), 'gemini-3.1-flash-lite')
|
|
|
|
def test_configured_provider_model_and_base_url_are_passed_to_aux_client(self):
|
|
"""Regression for #2235: task config must select the first title model.
|
|
|
|
If generate_title_raw_via_aux leaves provider/model/base_url as None,
|
|
agent.auxiliary_client.call_llm can fall back to the main chat model and
|
|
WebUI titles look like local first-message placeholders or unrelated
|
|
chat-model output instead of using auxiliary.title_generation.model.
|
|
"""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Configured Model Title'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
captured = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
captured.update(kwargs)
|
|
return mock_resp
|
|
|
|
tg_config = {
|
|
'provider': 'openrouter',
|
|
'model': 'anthropic/claude-haiku-title',
|
|
'base_url': 'https://openrouter.ai/api/v1',
|
|
'timeout': 22.0,
|
|
}
|
|
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='Explain why the moon has phases.',
|
|
assistant_text='The moon has phases because we see different sunlit portions.',
|
|
)
|
|
|
|
self.assertEqual(result, 'Configured Model Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
self.assertEqual(captured.get('task'), 'title_generation')
|
|
self.assertEqual(captured.get('provider'), 'openrouter')
|
|
self.assertEqual(captured.get('model'), 'anthropic/claude-haiku-title')
|
|
self.assertEqual(captured.get('base_url'), 'https://openrouter.ai/api/v1')
|
|
self.assertEqual(captured.get('timeout'), 22.0)
|
|
|
|
def test_configured_api_key_is_passed_to_aux_client(self):
|
|
"""Regression: explicit title_generation api_key must be forwarded.
|
|
|
|
Hermes Agent does not fall back to auxiliary.title_generation.api_key
|
|
once WebUI passes explicit provider/model/base_url values, so WebUI
|
|
must forward the configured task api_key with the rest of the route.
|
|
"""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Configured Key Title'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
captured = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
captured.update(kwargs)
|
|
return mock_resp
|
|
|
|
tg_config = {
|
|
'provider': '',
|
|
'model': 'gemma-4-31b-it',
|
|
'base_url': 'http://openrouter:4000/v1',
|
|
'api_key': 'test-title-api-key',
|
|
}
|
|
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='Summarize this title routing bug.',
|
|
assistant_text='The title model route needs its configured key.',
|
|
)
|
|
|
|
self.assertEqual(result, 'Configured Key Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
self.assertEqual(captured.get('task'), 'title_generation')
|
|
self.assertIsNone(captured.get('provider'))
|
|
self.assertEqual(captured.get('model'), 'gemma-4-31b-it')
|
|
self.assertEqual(captured.get('base_url'), 'http://openrouter:4000/v1')
|
|
self.assertEqual(captured.get('api_key'), 'test-title-api-key')
|
|
|
|
def test_title_prompt_requires_matching_user_language(self):
|
|
"""Conversation starts should get a language-neutral match-language instruction."""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Alte Session Bilder'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
captured = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
captured.update(kwargs)
|
|
return mock_resp
|
|
|
|
with _patch_tg_config({'provider': '', 'model': 'title-model', 'base_url': ''}):
|
|
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
|
result, status = generate_title_raw_via_aux(
|
|
user_text='Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
|
assistant_text='Ich prüfe die Attachment-Pfade im WebUI.',
|
|
)
|
|
|
|
self.assertEqual(result, 'Alte Session Bilder')
|
|
self.assertEqual(status, 'llm_aux')
|
|
messages = captured.get('messages') or []
|
|
self.assertIn('Match the language of the user question', messages[0]['content'])
|
|
self.assertNotIn('If the user writes German', messages[0]['content'])
|
|
self.assertNotIn('German good:', messages[0]['content'])
|
|
|
|
def test_title_prompt_language_rule_is_same_for_supported_locales(self):
|
|
from api.streaming import _title_prompt_language_rule
|
|
|
|
expected = "Match the language of the user question.\n"
|
|
examples = [
|
|
'Warum werden hier die Bilder nicht angezeigt?',
|
|
'Pourquoi les images ne sont-elles pas affichées ?',
|
|
'¿Por qué no se muestran las imágenes?',
|
|
'为什么图片没有显示?',
|
|
'Why are the images not displayed?',
|
|
]
|
|
for text in examples:
|
|
with self.subTest(text=text):
|
|
self.assertEqual(_title_prompt_language_rule(text), expected)
|
|
|
|
def test_title_language_detection_avoids_english_tech_false_positives(self):
|
|
"""English tech/jargon text must not be classified as German by shared tokens."""
|
|
from api.streaming import _detect_title_language
|
|
|
|
examples = [
|
|
'Why did the session die after the DAS storage failover?',
|
|
'The session can die when DAS storage disconnects.',
|
|
'Debug the session and DER certificate import failure.',
|
|
]
|
|
for text in examples:
|
|
with self.subTest(text=text):
|
|
self.assertEqual(_detect_title_language(text), '')
|
|
|
|
def test_title_language_detection_keeps_german_without_umlaut(self):
|
|
"""German without umlauts still needs a language hint when evidence is specific."""
|
|
from api.streaming import _detect_title_language
|
|
|
|
self.assertEqual(
|
|
_detect_title_language('Warum werden hier die Bilder der alten Session nicht angezeigt?'),
|
|
'de',
|
|
)
|
|
|
|
def test_german_source_rejects_english_aux_title(self):
|
|
"""Regression: an English aux title must not overwrite a German conversation."""
|
|
from api.streaming import _generate_llm_session_title_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Old Session Image Display Issue'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
|
|
with _patch_tg_config({'provider': '', 'model': 'title-model', 'base_url': ''}):
|
|
with patch('agent.auxiliary_client.call_llm', return_value=mock_resp, create=True):
|
|
title, status, raw_preview = _generate_llm_session_title_via_aux(
|
|
'Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
|
'Ich prüfe die Attachment-Pfade im WebUI.',
|
|
)
|
|
|
|
self.assertIsNone(title)
|
|
self.assertEqual(status, 'llm_language_mismatch_aux')
|
|
self.assertEqual(raw_preview, 'Old Session Image Display Issue')
|
|
|
|
def test_german_fallback_uses_generic_topic_extraction_without_literal_override(self):
|
|
from api.streaming import _fallback_title_from_exchange
|
|
|
|
title = _fallback_title_from_exchange(
|
|
'Warum werden hier die Bilder der alten Session nicht mehr angezeigt?',
|
|
'Ich prüfe die Rendering- und Attachment-Pfade im WebUI.',
|
|
)
|
|
|
|
self.assertIsNotNone(title)
|
|
self.assertIsInstance(title, str)
|
|
self.assertNotEqual(title, 'Alte Session Bilder')
|
|
self.assertNotEqual(title, 'Session Bilder')
|
|
self.assertIn('Warum', title)
|
|
|
|
def test_code_only_first_message_does_not_trigger_german_language_guard(self):
|
|
"""Code-only starts should fall through to the neutral/default title path."""
|
|
from api.streaming import _detect_title_language, _title_language_mismatch, _title_prompt_language_rule
|
|
|
|
code_only = "print('hello')\nfor i in range(3):\n print(i)"
|
|
|
|
self.assertEqual(_detect_title_language(code_only), '')
|
|
self.assertEqual(_title_prompt_language_rule(code_only), 'Match the language of the user question.\n')
|
|
self.assertFalse(_title_language_mismatch(code_only, 'Python Hello Loop'))
|
|
|
|
def test_configured_api_key_is_not_sent_to_caller_supplied_route(self):
|
|
"""Regression: title task keys must not leak to explicit fallback routes.
|
|
|
|
Some callers pass the active agent route into title generation. In that
|
|
path WebUI should still use title-generation prompts/timeouts, but it
|
|
must not attach an unrelated auxiliary.title_generation api_key to the
|
|
caller-supplied provider/model/base_url.
|
|
"""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
mock_resp = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Agent Route Title'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
captured = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
captured.update(kwargs)
|
|
return mock_resp
|
|
|
|
tg_config = {
|
|
'provider': '',
|
|
'model': 'gemma-4-31b-it',
|
|
'base_url': 'http://openrouter:4000/v1',
|
|
'api_key': 'test-title-api-key',
|
|
}
|
|
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='Summarize this title routing bug.',
|
|
assistant_text='The caller route should not receive the task key.',
|
|
provider='custom:agent-route',
|
|
model='agent-title-model',
|
|
base_url='https://agent-route.example/v1',
|
|
)
|
|
|
|
self.assertEqual(result, 'Agent Route Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
self.assertEqual(captured.get('task'), 'title_generation')
|
|
self.assertEqual(captured.get('provider'), 'custom:agent-route')
|
|
self.assertEqual(captured.get('model'), 'agent-title-model')
|
|
self.assertEqual(captured.get('base_url'), 'https://agent-route.example/v1')
|
|
self.assertIsNone(captured.get('api_key'))
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
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."""
|
|
from api.streaming import generate_title_raw_via_aux
|
|
|
|
call_count = [0]
|
|
|
|
def fake_call_llm(**kwargs):
|
|
call_count[0] += 1
|
|
return {
|
|
'choices': [
|
|
{
|
|
'message': {'content': '', 'reasoning': 'long hidden 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=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'}]},
|
|
{'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)
|
|
self.assertEqual(status, 'llm_empty_reasoning_aux')
|
|
|
|
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."""
|
|
from api.streaming import generate_title_raw_via_agent
|
|
|
|
call_count = [0]
|
|
|
|
def fake_create(**kwargs):
|
|
call_count[0] += 1
|
|
return {
|
|
'choices': [
|
|
{
|
|
'message': {'content': '', 'reasoning': 'long hidden reasoning'},
|
|
'finish_reason': 'length',
|
|
}
|
|
]
|
|
}
|
|
|
|
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'}]},
|
|
{'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()
|
|
|
|
|
|
class TestBackgroundTitleProfileRouting(unittest.TestCase):
|
|
def test_profile_env_context_logs_fail_open_resolution_errors(self):
|
|
"""Profile env setup failures should be diagnosable without breaking workers."""
|
|
import api.profiles as profiles
|
|
|
|
session = types.SimpleNamespace(profile='work')
|
|
captured = {}
|
|
|
|
with patch.object(
|
|
profiles,
|
|
'get_hermes_home_for_profile',
|
|
side_effect=RuntimeError('profile lookup failed'),
|
|
):
|
|
with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False):
|
|
with self.assertLogs('api.profiles', level='DEBUG') as logs:
|
|
with profiles.profile_env_for_background_worker(session, 'background title'):
|
|
captured['HERMES_HOME'] = os.environ.get('HERMES_HOME')
|
|
|
|
message_found = any(
|
|
'Failed to resolve profile env for background title profile work' in record.getMessage()
|
|
for record in logs.records
|
|
)
|
|
self.assertEqual(captured['HERMES_HOME'], 'default-home')
|
|
self.assertTrue(message_found)
|
|
self.assertTrue(any(record.exc_info for record in logs.records))
|
|
|
|
def test_skill_home_snapshot_removes_modules_imported_during_context(self):
|
|
"""Modules first imported inside a temporary profile context must not leak."""
|
|
import api.profiles as profiles
|
|
|
|
original_parent = sys.modules.get('tools')
|
|
original_skill_module = sys.modules.get('tools.skills_tool')
|
|
original_manager_module = sys.modules.get('tools.skill_manager_tool')
|
|
|
|
sys.modules.pop('tools.skills_tool', None)
|
|
sys.modules.pop('tools.skill_manager_tool', None)
|
|
tools_parent = types.ModuleType('tools')
|
|
sys.modules['tools'] = tools_parent
|
|
try:
|
|
snapshot = profiles.snapshot_skill_home_modules()
|
|
|
|
imported_during_context = types.ModuleType('tools.skills_tool')
|
|
setattr(imported_during_context, 'HERMES_HOME', 'profile-home')
|
|
setattr(imported_during_context, 'SKILLS_DIR', 'profile-home/skills')
|
|
sys.modules['tools.skills_tool'] = imported_during_context
|
|
setattr(tools_parent, 'skills_tool', imported_during_context)
|
|
|
|
profiles.restore_skill_home_modules(snapshot)
|
|
|
|
self.assertNotIn('tools.skills_tool', sys.modules)
|
|
self.assertFalse(hasattr(tools_parent, 'skills_tool'))
|
|
finally:
|
|
sys.modules.pop('tools.skills_tool', None)
|
|
sys.modules.pop('tools.skill_manager_tool', None)
|
|
if original_parent is None:
|
|
sys.modules.pop('tools', None)
|
|
else:
|
|
sys.modules['tools'] = original_parent
|
|
if original_skill_module is not None:
|
|
sys.modules['tools.skills_tool'] = original_skill_module
|
|
if original_manager_module is not None:
|
|
sys.modules['tools.skill_manager_tool'] = original_manager_module
|
|
|
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
|
@patch('api.streaming.get_session')
|
|
def test_background_title_generation_uses_session_profile_home(
|
|
self, mock_get_session, mock_configured,
|
|
):
|
|
"""A background title worker for a non-default profile must resolve aux config from that profile."""
|
|
from api.streaming import _run_background_title_update
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.title = 'Untitled'
|
|
mock_session.profile = 'work'
|
|
mock_session.llm_title_generated = False
|
|
mock_session.messages = [
|
|
{'role': 'user', 'content': 'This is a test message'},
|
|
{'role': 'assistant', 'content': 'Received.'},
|
|
]
|
|
mock_get_session.return_value = mock_session
|
|
|
|
captured = {}
|
|
|
|
original_skill_module = sys.modules.get('tools.skills_tool')
|
|
fake_skill_module = types.ModuleType('tools.skills_tool')
|
|
setattr(fake_skill_module, 'HERMES_HOME', 'default-home')
|
|
setattr(fake_skill_module, 'SKILLS_DIR', 'default-home/skills')
|
|
sys.modules['tools.skills_tool'] = fake_skill_module
|
|
|
|
def fake_aux_title(*args, **kwargs):
|
|
captured['hermes_home'] = os.environ.get('HERMES_HOME')
|
|
captured['skill_module_home'] = getattr(fake_skill_module, 'HERMES_HOME')
|
|
captured['skill_module_dir'] = getattr(fake_skill_module, 'SKILLS_DIR')
|
|
return ('Profile Routed Title', 'llm_aux', '')
|
|
|
|
events = []
|
|
try:
|
|
with patch('api.profiles.get_hermes_home_for_profile', return_value='profile-home'):
|
|
with patch('api.streaming._generate_llm_session_title_via_aux', side_effect=fake_aux_title):
|
|
with patch.dict(os.environ, {'HERMES_HOME': 'default-home'}, clear=False):
|
|
_run_background_title_update(
|
|
session_id='profile-title-session',
|
|
user_text='This is a test message',
|
|
assistant_text='Received.',
|
|
placeholder_title='Untitled',
|
|
put_event=lambda event_type, data: events.append((event_type, data)),
|
|
agent=None,
|
|
)
|
|
captured['restored_hermes_home'] = os.environ.get('HERMES_HOME')
|
|
finally:
|
|
if original_skill_module is None:
|
|
sys.modules.pop('tools.skills_tool', None)
|
|
else:
|
|
sys.modules['tools.skills_tool'] = original_skill_module
|
|
|
|
self.assertEqual(captured.get('hermes_home'), 'profile-home')
|
|
self.assertEqual(str(captured.get('skill_module_home')), 'profile-home')
|
|
self.assertEqual(
|
|
Path(str(captured.get('skill_module_dir'))),
|
|
Path('profile-home') / 'skills',
|
|
)
|
|
self.assertEqual(captured.get('restored_hermes_home'), 'default-home')
|
|
self.assertEqual(fake_skill_module.HERMES_HOME, 'default-home')
|
|
self.assertEqual(fake_skill_module.SKILLS_DIR, 'default-home/skills')
|
|
self.assertEqual(mock_session.title, 'Profile Routed Title')
|
|
|
|
def test_background_profile_env_routes_load_config_and_provider_credentials(self):
|
|
"""Hybrid worker env must satisfy config and os.getenv provider-key readers."""
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
import api.profiles as profiles
|
|
from api.config import _thread_ctx
|
|
try:
|
|
from hermes_cli import config as hermes_config
|
|
except ModuleNotFoundError:
|
|
pytest.skip('hermes_cli is not installed in this CI environment')
|
|
|
|
session = types.SimpleNamespace(profile='work')
|
|
captured = {}
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
default_home = os.path.join(tmp, 'default-home')
|
|
profile_home = os.path.join(tmp, 'profile-home')
|
|
os.makedirs(default_home, exist_ok=True)
|
|
os.makedirs(profile_home, exist_ok=True)
|
|
with open(os.path.join(default_home, 'config.yaml'), 'w', encoding='utf-8') as f:
|
|
f.write('model:\n provider: default-provider\n default: default-model\n')
|
|
with open(os.path.join(profile_home, 'config.yaml'), 'w', encoding='utf-8') as f:
|
|
f.write('model:\n provider: profile-provider\n default: profile-model\n')
|
|
|
|
with patch('api.profiles.get_hermes_home_for_profile', return_value=profile_home):
|
|
runtime_env = {
|
|
'PROFILE_ONLY_KEY': 'profile-only',
|
|
'OPENROUTER_API_KEY': 'profile-openrouter-key',
|
|
}
|
|
with patch('api.profiles.get_profile_runtime_env', return_value=runtime_env):
|
|
with patch.dict(os.environ, {'HERMES_HOME': default_home, 'OPENROUTER_API_KEY': 'default-openrouter-key'}, clear=False):
|
|
os.environ.pop('PROFILE_ONLY_KEY', None)
|
|
hermes_config._LOAD_CONFIG_CACHE.clear()
|
|
with profiles.profile_env_for_background_worker(session, 'background title'):
|
|
loaded = hermes_config.load_config()
|
|
captured['loaded_provider'] = loaded.get('model', {}).get('provider')
|
|
captured['process_home'] = os.environ.get('HERMES_HOME')
|
|
captured['process_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY')
|
|
captured['provider_credential'] = os.getenv('OPENROUTER_API_KEY')
|
|
captured['thread_home'] = getattr(_thread_ctx, 'env', {}).get('HERMES_HOME')
|
|
captured['thread_runtime_key'] = getattr(_thread_ctx, 'env', {}).get('PROFILE_ONLY_KEY')
|
|
captured['restored_home'] = os.environ.get('HERMES_HOME')
|
|
captured['restored_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY')
|
|
captured['restored_provider_credential'] = os.environ.get('OPENROUTER_API_KEY')
|
|
hermes_config._LOAD_CONFIG_CACHE.clear()
|
|
|
|
self.assertEqual(captured['loaded_provider'], 'profile-provider')
|
|
self.assertEqual(captured['process_home'], profile_home)
|
|
self.assertEqual(captured['process_runtime_key'], 'profile-only')
|
|
self.assertEqual(captured['provider_credential'], 'profile-openrouter-key')
|
|
self.assertEqual(captured['thread_home'], profile_home)
|
|
self.assertEqual(captured['thread_runtime_key'], 'profile-only')
|
|
self.assertEqual(captured['restored_home'], default_home)
|
|
self.assertIsNone(captured['restored_runtime_key'])
|
|
self.assertEqual(captured['restored_provider_credential'], 'default-openrouter-key')
|
|
|
|
|
|
class TestAuxTitleTimeoutEdgeCases(unittest.TestCase):
|
|
"""_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):
|
|
"""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()
|
|
|
|
|
|
class TestAuxTitleConversationContext(unittest.TestCase):
|
|
"""#7470: aux title-generation calls republish the session id as the Agent's
|
|
ambient conversation context so OpenCode relay targets get the same
|
|
x-opencode-session sticky key as the session's main turns."""
|
|
|
|
MOCK_RESP = types.SimpleNamespace(
|
|
choices=[
|
|
types.SimpleNamespace(
|
|
message=types.SimpleNamespace(content='Weather Title'),
|
|
finish_reason='stop',
|
|
)
|
|
]
|
|
)
|
|
|
|
def _portal_state(self):
|
|
from agent.portal_tags import _portal_state
|
|
return _portal_state
|
|
|
|
def _current_context(self):
|
|
from agent.portal_tags import get_conversation_context
|
|
return get_conversation_context()
|
|
|
|
def test_direct_call_publishes_and_restores_context_after_success(self):
|
|
from api.streaming import _generate_llm_session_title_via_aux
|
|
|
|
seen = {}
|
|
|
|
def fake_call_llm(**kwargs):
|
|
from agent.portal_tags import get_conversation_context
|
|
seen['context_during_call'] = get_conversation_context()
|
|
return self.MOCK_RESP
|
|
|
|
with _patch_tg_config({'provider': '', 'model': 'gpt-4o', 'base_url': ''}):
|
|
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
|
title, status, _raw = _generate_llm_session_title_via_aux(
|
|
'What is the weather?', 'It is sunny.', conversation_id='sid-123',
|
|
)
|
|
|
|
self.assertEqual(title, 'Weather Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
# Context published before the aux call and visible inside call_llm.
|
|
self.assertEqual(seen['context_during_call'], 'sid-123')
|
|
state = self._portal_state()
|
|
self.assertEqual(state['set_calls'], ['sid-123'])
|
|
# Reset restored the pre-call value (None) in the finally block.
|
|
self.assertIsNone(self._current_context())
|
|
self.assertEqual(len(state['reset_calls']), 1)
|
|
|
|
def test_context_restored_after_exception_escapes_aux_call(self):
|
|
from api.streaming import _generate_llm_session_title_via_aux
|
|
|
|
with _patch_tg_config({'provider': '', 'model': 'gpt-4o', 'base_url': ''}):
|
|
with patch('api.streaming.generate_title_raw_via_aux', side_effect=RuntimeError('boom')):
|
|
with self.assertRaises(RuntimeError):
|
|
_generate_llm_session_title_via_aux(
|
|
'What is the weather?', 'It is sunny.', conversation_id='sid-456',
|
|
)
|
|
state = self._portal_state()
|
|
self.assertEqual(state['set_calls'], ['sid-456'])
|
|
self.assertIsNone(self._current_context())
|
|
self.assertEqual(len(state['reset_calls']), 1)
|
|
|
|
def test_no_conversation_id_skips_context_publication(self):
|
|
from api.streaming import _generate_llm_session_title_via_aux
|
|
|
|
def fake_call_llm(**kwargs):
|
|
return self.MOCK_RESP
|
|
|
|
with _patch_tg_config({'provider': '', 'model': 'gpt-4o', 'base_url': ''}):
|
|
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
|
title, status, _raw = _generate_llm_session_title_via_aux(
|
|
'What is the weather?', 'It is sunny.',
|
|
)
|
|
|
|
self.assertEqual(title, 'Weather Title')
|
|
state = self._portal_state()
|
|
self.assertEqual(state['set_calls'], [])
|
|
self.assertEqual(state['reset_calls'], [])
|
|
|
|
def test_opencode_target_agent_model_publishes_non_empty_session_context(self):
|
|
"""use_agent_model=True through an OpenCode-style target still carries the key."""
|
|
from api.streaming import _generate_llm_session_title_via_aux
|
|
|
|
seen = {}
|
|
agent = types.SimpleNamespace(
|
|
provider='opencode', model='opencode-go', base_url='http://localhost:3456',
|
|
)
|
|
|
|
def fake_call_llm(**kwargs):
|
|
from agent.portal_tags import get_conversation_context
|
|
seen['context_during_call'] = get_conversation_context()
|
|
seen['provider'] = kwargs.get('provider')
|
|
seen['base_url'] = kwargs.get('base_url')
|
|
return self.MOCK_RESP
|
|
|
|
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
|
|
title, status, _raw = _generate_llm_session_title_via_aux(
|
|
'What is the weather?', 'It is sunny.',
|
|
agent=agent, use_agent_model=True, conversation_id='oc-sid',
|
|
)
|
|
|
|
self.assertEqual(title, 'Weather Title')
|
|
self.assertEqual(seen['context_during_call'], 'oc-sid')
|
|
self.assertEqual(seen['provider'], 'opencode')
|
|
self.assertEqual(seen['base_url'], 'http://localhost:3456')
|
|
self.assertIsNone(self._current_context())
|
|
|
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
|
@patch('api.streaming.get_session')
|
|
@patch('api.streaming._generate_llm_session_title_for_agent')
|
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
|
def test_background_update_entry_passes_session_id(
|
|
self, mock_aux_title, mock_agent_title, mock_get_session, mock_configured,
|
|
):
|
|
"""_run_background_title_update forwards the session id as conversation id."""
|
|
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
|
|
mock_aux_title.return_value = ('Greeting', 'llm_aux', '')
|
|
|
|
_run_background_title_update(
|
|
session_id='entry-update-sid',
|
|
user_text='Hello',
|
|
assistant_text='Hi there',
|
|
placeholder_title='Untitled',
|
|
put_event=lambda _t, _d: None,
|
|
agent=MagicMock(),
|
|
)
|
|
|
|
self.assertEqual(mock_aux_title.call_args.kwargs.get('conversation_id'), 'entry-update-sid')
|
|
mock_agent_title.assert_not_called()
|
|
|
|
@patch('api.streaming._aux_title_configured', return_value=True)
|
|
@patch('api.streaming.get_session')
|
|
@patch('api.streaming._generate_llm_session_title_for_agent')
|
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
|
def test_background_refresh_entry_passes_session_id(
|
|
self, mock_aux_title, mock_agent_title, mock_get_session, mock_configured,
|
|
):
|
|
"""_run_background_title_refresh forwards the session id as conversation id."""
|
|
from api.streaming import _run_background_title_refresh
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.title = 'Old Title'
|
|
mock_session.messages = [
|
|
{'role': 'user', 'content': 'Hello again'},
|
|
{'role': 'assistant', 'content': 'More context'},
|
|
]
|
|
mock_get_session.return_value = mock_session
|
|
mock_aux_title.return_value = ('Newer Title', 'llm_aux', '')
|
|
|
|
_run_background_title_refresh(
|
|
session_id='entry-refresh-sid',
|
|
user_text='Hello again',
|
|
assistant_text='More context',
|
|
current_title='Old Title',
|
|
put_event=lambda _t, _d: None,
|
|
agent=MagicMock(),
|
|
)
|
|
|
|
self.assertEqual(mock_aux_title.call_args.kwargs.get('conversation_id'), 'entry-refresh-sid')
|
|
mock_agent_title.assert_not_called()
|
|
|
|
@patch('api.streaming._generate_llm_session_title_via_aux')
|
|
def test_sync_regenerate_entry_passes_session_id(self, mock_aux_title):
|
|
"""generate_session_title_for_session forwards the session id (sync path)."""
|
|
from api.streaming import generate_session_title_for_session
|
|
|
|
mock_session = MagicMock()
|
|
mock_session.session_id = 'entry-sync-sid'
|
|
mock_session.title = 'Untitled'
|
|
mock_session.messages = [
|
|
{'role': 'user', 'content': 'Hello sync'},
|
|
{'role': 'assistant', 'content': 'Sync reply'},
|
|
]
|
|
mock_aux_title.return_value = ('Sync Title', 'llm_aux', '')
|
|
|
|
title, status, _raw = generate_session_title_for_session(mock_session)
|
|
|
|
self.assertEqual(title, 'Sync Title')
|
|
self.assertEqual(status, 'llm_aux')
|
|
self.assertEqual(mock_aux_title.call_args.kwargs.get('conversation_id'), 'entry-sync-sid')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|